Files
modeling-app/src/machines/settingsMachine.ts
Frank Noirot 7838b7c9fd Fix "include settings" setting to have an effect (#6917)
* Fix "include settings" setting to have an effect

I'm not sold on if we should have this setting, but this fixes it for
now. The issue is was that the new callback actor approach was using a
stale version of the settings every time it received an "update" event:
JS closure problems. Now it receives the new settings as an event
payload.

* Update src/machines/settingsMachine.ts

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
2025-05-14 15:14:33 +00:00

690 lines
21 KiB
TypeScript

import decamelize from 'decamelize'
import toast from 'react-hot-toast'
import type { AnyActorRef } from 'xstate'
import {
assign,
enqueueActions,
fromCallback,
fromPromise,
sendTo,
setup,
} from 'xstate'
import type { NamedView } from '@rust/kcl-lib/bindings/NamedView'
import {
createSettingsCommand,
settingsWithCommandConfigs,
} from '@src/lib/commandBarConfigs/settingsCommandConfig'
import type { Command } from '@src/lib/commandTypes'
import type { Project } from '@src/lib/project'
import type { SettingsType } from '@src/lib/settings/initialSettings'
import { createSettings, settings } from '@src/lib/settings/initialSettings'
import type {
BaseUnit,
SetEventTypes,
SettingsLevel,
SettingsPaths,
WildcardSetEvent,
} from '@src/lib/settings/settingsTypes'
import {
clearSettingsAtLevel,
configurationToSettingsPayload,
loadAndValidateSettings,
projectConfigurationToSettingsPayload,
saveSettings,
setSettingsAtLevel,
} from '@src/lib/settings/settingsUtils'
import {
Themes,
darkModeMatcher,
getOppositeTheme,
getSystemTheme,
setThemeClass,
} from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { ACTOR_IDS } from '@src/machines/machineConstants'
type SettingsMachineContext = SettingsType & {
currentProject?: Project
}
export const settingsMachine = setup({
types: {
context: {} as SettingsMachineContext,
input: {} as SettingsMachineContext,
events: {} as (
| WildcardSetEvent<SettingsPaths>
| SetEventTypes
| {
type: 'set.app.theme'
data: { level: SettingsLevel; value: Themes }
}
| {
type: 'set.modeling.units'
data: { level: SettingsLevel; value: BaseUnit }
}
| {
type: 'Reset settings'
level: SettingsLevel
}
| { type: 'Set all settings'; settings: typeof settings }
| {
type: 'set.app.namedViews'
data: {
value: NamedView
toastCallback: () => void
level: SettingsLevel
}
}
| { type: 'load.project'; project?: Project }
| { type: 'clear.project' }
) & { doNotPersist?: boolean },
},
actors: {
persistSettings: fromPromise<
void,
{
doNotPersist: boolean
context: SettingsMachineContext
toastCallback?: () => void
rootContext: any
}
>(async ({ input }) => {
// Without this, when a user changes the file, it'd
// create a detection loop with the file-system watcher.
if (input.doNotPersist) return
input.rootContext.codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
const { currentProject, ...settings } = input.context
const val = await saveSettings(settings, currentProject?.path)
if (input.toastCallback) {
input.toastCallback()
}
return val
}),
loadUserSettings: fromPromise<SettingsMachineContext, void>(async () => {
const { settings } = await loadAndValidateSettings()
return settings
}),
loadProjectSettings: fromPromise<
SettingsMachineContext,
{ project?: Project }
>(async ({ input }) => {
const { settings } = await loadAndValidateSettings(input.project?.path)
return settings
}),
watchSystemTheme: fromCallback<{
type: 'update.themeWatcher'
theme: Themes
}>(({ receive }) => {
const listener = (e: MediaQueryListEvent) => {
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
receive((event) => {
if (event.type !== 'update.themeWatcher') {
return
} else {
if (event.theme === Themes.System) {
darkModeMatcher?.addEventListener('change', listener)
} else {
darkModeMatcher?.removeEventListener('change', listener)
}
}
})
return () => darkModeMatcher?.removeEventListener('change', listener)
}),
registerCommands: fromCallback<
{ type: 'update'; settings: SettingsType },
{ settings: SettingsType; actor: AnyActorRef }
>(({ input, receive, system }) => {
// This assumes this actor is running in a system with a command palette
const commandBarActor = system.get(ACTOR_IDS.COMMAND_BAR)
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settings.commandBar.includeSettings.current === false) {
return
}
let commands: Command[] = []
const updateCommands = (newSettings: SettingsType) =>
settingsWithCommandConfigs(newSettings)
.map((type) =>
createSettingsCommand({
type,
actor: input.actor,
})
)
.filter((c) => c !== null)
if (commandBarActor === undefined) {
console.warn(
'Tried to register commands, but no command bar actor was found'
)
}
const addCommands = () =>
commandBarActor?.send({
type: 'Add commands',
data: { commands: commands },
})
const removeCommands = () =>
commandBarActor?.send({
type: 'Remove commands',
data: { commands: commands },
})
receive(({ type, settings: newSettings }) => {
if (type !== 'update') {
return
}
removeCommands()
commands =
newSettings.commandBar.includeSettings.current === false
? []
: updateCommands(newSettings)
addCommands()
})
commands = updateCommands(settings)
addCommands()
return () => {
removeCommands()
}
}),
},
actions: {
setEngineTheme: ({ context, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const engineCommandManager = rootContext.engineCommandManager
if (engineCommandManager && context.app.theme.current) {
engineCommandManager
.setTheme(context.app.theme.current)
.catch(reportRejection)
}
},
setClientTheme: ({ context, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const sceneInfra = rootContext.sceneInfra
const sceneEntitiesManager = rootContext.sceneEntitiesManager
if (!sceneInfra || !sceneEntitiesManager) {
return
}
const opposingTheme = getOppositeTheme(context.app.theme.current)
sceneInfra.theme = opposingTheme
sceneEntitiesManager.updateSegmentBaseColor(opposingTheme)
},
setAllowOrbitInSketchMode: ({ context, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const sceneInfra = rootContext.sceneInfra
if (!sceneInfra.camControls) {
return
}
sceneInfra.camControls._setting_allowOrbitInSketchMode =
context.app.allowOrbitInSketchMode.current
// ModelingMachineProvider will do a use effect to trigger the camera engine sync
},
toastSuccess: ({ event }) => {
if (!('data' in event)) {
return
}
const eventParts = event.type.replace(/^set./, '').split('.') as [
keyof typeof settings,
string,
]
const truncatedNewValue = event.data.value?.toString().slice(0, 28)
const message =
`Set ${decamelize(eventParts[1], { separator: ' ' })}` +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"${
event.data.level === 'project'
? ' for this project'
: ' as a user default'
}`
: '')
toast.success(message, {
duration: message.split(' ').length * 100 + 1500,
id: `${event.type}.success`,
})
},
'Execute AST': ({ context, event, self }) => {
const rootContext = self.system.get('root').getSnapshot().context
const kclManager = rootContext.kclManager
try {
const relevantSetting = (s: typeof settings) => {
return (
s.modeling?.defaultUnit?.current !==
context.modeling.defaultUnit.current ||
s.modeling.showScaleGrid.current !==
context.modeling.showScaleGrid.current ||
s.modeling?.highlightEdges.current !==
context.modeling.highlightEdges.current
)
}
const allSettingsIncludesUnitChange =
event.type === 'Set all settings' &&
relevantSetting(event.settings || context)
const resetSettingsIncludesUnitChange =
event.type === 'Reset settings' && relevantSetting(settings)
const shouldExecute =
kclManager !== undefined &&
(event.type === 'set.modeling.defaultUnit' ||
event.type === 'set.modeling.showScaleGrid' ||
event.type === 'set.modeling.highlightEdges' ||
allSettingsIncludesUnitChange ||
resetSettingsIncludesUnitChange)
if (shouldExecute) {
// Unit changes requires a re-exec of code
kclManager.executeCode().catch(reportRejection)
} else {
// For any future logging we'd like to do
// console.log(
// 'Not re-executing AST because the settings change did not affect the code interpretation'
// )
}
} catch (e) {
console.error('Error executing AST after settings change', e)
}
},
setThemeColor: ({ context }) => {
document.documentElement.style.setProperty(
`--primary-hue`,
context.app.themeColor.current
)
},
/**
* Update the --cursor-color CSS variable
* based on the setting textEditor.blinkingCursor.current
*/
setCursorColor: ({ context }) => {
document.documentElement.style.setProperty(
`--cursor-color`,
context.textEditor.blinkingCursor.current ? 'auto' : 'transparent'
)
},
/** Unload the project-level setting values from memory */
clearProjectSettings: assign(({ context }) => {
// Peel off all non-settings context
const { currentProject: _, ...settings } = context
const newSettings = clearSettingsAtLevel(settings, 'project')
return newSettings
}),
/** Unload the current project's info from memory */
clearCurrentProject: assign(({ context }) => {
return { ...context, currentProject: undefined }
}),
resetSettings: assign(({ context, event }) => {
if (!('level' in event)) return {}
console.log('Resetting settings at level', event.level)
// Create a new, blank payload
const newPayload =
event.level === 'user'
? configurationToSettingsPayload({})
: projectConfigurationToSettingsPayload({})
// Reset the settings at that level
const newSettings = setSettingsAtLevel(context, event.level, newPayload)
return newSettings
}),
setAllSettings: assign(({ event, context }) => {
if ('settings' in event) return event.settings
else if ('output' in event) return event.output || context
else return context
}),
setSettingAtLevel: assign(({ context, event }) => {
if (!('data' in event)) return {}
const { level, value } = event.data
const [category, setting] = event.type
.replace(/^set./, '')
.split('.') as [keyof typeof settings, string]
// @ts-ignore
context[category][setting][level] = value
const newContext = {
...context,
[category]: {
...context[category],
// @ts-ignore
[setting]: context[category][setting],
},
}
return newContext
}),
setThemeClass: ({ context }) => {
const currentTheme = context.app.theme.current ?? Themes.System
setThemeClass(
currentTheme === Themes.System ? getSystemTheme() : currentTheme
)
},
setEngineCameraProjection: ({ context, self }) => {
const newCurrentProjection = context.modeling.cameraProjection.current
const rootContext = self.system.get('root').getSnapshot().context
const sceneInfra = rootContext.sceneInfra
sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection)
},
sendThemeToWatcher: sendTo('watchSystemTheme', ({ context }) => ({
type: 'update.themeWatcher',
theme: context.app.theme.current,
})),
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IAzAA4x+AIyaAbJoCsAFl1njAJmOaANCACeiXQHZ1+a7bdWDATnUxawBfYIdUDB4CIlIKKjoGNAALMABbMABhRmJGDnEpJBBZeUVsZTUELR19IzMLUy97J0RfTXxDBr8DAxtdMSs-UPD0LFxoknJKNHxUxggwYh58eYAzakFiNABVbAV85WKFTCVCiqq9QxNzSxsm50qDU3wrMXV1V2M-bT8xV6GQCKjPCECZxaYJfDJNJgfaFQ6lcoabQXWrXBq3Bz3YzqPz4AyvL7qYw1TS6Az-QFREGxKY0ej4WBoDhgaipACSEwAsnMYZIDnIjidQGdkSS6jdbJjEK5zHi-PouqYiQZjBSRlSYpN4vTqMQcgB3ADyHBYCjZ2GQAGt0ABjJLc+awmQChGnJHVS7i9GS5oIQweVxueVWVz4mW6NWRMbUrXTWbzRa4fA21lgDjUAAKHEYACswDbSk6ii7jmU3ZVRZ60Y0pQg+rorPglQ2rKZ-FY3DLI0DxjSqPGFkskpgoElFqO0ABRaBwIvw0uIise1H1Gu+3Rk3R6Uydaz47qqsIA9XRzVkABKcHQAAIpj25yWhap3SirquMevAriTK4urYBhYViaN2GqghE166sQt4nngD4lAu5Zkni1yaKG2i6OoBgblYtY7sYniGNYmjqKYmjyropggaeoK0gOiZQAySSMPqyApqQADiHBEHBgplsKL5itWH73BYKr4OoLzmBhHahlRwJnjk1AQPgtDZnmBY8a6-EIKYVgePopEfKRmhAQ2xi1m8FyuCGnxmHhJFyb25AAFSaQh2nnIJ74+iJgZPK87ykmR-SmK4wFHpS0a0Gm8iMjw0FRngZAQMwYCENgABujDWvgkXAtFHCxUCCU9ggOBZSmhaSG5T4VKFuIkUEO7uPKfihaYtb6JZQR+J8Sq-iR4XDIlBAFUV8V3lEZBptmHAqcQAgrLkqS5TBo0xZgcW4CVURlZljCVaW+Q1Xxz46ciRhiFhBjvEEPmIKSVk2b1O4NA5EVrfgincLgWyUBwyWpelWU5XlBDfTwf1pntFUCEd1V8nCj6nWczyfPiYgfL4xhhZoqGdR8OhXT0xJiL1HxaI5X3sD9UBQwDM25PNi3LatI3U0pkP-TDB1w8wx2I868G1RomEEURGHWZjrydV0Hjym2bw3bY2LqFTEO4Fmub5mggPYGl5XZWlYMc7TWvqWgPOHfzCMFELvGLn035dGIPgYW1ui9bLv7PK8LxGGRFG6erNM8ObOvTRws3M2gS0cCtJsa1A4cFlbfPYALdvFsLKMaMS+BiKYfg2NigZk+85m+h2pg6L+MpthhKtEqER7YDy8CFGD-I54uAC06ie88ZL4i8FFfmSta92YBe-L8fhhaGLyYVTmrdw75ahbWqEGPgfjyj+PR762EYfezY2bcVk1jGvWlnWRTxB8YWEysY6O6Fve94vUB5fDXxIh5zX6-0b7uTOr3EMW4OzdH6K7JUZMJ7rh3I2X+PRpZvE+K4ABZs1I6xASLBAYhawdj6M8CSG4F64zVi3IAA */
initial: 'loadingUser',
context: ({ input }) => {
return {
...createSettings(),
...input,
}
},
invoke: [
{
src: 'watchSystemTheme',
id: 'watchSystemTheme',
},
{
src: 'registerCommands',
id: 'registerCommands',
// Peel off the non-settings context
input: ({ context: { currentProject, ...settings }, self }) => ({
settings,
actor: self,
}),
},
],
states: {
idle: {
entry: ['setThemeClass', 'sendThemeToWatcher'],
on: {
'*': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
enqueueActions(({ enqueue, check }) => {
if (
check(
({ event }) => event.type === 'set.textEditor.blinkingCursor'
)
) {
enqueue('setCursorColor')
}
}),
],
},
'set.app.namedViews': {
target: 'persisting settings',
actions: ['setSettingAtLevel'],
},
'set.app.onboardingStatus': {
target: 'persisting settings',
// No toast
actions: ['setSettingAtLevel'],
},
'set.app.themeColor': {
target: 'persisting settings',
// No toast
actions: ['setSettingAtLevel', 'setThemeColor'],
},
'set.commandBar.includeSettings': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
sendTo(
'registerCommands',
({ context: { currentProject: _, ...settings } }) => ({
type: 'update',
settings,
})
),
],
},
'set.modeling.defaultUnit': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
},
'set.app.theme': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setThemeClass',
'setEngineTheme',
'setClientTheme',
'sendThemeToWatcher',
],
},
'set.app.streamIdleMode': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess'],
},
'set.app.allowOrbitInSketchMode': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setAllowOrbitInSketchMode',
],
},
'set.modeling.cameraProjection': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setEngineCameraProjection',
],
},
'set.modeling.highlightEdges': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
},
'Reset settings': {
target: 'persisting settings',
actions: [
'resetSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'Execute AST',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
sendTo(
'registerCommands',
({ context: { currentProject: _, ...settings } }) => ({
type: 'update',
settings,
})
),
],
},
'Set all settings': {
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'Execute AST',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
sendTo(
'registerCommands',
({ context: { currentProject: _, ...settings } }) => ({
type: 'update',
settings,
})
),
],
},
'set.modeling.showScaleGrid': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
},
'load.project': {
target: 'loadingProject',
},
'clear.project': {
target: 'idle',
reenter: true,
actions: [
'clearProjectSettings',
'clearCurrentProject',
'setThemeColor',
sendTo(
'registerCommands',
({ context: { currentProject: _, ...settings } }) => ({
type: 'update',
settings,
})
),
],
},
},
},
'persisting settings': {
invoke: {
src: 'persistSettings',
onDone: {
target: 'idle',
},
onError: {
target: 'idle',
actions: () => {
console.error('Error persisting settings')
},
},
input: ({ context, event, self }) => {
if (
event.type === 'set.app.namedViews' &&
'toastCallback' in event.data
) {
return {
doNotPersist: event.doNotPersist ?? false,
context,
toastCallback: event.data.toastCallback,
rootContext: self.system.get('root').getSnapshot().context,
}
}
return {
doNotPersist: event.doNotPersist ?? false,
context,
rootContext: self.system.get('root').getSnapshot().context,
}
},
},
},
loadingUser: {
invoke: {
src: 'loadUserSettings',
onDone: {
target: 'idle',
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
sendTo(
'registerCommands',
({ context: { currentProject: _, ...settings } }) => ({
type: 'update',
settings,
})
),
],
},
onError: {
target: 'idle',
actions: ({ event }) => {
console.error('Error loading user settings', event)
},
},
},
},
loadingProject: {
entry: [
assign({
currentProject: ({ event }) =>
event.type === 'load.project' ? event.project : undefined,
}),
],
invoke: {
src: 'loadProjectSettings',
onDone: {
target: 'idle',
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'Execute AST',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
sendTo(
'registerCommands',
({ context: { currentProject: _, ...settings } }) => ({
type: 'update',
settings,
})
),
],
},
onError: 'idle',
input: ({ event }) => {
return {
project: event.type === 'load.project' ? event.project : undefined,
}
},
},
},
},
})