diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-win32.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-win32.png index e29c712a3..d2a7f5c4f 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-win32.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-win32.png differ diff --git a/package.json b/package.json index 1528e5290..763881845 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@ts-stack/markdown": "^1.5.0", "@tweenjs/tween.js": "^23.1.1", "@xstate/inspect": "^0.8.0", - "@xstate/react": "^3.2.2", + "@xstate/react": "^4.1.1", "bonjour-service": "^1.2.1", "codemirror": "^6.0.1", "decamelize": "^6.0.0", @@ -64,7 +64,7 @@ "vscode-languageserver-protocol": "^3.17.5", "vscode-uri": "^3.0.8", "web-vitals": "^3.5.2", - "xstate": "^4.38.2" + "xstate": "^5.17.4" }, "scripts": { "start": "vite", diff --git a/src/App.tsx b/src/App.tsx index 8be525016..ba0b73aad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,7 @@ export function App() { }) const newCmdId = uuidv4() - if (state.matches('idle.showPlanes')) return + if (state.matches({ idle: 'showPlanes' })) return if (context.store?.buttonDownInStream !== undefined) return debounceSocketSend({ type: 'modeling_cmd_req', diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx index 8f0c0fb91..416b6611d 100644 --- a/src/Toolbar.tsx +++ b/src/Toolbar.tsx @@ -70,12 +70,12 @@ export function Toolbar({ */ const configCallbackProps: ToolbarItemCallbackProps = useMemo( () => ({ - modelingStateMatches: state.matches, + modelingState: state, modelingSend: send, commandBarSend, sketchPathId, }), - [state.matches, send, commandBarSend, sketchPathId] + [state, send, commandBarSend, sketchPathId] ) /** diff --git a/src/clientSideScene/ClientSideSceneComp.tsx b/src/clientSideScene/ClientSideSceneComp.tsx index 6aa50db47..af9cbbd33 100644 --- a/src/clientSideScene/ClientSideSceneComp.tsx +++ b/src/clientSideScene/ClientSideSceneComp.tsx @@ -124,9 +124,9 @@ export const ClientSideScene = ({ } else if (context.mouseState.type === 'isDragging') { cursor = 'grabbing' } else if ( - state.matches('Sketch.Line tool') || - state.matches('Sketch.Tangential arc to') || - state.matches('Sketch.Rectangle tool') + state.matches({ Sketch: 'Line tool' }) || + state.matches({ Sketch: 'Tangential arc to' }) || + state.matches({ Sketch: 'Rectangle tool' }) ) { cursor = 'crosshair' } else { @@ -214,9 +214,9 @@ const Overlay = ({ overlay.visible && typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' && !( - state.matches('Sketch.Line tool') || - state.matches('Sketch.Tangential arc to') || - state.matches('Sketch.Rectangle tool') + state.matches({ Sketch: 'Line tool' }) || + state.matches({ Sketch: 'Tangential arc to' }) || + state.matches({ Sketch: 'Rectangle tool' }) ) return ( diff --git a/src/components/CommandBar/CommandBarProvider.tsx b/src/components/CommandBar/CommandBarProvider.tsx index ff9f7de1f..54be6f5bc 100644 --- a/src/components/CommandBar/CommandBarProvider.tsx +++ b/src/components/CommandBar/CommandBarProvider.tsx @@ -1,53 +1,43 @@ -import { useMachine } from '@xstate/react' +import { createActorContext } from '@xstate/react' import { editorManager } from 'lib/singletons' import { commandBarMachine } from 'machines/commandBarMachine' -import { createContext, useEffect } from 'react' -import { EventFrom, StateFrom } from 'xstate' +import { useEffect } from 'react' -type CommandsContextType = { - commandBarState: StateFrom - commandBarSend: (event: EventFrom) => void -} - -export const CommandsContext = createContext({ - commandBarState: commandBarMachine.initialState, - commandBarSend: () => {}, -}) - -export const CommandBarProvider = ({ - children, -}: { - children: React.ReactNode -}) => { - const [commandBarState, commandBarSend] = useMachine(commandBarMachine, { - devTools: true, +export const CommandsContext = createActorContext( + commandBarMachine.provide({ guards: { - 'Command has no arguments': (context, _event) => { + 'Command has no arguments': ({ context }) => { return ( !context.selectedCommand?.args || Object.keys(context.selectedCommand?.args).length === 0 ) }, - 'All arguments are skippable': (context, _event) => { + 'All arguments are skippable': ({ context }) => { return Object.values(context.selectedCommand!.args!).every( (argConfig) => argConfig.skip ) }, }, }) +) - useEffect(() => { - editorManager.setCommandBarSend(commandBarSend) - }) - +export const CommandBarProvider = ({ + children, +}: { + children: React.ReactNode +}) => { return ( - - {children} + + {children} ) } +function CommandBarProviderInner({ children }: { children: React.ReactNode }) { + const commandBarActor = CommandsContext.useActorRef() + + useEffect(() => { + editorManager.setCommandBarSend(commandBarActor.send) + }) + + return children +} diff --git a/src/components/CommandBar/CommandBarReview.tsx b/src/components/CommandBar/CommandBarReview.tsx index 280d1677a..d357a4d91 100644 --- a/src/components/CommandBar/CommandBarReview.tsx +++ b/src/components/CommandBar/CommandBarReview.tsx @@ -52,7 +52,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) { e.preventDefault() commandBarSend({ type: 'Submit command', - data: argumentsToSubmit, + output: argumentsToSubmit, }) } diff --git a/src/components/CommandBar/CommandBarSelectionInput.tsx b/src/components/CommandBar/CommandBarSelectionInput.tsx index 0144d26e3..e278e5a28 100644 --- a/src/components/CommandBar/CommandBarSelectionInput.tsx +++ b/src/components/CommandBar/CommandBarSelectionInput.tsx @@ -9,7 +9,7 @@ import { getSelectionTypeDisplayText, } from 'lib/selections' import { modelingMachine } from 'machines/modelingMachine' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { StateFrom } from 'xstate' const semanticEntityNames: { [key: string]: Array } = { @@ -48,15 +48,15 @@ function CommandBarSelectionInput({ const { commandBarState, commandBarSend } = useCommandsContext() const [hasSubmitted, setHasSubmitted] = useState(false) const selection = useSelector(arg.machineActor, selectionSelector) - const initSelectionsByType = useCallback(() => { + const selectionsByType = useMemo(() => { const selectionRangeEnd = selection.codeBasedSelections[0]?.range[1] return !selectionRangeEnd || selectionRangeEnd === code.length ? 'none' : getSelectionType(selection) }, [selection, code]) - const selectionsByType = initSelectionsByType() - const [canSubmitSelection, setCanSubmitSelection] = useState( - canSubmitSelectionArg(selectionsByType, arg) + const canSubmitSelection = useMemo( + () => canSubmitSelectionArg(selectionsByType, arg), + [selectionsByType] ) useEffect(() => { @@ -66,26 +66,18 @@ function CommandBarSelectionInput({ // Fast-forward through this arg if it's marked as skippable // and we have a valid selection already useEffect(() => { - console.log('selection input effect', { - selectionsByType, - canSubmitSelection, - arg, - }) - setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg)) const argValue = commandBarState.context.argumentsToSubmit[arg.name] if (canSubmitSelection && arg.skip && argValue === undefined) { - handleSubmit({ - preventDefault: () => {}, - } as React.FormEvent) + handleSubmit() } - }, [selectionsByType, arg]) + }, [canSubmitSelection]) function handleChange() { inputRef.current?.focus() } - function handleSubmit(e: React.FormEvent) { - e.preventDefault() + function handleSubmit(e?: React.FormEvent) { + e?.preventDefault() if (!canSubmitSelection) { setHasSubmitted(true) diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx index af45d52e5..4fcdf4444 100644 --- a/src/components/FileMachineProvider.tsx +++ b/src/components/FileMachineProvider.tsx @@ -5,13 +5,12 @@ import { PATHS } from 'lib/paths' import React, { createContext } from 'react' import { toast } from 'react-hot-toast' import { + Actor, AnyStateMachine, ContextFrom, - EventFrom, - InterpreterFrom, Prop, StateFrom, - assign, + fromPromise, } from 'xstate' import { useCommandsContext } from 'hooks/useCommandsContext' import { fileMachine } from 'machines/fileMachine' @@ -27,7 +26,7 @@ import { getNextDirName, getNextFileName } from 'lib/desktopFS' type MachineContext = { state: StateFrom context: ContextFrom - send: Prop, 'send'> + send: Prop, 'send'> } export const FileContext = createContext( @@ -43,239 +42,234 @@ export const FileMachineProvider = ({ const { commandBarSend } = useCommandsContext() const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData - const [state, send] = useMachine(fileMachine, { - context: { - project, - selectedDirectory: project, - }, - actions: { - navigateToFile: (context, event) => { - if (event.data && 'name' in event.data) { - commandBarSend({ type: 'Close' }) - navigate( - `..${PATHS.FILE}/${encodeURIComponent( - context.selectedDirectory + - window.electron.path.sep + - event.data.name - )}` + const [state, send] = useMachine( + fileMachine.provide({ + actions: { + renameToastSuccess: ({ event }) => { + if (event.type !== 'xstate.done.actor.rename-file') return + toast.success(event.output.message) + }, + createToastSuccess: ({ event }) => { + if (event.type !== 'xstate.done.actor.create-and-open-file') return + toast.success(event.output.message) + }, + toastSuccess: ({ event }) => { + if ( + event.type !== 'xstate.done.actor.rename-file' && + event.type !== 'xstate.done.actor.delete-file' ) - } else if ( - event.data && - 'path' in event.data && - event.data.path.endsWith(FILE_EXT) - ) { - // Don't navigate to newly created directories - navigate(`..${PATHS.FILE}/${encodeURIComponent(event.data.path)}`) - } + return + toast.success(event.output.message) + }, + toastError: ({ event }) => { + if (event.type !== 'xstate.done.actor.rename-file') return + toast.error(event.output.message) + }, + navigateToFile: ({ context, event }) => { + if (event.type !== 'xstate.done.actor.create-and-open-file') return + if (event.output && 'name' in event.output) { + commandBarSend({ type: 'Close' }) + navigate( + `..${PATHS.FILE}/${encodeURIComponent( + context.selectedDirectory + + window.electron.path.sep + + event.output.name + )}` + ) + } else if ( + event.output && + 'path' in event.output && + event.output.path.endsWith(FILE_EXT) + ) { + // Don't navigate to newly created directories + navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`) + } + }, }, - addFileToRenamingQueue: assign({ - itemsBeingRenamed: (context, event) => [ - ...context.itemsBeingRenamed, - event.data.path, - ], - }), - removeFileFromRenamingQueue: assign({ - itemsBeingRenamed: ( - context, - event: EventFrom - ) => - context.itemsBeingRenamed.filter( - (path) => path !== event.data.oldPath - ), - }), - renameToastSuccess: (_, event) => toast.success(event.data.message), - createToastSuccess: (_, event) => toast.success(event.data.message), - toastSuccess: (_, event) => - event.data && toast.success((event.data || '') + ''), - toastError: (_, event) => toast.error((event.data || '') + ''), - }, - services: { - readFiles: async (context: ContextFrom) => { - const newFiles = isDesktop() - ? (await getProjectInfo(context.project.path)).children - : [] - return { - ...context.project, - children: newFiles, - } - }, - createAndOpenFile: async (context, event) => { - let createdName = event.data.name.trim() || DEFAULT_FILE_NAME - let createdPath: string - - if (event.data.makeDir) { - let { name, path } = getNextDirName({ - entryName: createdName, - baseDir: context.selectedDirectory.path, - }) - createdName = name - createdPath = path - await window.electron.mkdir(createdPath) - } else { - const { name, path } = getNextFileName({ - entryName: createdName, - baseDir: context.selectedDirectory.path, - }) - createdName = name - createdPath = path - await window.electron.writeFile(createdPath, event.data.content ?? '') - } - - return { - message: `Successfully created "${createdName}"`, - path: createdPath, - } - }, - createFile: async (context, event) => { - let createdName = event.data.name.trim() || DEFAULT_FILE_NAME - let createdPath: string - - if (event.data.makeDir) { - let { name, path } = getNextDirName({ - entryName: createdName, - baseDir: context.selectedDirectory.path, - }) - createdName = name - createdPath = path - await window.electron.mkdir(createdPath) - } else { - const { name, path } = getNextFileName({ - entryName: createdName, - baseDir: context.selectedDirectory.path, - }) - createdName = name - createdPath = path - await window.electron.writeFile(createdPath, event.data.content ?? '') - } - - return { - path: createdPath, - } - }, - renameFile: async ( - context: ContextFrom, - event: EventFrom - ) => { - const { oldName, newName, isDir } = event.data - const name = newName - ? newName.endsWith(FILE_EXT) || isDir - ? newName - : newName + FILE_EXT - : DEFAULT_FILE_NAME - const oldPath = window.electron.path.join( - context.selectedDirectory.path, - oldName - ) - const newPath = window.electron.path.join( - context.selectedDirectory.path, - name - ) - - // no-op - if (oldPath === newPath) { + actors: { + readFiles: fromPromise(async ({ input }) => { + const newFiles = + (isDesktop() ? (await getProjectInfo(input.path)).children : []) ?? + [] return { - message: `Old is the same as new.`, + ...input, + children: newFiles, + } + }), + createAndOpenFile: fromPromise(async ({ input }) => { + let createdName = input.name.trim() || DEFAULT_FILE_NAME + let createdPath: string + + if (input.makeDir) { + let { name, path } = getNextDirName({ + entryName: createdName, + baseDir: input.selectedDirectory.path, + }) + createdName = name + createdPath = path + await window.electron.mkdir(createdPath) + } else { + const { name, path } = getNextFileName({ + entryName: createdName, + baseDir: input.selectedDirectory.path, + }) + createdName = name + createdPath = path + await window.electron.writeFile(createdPath, input.content ?? '') + } + + return { + message: `Successfully created "${createdName}"`, + path: createdPath, + } + }), + createFile: fromPromise(async ({ input }) => { + let createdName = input.name.trim() || DEFAULT_FILE_NAME + let createdPath: string + + if (input.makeDir) { + let { name, path } = getNextDirName({ + entryName: createdName, + baseDir: input.selectedDirectory.path, + }) + createdName = name + createdPath = path + await window.electron.mkdir(createdPath) + } else { + const { name, path } = getNextFileName({ + entryName: createdName, + baseDir: input.selectedDirectory.path, + }) + createdName = name + createdPath = path + await window.electron.writeFile(createdPath, input.content ?? '') + } + + return { + path: createdPath, + } + }), + renameFile: fromPromise(async ({ input }) => { + const { oldName, newName, isDir } = input + const name = newName + ? newName.endsWith(FILE_EXT) || isDir + ? newName + : newName + FILE_EXT + : DEFAULT_FILE_NAME + const oldPath = window.electron.path.join( + input.selectedDirectory.path, + oldName + ) + const newPath = window.electron.path.join( + input.selectedDirectory.path, + name + ) + + // no-op + if (oldPath === newPath) { + return { + message: `Old is the same as new.`, + newPath, + oldPath, + } + } + + // if there are any siblings with the same name, report error. + const entries = await window.electron.readdir( + window.electron.path.dirname(newPath) + ) + for (let entry of entries) { + if (entry === newName) { + return Promise.reject(new Error('Filename already exists.')) + } + } + + window.electron.rename(oldPath, newPath) + + if (!file) { + return Promise.reject(new Error('file is not defined')) + } + + if (oldPath === file.path && project?.path) { + // If we just renamed the current file, navigate to the new path + navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`) + } else if (file?.path.includes(oldPath)) { + // If we just renamed a directory that the current file is in, navigate to the new path + navigate( + `..${PATHS.FILE}/${encodeURIComponent( + file.path.replace(oldPath, newPath) + )}` + ) + } + + return { + message: `Successfully renamed "${oldName}" to "${name}"`, newPath, oldPath, } - } + }), + deleteFile: fromPromise(async ({ input }) => { + const isDir = !!input.children - // if there are any siblings with the same name, report error. - const entries = await window.electron.readdir( - window.electron.path.dirname(newPath) - ) - for (let entry of entries) { - if (entry === newName) { - return Promise.reject(new Error('Filename already exists.')) + if (isDir) { + await window.electron + .rm(input.path, { + recursive: true, + }) + .catch((e) => console.error('Error deleting directory', e)) + } else { + await window.electron + .rm(input.path) + .catch((e) => console.error('Error deleting file', e)) } - } - window.electron.rename(oldPath, newPath) + // If there are no more files at all in the project, create a main.kcl + // for when we navigate to the root. + if (!project?.path) { + return Promise.reject(new Error('Project path not set.')) + } - if (!file) { - return Promise.reject(new Error('file is not defined')) - } + const entries = await window.electron.readdir(project.path) + const hasKclEntries = + entries.filter((e: string) => e.endsWith('.kcl')).length !== 0 + if (!hasKclEntries) { + await window.electron.writeFile( + window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE), + '' + ) + // Refresh the route selected above because it's possible we're on + // the same path on the navigate, which doesn't cause anything to + // refresh, leaving a stale execution state. + navigate(0) + return { + message: 'No more files in project, created main.kcl', + } + } - if (oldPath === file.path && project?.path) { - // If we just renamed the current file, navigate to the new path - navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`) - } else if (file?.path.includes(oldPath)) { - // If we just renamed a directory that the current file is in, navigate to the new path - navigate( - `..${PATHS.FILE}/${encodeURIComponent( - file.path.replace(oldPath, newPath) - )}` - ) - } + // If we just deleted the current file or one of its parent directories, + // navigate to the project root + if ( + (input.path === file?.path || file?.path.includes(input.path)) && + project?.path + ) { + navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) + } - return { - message: `Successfully renamed "${oldName}" to "${name}"`, - newPath, - oldPath, - } + return { + message: `Successfully deleted ${isDir ? 'folder' : 'file'} "${ + input.name + }"`, + } + }), }, - deleteFile: async ( - context: ContextFrom, - event: EventFrom - ) => { - const isDir = !!event.data.children - - if (isDir) { - await window.electron - .rm(event.data.path, { - recursive: true, - }) - .catch((e) => console.error('Error deleting directory', e)) - } else { - await window.electron - .rm(event.data.path) - .catch((e) => console.error('Error deleting file', e)) - } - - // If there are no more files at all in the project, create a main.kcl - // for when we navigate to the root. - if (!project?.path) { - return Promise.reject(new Error('Project path not set.')) - } - - const entries = await window.electron.readdir(project.path) - const hasKclEntries = - entries.filter((e: string) => e.endsWith('.kcl')).length !== 0 - if (!hasKclEntries) { - await window.electron.writeFile( - window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE), - '' - ) - // Refresh the route selected above because it's possible we're on - // the same path on the navigate, which doesn't cause anything to - // refresh, leaving a stale execution state. - navigate(0) - return - } - - // If we just deleted the current file or one of its parent directories, - // navigate to the project root - if ( - (event.data.path === file?.path || - file?.path.includes(event.data.path)) && - project?.path - ) { - navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) - } - - return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ - event.data.name - }"` + }), + { + input: { + project, + selectedDirectory: project, }, - }, - guards: { - 'Has at least 1 file': (_, event: EventFrom) => { - if (event.type !== 'done.invoke.read-files') return false - return !!event?.data?.children && event.data.children.length > 0 - }, - 'Is not silent': (_, event) => !event.data?.silent, - }, - }) + } + ) return ( fileSend({ type: 'Set selected directory', - data: fileOrDir, + directory: fileOrDir, }) } onFocusCapture={(e) => fileSend({ type: 'Set selected directory', - data: fileOrDir, + directory: fileOrDir, }) } onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} @@ -296,13 +296,13 @@ const FileTreeItem = ({ onClickCapture={(e) => { fileSend({ type: 'Set selected directory', - data: fileOrDir, + directory: fileOrDir, }) }} onFocusCapture={(e) => fileSend({ type: 'Set selected directory', - data: fileOrDir, + directory: fileOrDir, }) } > @@ -482,7 +482,7 @@ export const FileTreeInner = ({ onClickCapture={(e) => { fileSend({ type: 'Set selected directory', - data: fileContext.project, + directory: fileContext.project, }) }} > diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 00c4091a1..2e3bc6032 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -1,15 +1,15 @@ import { useMachine } from '@xstate/react' import React, { createContext, useEffect, useMemo, useRef } from 'react' import { + Actor, AnyStateMachine, ContextFrom, - InterpreterFrom, Prop, StateFrom, assign, + fromPromise, } from 'xstate' import { - SetSelections, getPersistedContext, modelingMachine, modelingMachineDefaultContext, @@ -88,7 +88,7 @@ import { useFileContext } from 'hooks/useFileContext' type MachineContext = { state: StateFrom context: ContextFrom - send: Prop, 'send'> + send: Prop, 'send'> } export const ModelingMachineContext = createContext( @@ -134,15 +134,7 @@ export const ModelingMachineProvider = ({ // ) const [modelingState, modelingSend, modelingActor] = useMachine( - modelingMachine, - { - context: { - ...modelingMachineDefaultContext, - store: { - ...modelingMachineDefaultContext.store, - ...persistedContext, - }, - }, + modelingMachine.provide({ actions: { 'disable copilot': () => { editorManager.setCopilotEnabled(false) @@ -150,7 +142,7 @@ export const ModelingMachineProvider = ({ 'enable copilot': () => { editorManager.setCopilotEnabled(true) }, - 'sketch exit execute': ({ store }) => { + 'sketch exit execute': ({ context: { store } }) => { ;(async () => { sceneInfra.camControls.syncDirection = 'clientToEngine' @@ -169,9 +161,9 @@ export const ModelingMachineProvider = ({ }) })() }, - 'Set mouse state': assign({ - mouseState: (_, event) => event.data, - segmentHoverMap: ({ mouseState, segmentHoverMap }, event) => { + 'Set mouse state': assign(({ context, event }) => { + if (event.type !== 'Set mouse state') return {} + const nextSegmentHoverMap = () => { if (event.data.type === 'isHovering') { const parent = getParentGroup(event.data.on, [ STRAIGHT_SEGMENT, @@ -179,23 +171,25 @@ export const ModelingMachineProvider = ({ ]) const pathToNode = parent?.userData?.pathToNode const pathToNodeString = JSON.stringify(pathToNode) - if (!parent || !pathToNode) return segmentHoverMap - if (segmentHoverMap[pathToNodeString] !== undefined) - clearTimeout(segmentHoverMap[JSON.stringify(pathToNode)]) + if (!parent || !pathToNode) return context.segmentHoverMap + if (context.segmentHoverMap[pathToNodeString] !== undefined) + clearTimeout( + context.segmentHoverMap[JSON.stringify(pathToNode)] + ) return { - ...segmentHoverMap, + ...context.segmentHoverMap, [pathToNodeString]: 0, } } else if ( event.data.type === 'idle' && - mouseState.type === 'isHovering' + context.mouseState.type === 'isHovering' ) { - const mouseOnParent = getParentGroup(mouseState.on, [ + const mouseOnParent = getParentGroup(context.mouseState.on, [ STRAIGHT_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT, ]) if (!mouseOnParent || !mouseOnParent?.userData?.pathToNode) - return segmentHoverMap + return context.segmentHoverMap const pathToNodeString = JSON.stringify( mouseOnParent?.userData?.pathToNode ) @@ -209,157 +203,182 @@ export const ModelingMachineProvider = ({ }) }, 800) as unknown as number return { - ...segmentHoverMap, + ...context.segmentHoverMap, [pathToNodeString]: timeoutId, } } else if (event.data.type === 'timeoutEnd') { - const copy = { ...segmentHoverMap } + const copy = { ...context.segmentHoverMap } delete copy[event.data.pathToNodeString] return copy } return {} - }, + } + return { + mouseState: event.data, + segmentHoverMap: nextSegmentHoverMap(), + } }), 'Set Segment Overlays': assign({ - segmentOverlays: ({ segmentOverlays }, { data }) => { - if (data.type === 'set-many') return data.overlays - if (data.type === 'set-one') + segmentOverlays: ({ context: { segmentOverlays }, event }) => { + if (event.type !== 'Set Segment Overlays') return {} + if (event.data.type === 'set-many') return event.data.overlays + if (event.data.type === 'set-one') return { ...segmentOverlays, - [data.pathToNodeString]: data.seg, + [event.data.pathToNodeString]: event.data.seg, } - if (data.type === 'delete-one') { + if (event.data.type === 'delete-one') { const copy = { ...segmentOverlays } - delete copy[data.pathToNodeString] + delete copy[event.data.pathToNodeString] return copy } // data.type === 'clear' return {} }, }), - 'Set sketchDetails': assign(({ sketchDetails }, event) => - sketchDetails - ? { - sketchDetails: { - ...sketchDetails, - sketchPathToNode: event.data, - }, - } - : {} - ), - 'Set selection': assign(({ selectionRanges, sketchDetails }, event) => { - const setSelections = event.data as SetSelections // this was needed for ts after adding 'Set selection' action to on done modal events - const dispatchSelection = (selection?: EditorSelection) => { - if (!selection) return // TODO less of hack for the below please - if (!editorManager.editorView) return - setTimeout(() => { + 'Set sketchDetails': assign(({ context: { sketchDetails }, event }) => { + if (event.type !== 'Delete segment') return {} + if (!sketchDetails) return {} + return { + sketchDetails: { + ...sketchDetails, + sketchPathToNode: event.data, + }, + } + }), + 'Set selection': assign( + ({ context: { selectionRanges, sketchDetails }, event }) => { + // this was needed for ts after adding 'Set selection' action to on done modal events + const setSelections = + ('data' in event && + event.data && + 'selectionType' in event.data && + event.data) || + ('output' in event && + event.output && + 'selectionType' in event.output && + event.output) || + null + if (!setSelections) return {} + + const dispatchSelection = (selection?: EditorSelection) => { + if (!selection) return // TODO less of hack for the below please if (!editorManager.editorView) return - editorManager.editorView.dispatch({ - selection, - annotations: [ - modelingMachineEvent, - Transaction.addToHistory.of(false), - ], + setTimeout(() => { + if (!editorManager.editorView) return + editorManager.editorView.dispatch({ + selection, + annotations: [ + modelingMachineEvent, + Transaction.addToHistory.of(false), + ], + }) }) - }) - } - let selections: Selections = { - codeBasedSelections: [], - otherSelections: [], - } - if (setSelections.selectionType === 'singleCodeCursor') { - if (!setSelections.selection && editorManager.isShiftDown) { - } else if (!setSelections.selection && !editorManager.isShiftDown) { - selections = { - codeBasedSelections: [], - otherSelections: [], - } - } else if (setSelections.selection && !editorManager.isShiftDown) { - selections = { - codeBasedSelections: [setSelections.selection], - otherSelections: [], - } - } else if (setSelections.selection && editorManager.isShiftDown) { - selections = { - codeBasedSelections: [ - ...selectionRanges.codeBasedSelections, - setSelections.selection, - ], - otherSelections: selectionRanges.otherSelections, - } } - - const { - engineEvents, - codeMirrorSelection, - updateSceneObjectColors, - } = handleSelectionBatch({ - selections, - }) - codeMirrorSelection && dispatchSelection(codeMirrorSelection) - engineEvents && - engineEvents.forEach((event) => - engineCommandManager.sendSceneCommand(event) - ) - updateSceneObjectColors() - - return { - selectionRanges: selections, + let selections: Selections = { + codeBasedSelections: [], + otherSelections: [], } - } - - if (setSelections.selectionType === 'mirrorCodeMirrorSelections') { - return { - selectionRanges: setSelections.selection, - } - } - - if (setSelections.selectionType === 'otherSelection') { - if (editorManager.isShiftDown) { - selections = { - codeBasedSelections: selectionRanges.codeBasedSelections, - otherSelections: [setSelections.selection], + if (setSelections.selectionType === 'singleCodeCursor') { + if (!setSelections.selection && editorManager.isShiftDown) { + } else if ( + !setSelections.selection && + !editorManager.isShiftDown + ) { + selections = { + codeBasedSelections: [], + otherSelections: [], + } + } else if ( + setSelections.selection && + !editorManager.isShiftDown + ) { + selections = { + codeBasedSelections: [setSelections.selection], + otherSelections: [], + } + } else if (setSelections.selection && editorManager.isShiftDown) { + selections = { + codeBasedSelections: [ + ...selectionRanges.codeBasedSelections, + setSelections.selection, + ], + otherSelections: selectionRanges.otherSelections, + } } - } else { - selections = { - codeBasedSelections: [], - otherSelections: [setSelections.selection], - } - } - const { engineEvents, updateSceneObjectColors } = - handleSelectionBatch({ + + const { + engineEvents, + codeMirrorSelection, + updateSceneObjectColors, + } = handleSelectionBatch({ selections, }) - engineEvents && - engineEvents.forEach((event) => - engineCommandManager.sendSceneCommand(event) - ) - updateSceneObjectColors() - return { - selectionRanges: selections, + codeMirrorSelection && dispatchSelection(codeMirrorSelection) + engineEvents && + engineEvents.forEach((event) => + engineCommandManager.sendSceneCommand(event) + ) + updateSceneObjectColors() + + return { + selectionRanges: selections, + } } - } - if (setSelections.selectionType === 'completeSelection') { - editorManager.selectRange(setSelections.selection) - if (!sketchDetails) + + if (setSelections.selectionType === 'mirrorCodeMirrorSelections') { return { selectionRanges: setSelections.selection, } - return { - selectionRanges: setSelections.selection, - sketchDetails: { - ...sketchDetails, - sketchPathToNode: - setSelections.updatedPathToNode || - sketchDetails?.sketchPathToNode || - [], - }, } - } - return {} - }), - Make: async (_, event) => { + if (setSelections.selectionType === 'otherSelection') { + if (editorManager.isShiftDown) { + selections = { + codeBasedSelections: selectionRanges.codeBasedSelections, + otherSelections: [setSelections.selection], + } + } else { + selections = { + codeBasedSelections: [], + otherSelections: [setSelections.selection], + } + } + const { engineEvents, updateSceneObjectColors } = + handleSelectionBatch({ + selections, + }) + engineEvents && + engineEvents.forEach((event) => + engineCommandManager.sendSceneCommand(event) + ) + updateSceneObjectColors() + return { + selectionRanges: selections, + } + } + if (setSelections.selectionType === 'completeSelection') { + editorManager.selectRange(setSelections.selection) + if (!sketchDetails) + return { + selectionRanges: setSelections.selection, + } + return { + selectionRanges: setSelections.selection, + sketchDetails: { + ...sketchDetails, + sketchPathToNode: + setSelections.updatedPathToNode || + sketchDetails?.sketchPathToNode || + [], + }, + } + } + + return {} + } + ), + Make: async ({ event }) => { if (event.type !== 'Make') return // Check if we already have an export intent. if (engineCommandManager.exportIntent) { @@ -403,7 +422,7 @@ export const ModelingMachineProvider = ({ } ) }, - 'Engine export': async (_, event) => { + 'Engine export': async ({ event }) => { if (event.type !== 'Export') return if (engineCommandManager.exportIntent) { toast.error('Already exporting') @@ -466,8 +485,9 @@ export const ModelingMachineProvider = ({ } ) }, - 'Submit to Text-to-CAD API': async (_, { data }) => { - const trimmedPrompt = data.prompt.trim() + 'Submit to Text-to-CAD API': async ({ event }) => { + if (event.type !== 'Text-to-CAD') return + const trimmedPrompt = event.data.prompt.trim() if (!trimmedPrompt) return void submitAndAwaitTextToKcl({ @@ -485,7 +505,7 @@ export const ModelingMachineProvider = ({ }, }, guards: { - 'has valid extrude selection': ({ selectionRanges }) => { + 'has valid extrude selection': ({ context: { selectionRanges } }) => { // A user can begin extruding if they either have 1+ faces selected or nothing selected // TODO: I believe this guard only allows for extruding a single face at a time const isPipe = isSketchPipe(selectionRanges) @@ -505,19 +525,22 @@ export const ModelingMachineProvider = ({ return canExtrudeSelection(selectionRanges) }, - 'has valid selection for deletion': ({ selectionRanges }) => { + 'has valid selection for deletion': ({ + context: { selectionRanges }, + }) => { if (!commandBarState.matches('Closed')) return false if (selectionRanges.codeBasedSelections.length <= 0) return false return true }, - 'has valid fillet selection': ({ selectionRanges }) => + 'has valid fillet selection': ({ context: { selectionRanges } }) => hasValidFilletSelection({ selectionRanges, ast: kclManager.ast, code: codeManager.code, }), - 'Selection is on face': ({ selectionRanges }, { data }) => { - if (data?.forceNewSketch) return false + 'Selection is on face': ({ context: { selectionRanges }, event }) => { + if (event.type !== 'Enter sketch') return false + if (event.data?.forceNewSketch) return false if (!isSingleCursorInPipe(selectionRanges, kclManager.ast)) return false return !!isCursorInSketchCommandRange( @@ -543,30 +566,34 @@ export const ModelingMachineProvider = ({ } }, }, - services: { - 'AST-undo-startSketchOn': async ({ sketchDetails }) => { - if (!sketchDetails) return - if (kclManager.ast.body.length) { - // this assumes no changes have been made to the sketch besides what we did when entering the sketch - // i.e. doesn't account for user's adding code themselves, maybe we need store a flag userEditedSinceSketchMode? - const newAst = structuredClone(kclManager.ast) - const varDecIndex = sketchDetails.sketchPathToNode[1][0] - // remove body item at varDecIndex - newAst.body = newAst.body.filter((_, i) => i !== varDecIndex) - await kclManager.executeAstMock(newAst) + actors: { + 'AST-undo-startSketchOn': fromPromise( + async ({ input: { sketchDetails } }) => { + if (!sketchDetails) return + if (kclManager.ast.body.length) { + // this assumes no changes have been made to the sketch besides what we did when entering the sketch + // i.e. doesn't account for user's adding code themselves, maybe we need store a flag userEditedSinceSketchMode? + const newAst = structuredClone(kclManager.ast) + const varDecIndex = sketchDetails.sketchPathToNode[1][0] + // remove body item at varDecIndex + newAst.body = newAst.body.filter((_, i) => i !== varDecIndex) + await kclManager.executeAstMock(newAst) + } + sceneInfra.setCallbacks({ + onClick: () => {}, + onDrag: () => {}, + }) + return undefined } - sceneInfra.setCallbacks({ - onClick: () => {}, - onDrag: () => {}, - }) - }, - 'animate-to-face': async (_, { data }) => { - if (data.type === 'extrudeFace') { + ), + 'animate-to-face': fromPromise(async ({ input }) => { + if (!input) return undefined + if (input.type === 'extrudeFace') { const sketched = sketchOnExtrudedFace( kclManager.ast, - data.sketchPathToNode, - data.extrudePathToNode, - data.cap + input.sketchPathToNode, + input.extrudePathToNode, + input.cap ) if (trap(sketched)) return Promise.reject(sketched) const { modifiedAst, pathToNode: pathToNewSketchNode } = sketched @@ -575,361 +602,373 @@ export const ModelingMachineProvider = ({ await letEngineAnimateAndSyncCamAfter( engineCommandManager, - data.faceId + input.faceId ) sceneInfra.camControls.syncDirection = 'clientToEngine' return { sketchPathToNode: pathToNewSketchNode, - zAxis: data.zAxis, - yAxis: data.yAxis, - origin: data.position, + zAxis: input.zAxis, + yAxis: input.yAxis, + origin: input.position, } } const { modifiedAst, pathToNode } = startSketchOnDefault( kclManager.ast, - data.plane + input.plane ) await kclManager.updateAst(modifiedAst, false) sceneInfra.camControls.syncDirection = 'clientToEngine' await letEngineAnimateAndSyncCamAfter( engineCommandManager, - data.planeId + input.planeId ) return { sketchPathToNode: pathToNode, - zAxis: data.zAxis, - yAxis: data.yAxis, + zAxis: input.zAxis, + yAxis: input.yAxis, origin: [0, 0, 0], } - }, - 'animate-to-sketch': async ({ selectionRanges }) => { - const sourceRange = selectionRanges.codeBasedSelections[0].range - const sketchPathToNode = getNodePathFromSourceRange( - kclManager.ast, - sourceRange - ) - const info = await getSketchOrientationDetails(sketchPathToNode || []) - await letEngineAnimateAndSyncCamAfter( - engineCommandManager, - info?.sketchDetails?.faceId || '' - ) - return { - sketchPathToNode: sketchPathToNode || [], - zAxis: info.sketchDetails.zAxis || null, - yAxis: info.sketchDetails.yAxis || null, - origin: info.sketchDetails.origin.map( - (a) => a / sceneInfra._baseUnitMultiplier - ) as [number, number, number], + }), + 'animate-to-sketch': fromPromise( + async ({ input: { selectionRanges } }) => { + const sourceRange = selectionRanges.codeBasedSelections[0].range + const sketchPathToNode = getNodePathFromSourceRange( + kclManager.ast, + sourceRange + ) + const info = await getSketchOrientationDetails( + sketchPathToNode || [] + ) + await letEngineAnimateAndSyncCamAfter( + engineCommandManager, + info?.sketchDetails?.faceId || '' + ) + return { + sketchPathToNode: sketchPathToNode || [], + zAxis: info.sketchDetails.zAxis || null, + yAxis: info.sketchDetails.yAxis || null, + origin: info.sketchDetails.origin.map( + (a) => a / sceneInfra._baseUnitMultiplier + ) as [number, number, number], + } } - }, - 'Get horizontal info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const { modifiedAst, pathToNodeMap } = - await applyConstraintHorzVertDistance({ - constraint: 'setHorzDistance', - selectionRanges, - }) - const _modifiedAst = parse(recast(modifiedAst)) - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get vertical info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const { modifiedAst, pathToNodeMap } = - await applyConstraintHorzVertDistance({ - constraint: 'setVertDistance', - selectionRanges, - }) - const _modifiedAst = parse(recast(modifiedAst)) - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get angle info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const info = angleBetweenInfo({ - selectionRanges, - }) - if (err(info)) return Promise.reject(info) - const { modifiedAst, pathToNodeMap } = await (info.enabled - ? applyConstraintAngleBetween({ + ), + 'Get horizontal info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const { modifiedAst, pathToNodeMap } = + await applyConstraintHorzVertDistance({ + constraint: 'setHorzDistance', selectionRanges, }) - : applyConstraintAngleLength({ - selectionRanges, - angleOrLength: 'setAngle', - })) - const _modifiedAst = parse(recast(modifiedAst)) - if (err(_modifiedAst)) return Promise.reject(_modifiedAst) - - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get length info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const { modifiedAst, pathToNodeMap } = - await applyConstraintAngleLength({ selectionRanges }) - const _modifiedAst = parse(recast(modifiedAst)) - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get perpendicular distance info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect( - { - selectionRanges, - } - ) - const _modifiedAst = parse(recast(modifiedAst)) - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get ABS X info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const { modifiedAst, pathToNodeMap } = - await applyConstraintAbsDistance({ - constraint: 'xAbs', - selectionRanges, - }) - const _modifiedAst = parse(recast(modifiedAst)) - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get ABS Y info': async ({ - selectionRanges, - sketchDetails, - }): Promise => { - const { modifiedAst, pathToNodeMap } = - await applyConstraintAbsDistance({ - constraint: 'yAbs', - selectionRanges, - }) - const _modifiedAst = parse(recast(modifiedAst)) - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const updatedPathToNode = updatePathToNodeFromMap( - sketchDetails.sketchPathToNode, - pathToNodeMap - ) - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - updatedPathToNode, - _modifiedAst, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - pathToNodeMap, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode, - } - }, - 'Get convert to variable info': async ( - { sketchDetails, selectionRanges }, - { data } - ): Promise => { - if (!sketchDetails) - return Promise.reject(new Error('No sketch details')) - const { variableName } = await getVarNameModal({ - valueName: data.variableName || 'var', - }) - let parsed = parse(recast(kclManager.ast)) - if (trap(parsed)) return Promise.reject(parsed) - parsed = parsed as Program - - const { modifiedAst: _modifiedAst, pathToReplacedNode } = - moveValueIntoNewVariablePath( - parsed, - kclManager.programMemory, - data.pathToNode, - variableName + const _modifiedAst = parse(recast(modifiedAst)) + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap ) - parsed = parse(recast(_modifiedAst)) - if (trap(parsed)) return Promise.reject(parsed) - parsed = parsed as Program - if (!pathToReplacedNode) - return Promise.reject(new Error('No path to replaced node')) - - const updatedAst = await sceneEntitiesManager.updateAstAndRejigSketch( - pathToReplacedNode || [], - parsed, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - if (err(updatedAst)) return Promise.reject(updatedAst) - const selection = updateSelections( - { 0: pathToReplacedNode }, - selectionRanges, - updatedAst.newAst - ) - if (err(selection)) return Promise.reject(selection) - return { - selectionType: 'completeSelection', - selection, - updatedPathToNode: pathToReplacedNode, + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } } + ), + 'Get vertical info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const { modifiedAst, pathToNodeMap } = + await applyConstraintHorzVertDistance({ + constraint: 'setVertDistance', + selectionRanges, + }) + const _modifiedAst = parse(recast(modifiedAst)) + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap + ) + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } + } + ), + 'Get angle info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const info = angleBetweenInfo({ + selectionRanges, + }) + if (err(info)) return Promise.reject(info) + const { modifiedAst, pathToNodeMap } = await (info.enabled + ? applyConstraintAngleBetween({ + selectionRanges, + }) + : applyConstraintAngleLength({ + selectionRanges, + angleOrLength: 'setAngle', + })) + const _modifiedAst = parse(recast(modifiedAst)) + if (err(_modifiedAst)) return Promise.reject(_modifiedAst) + + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap + ) + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } + } + ), + 'Get length info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const { modifiedAst, pathToNodeMap } = + await applyConstraintAngleLength({ selectionRanges }) + const _modifiedAst = parse(recast(modifiedAst)) + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap + ) + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } + } + ), + 'Get perpendicular distance info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const { modifiedAst, pathToNodeMap } = + await applyConstraintIntersect({ + selectionRanges, + }) + const _modifiedAst = parse(recast(modifiedAst)) + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap + ) + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } + } + ), + 'Get ABS X info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const { modifiedAst, pathToNodeMap } = + await applyConstraintAbsDistance({ + constraint: 'xAbs', + selectionRanges, + }) + const _modifiedAst = parse(recast(modifiedAst)) + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap + ) + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } + } + ), + 'Get ABS Y info': fromPromise( + async ({ input: { selectionRanges, sketchDetails } }) => { + const { modifiedAst, pathToNodeMap } = + await applyConstraintAbsDistance({ + constraint: 'yAbs', + selectionRanges, + }) + const _modifiedAst = parse(recast(modifiedAst)) + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const updatedPathToNode = updatePathToNodeFromMap( + sketchDetails.sketchPathToNode, + pathToNodeMap + ) + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + updatedPathToNode, + _modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + pathToNodeMap, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode, + } + } + ), + 'Get convert to variable info': fromPromise( + async ({ input: { selectionRanges, sketchDetails, data } }) => { + if (!sketchDetails) + return Promise.reject(new Error('No sketch details')) + const { variableName } = await getVarNameModal({ + valueName: data?.variableName || 'var', + }) + let parsed = parse(recast(kclManager.ast)) + if (trap(parsed)) return Promise.reject(parsed) + parsed = parsed as Program + + const { modifiedAst: _modifiedAst, pathToReplacedNode } = + moveValueIntoNewVariablePath( + parsed, + kclManager.programMemory, + data?.pathToNode || [], + variableName + ) + parsed = parse(recast(_modifiedAst)) + if (trap(parsed)) return Promise.reject(parsed) + parsed = parsed as Program + if (!pathToReplacedNode) + return Promise.reject(new Error('No path to replaced node')) + + const updatedAst = + await sceneEntitiesManager.updateAstAndRejigSketch( + pathToReplacedNode || [], + parsed, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + if (err(updatedAst)) return Promise.reject(updatedAst) + const selection = updateSelections( + { 0: pathToReplacedNode }, + selectionRanges, + updatedAst.newAst + ) + if (err(selection)) return Promise.reject(selection) + return { + selectionType: 'completeSelection', + selection, + updatedPathToNode: pathToReplacedNode, + } + } + ), + }, + }), + { + input: { + ...modelingMachineDefaultContext, + store: { + ...modelingMachineDefaultContext.store, + ...persistedContext, }, }, - devTools: true, + // devTools: true, } ) @@ -965,8 +1004,8 @@ export const ModelingMachineProvider = ({ }, [modelingSend]) useEffect(() => { - editorManager.modelingEvent = modelingState.event - }, [modelingState.event]) + editorManager.modelingState = modelingState + }, [modelingState]) useEffect(() => { editorManager.selectionRanges = modelingState.context.selectionRanges diff --git a/src/components/Settings/SettingsFieldInput.tsx b/src/components/Settings/SettingsFieldInput.tsx index 2985263a9..8b1aa2638 100644 --- a/src/components/Settings/SettingsFieldInput.tsx +++ b/src/components/Settings/SettingsFieldInput.tsx @@ -8,7 +8,7 @@ import { } from 'lib/settings/settingsTypes' import { getSettingInputType } from 'lib/settings/settingsUtils' import { useMemo } from 'react' -import { Event } from 'xstate' +import { EventFrom } from 'xstate' interface SettingsFieldInputProps { // We don't need the fancy types here, @@ -59,7 +59,7 @@ export function SettingsFieldInput({ level: settingsLevel, value: newValue, }, - } as unknown as Event) + } as unknown as EventFrom) }} /> ) @@ -103,7 +103,7 @@ export function SettingsFieldInput({ level: settingsLevel, value: e.target.value, }, - } as unknown as Event) + } as unknown as EventFrom) } > {options && @@ -137,7 +137,7 @@ export function SettingsFieldInput({ level: settingsLevel, value: e.target.value, }, - } as unknown as Event) + } as unknown as EventFrom) } }} /> diff --git a/src/components/SettingsAuthProvider.tsx b/src/components/SettingsAuthProvider.tsx index 54ad27929..d7d7b7987 100644 --- a/src/components/SettingsAuthProvider.tsx +++ b/src/components/SettingsAuthProvider.tsx @@ -14,13 +14,7 @@ import { Themes, } from 'lib/theme' import decamelize from 'decamelize' -import { - AnyStateMachine, - ContextFrom, - InterpreterFrom, - Prop, - StateFrom, -} from 'xstate' +import { Actor, AnyStateMachine, ContextFrom, Prop, StateFrom } from 'xstate' import { isDesktop } from 'lib/isDesktop' import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig' import { kclManager, sceneInfra, engineCommandManager } from 'lib/singletons' @@ -39,7 +33,7 @@ import { saveSettings } from 'lib/settings/settingsUtils' type MachineContext = { state: StateFrom context: ContextFrom - send: Prop, 'send'> + send: Prop, 'send'> } type SettingsAuthContextType = { @@ -50,7 +44,7 @@ type SettingsAuthContextType = { // a little hacky for sure, open to changing it // this implies that we should only even have one instance of this provider mounted at any one time // but I think that's a safe assumption -let settingsStateRef: (typeof settingsMachine)['context'] | undefined +let settingsStateRef: ContextFrom | undefined export const getSettingsState = () => settingsStateRef export const SettingsAuthContext = createContext({} as SettingsAuthContextType) @@ -101,21 +95,19 @@ export const SettingsAuthProviderBase = ({ const { commandBarSend } = useCommandsContext() const [settingsState, settingsSend, settingsActor] = useMachine( - settingsMachine, - { - context: loadedSettings, + settingsMachine.provide({ actions: { //TODO: batch all these and if that's difficult to do from tsx, // make it easy to do - setClientSideSceneUnits: (context, event) => { + setClientSideSceneUnits: ({ context, event }) => { const newBaseUnit = event.type === 'set.modeling.defaultUnit' ? (event.data.value as BaseUnit) : context.modeling.defaultUnit.current sceneInfra.baseUnit = newBaseUnit }, - setEngineTheme: (context) => { + setEngineTheme: ({ context }) => { engineCommandManager.sendSceneCommand({ cmd_id: uuidv4(), type: 'modeling_cmd_req', @@ -135,16 +127,16 @@ export const SettingsAuthProviderBase = ({ }, }) }, - setEngineScaleGridVisibility: (context) => { + setEngineScaleGridVisibility: ({ context }) => { engineCommandManager.setScaleGridVisibility( context.modeling.showScaleGrid.current ) }, - setClientTheme: (context) => { + setClientTheme: ({ context }) => { const opposingTheme = getOppositeTheme(context.app.theme.current) sceneInfra.theme = opposingTheme }, - setEngineEdges: (context) => { + setEngineEdges: ({ context }) => { engineCommandManager.sendSceneCommand({ cmd_id: uuidv4(), type: 'modeling_cmd_req', @@ -154,7 +146,8 @@ export const SettingsAuthProviderBase = ({ }, }) }, - toastSuccess: (_, event) => { + toastSuccess: ({ event }) => { + if (!('data' in event)) return const eventParts = event.type.replace(/^set./, '').split('.') as [ keyof typeof settings, string @@ -176,7 +169,7 @@ export const SettingsAuthProviderBase = ({ id: `${event.type}.success`, }) }, - 'Execute AST': (context, event) => { + 'Execute AST': ({ context, event }) => { try { const allSettingsIncludesUnitChange = event.type === 'Set all settings' && @@ -204,12 +197,11 @@ export const SettingsAuthProviderBase = ({ console.error('Error executing AST after settings change', e) } }, - }, - services: { - 'Persist settings': (context) => + persistSettings: ({ context }) => saveSettings(context, loadedProject?.project?.path), }, - } + }), + { input: loadedSettings } ) settingsStateRef = settingsState.context @@ -292,19 +284,21 @@ export const SettingsAuthProviderBase = ({ }, [settingsState.context.textEditor.blinkingCursor.current]) // Auth machine setup - const [authState, authSend, authActor] = useMachine(authMachine, { - actions: { - goToSignInPage: () => { - navigate(PATHS.SIGN_IN) - logout() + const [authState, authSend, authActor] = useMachine( + authMachine.provide({ + actions: { + goToSignInPage: () => { + navigate(PATHS.SIGN_IN) + logout() + }, + goToIndexPage: () => { + if (location.pathname.includes(PATHS.SIGN_IN)) { + navigate(PATHS.INDEX) + } + }, }, - goToIndexPage: () => { - if (location.pathname.includes(PATHS.SIGN_IN)) { - navigate(PATHS.INDEX) - } - }, - }, - }) + }) + ) useStateMachineCommands({ machineId: 'auth', diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index eb712f1e9..db8d224ef 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -287,7 +287,7 @@ export const Stream = () => { }, }) if (state.matches('Sketch')) return - if (state.matches('idle.showPlanes')) return + if (state.matches({ idle: 'showPlanes' })) return if (!context.store?.didDragInStream && btnName(e).left) { sendSelectEventToEngine( diff --git a/src/components/ToastTextToCad.tsx b/src/components/ToastTextToCad.tsx index 6d5f46c66..d821b4905 100644 --- a/src/components/ToastTextToCad.tsx +++ b/src/components/ToastTextToCad.tsx @@ -26,7 +26,7 @@ import { sendTelemetry } from 'lib/textToCad' import { Themes } from 'lib/theme' import { ActionButton } from './ActionButton' import { commandBarMachine } from 'machines/commandBarMachine' -import { EventData, EventFrom } from 'xstate' +import { EventFrom } from 'xstate' import { fileMachine } from 'machines/fileMachine' const CANVAS_SIZE = 128 @@ -45,7 +45,7 @@ export function ToastTextToCadError({ prompt: string commandBarSend: ( event: EventFrom, - data?: EventData + data?: unknown ) => void }) { return ( @@ -112,7 +112,7 @@ export function ToastTextToCadSuccess({ token?: string fileMachineSend: ( event: EventFrom, - data?: EventData + data?: unknown ) => void settings: { theme: Themes diff --git a/src/components/UserSidebarMenu.tsx b/src/components/UserSidebarMenu.tsx index 42033a6f4..711cf8f9a 100644 --- a/src/components/UserSidebarMenu.tsx +++ b/src/components/UserSidebarMenu.tsx @@ -133,7 +133,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => { Element: 'button', 'data-testid': 'user-sidebar-sign-out', children: 'Sign out', - onClick: () => send('Log out'), + onClick: () => send({ type: 'Log out' }), className: '', // Just making TS's filter type coercion happy 😠 }, ].filter( diff --git a/src/editor/manager.ts b/src/editor/manager.ts index da04a2526..cd5a31e62 100644 --- a/src/editor/manager.ts +++ b/src/editor/manager.ts @@ -1,7 +1,7 @@ import { EditorView, ViewUpdate } from '@codemirror/view' import { EditorSelection, Annotation, Transaction } from '@codemirror/state' import { engineCommandManager } from 'lib/singletons' -import { ModelingMachineEvent } from 'machines/modelingMachine' +import { modelingMachine, ModelingMachineEvent } from 'machines/modelingMachine' import { Selections, processCodeMirrorRanges, Selection } from 'lib/selections' import { undo, redo } from '@codemirror/commands' import { CommandBarMachineEvent } from 'machines/commandBarMachine' @@ -11,6 +11,7 @@ import { forEachDiagnostic, setDiagnosticsEffect, } from '@codemirror/lint' +import { StateFrom } from 'xstate' const updateOutsideEditorAnnotation = Annotation.define() export const updateOutsideEditorEvent = updateOutsideEditorAnnotation.of(true) @@ -38,7 +39,7 @@ export default class EditorManager { private _lastEvent: { event: string; time: number } | null = null private _modelingSend: (eventInfo: ModelingMachineEvent) => void = () => {} - private _modelingEvent: ModelingMachineEvent | null = null + private _modelingState: StateFrom | null = null private _commandBarSend: (eventInfo: CommandBarMachineEvent) => void = () => {} @@ -80,8 +81,8 @@ export default class EditorManager { this._modelingSend = send } - set modelingEvent(event: ModelingMachineEvent) { - this._modelingEvent = event + set modelingState(state: StateFrom) { + this._modelingState = state } setCommandBarSend(send: (eventInfo: CommandBarMachineEvent) => void) { @@ -248,13 +249,11 @@ export default class EditorManager { return } - const ignoreEvents: ModelingMachineEvent['type'][] = ['change tool'] - - if (!this._modelingEvent) { + if (!this._modelingState) { return } - if (ignoreEvents.includes(this._modelingEvent.type)) { + if (this._modelingState.matches({ Sketch: 'Change Tool' })) { return } diff --git a/src/hooks/useCommandsContext.ts b/src/hooks/useCommandsContext.ts index 536d00085..e34e894ce 100644 --- a/src/hooks/useCommandsContext.ts +++ b/src/hooks/useCommandsContext.ts @@ -1,6 +1,10 @@ import { CommandsContext } from 'components/CommandBar/CommandBarProvider' -import { useContext } from 'react' export const useCommandsContext = () => { - return useContext(CommandsContext) + const commandBarActor = CommandsContext.useActorRef() + const commandBarState = CommandsContext.useSelector((state) => state) + return { + commandBarSend: commandBarActor.send, + commandBarState, + } } diff --git a/src/hooks/useRefreshSettings.ts b/src/hooks/useRefreshSettings.ts index 5caf44c04..6c1447b7b 100644 --- a/src/hooks/useRefreshSettings.ts +++ b/src/hooks/useRefreshSettings.ts @@ -23,7 +23,8 @@ export function useRefreshSettings(routeId: string = PATHS.INDEX) { } useEffect(() => { - ctx.settings.send('Set all settings', { + ctx.settings.send({ + type: 'Set all settings', settings: routeData, }) }, []) diff --git a/src/hooks/useStateMachineCommands.ts b/src/hooks/useStateMachineCommands.ts index 6eed4c647..14adeb640 100644 --- a/src/hooks/useStateMachineCommands.ts +++ b/src/hooks/useStateMachineCommands.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { AnyStateMachine, InterpreterFrom, StateFrom } from 'xstate' +import { AnyStateMachine, Actor, StateFrom } from 'xstate' import { createMachineCommand } from '../lib/createMachineCommand' import { useCommandsContext } from './useCommandsContext' import { modelingMachine } from 'machines/modelingMachine' @@ -15,6 +15,7 @@ import { useKclContext } from 'lang/KclProvider' import { useNetworkContext } from 'hooks/useNetworkContext' import { NetworkHealthState } from 'hooks/useNetworkStatus' import { useAppState } from 'AppState' +import { getActorNextEvents } from 'lib/utils' // This might not be necessary, AnyStateMachine from xstate is working export type AllMachines = @@ -30,7 +31,7 @@ interface UseStateMachineCommandsArgs< machineId: T['id'] state: StateFrom send: Function - actor: InterpreterFrom + actor: Actor commandBarConfig?: StateMachineCommandSetConfig allCommandsRequireNetwork?: boolean onCancel?: () => void @@ -59,7 +60,7 @@ export default function useStateMachineCommands< overallState !== NetworkHealthState.Weak) || isExecuting || !isStreamReady - const newCommands = state.nextEvents + const newCommands = getActorNextEvents(state) .filter((_) => !allCommandsRequireNetwork || !disableAllButtons) .filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) .flatMap((type) => diff --git a/src/lib/commandBarConfigs/settingsCommandConfig.ts b/src/lib/commandBarConfigs/settingsCommandConfig.ts index 2a6ea3708..cc691b439 100644 --- a/src/lib/commandBarConfigs/settingsCommandConfig.ts +++ b/src/lib/commandBarConfigs/settingsCommandConfig.ts @@ -10,7 +10,7 @@ import { } from 'lib/settings/settingsTypes' import { settingsMachine } from 'machines/settingsMachine' import { PathValue } from 'lib/types' -import { AnyStateMachine, ContextFrom, InterpreterFrom } from 'xstate' +import { Actor, AnyStateMachine, ContextFrom } from 'xstate' import { getPropertyByPath } from 'lib/objectPropertyByPath' import { buildCommandArgument } from 'lib/createMachineCommand' import decamelize from 'decamelize' @@ -28,7 +28,7 @@ export const settingsWithCommandConfigs = ( ) as SettingsPaths[] const levelArgConfig = ( - actor: InterpreterFrom, + actor: Actor, isProjectAvailable: boolean, hideOnLevel?: SettingsLevel ): CommandArgument => ({ @@ -55,7 +55,7 @@ interface CreateSettingsArgs { type: SettingsPaths send: Function context: ContextFrom - actor: InterpreterFrom + actor: Actor isProjectAvailable: boolean } @@ -132,7 +132,7 @@ export function createSettingsCommand({ if (data !== undefined && data !== null) { send({ type: `set.${type}`, data }) } else { - send(type) + send({ type }) } }, args: { diff --git a/src/lib/commandTypes.ts b/src/lib/commandTypes.ts index 9ef74c992..6643783a6 100644 --- a/src/lib/commandTypes.ts +++ b/src/lib/commandTypes.ts @@ -1,11 +1,6 @@ import { CustomIconName } from 'components/CustomIcon' import { AllMachines } from 'hooks/useStateMachineCommands' -import { - AnyStateMachine, - ContextFrom, - EventFrom, - InterpreterFrom, -} from 'xstate' +import { Actor, AnyStateMachine, ContextFrom, EventFrom } from 'xstate' import { Selection } from './selections' import { Identifier, Expr, VariableDeclaration } from 'lang/wasm' import { commandBarMachine } from 'machines/commandBarMachine' @@ -186,7 +181,7 @@ export type CommandArgument< machineContext?: ContextFrom ) => boolean) skip?: boolean - machineActor: InterpreterFrom + machineActor: Actor /** For showing a summary display of the current value, such as in * the command bar's header */ diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 4d9557c13..5201b714a 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -2,7 +2,7 @@ import { AnyStateMachine, ContextFrom, EventFrom, - InterpreterFrom, + Actor, StateFrom, } from 'xstate' import { isDesktop } from './isDesktop' @@ -23,7 +23,7 @@ interface CreateMachineCommandProps< groupId: T['id'] state: StateFrom send: Function - actor: InterpreterFrom + actor: Actor commandBarConfig?: StateMachineCommandSetConfig onCancel?: () => void } @@ -90,9 +90,9 @@ export function createMachineCommand< needsReview: commandConfig.needsReview || false, onSubmit: (data?: S[typeof type]) => { if (data !== undefined && data !== null) { - send(type, { data }) + send({ type, data }) } else { - send(type) + send({ type }) } }, } @@ -124,7 +124,7 @@ function buildCommandArguments< >( state: StateFrom, args: CommandConfig['args'], - machineActor: InterpreterFrom + machineActor: Actor ): NonNullable['args']> { const newArgs = {} as NonNullable['args']> @@ -143,7 +143,7 @@ export function buildCommandArgument< >( arg: CommandArgumentConfig, context: ContextFrom, - machineActor: InterpreterFrom + machineActor: Actor ): CommandArgument & { inputType: typeof arg.inputType } { const baseCommandArgument = { description: arg.description, diff --git a/src/lib/textToCad.ts b/src/lib/textToCad.ts index 4d061c936..3a93382f0 100644 --- a/src/lib/textToCad.ts +++ b/src/lib/textToCad.ts @@ -6,7 +6,7 @@ import { import { VITE_KC_API_BASE_URL } from 'env' import toast from 'react-hot-toast' import { FILE_EXT } from './constants' -import { ContextFrom, EventData, EventFrom } from 'xstate' +import { ContextFrom, EventFrom } from 'xstate' import { fileMachine } from 'machines/fileMachine' import { NavigateFunction } from 'react-router-dom' import crossPlatformFetch from './crossPlatformFetch' @@ -63,12 +63,12 @@ interface TextToKclProps { trimmedPrompt: string fileMachineSend: ( type: EventFrom, - data?: EventData + data?: unknown ) => unknown navigate: NavigateFunction commandBarSend: ( type: EventFrom, - data?: EventData + data?: unknown ) => unknown context: ContextFrom token?: string diff --git a/src/lib/toolbar.ts b/src/lib/toolbar.ts index 29b4e2c60..c791ffe1e 100644 --- a/src/lib/toolbar.ts +++ b/src/lib/toolbar.ts @@ -16,7 +16,7 @@ type ToolbarMode = { } export interface ToolbarItemCallbackProps { - modelingStateMatches: StateFrom['matches'] + modelingState: StateFrom modelingSend: (event: EventFrom) => void commandBarSend: (event: EventFrom) => void sketchPathId: string | false @@ -84,7 +84,7 @@ export const toolbarConfig: Record = { type: 'Find and select command', data: { name: 'Extrude', groupId: 'modeling' }, }), - disabled: (state) => !state.can('Extrude'), + disabled: (state) => !state.can({ type: 'Extrude' }), icon: 'extrude', status: 'available', title: 'Extrude', @@ -155,7 +155,7 @@ export const toolbarConfig: Record = { }), icon: 'fillet3d', status: DEV ? 'available' : 'kcl-only', - disabled: (state) => !state.can('Fillet'), + disabled: (state) => !state.can({ type: 'Fillet' }), title: 'Fillet', hotkey: 'F', description: 'Round the edges of a 3D solid.', @@ -272,7 +272,7 @@ export const toolbarConfig: Record = { }), disableHotkey: (state) => !( - state.matches('Sketch.SketchIdle') || + state.matches({ Sketch: 'SketchIdle' }) || state.matches('Sketch no face') ), icon: 'arrowLeft', @@ -286,33 +286,37 @@ export const toolbarConfig: Record = { 'break', { id: 'line', - onClick: ({ modelingStateMatches: matches, modelingSend }) => + onClick: ({ modelingState, modelingSend }) => modelingSend({ type: 'change tool', data: { - tool: !matches('Sketch.Line tool') ? 'line' : 'none', + tool: !modelingState.matches({ Sketch: 'Line tool' }) + ? 'line' + : 'none', }, }), icon: 'line', status: 'available', disabled: (state) => state.matches('Sketch no face') || - state.matches('Sketch.Rectangle tool.Awaiting second corner'), + state.matches({ + Sketch: { 'Rectangle tool': 'Awaiting second corner' }, + }), title: 'Line', hotkey: (state) => - state.matches('Sketch.Line tool') ? ['Esc', 'L'] : 'L', + state.matches({ Sketch: 'Line tool' }) ? ['Esc', 'L'] : 'L', description: 'Start drawing straight lines', links: [], - isActive: (state) => state.matches('Sketch.Line tool'), + isActive: (state) => state.matches({ Sketch: 'Line tool' }), }, [ { id: 'tangential-arc', - onClick: ({ modelingStateMatches, modelingSend }) => + onClick: ({ modelingState, modelingSend }) => modelingSend({ type: 'change tool', data: { - tool: !modelingStateMatches('Sketch.Tangential arc to') + tool: !modelingState.matches({ Sketch: 'Tangential arc to' }) ? 'tangentialArc' : 'none', }, @@ -321,13 +325,13 @@ export const toolbarConfig: Record = { status: 'available', disabled: (state) => !isEditingExistingSketch(state.context) && - !state.matches('Sketch.Tangential arc to'), + !state.matches({ Sketch: 'Tangential arc to' }), title: 'Tangential Arc', hotkey: (state) => - state.matches('Sketch.Tangential arc to') ? ['Esc', 'A'] : 'A', + state.matches({ Sketch: 'Tangential arc to' }) ? ['Esc', 'A'] : 'A', description: 'Start drawing an arc tangent to the current segment', links: [], - isActive: (state) => state.matches('Sketch.Tangential arc to'), + isActive: (state) => state.matches({ Sketch: 'Tangential arc to' }), }, { id: 'three-point-arc', @@ -388,11 +392,11 @@ export const toolbarConfig: Record = { [ { id: 'corner-rectangle', - onClick: ({ modelingStateMatches, modelingSend }) => + onClick: ({ modelingState, modelingSend }) => modelingSend({ type: 'change tool', data: { - tool: !modelingStateMatches('Sketch.Rectangle tool') + tool: !modelingState.matches({ Sketch: 'Rectangle tool' }) ? 'rectangle' : 'none', }, @@ -401,13 +405,13 @@ export const toolbarConfig: Record = { status: 'available', disabled: (state) => !canRectangleTool(state.context) && - !state.matches('Sketch.Rectangle tool'), + !state.matches({ Sketch: 'Rectangle tool' }), title: 'Corner rectangle', hotkey: (state) => - state.matches('Sketch.Rectangle tool') ? ['Esc', 'R'] : 'R', + state.matches({ Sketch: 'Rectangle tool' }) ? ['Esc', 'R'] : 'R', description: 'Start drawing a rectangle', links: [], - isActive: (state) => state.matches('Sketch.Rectangle tool'), + isActive: (state) => state.matches({ Sketch: 'Rectangle tool' }), }, { id: 'center-rectangle', @@ -456,9 +460,8 @@ export const toolbarConfig: Record = { id: 'constraint-length', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain length') && - state.can('Constrain length') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain length' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain length' }), @@ -473,9 +476,8 @@ export const toolbarConfig: Record = { id: 'constraint-angle', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain angle') && - state.can('Constrain angle') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain angle' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain angle' }), @@ -489,9 +491,8 @@ export const toolbarConfig: Record = { id: 'constraint-vertical', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Make segment vertical') && - state.can('Make segment vertical') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Make segment vertical' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Make segment vertical' }), @@ -506,9 +507,8 @@ export const toolbarConfig: Record = { id: 'constraint-horizontal', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Make segment horizontal') && - state.can('Make segment horizontal') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Make segment horizontal' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Make segment horizontal' }), @@ -523,9 +523,8 @@ export const toolbarConfig: Record = { id: 'constraint-parallel', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain parallel') && - state.can('Constrain parallel') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain parallel' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain parallel' }), @@ -539,9 +538,8 @@ export const toolbarConfig: Record = { id: 'constraint-equal-length', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain equal length') && - state.can('Constrain equal length') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain equal length' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain equal length' }), @@ -555,9 +553,8 @@ export const toolbarConfig: Record = { id: 'constraint-horizontal-distance', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain horizontal distance') && - state.can('Constrain horizontal distance') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain horizontal distance' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain horizontal distance' }), @@ -571,9 +568,8 @@ export const toolbarConfig: Record = { id: 'constraint-vertical-distance', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain vertical distance') && - state.can('Constrain vertical distance') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain vertical distance' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain vertical distance' }), @@ -587,9 +583,8 @@ export const toolbarConfig: Record = { id: 'constraint-absolute-x', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain ABS X') && - state.can('Constrain ABS X') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain ABS X' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain ABS X' }), @@ -603,9 +598,8 @@ export const toolbarConfig: Record = { id: 'constraint-absolute-y', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain ABS Y') && - state.can('Constrain ABS Y') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain ABS Y' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain ABS Y' }), @@ -619,9 +613,8 @@ export const toolbarConfig: Record = { id: 'constraint-perpendicular-distance', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain perpendicular distance') && - state.can('Constrain perpendicular distance') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain perpendicular distance' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain perpendicular distance' }), @@ -636,9 +629,8 @@ export const toolbarConfig: Record = { id: 'constraint-align-horizontal', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain horizontally align') && - state.can('Constrain horizontally align') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain horizontally align' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain horizontally align' }), @@ -652,9 +644,8 @@ export const toolbarConfig: Record = { id: 'constraint-align-vertical', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain vertically align') && - state.can('Constrain vertically align') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain vertically align' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain vertically align' }), @@ -668,9 +659,8 @@ export const toolbarConfig: Record = { id: 'snap-to-x', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain snap to X') && - state.can('Constrain snap to X') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain snap to X' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain snap to X' }), @@ -684,9 +674,8 @@ export const toolbarConfig: Record = { id: 'snap-to-y', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain snap to Y') && - state.can('Constrain snap to Y') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain snap to Y' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain snap to Y' }), @@ -700,9 +689,8 @@ export const toolbarConfig: Record = { id: 'constraint-remove', disabled: (state) => !( - state.matches('Sketch.SketchIdle') && - state.nextEvents.includes('Constrain remove constraints') && - state.can('Constrain remove constraints') + state.matches({ Sketch: 'SketchIdle' }) && + state.can({ type: 'Constrain remove constraints' }) ), onClick: ({ modelingSend }) => modelingSend({ type: 'Constrain remove constraints' }), diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 501c5b3f9..8ebc70c0c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,7 @@ import { SourceRange } from '../lang/wasm' import { v4 } from 'uuid' import { isDesktop } from './isDesktop' +import { AnyMachineSnapshot } from 'xstate' export const uuidv4 = v4 @@ -208,3 +209,7 @@ export function isReducedMotion(): boolean { export function XOR(bool1: boolean, bool2: boolean): boolean { return (bool1 || bool2) && !(bool1 && bool2) } + +export function getActorNextEvents(snapshot: AnyMachineSnapshot) { + return [...new Set([...snapshot._nodes.flatMap((sn) => sn.ownEvents)])] +} diff --git a/src/machines/authMachine.ts b/src/machines/authMachine.ts index cae5b4b54..c3770a569 100644 --- a/src/machines/authMachine.ts +++ b/src/machines/authMachine.ts @@ -1,4 +1,4 @@ -import { createMachine, assign } from 'xstate' +import { assign, setup, fromPromise } from 'xstate' import { Models } from '@kittycad/lib' import withBaseURL from '../lib/withBaseURL' import { isDesktop } from 'lib/isDesktop' @@ -55,79 +55,92 @@ const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || '' -export const authMachine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QEECuAXAFgOgMabFwGsBJAMwBkB7KGCEgOwGIIqGxsBLBgNyqI75CRALQAbGnRHcA2gAYAuolAAHKrE7pObZSAAeiAIwBmAEzYA7ABYAbAFZTcgBzGbN44adWANCACeiKbGdthypk4AnBFyVs6uQXYAvom+aFh4BMTk1LSQjExgAE6FVIXYKmIAhuhkpQC2GcLikpDSDPJKSCBqGlo6XQYIrk7YETYWctYRxmMWFk6+AUPj2I5OdjZyrnZOFmbJqRg4Ern0zDkABFQYHbo9mtoMuoOGFhHYxlZOhvbOsUGGRaIL4WbBONzWQxWYwWOx2H4HEBpY4tCAAeQwTEuskUd3UD36oEGIlMNlCuzk8Js0TcVisgP8iG2lmcGysb0mW3ByRSIAYVAgcF0yLxvUez0QIms5ImVJpNjpDKWxmw9PGdLh4Te00+iORjSylFRjFFBKeA0QThGQWcexMwWhniBCGiqrepisUVMdlszgieqO2BOdBNXXufXNRKMHtGVuphlJkXs4Wdriso2CCasdgipOidID6WDkAx6FNEYlCAT5jmcjrckMdj2b3GzpsjbBMVMWezDbGPMSQA */ - id: 'Auth', - initial: 'checkIfLoggedIn', - states: { - checkIfLoggedIn: { - id: 'check-if-logged-in', - invoke: { - src: 'getUser', - id: 'check-logged-in', - onDone: [ - { - target: 'loggedIn', - actions: assign((context, event) => ({ - user: event.data.user, - token: event.data.token || context.token, - })), - }, - ], - onError: [ - { - target: 'loggedOut', - actions: assign({ - user: () => undefined, - }), - }, - ], - }, - }, - loggedIn: { - entry: ['goToIndexPage'], - on: { - 'Log out': { - target: 'loggedOut', - actions: () => { - if (isDesktop()) writeTokenFile('') - }, +export const authMachine = setup({ + types: {} as { + context: UserContext + events: + | Events + | { + type: 'xstate.done.actor.check-logged-in' + output: { + user: Models['User_type'] + token: string + } + } + }, + actions: { + goToIndexPage: () => {}, + goToSignInPage: () => {}, + }, + actors: { + getUser: fromPromise(({ input }: { input: { token?: string } }) => + getUser(input) + ), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QEECuAXAFgOgMabFwGsBJAMwBkB7KGCEgOwGIIqGxsBLBgNyqI75CRALQAbGnRHcA2gAYAuolAAHKrE7pObZSAAeiAIwBmAEzYA7ABYAbAFZTcgBzGbN44adWANCACeiKbGdthypk4AnBFyVs6uQXYAvom+aFh4BMTk1LSQjExgAE6FVIXYKmIAhuhkpQC2GcLikpDSDPJKSCBqGlo6XQYIrk7YETYWctYRxmMWFk6+AUPj2I5OdjZyrnZOFmbJqRg4Ern0zDkABFQYHbo9mtoMuoOGFhHYxlZOhvbOsUGGRaIL4WbBONzWQxWYwWOx2H4HEBpY4tCAAeQwTEuskUd3UD36oEGIlMNlCuzk8Js0TcVisgP8iG2lmcGysb0mW3ByRSIAYVAgcF0yLxvUez0QIms5ImVJpNjpDKWxmw9PGdLh4Te00+iORjSylFRjFFBKeA0QThGQWcexMwWhniBCGiqrepisUVMdlszgieqO2BOdBNXXufXNRKMHtGVuphlJkXs4Wdriso2CCasdgipOidID6WDkAx6FNEYlCAT5jmcjrckMdj2b3GzpsjbBMVMWezDbGPMSQA */ + id: 'Auth', + initial: 'checkIfLoggedIn', + context: { + token: persistedToken, + }, + states: { + checkIfLoggedIn: { + id: 'check-if-logged-in', + invoke: { + src: 'getUser', + input: ({ context }) => ({ token: context.token }), + id: 'check-logged-in', + onDone: [ + { + target: 'loggedIn', + actions: assign(({ context, event }) => ({ + user: event.output.user, + token: event.output.token || context.token, + })), }, - }, - }, - loggedOut: { - entry: ['goToSignInPage'], - on: { - 'Log in': { - target: 'checkIfLoggedIn', + ], + onError: [ + { + target: 'loggedOut', actions: assign({ - token: (_, event) => { - const token = event.token || '' - return token - }, + user: () => undefined, }), }, + ], + }, + }, + loggedIn: { + entry: ['goToIndexPage'], + on: { + 'Log out': { + target: 'loggedOut', + actions: () => { + if (isDesktop()) writeTokenFile('') + }, }, }, }, - schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } }, - predictableActionArguments: true, - preserveActionOrder: true, - context: { - token: persistedToken, + loggedOut: { + entry: ['goToSignInPage'], + on: { + 'Log in': { + target: 'checkIfLoggedIn', + actions: assign({ + token: ({ event }) => { + const token = event.token || '' + return token + }, + }), + }, + }, }, }, - { - actions: {}, - services: { getUser }, - guards: {}, - delays: {}, - } -) + schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } }, +}) -async function getUser(context: UserContext) { - const token = await getAndSyncStoredToken(context) +async function getUser(input: { token?: string }) { + const token = await getAndSyncStoredToken(input) const url = withBaseURL('/user') const headers: { [key: string]: string } = { 'Content-Type': 'application/json', @@ -156,7 +169,7 @@ async function getUser(context: UserContext) { }) .then((res) => res.json()) .catch((err) => console.error('error from Browser getUser', err)) - : getUserDesktop(context.token ?? '', VITE_KC_API_BASE_URL) + : getUserDesktop(input.token ?? '', VITE_KC_API_BASE_URL) const user = await userPromise @@ -193,13 +206,15 @@ function getCookie(cname: string): string | null { return null } -async function getAndSyncStoredToken(context: UserContext): Promise { +async function getAndSyncStoredToken(input: { + token?: string +}): Promise { // dev mode if (VITE_KC_DEV_TOKEN) return VITE_KC_DEV_TOKEN const token = - context.token && context.token !== '' - ? context.token + input.token && input.token !== '' + ? input.token : getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || '' if (token) { // has just logged in, update storage diff --git a/src/machines/commandBarMachine.ts b/src/machines/commandBarMachine.ts index 0d89a9387..1c8347a2f 100644 --- a/src/machines/commandBarMachine.ts +++ b/src/machines/commandBarMachine.ts @@ -1,4 +1,4 @@ -import { assign, createMachine } from 'xstate' +import { assign, fromPromise, setup } from 'xstate' import { Command, CommandArgument, @@ -25,7 +25,7 @@ export type CommandBarMachineEvent = data: { command: Command; argDefaultValues?: { [x: string]: unknown } } } | { type: 'Deselect command' } - | { type: 'Submit command'; data: { [x: string]: unknown } } + | { type: 'Submit command'; output: { [x: string]: unknown } } | { type: 'Add argument' data: { argument: CommandArgumentWithName } @@ -48,12 +48,16 @@ export type CommandBarMachineEvent = } | { type: 'Submit argument'; data: { [x: string]: unknown } } | { - type: 'done.invoke.validateArguments' - data: { [x: string]: unknown } + type: 'xstate.done.actor.validateSingleArgument' + output: { [x: string]: unknown } } | { - type: 'error.platform.validateArguments' - data: { message: string; arg: CommandArgumentWithName } + type: 'xstate.done.actor.validateArguments' + output: { [x: string]: unknown } + } + | { + type: 'xstate.error.actor.validateArguments' + error: { message: string; arg: CommandArgumentWithName } } | { type: 'Find and select command' @@ -68,382 +72,199 @@ export type CommandBarMachineEvent = data: { [x: string]: CommandArgumentWithName } } -export const commandBarMachine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswZJbPltM0U3eXLZAsRFeQcrerUHRbTFvfkmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUilkskqdiUWjWCAcDC0kjUqnElkc6lkoK0JzOT0CnWo1zIAEEIARvn48LA-uwuIC4qAEqIHJjJPJcYtxFoYdJFFjVsZEG5pqCquJxJoGvJxOUCT82sSrj0AEpgdCoABuYC+yog9OYIyZsQmYmhWzMmTqLnU0kyikRGVRbj5lg2SmUam5StpFxIkgAymBXh8HlADQGyKHw58aecGZEzUDWYgchY7CisWoSlpQQ5EVDLJJxKkdIpyypUop-ed2kHCW0Xu9uD1kwDzcCEJCtlUNgWhZZ1OlnaKEBpZJItHVzDZ5xlpPWiZdDc8w22O7JwozosyLUj3KiZZY85YtMUNgx5Ii81JuS5xBtPVCCyuVR0AOJYbgACzAEhI3wUgoAAV3QQZuFgCg-1wGAvjAkgSCgkCSHAyCcG4TtUxZYRED2TQZGSOw80qaQ7ARCc9mmZYSjPPFpDSQUP0DSQf3-QDgNAiCoJggAROBNw+aMkxNf5cMPHJSjPWRdhvZ9LGcF0b0kVQ8ycDRNkvNRWMbdjfwAoCcCjHjMOgyRyQAdywGITPwB42DA7hYzAgAjdAeDQjCoJw-du3TJE6mnWwoT0NIUTUAs71sMtdGsRYCyUtI9OJDijO49DeKw2BJAANSwShOAgX9IzICB+DASQHh1VAAGsqp1Qrit-MBySy8y-LGPCElSeoy2lZJcwcNRfXHQpBWmWQsRsPYdDPZJUu-QyuPssy+Py5qSt4EyyEAkhUCDNhKF-AAzQ70EkJqiu2tqOt88S926w9UhhLlykWXFHXcTJixsd60hHGxNj2Jag01HVODAKzXI8rzE1+R6U38tN8IQJSuR0OZ0gvHQXHGxBPWmUdppUWwFV0MHJAhqGYcpAh1qwrqDx7ZFajUmVpulEbqlqREsfBTQ0jcPM5P5KmaehshNW1PVvOy7Cka7VHeoYDkyjSPQtAvPMqkRQU1BnX1+UydFkQvCWwEhqWAFEIC8xnFd3ZHntZuTDZG4ob1nGxPTUfXrBnS8nDmEpT2ObxTnXVUALeOrgPanycvKyrqpwWqGqurbWsThXjWd5WesQZYtmBkplCceplIncK0SrXYqnccxxcj5s2OQWP4-s3PzJgiqcCqmr6sa7P2x7vi6AcAvJJ7XEy0dUFMWsFxlnKfn+S5CL3E9Jiuapjv3i7qNx+T-bDskY6zourObpz+6cuZgK0Y5Ua0TqEvXDkJR-YnR0s1xjkIMnDln3uuVsHwOxT1NCjIuR5nCSBUExJi1huRqyooUTYpYnzeyUFkKsy4Tg4FQBAOAgg26Nmga7QKohshSBtNYXGDonQumtPYaQfsSjLBHDefepJICUJZtQ4oMx8wXj6jebGt4JwbC2OiVwig9hh2hLpVu0cOhxjbMBBGeABFPwSA3ERRMlhKWCn9WRVQzZQirMo0BAYNzxn4RJGBh5MRbGXsHawGQFBmLrpUasOQorOAjs0OxaUVrGVMvfaCuiVYEV2KWTYg1yxyA0i6ZYaIPSL3lOkS8wSo6hOWpxCJ8te6WRsnZKMjlnIxNgSNIiiTF6vVSdI9JM1taXlxLzTwqiClBnSqtSJScLIFVvjtKANSXquENqoJwtgyZzRFIUQ4MhqgNACdNTQCpLbWyshMnsjpSwjXRJYHecgcmImsLIz0odNAaGqPiHpDYY6HwTlE+ATiqHPwohYewWQshmGhHrCcJz5Bck5toZwGhMheC8EAA */ - predictableActionArguments: true, - tsTypes: {} as import('./commandBarMachine.typegen').Typegen0, - context: { - commands: [], - selectedCommand: undefined, - currentArgument: undefined, - selectionRanges: { - otherSelections: [], - codeBasedSelections: [], - }, - argumentsToSubmit: {}, - } as CommandBarContext, - id: 'Command Bar', - initial: 'Closed', - states: { - Closed: { - on: { - Open: { - target: 'Selecting command', - }, - - 'Find and select command': { - target: 'Command selected', - actions: [ - 'Find and select command', - 'Initialize arguments to submit', - ], - }, - - 'Add commands': { - target: 'Closed', - - actions: [ - assign({ - commands: (context, event) => - [...context.commands, ...event.data.commands].sort( - sortCommands - ), - }), - ], - - internal: true, - }, - - 'Remove commands': { - target: 'Closed', - - actions: [ - assign({ - commands: (context, event) => - context.commands.filter( - (c) => - !event.data.commands.some( - (c2) => c2.name === c.name && c2.groupId === c.groupId - ) - ), - }), - ], - - internal: true, - }, - }, - }, - - 'Selecting command': { - on: { - 'Select command': { - target: 'Command selected', - actions: ['Set selected command', 'Initialize arguments to submit'], - }, - }, - }, - - 'Command selected': { - always: [ - { - target: 'Closed', - cond: 'Command has no arguments', - actions: ['Execute command'], - }, - { - target: 'Checking Arguments', - cond: 'All arguments are skippable', - }, - { - target: 'Gathering arguments', - actions: ['Set current argument to first non-skippable'], - }, - ], - }, - - 'Gathering arguments': { - states: { - 'Awaiting input': { - on: { - 'Submit argument': { - target: 'Validating', - }, - }, - }, - - Validating: { - invoke: { - src: 'Validate argument', - id: 'validateArgument', - onDone: { - target: '#Command Bar.Checking Arguments', - actions: [ - assign({ - argumentsToSubmit: (context, event) => { - const [argName, argData] = Object.entries(event.data)[0] - const { currentArgument } = context - if (!currentArgument) return {} - return { - ...context.argumentsToSubmit, - [argName]: argData, - } - }, - }), - ], - }, - onError: [ - { - target: 'Awaiting input', - }, - ], - }, - }, - }, - - initial: 'Awaiting input', - - on: { - 'Change current argument': { - target: 'Gathering arguments', - internal: true, - actions: ['Set current argument'], - }, - - 'Deselect command': { - target: 'Selecting command', - actions: [ - assign({ - selectedCommand: (_c, _e) => undefined, - }), - ], - }, - }, - }, - - Review: { - entry: ['Clear current argument'], - on: { - 'Submit command': { - target: 'Closed', - actions: ['Execute command'], - }, - - 'Add argument': { - target: 'Gathering arguments', - actions: ['Set current argument'], - }, - - 'Remove argument': { - target: 'Review', - actions: ['Remove argument'], - }, - - 'Edit argument': { - target: 'Gathering arguments', - actions: ['Set current argument'], - }, - }, - }, - - 'Checking Arguments': { - invoke: { - src: 'Validate all arguments', - id: 'validateArguments', - onDone: [ - { - target: 'Review', - cond: 'Command needs review', - }, - { - target: 'Closed', - actions: 'Execute command', - }, - ], - onError: [ - { - target: 'Gathering arguments', - actions: ['Set current argument to first non-skippable'], - }, - ], - }, - }, - }, - on: { - Close: { - target: '.Closed', - }, - - Clear: { - target: '#Command Bar', - internal: true, - actions: ['Clear argument data'], - }, - }, - schema: { - events: {} as CommandBarMachineEvent, - }, - preserveActionOrder: true, +export const commandBarMachine = setup({ + types: { + context: {} as CommandBarContext, + events: {} as CommandBarMachineEvent, }, - { - actions: { - 'Execute command': (context, event) => { - const { selectedCommand } = context - if (!selectedCommand) return - if ( - (selectedCommand?.args && event.type === 'Submit command') || - event.type === 'done.invoke.validateArguments' - ) { - const resolvedArgs = {} as { [x: string]: unknown } - for (const [argName, argValue] of Object.entries( - getCommandArgumentKclValuesOnly(event.data) - )) { - resolvedArgs[argName] = - typeof argValue === 'function' ? argValue(context) : argValue - } - selectedCommand?.onSubmit(resolvedArgs) - } else { - selectedCommand?.onSubmit() + actions: { + enqueueValidArgsToSubmit: assign({ + argumentsToSubmit: ({ context, event }) => { + if (event.type !== 'xstate.done.actor.validateSingleArgument') return {} + const [argName, argData] = Object.entries(event.output)[0] + const { currentArgument } = context + if (!currentArgument) return {} + return { + ...context.argumentsToSubmit, + [argName]: argData, } }, - 'Set current argument to first non-skippable': assign({ - currentArgument: (context, event) => { - const { selectedCommand } = context - if (!(selectedCommand && selectedCommand.args)) return undefined - const rejectedArg = 'data' in event && event.data.arg + }), + 'Execute command': ({ context, event }) => { + const { selectedCommand } = context + if (!selectedCommand) return + if ( + (selectedCommand?.args && event.type === 'Submit command') || + event.type === 'xstate.done.actor.validateArguments' + ) { + const resolvedArgs = {} as { [x: string]: unknown } + for (const [argName, argValue] of Object.entries( + getCommandArgumentKclValuesOnly(event.output) + )) { + resolvedArgs[argName] = + typeof argValue === 'function' ? argValue(context) : argValue + } + selectedCommand?.onSubmit(resolvedArgs) + } else { + selectedCommand?.onSubmit() + } + }, + 'Set current argument to first non-skippable': assign({ + currentArgument: ({ context, event }) => { + const { selectedCommand } = context + if (!(selectedCommand && selectedCommand.args)) return undefined + const rejectedArg = + 'data' in event && 'arg' in event.data && event.data.arg - // Find the first argument that is not to be skipped: - // that is, the first argument that is not already in the argumentsToSubmit - // or that is not undefined, or that is not marked as "skippable". - // TODO validate the type of the existing arguments - let argIndex = 0 + // Find the first argument that is not to be skipped: + // that is, the first argument that is not already in the argumentsToSubmit + // or that is not undefined, or that is not marked as "skippable". + // TODO validate the type of the existing arguments + let argIndex = 0 - while (argIndex < Object.keys(selectedCommand.args).length) { - const [argName, argConfig] = Object.entries(selectedCommand.args)[ - argIndex - ] - const argIsRequired = - typeof argConfig.required === 'function' - ? argConfig.required(context) - : argConfig.required - const mustNotSkipArg = - argIsRequired && - (!context.argumentsToSubmit.hasOwnProperty(argName) || - context.argumentsToSubmit[argName] === undefined || - (rejectedArg && rejectedArg.name === argName)) + while (argIndex < Object.keys(selectedCommand.args).length) { + const [argName, argConfig] = Object.entries(selectedCommand.args)[ + argIndex + ] + const argIsRequired = + typeof argConfig.required === 'function' + ? argConfig.required(context) + : argConfig.required + const mustNotSkipArg = + argIsRequired && + (!context.argumentsToSubmit.hasOwnProperty(argName) || + context.argumentsToSubmit[argName] === undefined || + (rejectedArg && + typeof rejectedArg === 'object' && + 'name' in rejectedArg && + rejectedArg.name === argName)) - if ( - mustNotSkipArg === true || - argIndex + 1 === Object.keys(selectedCommand.args).length - ) { - // If we have reached the end of the arguments and none are skippable, - // return the last argument. - return { - ...selectedCommand.args[argName], - name: argName, - } + if ( + mustNotSkipArg === true || + argIndex + 1 === Object.keys(selectedCommand.args).length + ) { + // If we have reached the end of the arguments and none are skippable, + // return the last argument. + return { + ...selectedCommand.args[argName], + name: argName, } - argIndex++ } + argIndex++ + } - // TODO: use an XState service to continue onto review step - // if all arguments are skippable and contain values. - return undefined - }, - }), - 'Clear current argument': assign({ - currentArgument: undefined, - }), - 'Remove argument': assign({ - argumentsToSubmit: (context, event) => { - if (event.type !== 'Remove argument') return context.argumentsToSubmit - const argToRemove = Object.values(event.data)[0] - // Extract all but the argument to remove and return it - const { [argToRemove.name]: _, ...rest } = context.argumentsToSubmit - return rest - }, - }), - 'Set current argument': assign({ - currentArgument: (context, event) => { - switch (event.type) { - case 'Edit argument': - return event.data.arg - case 'Change current argument': - return Object.values(event.data)[0] - default: - return context.currentArgument - } - }, - }), - 'Clear argument data': assign({ - selectedCommand: undefined, - currentArgument: undefined, - argumentsToSubmit: {}, - }), - 'Set selected command': assign({ - selectedCommand: (c, e) => - e.type === 'Select command' ? e.data.command : c.selectedCommand, - }), - 'Find and select command': assign({ - selectedCommand: (c, e) => { - if (e.type !== 'Find and select command') return c.selectedCommand - const found = c.commands.find( - (cmd) => cmd.name === e.data.name && cmd.groupId === e.data.groupId - ) - - return !!found ? found : c.selectedCommand - }, - }), - 'Initialize arguments to submit': assign({ - argumentsToSubmit: (c, e) => { - const command = - 'command' in e.data ? e.data.command : c.selectedCommand - if (!command?.args) return {} - const args: { [x: string]: unknown } = {} - for (const [argName, arg] of Object.entries(command.args)) { - args[argName] = - e.data.argDefaultValues && argName in e.data.argDefaultValues - ? e.data.argDefaultValues[argName] - : arg.skip && 'defaultValue' in arg - ? arg.defaultValue - : undefined - } - return args - }, - }), - }, - guards: { - 'Command needs review': (context, _) => - context.selectedCommand?.needsReview || false, - }, - services: { - 'Validate argument': (context, event) => { - if (event.type !== 'Submit argument') return Promise.reject() - return new Promise((resolve, reject) => { - // TODO: figure out if we should validate argument data here or in the form itself, - // and if we should support people configuring a argument's validation function - - resolve(event.data) - }) + // TODO: use an XState service to continue onto review step + // if all arguments are skippable and contain values. + return undefined }, - 'Validate all arguments': (context, _) => { + }), + 'Clear current argument': assign({ + currentArgument: undefined, + }), + 'Remove argument': assign({ + argumentsToSubmit: ({ context, event }) => { + if (event.type !== 'Remove argument') return context.argumentsToSubmit + const argToRemove = Object.values(event.data)[0] + // Extract all but the argument to remove and return it + const { [argToRemove.name]: _, ...rest } = context.argumentsToSubmit + return rest + }, + }), + 'Set current argument': assign({ + currentArgument: ({ context, event }) => { + switch (event.type) { + case 'Edit argument': + return event.data.arg + case 'Change current argument': + return Object.values(event.data)[0] + default: + return context.currentArgument + } + }, + }), + 'Clear argument data': assign({ + selectedCommand: undefined, + currentArgument: undefined, + argumentsToSubmit: {}, + }), + 'Set selected command': assign({ + selectedCommand: ({ context, event }) => + event.type === 'Select command' + ? event.data.command + : context.selectedCommand, + }), + 'Find and select command': assign({ + selectedCommand: ({ context, event }) => { + if (event.type !== 'Find and select command') + return context.selectedCommand + const found = context.commands.find( + (cmd) => + cmd.name === event.data.name && cmd.groupId === event.data.groupId + ) + + return !!found ? found : context.selectedCommand + }, + }), + 'Initialize arguments to submit': assign({ + argumentsToSubmit: ({ context, event }) => { + if ( + event.type !== 'Select command' && + event.type !== 'Find and select command' + ) + return {} + const command = + 'data' in event && 'command' in event.data + ? event.data.command + : context.selectedCommand + if (!command?.args) return {} + const args: { [x: string]: unknown } = {} + for (const [argName, arg] of Object.entries(command.args)) { + args[argName] = + event.data.argDefaultValues && + argName in event.data.argDefaultValues + ? event.data.argDefaultValues[argName] + : arg.skip && 'defaultValue' in arg + ? arg.defaultValue + : undefined + } + return args + }, + }), + }, + guards: { + 'Command needs review': ({ context }) => + context.selectedCommand?.needsReview || false, + 'Command has no arguments': () => false, + 'All arguments are skippable': () => false, + }, + actors: { + 'Validate argument': fromPromise(({ input }) => { + return new Promise((resolve, reject) => { + // TODO: figure out if we should validate argument data here or in the form itself, + // and if we should support people configuring a argument's validation function + + resolve(input) + }) + }), + 'Validate all arguments': fromPromise( + ({ input }: { input: CommandBarContext }) => { return new Promise((resolve, reject) => { for (const [argName, argConfig] of Object.entries( - context.selectedCommand!.args! + input.selectedCommand!.args! )) { - let arg = context.argumentsToSubmit[argName] - let argValue = typeof arg === 'function' ? arg(context) : arg + let arg = input.argumentsToSubmit[argName] + let argValue = typeof arg === 'function' ? arg(input) : arg try { const isRequired = typeof argConfig.required === 'function' - ? argConfig.required(context) + ? argConfig.required(input) : argConfig.required const resolvedDefaultValue = 'defaultValue' in argConfig ? typeof argConfig.defaultValue === 'function' - ? argConfig.defaultValue(context) + ? argConfig.defaultValue(input) : argConfig.defaultValue : undefined @@ -461,7 +282,7 @@ export const commandBarMachine = createMachine( !( typeof argConfig.options === 'function' ? argConfig.options( - context, + input, argConfig.machineActor.getSnapshot().context ) : argConfig.options @@ -502,13 +323,214 @@ export const commandBarMachine = createMachine( } } - return resolve(context.argumentsToSubmit) + return resolve(input.argumentsToSubmit) }) + } + ), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswZJbPltM0U3eXLZAsRFeQcrerUHRbTFvfkmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUilkskqdiUWjWCAcDC0kjUqnElkc6lkoK0JzOT0CnWo1zIAEEIARvn48LA-uwuIC4qAEqIHJjJPJcYtxFoYdJFFjVsZEG5pqCquJxJoGvJxOUCT82sSrj0AEpgdCoABuYC+yog9OYIyZsQmYmhWzMmTqLnU0kyikRGVRbj5lg2SmUam5StpFxIkgAymBXh8HlADQGyKHw58aecGZEzUDWYgchY7CisWoSlpQQ5EVDLJJxKkdIpyypUop-ed2kHCW0Xu9uD1kwDzcCEJCtlUNgWhZZ1OlnaKEBpZJItHVzDZ5xlpPWiZdDc8w22O7JwozosyLUj3KiZZY85YtMUNgx5Ii81JuS5xBtPVCCyuVR0AOJYbgACzAEhI3wUgoAAV3QQZuFgCg-1wGAvjAkgSCgkCSHAyCcG4TtUxZYRED2TQZGSOw80qaQ7ARCc9mmZYSjPPFpDSQUP0DSQf3-QDgNAiCoJggAROBNw+aMkxNf5cMPHJSjPWRdhvZ9LGcF0b0kVQ8ycDRNkvNRWMbdjfwAoCcCjHjMOgyRyQAdywGITPwB42DA7hYzAgAjdAeDQjCoJw-du3TJE6mnWwoT0NIUTUAs71sMtdGsRYCyUtI9OJDijO49DeKw2BJAANSwShOAgX9IzICB+DASQHh1VAAGsqp1Qrit-MBySy8y-LGPCElSeoy2lZJcwcNRfXHQpBWmWQsRsPYdDPZJUu-QyuPssy+Py5qSt4EyyEAkhUCDNhKF-AAzQ70EkJqiu2tqOt88S926w9UhhLlykWXFHXcTJixsd60hHGxNj2Jag01HVODAKzXI8rzE1+R6U38tN8IQJSuR0OZ0gvHQXHGxBPWmUdppUWwFV0MHJAhqGYcpAh1qwrqDx7ZFajUmVpulEbqlqREsfBTQ0jcPM5P5KmaehshNW1PVvOy7Cka7VHeoYDkyjSPQtAvPMqkRQU1BnX1+UydFkQvCWwEhqWAFEIC8xnFd3ZHntZuTDZG4ob1nGxPTUfXrBnS8nDmEpT2ObxTnXVUALeOrgPanycvKyrqpwWqGqurbWsThXjWd5WesQZYtmBkplCceplIncK0SrXYqnccxxcj5s2OQWP4-s3PzJgiqcCqmr6sa7P2x7vi6AcAvJJ7XEy0dUFMWsFxlnKfn+S5CL3E9Jiuapjv3i7qNx+T-bDskY6zourObpz+6cuZgK0Y5Ua0TqEvXDkJR-YnR0s1xjkIMnDln3uuVsHwOxT1NCjIuR5nCSBUExJi1huRqyooUTYpYnzeyUFkKsy4Tg4FQBAOAgg26Nmga7QKohshSBtNYXGDonQumtPYaQfsSjLBHDefepJICUJZtQ4oMx8wXj6jebGt4JwbC2OiVwig9hh2hLpVu0cOhxjbMBBGeABFPwSA3ERRMlhKWCn9WRVQzZQirMo0BAYNzxn4RJGBh5MRbGXsHawGQFBmLrpUasOQorOAjs0OxaUVrGVMvfaCuiVYEV2KWTYg1yxyA0i6ZYaIPSL3lOkS8wSo6hOWpxCJ8te6WRsnZKMjlnIxNgSNIiiTF6vVSdI9JM1taXlxLzTwqiClBnSqtSJScLIFVvjtKANSXquENqoJwtgyZzRFIUQ4MhqgNACdNTQCpLbWyshMnsjpSwjXRJYHecgcmImsLIz0odNAaGqPiHpDYY6HwTlE+ATiqHPwohYewWQshmGhHrCcJz5Bck5toZwGhMheC8EAA */ + context: { + commands: [], + selectedCommand: undefined, + currentArgument: undefined, + selectionRanges: { + otherSelections: [], + codeBasedSelections: [], + }, + argumentsToSubmit: {}, + }, + id: 'Command Bar', + initial: 'Closed', + states: { + Closed: { + on: { + Open: { + target: 'Selecting command', + }, + + 'Find and select command': { + target: 'Command selected', + actions: [ + 'Find and select command', + 'Initialize arguments to submit', + ], + }, + + 'Add commands': { + target: 'Closed', + + actions: [ + assign({ + commands: ({ context, event }) => + [...context.commands, ...event.data.commands].sort( + sortCommands + ), + }), + ], + + reenter: false, + }, + + 'Remove commands': { + target: 'Closed', + + actions: [ + assign({ + commands: ({ context, event }) => + context.commands.filter( + (c) => + !event.data.commands.some( + (c2) => c2.name === c.name && c2.groupId === c.groupId + ) + ), + }), + ], + + reenter: false, + }, }, }, - delays: {}, - } -) + + 'Selecting command': { + on: { + 'Select command': { + target: 'Command selected', + actions: ['Set selected command', 'Initialize arguments to submit'], + }, + }, + }, + + 'Command selected': { + always: [ + { + target: 'Closed', + guard: 'Command has no arguments', + actions: ['Execute command'], + }, + { + target: 'Checking Arguments', + guard: 'All arguments are skippable', + }, + { + target: 'Gathering arguments', + actions: ['Set current argument to first non-skippable'], + }, + ], + }, + + 'Gathering arguments': { + states: { + 'Awaiting input': { + on: { + 'Submit argument': { + target: 'Validating', + }, + }, + }, + + Validating: { + invoke: { + src: 'Validate argument', + id: 'validateSingleArgument', + input: ({ event }) => { + if (event.type !== 'Submit argument') return {} + return event.data + }, + onDone: { + target: '#Command Bar.Checking Arguments', + actions: ['enqueueValidArgsToSubmit'], + }, + onError: [ + { + target: 'Awaiting input', + }, + ], + }, + }, + }, + + initial: 'Awaiting input', + + on: { + 'Change current argument': { + target: 'Gathering arguments', + internal: true, + actions: ['Set current argument'], + }, + + 'Deselect command': { + target: 'Selecting command', + actions: [ + assign({ + selectedCommand: (_c, _e) => undefined, + }), + ], + }, + }, + }, + + Review: { + entry: ['Clear current argument'], + on: { + 'Submit command': { + target: 'Closed', + actions: ['Execute command'], + }, + + 'Add argument': { + target: 'Gathering arguments', + actions: ['Set current argument'], + }, + + 'Remove argument': { + target: 'Review', + actions: ['Remove argument'], + }, + + 'Edit argument': { + target: 'Gathering arguments', + actions: ['Set current argument'], + }, + }, + }, + + 'Checking Arguments': { + invoke: { + src: 'Validate all arguments', + id: 'validateArguments', + input: ({ context }) => context, + onDone: [ + { + target: 'Review', + guard: 'Command needs review', + }, + { + target: 'Closed', + actions: 'Execute command', + }, + ], + onError: [ + { + target: 'Gathering arguments', + actions: ['Set current argument to first non-skippable'], + }, + ], + }, + }, + }, + on: { + Close: { + target: '.Closed', + }, + + Clear: { + target: '#Command Bar', + reenter: false, + actions: ['Clear argument data'], + }, + }, +}) function sortCommands(a: Command, b: Command) { if (b.groupId === 'auth' && !(a.groupId === 'auth')) return -2 diff --git a/src/machines/fileMachine.ts b/src/machines/fileMachine.ts index b3c114e29..4a7a58c14 100644 --- a/src/machines/fileMachine.ts +++ b/src/machines/fileMachine.ts @@ -1,233 +1,393 @@ -import { assign, createMachine } from 'xstate' +import { assign, fromPromise, setup } from 'xstate' import { Project, FileEntry } from 'lib/project' -export const fileMachine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QDECWAbMACAtgQwGMALVAOzAGI9ZZUpSBtABgF1FQAHAe1oBdUupdiAAeiACwAmADQgAnogAcANgCsAOnHiAjOICcAZh3K9TRQHYAvpdlpMuQiXIUASmABmAJzhFmbJCDcfAJCAWIIUrIKCHpq6nraipJJKorahqrWthjY+MRkYOoAEtRYpFxY7jmwFADC3ni82FWYfsJBqPyCwuG6hurKTAYG5mlSyeJRiHqS2prKg6Mj2pKz4lkgdrmOBcWlLXCuYKR4OM05bQEdXaGgvdpD6qPiioqqieJM2gaqUxELT3MQz0eleBlMhmUGy2Dny5D2sEq1TqDSaSNarHaPE6IR6iD6BgGQxGY1Wikm8gkQM0n2UknERm0qmUw2hOVhTkKJURBxq9TAjXOrW0-k42JueIQBKJw1GujJFOiqkkTE0X3M5mUuiYgxmbPseU5CPRhwAImBMGiDpcxcFumF8dptMp1GZRlqNYYdXo-ml1IlzMkHuZVAZJAY1PrtnCuftkQB5DjHE02wLi3EOqX6QmDWWkiZ-HSKf3gph6ZWqcwJIZRjm7bkmmoAZTAvCwsAtYAITQgWAgqG83a4njkqeuGbu+MkLPU7x+iiGszJfwj4ldTvB6UURh0klrht2-MaZCgWDwpF7XCTpBPJooEEEhTIADcuABrQoEVFgAC054gP5XscP7WpiVzpvak5SnO6hJD8RYfJ8ir4kw06zqoTDiMyTA4WGPz7js8JHvwpCnv+WBATepF3mAnieMO6gcOgjTuMOODqF+ApNH+F6AdeIEXGBto4pBoj4u8GjOiMqjiKMqhJFhfyVqqEbJIoCTkmk3wETG6huCcOC3gc96PuoL7voU3gGb+oGimmdq3GJCARuY8RMroEk6MMihKeWrpYepEZMKMSw6Ua+mnEZOQULR9GeIxzG8KxnjsVZpw2YJdnjqJ4QjK59KaoGLKhh6fyBpIsFgtqKjKuCYW7OalpRZgJnwuZH7qBAnbcbZWIOZKeXqAVyhFT8EbaOYK44f6kjlhYG6FVYNibOyB7wo1rbNZQsUMUxLFsZ13UZRiWUQY5uUakNskjdOY2lZSCAqhV24LlhHpMl89Xwm4eD9tRvKtU+pCvh1DQAbyY5nZKMwqZWwxqMorzltoZUrK6YbOlJoazIoX2FD9f2ngDD5tcDFnqGDAmYLADAin1InndMKrqD85jw8ySPvH8pgulqoYWEjc16HjekCoTjYxXRu2JclqVi1TcCQ-1mYwyzcMRhz6lcw9C56Cz4Yatd05ISLxFbYDZlkx1nGCgrSsM5KTJVgMMmjKkEYmAYfwrOkQ30i8WFSF8mTLTCa2FGb-3RTt8V7UlB02z1mX0xKmZMgu8R6C8YahqYwUow9TqBkNxXiLmUgGEyIsRYZUctSTQMg5ZxzpXbdPgcrUEuW57xYUyXkGD5D2Bhog9aKsLyzQywsbOUXXwAEYeEWAKcTk5P7KH8G+ujhuHDDJTJZ0t2QGsvxrlI2q85fiBhlgMZcQq8+iqDJ3OzAML2qCCqxDEkIsNryK+jMpSV1clIck3xB6ViLIWEwmhXiJF0EYIqptUS3nIpRLaQDHajAqvKCwqxEZTxkIXVChJNTqUDCkB4L9q4t1rkTHI2DMyRAeosIawxFxDESMoLCIsNokUYZgZhUF1IDGdK7LyWgX6TULqCIagYcKSHMAya6VdQ6rTPgTLaC9hKpygnSOY8FA7kj0J6WR0QISzn0J8IYN0tIi0TMcLBHcHZp1wf6cB5UiFZxIdEcEhJKyvQ9BqGSqCuIuL0WvXoHj8HeKSL472E0KrBRfrVRGL9cbWEsEAA */ - id: 'File machine', +type FileMachineContext = { + project: Project + selectedDirectory: FileEntry + itemsBeingRenamed: (FileEntry | string)[] +} - initial: 'Reading files', +type FileMachineEvents = + | { type: 'Open file'; data: { name: string } } + | { + type: 'Rename file' + data: { oldName: string; newName: string; isDir: boolean } + } + | { + type: 'Create file' + data: { + name: string + makeDir: boolean + content?: string + silent?: boolean + } + } + | { type: 'Delete file'; data: FileEntry } + | { type: 'Set selected directory'; directory: FileEntry } + | { type: 'navigate'; data: { name: string } } + | { + type: 'xstate.done.actor.read-files' + output: Project + } + | { + type: 'xstate.done.actor.rename-file' + output: { + message: string + oldPath: string + newPath: string + } + } + | { + type: 'xstate.done.actor.create-and-open-file' + output: { + message: string + path: string + } + } + | { + type: 'xstate.done.actor.create-file' + output: { + path: string + } + } + | { + type: 'xstate.done.actor.delete-file' + output: { + message: string + } + } + | { type: 'assign'; data: { [key: string]: any } } + | { type: 'Refresh' } - context: { - project: {} as Project, - selectedDirectory: {} as FileEntry, - itemsBeingRenamed: [] as string[], - }, - - on: { - assign: { - actions: assign((_, event) => ({ - ...event.data, - })), - target: '.Reading files', - }, - - Refresh: '.Reading files', - }, - states: { - 'Has no files': { - on: { - 'Create file': { - target: 'Creating and opening file', - }, - }, - }, - - 'Has files': { - on: { - 'Rename file': { - target: 'Renaming file', - }, - - 'Create file': [ - { - target: 'Creating and opening file', - cond: 'Is not silent', - }, - 'Creating file', - ], - - 'Delete file': { - target: 'Deleting file', - }, - - 'Open file': { - target: 'Opening file', - }, - - 'Set selected directory': { - target: 'Has files', - actions: ['setSelectedDirectory'], - }, - }, - }, - - 'Creating and opening file': { - invoke: { - id: 'create-and-open-file', - src: 'createAndOpenFile', - onDone: [ - { - target: 'Reading files', - actions: [ - 'createToastSuccess', - 'addFileToRenamingQueue', - 'navigateToFile', - ], - }, - ], - onError: [ - { - target: 'Reading files', - actions: ['toastError'], - }, - ], - }, - }, - - 'Renaming file': { - invoke: { - id: 'rename-file', - src: 'renameFile', - onDone: [ - { - target: '#File machine.Reading files', - actions: ['renameToastSuccess'], - cond: 'Name has been changed', - }, - 'Reading files', - ], - onError: [ - { - target: '#File machine.Reading files', - actions: ['toastError'], - }, - ], - }, - - exit: 'removeFileFromRenamingQueue', - }, - - 'Deleting file': { - invoke: { - id: 'delete-file', - src: 'deleteFile', - onDone: [ - { - actions: ['toastSuccess'], - target: '#File machine.Reading files', - }, - ], - onError: { - actions: ['toastError'], - target: '#File machine.Has files', - }, - }, - }, - - 'Reading files': { - invoke: { - id: 'read-files', - src: 'readFiles', - onDone: [ - { - cond: 'Has at least 1 file', - target: 'Has files', - actions: ['setFiles'], - }, - { - target: 'Has no files', - actions: ['setFiles'], - }, - ], - onError: [ - { - target: 'Has no files', - actions: ['toastError'], - }, - ], - }, - }, - - 'Opening file': { - entry: ['navigateToFile'], - }, - - 'Creating file': { - invoke: { - src: 'createFile', - id: 'create-file', - onDone: 'Reading files', - onError: 'Reading files', - }, - }, - }, - - schema: { - events: {} as - | { type: 'Open file'; data: { name: string } } - | { - type: 'Rename file' - data: { oldName: string; newName: string; isDir: boolean } - } - | { - type: 'Create file' - data: { - name: string - makeDir: boolean - content?: string - silent?: boolean - } - } - | { type: 'Delete file'; data: FileEntry } - | { type: 'Set selected directory'; data: FileEntry } - | { type: 'navigate'; data: { name: string } } - | { - type: 'done.invoke.read-files' - data: Project - } - | { - type: 'done.invoke.rename-file' - data: { - message: string - oldPath: string - newPath: string - } - } - | { - type: 'done.invoke.create-and-open-file' - data: { - message: string - path: string - } - } - | { - type: 'done.invoke.create-file' - data: { - path: string - } - } - | { type: 'assign'; data: { [key: string]: any } } - | { type: 'Refresh' }, - }, - - predictableActionArguments: true, - preserveActionOrder: true, - tsTypes: {} as import('./fileMachine.typegen').Typegen0, +export const fileMachine = setup({ + types: {} as { + context: FileMachineContext + events: FileMachineEvents + input: Partial> }, - { - actions: { - setFiles: assign((_, event) => { - return { project: event.data } - }), - setSelectedDirectory: assign((_, event) => { - return { selectedDirectory: event.data } - }), + actions: { + setFiles: assign(({ event }) => { + if (event.type !== 'xstate.done.actor.read-files') return {} + return { project: event.output } + }), + setSelectedDirectory: assign(({ event }) => { + if (event.type !== 'Set selected directory') return {} + return { selectedDirectory: event.directory } + }), + addFileToRenamingQueue: assign({ + itemsBeingRenamed: ({ context, event }) => { + if (event.type !== 'xstate.done.actor.create-and-open-file') + return context.itemsBeingRenamed + return [...context.itemsBeingRenamed, event.output.path] + }, + }), + removeFileFromRenamingQueue: assign({ + itemsBeingRenamed: ({ context, event }) => { + if (event.type !== 'xstate.done.actor.rename-file') + return context.itemsBeingRenamed + return context.itemsBeingRenamed.filter( + (path) => path !== event.output.oldPath + ) + }, + }), + navigateToFile: () => {}, + renameToastSuccess: () => {}, + createToastSuccess: () => {}, + toastSuccess: () => {}, + toastError: () => {}, + }, + guards: { + 'Name has been changed': ({ event }) => { + if (event.type !== 'xstate.done.actor.rename-file') return false + return event.output.newPath !== event.output.oldPath }, - guards: { - 'Name has been changed': (_, event) => { - return event.data.newPath !== event.data.oldPath + 'Has at least 1 file': ({ event }) => { + if (event.type !== 'xstate.done.actor.read-files') return false + return !!event?.output?.children && event.output.children.length > 0 + }, + 'Is not silent': ({ event }) => + event.type === 'Create file' ? !event.data.silent : false, + }, + actors: { + readFiles: fromPromise(({ input }: { input: Project }) => + Promise.resolve(input) + ), + createAndOpenFile: fromPromise( + (_: { + input: { + name: string + makeDir: boolean + selectedDirectory: FileEntry + content: string + } + }) => Promise.resolve({ message: '', path: '' }) + ), + renameFile: fromPromise( + (_: { + input: { + oldName: string + newName: string + isDir: boolean + selectedDirectory: FileEntry + } + }) => Promise.resolve({ message: '', newPath: '', oldPath: '' }) + ), + deleteFile: fromPromise( + (_: { + input: { path: string; children: FileEntry[] | null; name: string } + }) => Promise.resolve({ message: '' } as { message: string } | undefined) + ), + createFile: fromPromise( + (_: { + input: { + name: string + makeDir: boolean + selectedDirectory: FileEntry + content: string + } + }) => Promise.resolve({ path: '' }) + ), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QDECWAbMACAtgQwGMALVAOzAGI9ZZUpSBtABgF1FQAHAe1oBdUupdiAAeiACwAmADQgAnogAcANgCsAOnHiAjOICcAZh3K9TRQHYAvpdlpMuQiXIUASmABmAJzhFmbJCDcfAJCAWIIUrIKCHpq6nraipJJKorahqrWthjY+MRkYOoAEtRYpFxY7jmwFADC3ni82FWYfsJBqPyCwuG6hurKTAYG5mlSyeJRiHqS2prKg6Mj2pKz4lkgdrmOBcWlLXCuYKR4OM05bQEdXaGgvdpD6qPiioqqieJM2gaqUxELT3MQz0eleBlMhmUGy2Dny5D2sEq1TqDSaSNarHaPE6IR6iD6BgGQxGY1Wikm8gkQM0n2UknERm0qmUw2hOVhTkKJURBxq9TAjXOrW0-k42JueIQBKJw1GujJFOiqkkTE0X3M5mUuiYgxmbPseU5CPRhwAImBMGiDpcxcFumF8dptMp1GZRlqNYYdXo-ml1IlzMkHuZVAZJAY1PrtnCuftkQB5DjHE02wLi3EOqX6QmDWWkiZ-HSKf3gph6ZWqcwJIZRjm7bkmmoAZTAvCwsAtYAITQgWAgqG83a4njkqeuGbu+MkLPU7x+iiGszJfwj4ldTvB6UURh0klrht2-MaZCgWDwpF7XCTpBPJooEEEhTIADcuABrQoEVFgAC054gP5XscP7WpiVzpvak5SnO6hJD8RYfJ8ir4kw06zqoTDiMyTA4WGPz7js8JHvwpCnv+WBATepF3mAnieMO6gcOgjTuMOODqF+ApNH+F6AdeIEXGBto4pBoj4u8GjOiMqjiKMqhJFhfyVqqEbJIoCTkmk3wETG6huCcOC3gc96PuoL7voU3gGb+oGimmdq3GJCARuY8RMroEk6MMihKeWrpYepEZMKMSw6Ua+mnEZOQULR9GeIxzG8KxnjsVZpw2YJdnjqJ4QjK59KaoGLKhh6fyBpIsFgtqKjKuCYW7OalpRZgJnwuZH7qBAnbcbZWIOZKeXqAVyhFT8EbaOYK44f6kjlhYG6FVYNibOyB7wo1rbNZQsUMUxLFsZ13UZRiWUQY5uUakNskjdOY2lZSCAqhV24LlhHpMl89Xwm4eD9tRvKtU+pCvh1DQAbyY5nZKMwqZWwxqMorzltoZUrK6YbOlJoazIoX2FD9f2ngDD5tcDFnqGDAmYLADAin1InndMKrqD85jw8ySPvH8pgulqoYWEjc16HjekCoTjYxXRu2JclqVi1TcCQ-1mYwyzcMRhz6lcw9C56Cz4Yatd05ISLxFbYDZlkx1nGCgrSsM5KTJVgMMmjKkEYmAYfwrOkQ30i8WFSF8mTLTCa2FGb-3RTt8V7UlB02z1mX0xKmZMgu8R6C8YahqYwUow9TqBkNxXiLmUgGEyIsRYZUctSTQMg5ZxzpXbdPgcrUEuW57xYUyXkGD5D2Bhog9aKsLyzQywsbOUXXwAEYeEWAKcTk5P7KH8G+ujhuHDDJTJZ0t2QGsvxrlI2q85fiBhlgMZcQq8+iqDJ3OzAML2qCCqxDEkIsNryK+jMpSV1clIck3xB6ViLIWEwmhXiJF0EYIqptUS3nIpRLaQDHajAqvKCwqxEZTxkIXVChJNTqUDCkB4L9q4t1rkTHI2DMyRAeosIawxFxDESMoLCIsNokUYZgZhUF1IDGdK7LyWgX6TULqCIagYcKSHMAya6VdQ6rTPgTLaC9hKpygnSOY8FA7kj0J6WR0QISzn0J8IYN0tIi0TMcLBHcHZp1wf6cB5UiFZxIdEcEhJKyvQ9BqGSqCuIuL0WvXoHj8HeKSL472E0KrBRfrVRGL9cbWEsEAA */ + id: 'File machine', + + initial: 'Reading files', + + context: ({ input }) => { + return { + project: input.project ?? ({} as Project), // TODO: Either make this a flexible type or type this property to allow empty object + selectedDirectory: input.selectedDirectory ?? ({} as FileEntry), // TODO: Either make this a flexible type or type this property to allow empty object + itemsBeingRenamed: [], + } + }, + + on: { + assign: { + actions: assign(({ event }) => ({ + ...event.data, + })), + target: '.Reading files', + }, + + Refresh: '.Reading files', + }, + states: { + 'Has no files': { + on: { + 'Create file': { + target: 'Creating and opening file', + }, }, }, - } -) + + 'Has files': { + on: { + 'Rename file': { + target: 'Renaming file', + }, + + 'Create file': [ + { + target: 'Creating and opening file', + guard: 'Is not silent', + }, + 'Creating file', + ], + + 'Delete file': { + target: 'Deleting file', + }, + + 'Open file': { + target: 'Opening file', + }, + + 'Set selected directory': { + target: 'Has files', + actions: ['setSelectedDirectory'], + }, + }, + }, + + 'Creating and opening file': { + invoke: { + id: 'create-and-open-file', + src: 'createAndOpenFile', + input: ({ event, context }) => { + if (event.type !== 'Create file') + // This is just to make TS happy + return { + name: '', + makeDir: false, + selectedDirectory: context.selectedDirectory, + content: '', + } + return { + name: event.data.name, + makeDir: event.data.makeDir, + selectedDirectory: context.selectedDirectory, + content: event.data.content ?? '', + } + }, + onDone: [ + { + target: 'Reading files', + actions: [ + { + type: 'createToastSuccess', + params: ({ + event, + }: { + // TODO: rely on type inference + event: Extract< + FileMachineEvents, + { type: 'xstate.done.actor.create-and-open-file' } + > + }) => { + return { message: event.output.message } + }, + }, + 'addFileToRenamingQueue', + 'navigateToFile', + ], + }, + ], + onError: [ + { + target: 'Reading files', + actions: ['toastError'], + }, + ], + }, + }, + + 'Renaming file': { + invoke: { + id: 'rename-file', + src: 'renameFile', + input: ({ event, context }) => { + if (event.type !== 'Rename file') { + // This is just to make TS happy + return { + oldName: '', + newName: '', + isDir: false, + selectedDirectory: {} as FileEntry, + } + } + return { + oldName: event.data.oldName, + newName: event.data.newName, + isDir: event.data.isDir, + selectedDirectory: context.selectedDirectory, + } + }, + + onDone: [ + { + target: '#File machine.Reading files', + actions: ['renameToastSuccess'], + guard: 'Name has been changed', + }, + 'Reading files', + ], + onError: [ + { + target: '#File machine.Reading files', + actions: ['toastError'], + }, + ], + }, + + exit: 'removeFileFromRenamingQueue', + }, + + 'Deleting file': { + invoke: { + id: 'delete-file', + src: 'deleteFile', + input: ({ event }) => { + if (event.type !== 'Delete file') { + // This is just to make TS happy + return { + path: '', + children: [], + name: '', + } + } + return { + path: event.data.path, + children: event.data.children, + name: event.data.name, + } + }, + onDone: [ + { + actions: ['toastSuccess'], + target: '#File machine.Reading files', + }, + ], + onError: { + actions: ['toastError'], + target: '#File machine.Has files', + }, + }, + }, + + 'Reading files': { + invoke: { + id: 'read-files', + src: 'readFiles', + input: ({ context }) => context.project, + onDone: [ + { + guard: 'Has at least 1 file', + target: 'Has files', + actions: ['setFiles'], + }, + { + target: 'Has no files', + actions: ['setFiles'], + }, + ], + onError: [ + { + target: 'Has no files', + actions: ['toastError'], + }, + ], + }, + }, + + 'Opening file': { + entry: ['navigateToFile'], + }, + + 'Creating file': { + invoke: { + src: 'createFile', + id: 'create-file', + input: ({ event, context }) => { + if (event.type !== 'Create file') { + // This is just to make TS happy + return { + name: '', + makeDir: false, + selectedDirectory: {} as FileEntry, + content: '', + } + } + return { + name: event.data.name, + makeDir: event.data.makeDir, + selectedDirectory: context.selectedDirectory, + content: event.data.content ?? '', + } + }, + onDone: 'Reading files', + onError: 'Reading files', + }, + }, + }, +}) diff --git a/src/machines/homeMachine.ts b/src/machines/homeMachine.ts index 555ea171d..a6101c9f4 100644 --- a/src/machines/homeMachine.ts +++ b/src/machines/homeMachine.ts @@ -1,164 +1,233 @@ -import { assign, createMachine } from 'xstate' +import { assign, fromPromise, setup } from 'xstate' import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig' import { Project } from 'lib/project' -export const homeMachine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */ - id: 'Home machine', - - initial: 'Reading projects', - - context: { - projects: [] as Project[], - defaultProjectName: '', - defaultDirectory: '', +export const homeMachine = setup({ + types: { + context: {} as { + projects: Project[] + defaultProjectName: string + defaultDirectory: string }, - - on: { - assign: { - actions: assign((_, event) => ({ - ...event.data, - })), - target: '.Reading projects', - }, + events: {} as + | { type: 'Open project'; data: HomeCommandSchema['Open project'] } + | { type: 'Rename project'; data: HomeCommandSchema['Rename project'] } + | { type: 'Create project'; data: HomeCommandSchema['Create project'] } + | { type: 'Delete project'; data: HomeCommandSchema['Delete project'] } + | { type: 'navigate'; data: { name: string } } + | { + type: 'xstate.done.actor.read-projects' + output: Project[] + } + | { type: 'assign'; data: { [key: string]: any } }, + input: {} as { + projects: Project[] + defaultProjectName: string + defaultDirectory: string }, - states: { - 'Has no projects': { - on: { - 'Create project': { - target: 'Creating project', - }, - }, - }, - - 'Has projects': { - on: { - 'Rename project': { - target: 'Renaming project', - }, - - 'Create project': { - target: 'Creating project', - }, - - 'Delete project': { - target: 'Deleting project', - }, - - 'Open project': { - target: 'Opening project', - }, - }, - }, - - 'Creating project': { - invoke: { - id: 'create-project', - src: 'createProject', - onDone: [ - { - target: 'Reading projects', - actions: ['toastSuccess'], - }, - ], - onError: [ - { - target: 'Reading projects', - actions: ['toastError'], - }, - ], - }, - }, - - 'Renaming project': { - invoke: { - id: 'rename-project', - src: 'renameProject', - onDone: [ - { - target: '#Home machine.Reading projects', - actions: ['toastSuccess'], - }, - ], - onError: [ - { - target: '#Home machine.Reading projects', - actions: ['toastError'], - }, - ], - }, - }, - - 'Deleting project': { - invoke: { - id: 'delete-project', - src: 'deleteProject', - onDone: [ - { - actions: ['toastSuccess'], - target: '#Home machine.Reading projects', - }, - ], - onError: { - actions: ['toastError'], - target: '#Home machine.Has projects', - }, - }, - }, - - 'Reading projects': { - invoke: { - id: 'read-projects', - src: 'readProjects', - onDone: [ - { - cond: 'Has at least 1 project', - target: 'Has projects', - actions: ['setProjects'], - }, - { - target: 'Has no projects', - actions: ['setProjects'], - }, - ], - onError: [ - { - target: 'Has no projects', - actions: ['toastError'], - }, - ], - }, - }, - - 'Opening project': { - entry: ['navigateToProject'], - }, - }, - - schema: { - events: {} as - | { type: 'Open project'; data: HomeCommandSchema['Open project'] } - | { type: 'Rename project'; data: HomeCommandSchema['Rename project'] } - | { type: 'Create project'; data: HomeCommandSchema['Create project'] } - | { type: 'Delete project'; data: HomeCommandSchema['Delete project'] } - | { type: 'navigate'; data: { name: string } } - | { - type: 'done.invoke.read-projects' - data: Project[] - } - | { type: 'assign'; data: { [key: string]: any } }, - }, - - predictableActionArguments: true, - preserveActionOrder: true, - tsTypes: {} as import('./homeMachine.typegen').Typegen0, }, - { - actions: { - setProjects: assign((_, event) => { - return { projects: event.data as Project[] } - }), + actions: { + setProjects: assign({ + projects: ({ context, event }) => + 'output' in event ? event.output : context.projects, + }), + toastSuccess: () => {}, + toastError: () => {}, + navigateToProject: () => {}, + }, + actors: { + readProjects: fromPromise(() => Promise.resolve([] as Project[])), + createProject: fromPromise((_: { input: { name: string } }) => + Promise.resolve('') + ), + renameProject: fromPromise( + (_: { + input: { + oldName: string + newName: string + defaultProjectName: string + defaultDirectory: string + } + }) => Promise.resolve('') + ), + deleteProject: fromPromise( + (_: { input: { defaultDirectory: string; name: string } }) => + Promise.resolve('') + ), + }, + guards: { + 'Has at least 1 project': () => false, + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */ + id: 'Home machine', + + initial: 'Reading projects', + + context: { + projects: [], + defaultProjectName: '', + defaultDirectory: '', + }, + + on: { + assign: { + actions: assign(({ event }) => ({ + ...event.data, + })), + target: '.Reading projects', }, - } -) + }, + states: { + 'Has no projects': { + on: { + 'Create project': { + target: 'Creating project', + }, + }, + }, + + 'Has projects': { + on: { + 'Rename project': { + target: 'Renaming project', + }, + + 'Create project': { + target: 'Creating project', + }, + + 'Delete project': { + target: 'Deleting project', + }, + + 'Open project': { + target: 'Opening project', + }, + }, + }, + + 'Creating project': { + invoke: { + id: 'create-project', + src: 'createProject', + input: ({ event }) => { + if (event.type !== 'Create project') { + return { + name: '', + } + } + return { + name: event.data.name, + } + }, + onDone: [ + { + target: 'Reading projects', + actions: ['toastSuccess'], + }, + ], + onError: [ + { + target: 'Reading projects', + actions: ['toastError'], + }, + ], + }, + }, + + 'Renaming project': { + invoke: { + id: 'rename-project', + src: 'renameProject', + input: ({ event, context }) => { + if (event.type !== 'Rename project') { + // This is to make TS happy + return { + defaultProjectName: context.defaultProjectName, + defaultDirectory: context.defaultDirectory, + oldName: '', + newName: '', + } + } + return { + defaultProjectName: context.defaultProjectName, + defaultDirectory: context.defaultDirectory, + oldName: event.data.oldName, + newName: event.data.newName, + } + }, + onDone: [ + { + target: '#Home machine.Reading projects', + actions: ['toastSuccess'], + }, + ], + onError: [ + { + target: '#Home machine.Reading projects', + actions: ['toastError'], + }, + ], + }, + }, + + 'Deleting project': { + invoke: { + id: 'delete-project', + src: 'deleteProject', + input: ({ event, context }) => { + if (event.type !== 'Delete project') { + // This is to make TS happy + return { + defaultDirectory: context.defaultDirectory, + name: '', + } + } + return { + defaultDirectory: context.defaultDirectory, + name: event.data.name, + } + }, + onDone: [ + { + actions: ['toastSuccess'], + target: '#Home machine.Reading projects', + }, + ], + onError: { + actions: ['toastError'], + target: '#Home machine.Has projects', + }, + }, + }, + + 'Reading projects': { + invoke: { + id: 'read-projects', + src: 'readProjects', + onDone: [ + { + guard: 'Has at least 1 project', + target: 'Has projects', + actions: ['setProjects'], + }, + { + target: 'Has no projects', + actions: ['setProjects'], + }, + ], + onError: [ + { + target: 'Has no projects', + actions: ['toastError'], + }, + ], + }, + }, + + 'Opening project': { + entry: ['navigateToProject'], + }, + }, +}) diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index 549b509b9..7eb7fc220 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -6,7 +6,7 @@ import { recast, } from 'lang/wasm' import { Axis, Selection, Selections, updateSelections } from 'lib/selections' -import { assign, createMachine } from 'xstate' +import { assign, fromPromise, setup } from 'xstate' import { SidebarType } from 'components/ModelingSidebar/ModelingPanes' import { isNodeSafeToReplacePath, @@ -49,7 +49,6 @@ import { absDistanceInfo, applyConstraintAxisAlign, } from 'components/Toolbar/SetAbsDistance' -import { Models } from '@kittycad/lib/dist/types/src' import { ModelingCommandSchema } from 'lib/commandBarConfigs/modelingCommandConfig' import { err, trap } from 'lib/trap' import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities' @@ -58,6 +57,7 @@ import { Coords2d } from 'lang/std/sketch' import { deleteSegment } from 'clientSideScene/ClientSideSceneComp' import { executeAst } from 'lang/langHelpers' import toast from 'react-hot-toast' +import { ToolbarModeName } from 'lib/toolbar' export const MODELING_PERSIST_KEY = 'MODELING_PERSIST_KEY' @@ -116,6 +116,25 @@ export interface SegmentOverlays { [pathToNodeString: string]: SegmentOverlay } +export type ExtrudeFacePlane = { + type: 'extrudeFace' + position: [number, number, number] + sketchPathToNode: PathToNode + extrudePathToNode: PathToNode + cap: 'start' | 'end' | 'none' + faceId: string + zAxis: [number, number, number] + yAxis: [number, number, number] +} + +export type DefaultPlane = { + type: 'defaultPlane' + plane: DefaultPlaneStr + planeId: string + zAxis: [number, number, number] + yAxis: [number, number, number] +} + export type SegmentOverlayPayload = | { type: 'set-one' @@ -152,24 +171,7 @@ export type ModelingMachineEvent = | { type: 'Sketch On Face' } | { type: 'Select default plane' - data: { - zAxis: [number, number, number] - yAxis: [number, number, number] - } & ( - | { - type: 'defaultPlane' - plane: DefaultPlaneStr - planeId: string - } - | { - type: 'extrudeFace' - position: [number, number, number] - sketchPathToNode: PathToNode - extrudePathToNode: PathToNode - cap: 'start' | 'end' | 'none' - faceId: string - } - ) + data: DefaultPlane | ExtrudeFacePlane } | { type: 'Set selection' @@ -210,9 +212,11 @@ export type ModelingMachineEvent = data: [x: number, y: number] } | { - type: 'done.invoke.animate-to-face' | 'done.invoke.animate-to-sketch' - data: SketchDetails + type: 'xstate.done.actor.animate-to-face' + output: SketchDetails } + | { type: 'xstate.done.actor.animate-to-sketch'; output: SketchDetails } + | { type: `xstate.done.actor.do-constrain${string}`; output: SetSelections } | { type: 'Set mouse state'; data: MouseState } | { type: 'Set context'; data: Partial } | { @@ -261,800 +265,224 @@ export const getPersistedContext = (): Partial => { return c } -export const modelingMachineDefaultContext = { - tool: null as Models['SceneToolType_type'] | null, - selection: [] as string[], +export interface ModelingMachineContext { + currentMode: ToolbarModeName + currentTool: SketchTool + selection: string[] + selectionRanges: Selections + sketchDetails: SketchDetails | null + sketchPlaneId: string + sketchEnginePathId: string + moveDescs: MoveDesc[] + mouseState: MouseState + segmentOverlays: SegmentOverlays + segmentHoverMap: { [pathToNodeString: string]: number } + store: Store +} +export const modelingMachineDefaultContext: ModelingMachineContext = { + currentMode: 'modeling', + currentTool: 'none', + selection: [], selectionRanges: { otherSelections: [], codeBasedSelections: [], - } as Selections, + }, sketchDetails: { sketchPathToNode: [], zAxis: [0, 0, 1], yAxis: [0, 1, 0], origin: [0, 0, 0], - } as null | SketchDetails, - sketchPlaneId: '' as string, - sketchEnginePathId: '' as string, - moveDescs: [] as MoveDesc[], - mouseState: { type: 'idle' } as MouseState, - segmentOverlays: {} as SegmentOverlays, - segmentHoverMap: {} as { [pathToNodeString: string]: number }, + }, + sketchPlaneId: '', + sketchEnginePathId: '', + moveDescs: [], + mouseState: { type: 'idle' }, + segmentOverlays: {}, + segmentHoverMap: {}, store: { buttonDownInStream: undefined, didDragInStream: false, streamDimensions: { streamWidth: 1280, streamHeight: 720 }, openPanes: getPersistedContext().openPanes || ['code'], - } as Store, + }, } -export const modelingMachine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QFkD2EwBsCWA7KAxAMICGuAxlgNoAMAuoqAA6qzYAu2qujIAHogC0ANhoBWAHQAOAMwB2KQEY5AFgCcGqWqkAaEAE9Ew0RLEqa64TIBMKmTUXCAvk71oMOfAQDKYdgAJYLDByTm5aBiQQFjYwniiBBEEpYSkJOUUaOWsxeylrWzk9QwQClQkrVOsZNWExFItnVxB3LDxCXwCAW1QAVyDA9hJ2MAjeGI4ueNBE5KkaCWspZfMM+XE1YsQZMQWxNQtxao0ZeRc3dDavTv9ybhG+djGoibjeRJothBpzlsvPDp+fy+KBdMC4AIAeQAbmAAE6YEj6WDPZisSbcd6IT4GbG-VoAiTYCCYMAEACiEPhgQA1n5yAALVHRdFvBJCazCOQSRSctQyKS5RRqFTWL6nNTSfnSsS5FRiZT4-7tIkksmUkZw2n0pmKSJo2JTLFJWwLFSOBy7WQ0eZFXEIczWCRqaxyOq81KKAWOJUeFXE0kUx5w3oYZmvI3spJiOQyZ1mZQyeUyRSC4RfXmndKOKRyWUpmqKX1XKCqwMAMWwmFJT3o41ZkZmQnzEjsgoVXPE2TtJS9KmEPNjuZsKbkDmLhID6r4LDhtf1LMNmKjgjEdXSrtENuEihUcjUigz-cUEhqeaUwmsqavE-9aoIyBIdPDDeXTYdTtSIusalj5qy+xfFyaSxvUKh7rKdgqFIt74GWZIACLBCMgTBKEUwvku0z8EIO7lNoCZjqm5qKGIGYxuUNSXj+iiOLR+6waWU4EAAKmAjyCOwqCCEQACCCGYRi2GzAqTpmCkspqLKf7pvayj9hU8gFPs+TCOaciMfBEgMsSYAAAqIrgcAELxc7YAAZiQoT+FAcIkEwDL+CwTC9IiIwQIJbLvoI-aSuIia-loQUyLJJS-nGlrQbmXq0VIKiaVOEiwAyqAAO4GWQxmmZwlnWbZ9mOWAXRMJwkCeY2OFJHu3IKDGYicsB4F5hmvJOqmB5ejQNBWAe1iad4dLsIyxBkJQmADTq5VvpVmZxrYSacmuHq2C1BwSN1-Y7DG+40DI-WDcNSE1mAqGguC871lhxpyHmzo-vVcW-jashfPK3IaOY+a7Gp3X7TqBB3Bg-iQBw-gQL0cLtNqQ1MnWLyvsJiBei6ph7oFWhqWYPaINK61rsstRZLRIp-TDEgTTDACS96PnSp1ghC-iwmZ5AkJgU2IwgXoCqeqaqY40G1EeBSLPKj3zMsSx7c0BIqhTjLkwdDLU4GtMnUEZ2MylkMAF73GzHPGl6V7pOB9Q7PylQqBmNhmge9X7ETXVFjLypwfLDKKzqKtkkQ3CwOwdl4P42vYHrEJs2D2AB6NoxwwaQlG-YJ7KPkywpjGyxfKakjOzYe47KRbqkwrHs+8Q-uByQwfM5wrOYFHMcUHHC4RtNiQxQOz3WrywpytnSxOnuXU0AcuQpiXntl-efu4AHQe4P4vEAELeP4AAahtRtzYl-vy5pXjuZH2j+VjOjQnIWPY1gODBrt+u7Ste1TM+VwvS+r-4ACaW-vjvp4KF3KPWoDtrb2nsL+CoaxzxxQUCkSez9GTl1nvPaui8yBQFJL-GaNQ9jCltBsCw+Rs4dkWBYR0u0ljgXivfEsiCp5K2QW-NB-hST4HYLDVuCMk5xVMJaXMO4UxxRkNnS8NVSK2AKDfZYpEEHT0DCgquwcmDwhUbgCA2ByCuRIFqDRTdKDYI7q6SQVh6oHEltkbQ2d8hxmMDGAoI91B9VoYSD29CmFzyUYvUO4chjVn0P4Nm2AoC4EMUjCBzoRSbVUsY4+vYHai0cNkLqIUbTSwuA-Usbj5G+2YTXeEdc2aYACUEkJYSuY2AHPjQU+5fycjsBmKw5RchrgKFJA4I45GMNfp49+sBcD2X8FxDe5SvQmBqIFVQ+xMjEJPv5Qcu5zFKAPBpFxcsn45Irr0lh-TBnDJ-vHRcidt6pMWK1WoV59yxhESfUiA51Dyl2MKHYo8unex6ag4OYAACOvRI5sKgBw0ZSYFh1AAkoFI5yMyxklPIHc81Km7jeS-BReTF5MB0UU6ghy26cy9KoHkl59hckyLuL0GYpJVNlHFBwak+zIqQR8rx-g4RFVQLCW4aL2Aohxdw7e91TbCL3GC4xGZZCSguaoYeB4UwuwyXQ7J3TAwACUwCCHYiEXoIxRk7jSNoc0dQNCiF-BmcQZpZDgUpcIE4zj5WuI2UqskgMTogwCODSG+BoaMmBSjHcHUFBjjsMKU1u11pulMWufkso5V-EyfQ9xPTa5DNQEzHR2ASAACMsG8quicjQ0gsiqRFMKKwYpwGH1PGuPCN99g0LtesnUCbAyMgwSdLiqB2Y5uOe+HIF9pA-lHjkA44Uy29gkWGrcbTZBWDvvWx+jbeKpWrgEHx+sG56KGM3fweBzKoAIBAbgYAiS4GhKgOkEgYDsEEKuiOmBBA7tQOU6oCpTA2MltI-scTECSJAtoNSUrcgODrbGhVT9F3LpDqgXWa7G6bsoNu3Au6CDwjhFBiQTA3K7rhF0C9fhr1QbDmu+9iHH1dq8pVZ9Tpe0Cm0JyUimwT64OdCkUU-YljzBjAg8DoNa6aMjhu2OCGkMHqMse0957L2CF4-XYju6n0W0WLGOBcV5iXgHijfImQ3TmBdIqNZ86ybcYCNJ-j0c4MnQfchuEqG4Tocw1BnDkmTN3offJ-Yzp1JwrXGOEUA8X20V3A1NSpx0kgftQupdoMV5r3XkJvdImj14HE0eyTmbYCCD4LJ0jXDc09pCnGLQ+5bRJktdnX8J5tymjHCkbIXHIsBGixvOLVmbN2eGFhxzeG0sZay-JtSphrUpBvhkdSZWcinnITdAUWR+SrLnVksD9WP5ry-s1hLYmz0pa6xm9L+hetkYqokaoZ91CJnApkea2MED2DdK2JqqhBTDtzHViDjXVuWZQ2hjD7WHO4avd1vbrmDvt2-SFcojgQpFz3FeG04pEzrVItBOoroDVzbCw2wzS2MGkjW4ejbEmuv4FJPtnL3aKNg9bPIWi8hzCzJKDkdcAibBmBTL1YDssDMKyM4EonFmSMta+-Z7Df3BDY7VUD0n5GjshUkDSzIxgHBukPCfeqXd9g1HlItEbL3QYAo4bj0TSXNsi71wyEnl0yfS8vAN2Q2Q6i7GyKIkU61R5kqsDdTj+mFsRYg6b5rn3bPffYB1k34JAVm4lxbqXoOz7HdOMYdQSZdyiJfaIWQ+KFT5F2jrgIKi4RqI0VoxEuizOCcs+to3BOr154L5o7RcJBACebub+GuXyeZHWiKewKZdrRqu1LSiygB25mkjnpyqjwSF-r7Bsv-OA9teD79yTNfJ91+L430vzfI+t8t6Djvo87C7UyFtZQ2cFADlHMPvMnv5vxoADJ4DbagDtAMGStuTR2p9ogqlixuqmUtXwLoaQquuwtQogro44Xu9+j+H+mAEglMuAHABAT6UszoUqICeYvIL09o1qCwxEbopwXotutq6OnOnsD+RksB8BiB7AyBeoUeh236NEaBWB-Y4ESkoUiA+4A4v41qEOV4AoCgCCFBT+HaEgAAcimnpKgHgNyiZBABAIMDornjIRCF-mtNfDUCONoFJEBNkK2EfNtNkPsAxFAW4iIVQZIf4NIbIbACNM3J2pLowVzBjIStOl2PYroPaBkGaKKIrvUFoDaAgggUgaMt1LYofFng0OfhmJIq2H2LsPHhAsETQXQU4SDlzMAutJyOnLREsGOJwRUl1OkLKoGhfLkIKAgixK2hCOmg3DouQMmq-u-u2o4QwRkesBfvKOINasNqIFdueBUMPAQTNjaGIAgr0OoimjHHOB7JCLgPunjpXkerxN4CxIIJMQeoIDMewHMaEsDnipDq+jdFeGLPsL5t4f5pjNBJnGjOzm7N7mTKqqEGLrAc0fgKIW0TvtHtdpkHgeJCjryOfldhdpKLqvkHmGMWuAgs8Zupgp8RIEZlDEEHcOopynCEZHCAQJWIgclCyiEHCdmukZzAtJ+EKI0P5AqGAiULmE6PnN+DuJkHuPcXGm4rCa8a0YifVlDARlAHgPIYoayi8bzv4LyXgOUiCieHmD+I8tajdl+ggD4WGkcMsicJeDCQSRyc-nATidHAyJAP4OybzsgQcUbGOAsPdJbCpI4JyIAWfAeBjKPBtC6AguQKSGQIEG5GSKMseKbKROsBfDYDkC1CbFJM8uYFuJDmPqiUmsMtCGmpmoGBXiesbpJjGQUpxNxPGZDImeLiRvJiLLYP+gUH6vYIUUGWkIFnYF5o4C6WYYthBumXOMmqmjmVmmSPPkHiHmmdwLXJmVJgme2S3gnD8dUPIBUGMkRA4F1OWSFCBJYqKIycsiyaBj7qDKiZ8ovKyj0ByhuV4nIcmclhIFsXuQvIINueymqqeWghdN8c4dUCbGYv+D+LICmIUamFmBkECbRPUDaBkNGWipBtBresUosYbimeeieWivhsBX4sUvJtBCUT+PICsIPNSeEh3mpBoDNh2LuOMfWWuQENefkizEUvoGBYlhBUelBdsngFJgUnxv4m5vcoPK6FoCjuhVzLTqeKkGsLsLfKcABbRd4gRr4mRYEjgCEhRfjtRdxMRbgDBYRiBXtqUvscScaGOWCQqLsLTvKJxaRMoFKI0NeDcUIQRZjo2YBc5sUhJcEgsYecbjRZufRaRf4qLpJWpe0ZzA+SeI7FWUoAqI0l6BNg6VJM+kmEJZuYEAMkwC2evNJcsceXJdBbskwP2XwAhTVBkHwWqYFtnOQutFUNavbC8jGhzo8VzktvJdFXsiml-AlVRUlYIPJdsTFf2foG5rYoWk4sIt3tnF6JIDkbRFyL+bRJFcyj8n8g3Kbg1UeU5V4uqr8mzIIKbvJjUIVVkD+JMrKNCuNqPJjK6B0p0uZZVZZcJU5JitWFgLNY5clcJYIBinZFdV8SOfeTsF3KKErrtAnrGBSnuDyLIFeLWXchFSdZ7EQG-h8f4CxNqSaepVGICQjqcA8jGBdjcr2K7iUaoHUujLUAghDe-jDS-lQPQXeRkTROUOgTfKxsKMGXJJjUpuoJYnAnjWDRIATVDUTZgMgdYPDe+CmOoDyNtF1PNMaujUjAzTTjjSzSueFmTBzTANDbDVQDIHzZVCFDdBUFJIKP+FRDsKageFjUzT+CzU0Lfh7P4LgCmrlGSL4KSNZBgJZK5LnoZC3F5caIIDfEPMhSWu2MsoUVyHGOGnUl1O+moJpGQNgF0MMFDMMt9kZDdeepHdHSMP2TbeUjGF1RcsKvtVnDgTuIsA7ssJCrGCQeVRIMnTHZ6sMuxGZsiUrInUepXanVxNsUrBnYZZeJSscMoFMuKOIDxTKh2NkDaU0M0FbRgPAFEOVe7SuKkGkJtPUAoOoBYAxr2JyPEaPCpG6LmEAQlGqLPd5D9AYTsLmI9KvV8OKjyNBPlqkKjmXQ8VpDpBgBlEZFPa9RkauCjIvWfSveoKauNksKRBfE9AGg-XGolMlGlK-XAIfZVIIDsE6AoB9LtNaLUgA4NQFSA2OGA5PHAx3JaBUJEdIv0SkP1XGNaVJMAmpOqWzTkvg0jN0ZTkmNtHUKVnJDfBFLmOICvb+DfWPjen4jPlug+gw1zPbKYG6FJBkJbBrtnOYCeDRLyCjW+WPs5sI-BqI2TXii6JKJaS6IrhaKOkwfUGgSPBREOjfqQRVZ7Nzo1rFlox-To+YGcgeN1SbWVi446DuCAzDqFuXW4nY5-O9iRmI88mCWpM+YKN1C6P3QSsoEQamA9qBGPq8Y40cj8cKKKOkArleLvTUIKCQoZVI1yNoNOlUGPn7uk7ikbLNqYDUGYIfoGfUKIvKFjUoH3LKJAebQ2aDCvuomvjoho3zrumEx0s6PnCkMsObHTfTjtBUHUKkEmEIp9MITAa0WI1eLsGQrI7+OFFdvUINaoAIVvd6GbdY9AZQZySEewJs-gjs-lns0pl8ERDyL3F+MAysms1c9qRIFbdhmzJs40xOacABIyQeC8x3uBDfMoAEeYt8wiVYTYRCO-Rk-efxfEb1HUHFJkDtd4RkIsHyDTpULoykRwGE-LukEoI6AqC6N-qtAsBtBGjdB0lUTUZwJHA0cmmI0pOUBYDpVNuaWQzgetcYJeIyTGJSuHWzZsdMUMLMUrPMWE7GAOCSmwcNWOMYwgLKJIKsA9oDdaLLRjgrEafCbATy91CeNFGYDkF7d1F8DGAOJw6JESlBPhT042qazjpyUiZ6iidwIoXcBifCDy5kJKNa6KPVBfPa-aB09ICkLKqIGuInhqcKWaz69yZ6mKdhDU1GALSeKYueDE-KZC4y2OA9MKGqeA6uU8ZqSKZybqclAaV62AKG0mKYPVD+bKJyNIoAd1NILgTYNkBCXUK6e6YvLAF6WE+BKCsg3mNkAasrhjfoQcE0vvFNu6xc4E1Vb2QUi2dmemu2WM6GpbHYJ6JIhfOKG0osBoDdNkIfJjONe-BebuVyqi7m3-FoBViOBfJuLkAUGKqGShYHZ9AIU+ywoI2RWE1++tD+wUG6P+-pS+dIAQRoAeNahU2zdztVdZSUE40bDB5QtfH+-nEeP226LtC6NkGWc9lhzu+dZB-4rZSEtB4bURyhXUsBORP28NVQ-+922jgE700RVZQxfXDZapax9+2OXOzkY0g4PU8m7GDOYHeB8HKlXFce3GJbDkPivMAzvlTYDexBENvYPKGpxOzFS2V-FpxM0Snp+UYUQIdyK6LUtBLWYJXR2dVFZNf8mHhwlJ7BzJ4VpyAoNCv23CsoITO5xYBZxdU9aSJgMe1RvMIQWfSy1q88rq6PCpj0YBuc0J42grSdFzZs3bqjEvRkAm5lwZUbdLQTAV4-RbVbf4DbWI57TzLKL0cXccw4K9OuNMrULUCFpE0a3BM3bHSmvHa29o0bPafkNBAQZnX+kBPVMxmCss9tNW4SBN9XSmrXQHPXTqGI8YIpjkKcK6EAzdF4SUG+ULVfGInRKsi4EAA */ - id: 'Modeling', - - tsTypes: {} as import('./modelingMachine.typegen').Typegen0, - predictableActionArguments: true, - preserveActionOrder: true, - - context: modelingMachineDefaultContext, - - schema: { - events: {} as ModelingMachineEvent, +export const modelingMachine = setup({ + types: { + context: {} as ModelingMachineContext, + events: {} as ModelingMachineEvent, + input: {} as ModelingMachineContext, + }, + guards: { + 'Selection is on face': () => false, + 'has valid extrude selection': () => false, + 'has valid fillet selection': () => false, + 'Has exportable geometry': () => false, + 'has valid selection for deletion': () => false, + 'has made first point': ({ context }) => { + if (!context.sketchDetails?.sketchPathToNode) return false + const variableDeclaration = getNodeFromPath( + kclManager.ast, + context.sketchDetails.sketchPathToNode, + 'VariableDeclarator' + ) + if (err(variableDeclaration)) return false + if (variableDeclaration.node.type !== 'VariableDeclarator') return false + const pipeExpression = variableDeclaration.node.init + if (pipeExpression.type !== 'PipeExpression') return false + const hasStartSketchOn = pipeExpression.body.some( + (item) => + item.type === 'CallExpression' && item.callee.name === 'startSketchOn' + ) + return hasStartSketchOn && pipeExpression.body.length > 1 }, - - states: { - idle: { - on: { - 'Enter sketch': [ - { - target: 'animating to existing sketch', - cond: 'Selection is on face', - }, - 'Sketch no face', - ], - - Extrude: { - target: 'idle', - cond: 'has valid extrude selection', - actions: ['AST extrude'], - internal: true, - }, - - Fillet: { - target: 'idle', - cond: 'has valid fillet selection', // TODO: fix selections - actions: ['AST fillet'], - internal: true, - }, - - Export: { - target: 'idle', - internal: true, - cond: 'Has exportable geometry', - actions: 'Engine export', - }, - - Make: { - target: 'idle', - internal: true, - cond: 'Has exportable geometry', - actions: 'Make', - }, - - 'Delete selection': { - target: 'idle', - cond: 'has valid selection for deletion', - actions: ['AST delete selection'], - internal: true, - }, - - 'Text-to-CAD': { - target: 'idle', - internal: true, - actions: ['Submit to Text-to-CAD API'], - }, - }, - - entry: 'reset client scene mouse handlers', - - states: { - hidePlanes: { - on: { - 'Artifact graph populated': 'showPlanes', - }, - - entry: 'hide default planes', - }, - - showPlanes: { - on: { - 'Artifact graph emptied': 'hidePlanes', - }, - - entry: ['show default planes', 'reset camera position'], - }, - }, - - initial: 'hidePlanes', - }, - - Sketch: { - states: { - SketchIdle: { - on: { - 'Make segment vertical': { - cond: 'Can make selection vertical', - target: 'Await constrain vertically', - }, - - 'Make segment horizontal': { - cond: 'Can make selection horizontal', - target: 'Await constrain horizontally', - }, - - 'Constrain horizontal distance': { - target: 'Await horizontal distance info', - cond: 'Can constrain horizontal distance', - }, - - 'Constrain vertical distance': { - target: 'Await vertical distance info', - cond: 'Can constrain vertical distance', - }, - - 'Constrain ABS X': { - target: 'Await ABS X info', - cond: 'Can constrain ABS X', - }, - - 'Constrain ABS Y': { - target: 'Await ABS Y info', - cond: 'Can constrain ABS Y', - }, - - 'Constrain angle': { - target: 'Await angle info', - cond: 'Can constrain angle', - }, - - 'Constrain length': { - target: 'Await length info', - cond: 'Can constrain length', - }, - - 'Constrain perpendicular distance': { - target: 'Await perpendicular distance info', - cond: 'Can constrain perpendicular distance', - }, - - 'Constrain horizontally align': { - cond: 'Can constrain horizontally align', - target: 'Await constrain horizontally align', - }, - - 'Constrain vertically align': { - cond: 'Can constrain vertically align', - target: 'Await constrain vertically align', - }, - - 'Constrain snap to X': { - cond: 'Can constrain snap to X', - target: 'Await constrain snap to X', - }, - - 'Constrain snap to Y': { - cond: 'Can constrain snap to Y', - target: 'Await constrain snap to Y', - }, - - 'Constrain equal length': { - cond: 'Can constrain equal length', - target: 'Await constrain equal length', - }, - - 'Constrain parallel': { - target: 'Await constrain parallel', - cond: 'Can canstrain parallel', - }, - - 'Constrain remove constraints': { - cond: 'Can constrain remove constraints', - target: 'Await constrain remove constraints', - }, - - 'Re-execute': { - target: 'SketchIdle', - internal: true, - actions: ['set sketchMetadata from pathToNode'], - }, - - 'code edit during sketch': 'clean slate', - - 'Convert to variable': { - target: 'Await convert to variable', - cond: 'Can convert to variable', - }, - - 'change tool': { - target: 'Change Tool', - }, - }, - - entry: 'setup client side sketch segments', - }, - - 'Await horizontal distance info': { - invoke: { - src: 'Get horizontal info', - id: 'get-horizontal-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Await vertical distance info': { - invoke: { - src: 'Get vertical info', - id: 'get-vertical-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Await ABS X info': { - invoke: { - src: 'Get ABS X info', - id: 'get-abs-x-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Await ABS Y info': { - invoke: { - src: 'Get ABS Y info', - id: 'get-abs-y-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Await angle info': { - invoke: { - src: 'Get angle info', - id: 'get-angle-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Await length info': { - invoke: { - src: 'Get length info', - id: 'get-length-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Await perpendicular distance info': { - invoke: { - src: 'Get perpendicular distance info', - id: 'get-perpendicular-distance-info', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - onError: 'SketchIdle', - }, - }, - - 'Line tool': { - exit: [], - - states: { - Init: { - always: [ - { - target: 'normal', - cond: 'has made first point', - actions: 'set up draft line', - }, - 'No Points', - ], - }, - - normal: {}, - - 'No Points': { - entry: 'setup noPoints onClick listener', - - on: { - 'Add start point': { - target: 'normal', - actions: 'set up draft line without teardown', - }, - - Cancel: '#Modeling.Sketch.undo startSketchOn', - }, - }, - }, - - initial: 'Init', - - on: { - 'change tool': { - target: 'Change Tool', - }, - }, - }, - - Init: { - always: [ - { - target: 'SketchIdle', - cond: 'is editing existing sketch', - }, - 'Line tool', - ], - }, - - 'Tangential arc to': { - entry: 'set up draft arc', - - on: { - 'change tool': { - target: 'Change Tool', - }, - }, - }, - - 'undo startSketchOn': { - invoke: { - src: 'AST-undo-startSketchOn', - id: 'AST-undo-startSketchOn', - onDone: '#Modeling.idle', - }, - }, - - 'Rectangle tool': { - entry: ['listen for rectangle origin'], - - states: { - 'Awaiting second corner': { - on: { - 'Finish rectangle': 'Finished Rectangle', - }, - }, - - 'Awaiting origin': { - on: { - 'Add rectangle origin': { - target: 'Awaiting second corner', - actions: 'set up draft rectangle', - }, - }, - }, - - 'Finished Rectangle': { - always: '#Modeling.Sketch.SketchIdle', - }, - }, - - initial: 'Awaiting origin', - - on: { - 'change tool': { - target: 'Change Tool', - }, - }, - }, - - 'clean slate': { - always: 'SketchIdle', - }, - - 'Await convert to variable': { - invoke: { - src: 'Get convert to variable info', - id: 'get-convert-to-variable-info', - onError: 'SketchIdle', - onDone: { - target: 'SketchIdle', - actions: ['Set selection'], - }, - }, - }, - - 'Await constrain remove constraints': { - invoke: { - src: 'do-constrain-remove-constraint', - id: 'do-constrain-remove-constraint', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain horizontally': { - invoke: { - src: 'do-constrain-horizontally', - id: 'do-constrain-horizontally', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain vertically': { - invoke: { - src: 'do-constrain-vertically', - id: 'do-constrain-vertically', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain horizontally align': { - invoke: { - src: 'do-constrain-horizontally-align', - id: 'do-constrain-horizontally-align', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain vertically align': { - invoke: { - src: 'do-constrain-vertically-align', - id: 'do-constrain-vertically-align', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain snap to X': { - invoke: { - src: 'do-constrain-snap-to-x', - id: 'do-constrain-snap-to-x', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain snap to Y': { - invoke: { - src: 'do-constrain-snap-to-y', - id: 'do-constrain-snap-to-y', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain equal length': { - invoke: { - src: 'do-constrain-equal-length', - id: 'do-constrain-equal-length', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Await constrain parallel': { - invoke: { - src: 'do-constrain-parallel', - id: 'do-constrain-parallel', - onDone: { - target: 'SketchIdle', - actions: 'Set selection', - }, - }, - }, - - 'Change Tool': { - always: [ - { - target: 'SketchIdle', - cond: 'next is none', - }, - { - target: 'Line tool', - cond: 'next is line', - }, - { - target: 'Rectangle tool', - cond: 'next is rectangle', - }, - { - target: 'Tangential arc to', - cond: 'next is tangential arc', - }, - ], - }, - }, - - initial: 'Init', - - on: { - CancelSketch: '.SketchIdle', - - 'Delete segment': { - internal: true, - actions: ['Delete segment', 'Set sketchDetails'], - }, - 'code edit during sketch': '.clean slate', - }, - - exit: [ - 'sketch exit execute', - 'tear down client sketch', - 'remove sketch grid', - 'engineToClient cam sync direction', - 'Reset Segment Overlays', - 'enable copilot', - ], - - entry: [ - 'add axis n grid', - 'conditionally equip line tool', - 'clientToEngine cam sync direction', - ], - }, - - 'Sketch no face': { - entry: [ - 'disable copilot', - 'show default planes', - 'set selection filter to faces only', - ], - - exit: ['hide default planes', 'set selection filter to defaults'], - on: { - 'Select default plane': { - target: 'animating to plane', - actions: ['reset sketch metadata'], - }, - }, - }, - - 'animating to plane': { - invoke: { - src: 'animate-to-face', - id: 'animate-to-face', - onDone: { - target: 'Sketch', - actions: 'set new sketch metadata', - }, - }, - }, - - 'animating to existing sketch': { - invoke: [ - { - src: 'animate-to-sketch', - id: 'animate-to-sketch', - onDone: { - target: 'Sketch', - actions: ['disable copilot', 'set new sketch metadata'], - }, - }, - ], - }, + 'is editing existing sketch': ({ context: { sketchDetails } }) => + isEditingExistingSketch({ sketchDetails }), + 'Can make selection horizontal': ({ context: { selectionRanges } }) => { + const info = horzVertInfo(selectionRanges, 'horizontal') + if (trap(info)) return false + return info.enabled }, + 'Can make selection vertical': ({ context: { selectionRanges } }) => { + const info = horzVertInfo(selectionRanges, 'vertical') + if (trap(info)) return false + return info.enabled + }, + 'Can constrain horizontal distance': ({ context: { selectionRanges } }) => { + const info = horzVertDistanceInfo({ + selectionRanges, + constraint: 'setHorzDistance', + }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain vertical distance': ({ context: { selectionRanges } }) => { + const info = horzVertDistanceInfo({ + selectionRanges, + constraint: 'setVertDistance', + }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain ABS X': ({ context: { selectionRanges } }) => { + const info = absDistanceInfo({ selectionRanges, constraint: 'xAbs' }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain ABS Y': ({ context: { selectionRanges } }) => { + const info = absDistanceInfo({ selectionRanges, constraint: 'yAbs' }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain angle': ({ context: { selectionRanges } }) => { + const angleBetween = angleBetweenInfo({ selectionRanges }) + if (trap(angleBetween)) return false + const angleLength = angleLengthInfo({ + selectionRanges, + angleOrLength: 'setAngle', + }) + if (trap(angleLength)) return false + return angleBetween.enabled || angleLength.enabled + }, + 'Can constrain length': ({ context: { selectionRanges } }) => { + const angleLength = angleLengthInfo({ selectionRanges }) + if (trap(angleLength)) return false + return angleLength.enabled + }, + 'Can constrain perpendicular distance': ({ + context: { selectionRanges }, + }) => { + const info = intersectInfo({ selectionRanges }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain horizontally align': ({ context: { selectionRanges } }) => { + const info = horzVertDistanceInfo({ + selectionRanges, + constraint: 'setHorzDistance', + }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain vertically align': ({ context: { selectionRanges } }) => { + const info = horzVertDistanceInfo({ + selectionRanges, + constraint: 'setHorzDistance', + }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain snap to X': ({ context: { selectionRanges } }) => { + const info = absDistanceInfo({ + selectionRanges, + constraint: 'snapToXAxis', + }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain snap to Y': ({ context: { selectionRanges } }) => { + const info = absDistanceInfo({ + selectionRanges, + constraint: 'snapToYAxis', + }) + if (trap(info)) return false + return info.enabled + }, + 'Can constrain equal length': ({ context: { selectionRanges } }) => { + const info = setEqualLengthInfo({ selectionRanges }) + if (trap(info)) return false + return info.enabled + }, + 'Can canstrain parallel': ({ context: { selectionRanges } }) => { + const info = equalAngleInfo({ selectionRanges }) + if (err(info)) return false + return info.enabled + }, + 'Can constrain remove constraints': ({ + context: { selectionRanges }, + event, + }) => { + if (event.type !== 'Constrain remove constraints') return false + const info = removeConstrainingValuesInfo({ + selectionRanges, + pathToNodes: event.data && [event.data], + }) + if (trap(info)) return false + return info.enabled + }, + 'Can convert to variable': ({ event }) => { + if (event.type !== 'Convert to variable') return false + if (!event.data) return false + const ast = parse(recast(kclManager.ast)) + if (err(ast)) return false + const isSafeRetVal = isNodeSafeToReplacePath(ast, event.data.pathToNode) + if (err(isSafeRetVal)) return false + return isSafeRetVal.isSafe + }, + 'next is tangential arc': ({ context: { sketchDetails, currentTool } }) => + currentTool === 'tangentialArc' && + isEditingExistingSketch({ sketchDetails }), - initial: 'idle', - - on: { - Cancel: { - target: 'idle', - // TODO what if we're existing extrude equipped, should these actions still be fired? - // maybe cancel needs to have a guard for if else logic? - actions: ['reset sketch metadata', 'enable copilot'], - }, - - 'Set selection': { - internal: true, - actions: 'Set selection', - }, - - 'Set mouse state': { - internal: true, - actions: 'Set mouse state', - }, - 'Set context': { - internal: true, - actions: 'Set context', - }, - 'Set Segment Overlays': { - internal: true, - actions: 'Set Segment Overlays', - }, + 'next is rectangle': ({ context: { sketchDetails, currentTool } }) => + currentTool === 'rectangle' && canRectangleTool({ sketchDetails }), + 'next is line': ({ context }) => context.currentTool === 'line', + 'next is none': ({ context }) => { + console.log('is next none?', context) + return context.currentTool === 'none' }, }, - { - guards: { - 'has made first point': ({ sketchDetails }) => { - if (!sketchDetails?.sketchPathToNode) return false - const variableDeclaration = getNodeFromPath( - kclManager.ast, - sketchDetails.sketchPathToNode, - 'VariableDeclarator' - ) - if (err(variableDeclaration)) return false - if (variableDeclaration.node.type !== 'VariableDeclarator') return false - const pipeExpression = variableDeclaration.node.init - if (pipeExpression.type !== 'PipeExpression') return false - const hasStartSketchOn = pipeExpression.body.some( - (item) => - item.type === 'CallExpression' && - item.callee.name === 'startSketchOn' - ) - return hasStartSketchOn && pipeExpression.body.length > 1 - }, - 'is editing existing sketch': ({ sketchDetails }) => - isEditingExistingSketch({ sketchDetails }), - 'Can make selection horizontal': ({ selectionRanges }) => { - const info = horzVertInfo(selectionRanges, 'horizontal') - if (trap(info)) return false - return info.enabled - }, - 'Can make selection vertical': ({ selectionRanges }) => { - const info = horzVertInfo(selectionRanges, 'vertical') - if (trap(info)) return false - return info.enabled - }, - 'Can constrain horizontal distance': ({ selectionRanges }) => { - const info = horzVertDistanceInfo({ - selectionRanges, - constraint: 'setHorzDistance', - }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain vertical distance': ({ selectionRanges }) => { - const info = horzVertDistanceInfo({ - selectionRanges, - constraint: 'setVertDistance', - }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain ABS X': ({ selectionRanges }) => { - const info = absDistanceInfo({ selectionRanges, constraint: 'xAbs' }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain ABS Y': ({ selectionRanges }) => { - const info = absDistanceInfo({ selectionRanges, constraint: 'yAbs' }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain angle': ({ selectionRanges }) => { - const angleBetween = angleBetweenInfo({ selectionRanges }) - if (trap(angleBetween)) return false - const angleLength = angleLengthInfo({ - selectionRanges, - angleOrLength: 'setAngle', - }) - if (trap(angleLength)) return false - return angleBetween.enabled || angleLength.enabled - }, - 'Can constrain length': ({ selectionRanges }) => { - const angleLength = angleLengthInfo({ selectionRanges }) - if (trap(angleLength)) return false - return angleLength.enabled - }, - 'Can constrain perpendicular distance': ({ selectionRanges }) => { - const info = intersectInfo({ selectionRanges }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain horizontally align': ({ selectionRanges }) => { - const info = horzVertDistanceInfo({ - selectionRanges, - constraint: 'setHorzDistance', - }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain vertically align': ({ selectionRanges }) => { - const info = horzVertDistanceInfo({ - selectionRanges, - constraint: 'setHorzDistance', - }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain snap to X': ({ selectionRanges }) => { - const info = absDistanceInfo({ - selectionRanges, - constraint: 'snapToXAxis', - }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain snap to Y': ({ selectionRanges }) => { - const info = absDistanceInfo({ - selectionRanges, - constraint: 'snapToYAxis', - }) - if (trap(info)) return false - return info.enabled - }, - 'Can constrain equal length': ({ selectionRanges }) => { - const info = setEqualLengthInfo({ selectionRanges }) - if (trap(info)) return false - return info.enabled - }, - 'Can canstrain parallel': ({ selectionRanges }) => { - const info = equalAngleInfo({ selectionRanges }) - if (err(info)) return false - return info.enabled - }, - 'Can constrain remove constraints': ({ selectionRanges }, { data }) => { - const info = removeConstrainingValuesInfo({ - selectionRanges, - pathToNodes: data && [data], - }) - if (trap(info)) return false - return info.enabled - }, - 'Can convert to variable': (_, { data }) => { - if (!data) return false - const ast = parse(recast(kclManager.ast)) - if (err(ast)) return false - const isSafeRetVal = isNodeSafeToReplacePath(ast, data.pathToNode) - if (err(isSafeRetVal)) return false - return isSafeRetVal.isSafe - }, - 'next is tangential arc': ({ sketchDetails }, _, { state }) => - (state?.event as any).data.tool === 'tangentialArc' && - isEditingExistingSketch({ sketchDetails }), - 'next is rectangle': ({ sketchDetails }, _, { state }) => { - if ((state?.event as any).data.tool !== 'rectangle') return false - return canRectangleTool({ sketchDetails }) - }, - 'next is line': (_, __, { state }) => - (state?.event as any).data.tool === 'line', - 'next is none': (_, __, { state }) => - (state?.event as any).data.tool === 'none', - }, - // end guards - actions: { - 'set sketchMetadata from pathToNode': assign(({ sketchDetails }) => { + // end guards + actions: { + 'assign tool in context': assign({ + currentTool: ({ event }) => + 'data' in event && event.data && 'tool' in event.data + ? event.data.tool + : 'none', + }), + 'enter sketching mode': assign({ currentMode: 'sketching' }), + 'enter modeling mode': assign({ currentMode: 'modeling' }), + 'set sketchMetadata from pathToNode': assign( + ({ context: { sketchDetails } }) => { if (!sketchDetails?.sketchPathToNode || !sketchDetails) return {} return { sketchDetails: { @@ -1062,287 +490,315 @@ export const modelingMachine = createMachine( sketchPathToNode: sketchDetails.sketchPathToNode, }, } - }), - 'hide default planes': () => kclManager.hidePlanes(), - 'reset sketch metadata': assign({ - sketchDetails: null, - sketchEnginePathId: '', - sketchPlaneId: '', - }), - 'reset camera position': () => - engineCommandManager.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_look_at', - center: { x: 0, y: 0, z: 0 }, - vantage: { x: 0, y: -1250, z: 580 }, - up: { x: 0, y: 0, z: 1 }, - }, - }), - 'set new sketch metadata': assign((_, { data }) => ({ - sketchDetails: data, - })), - 'AST extrude': async ({ store }, event) => { - if (!event.data) return - const { selection, distance } = event.data - let ast = kclManager.ast - if ( - 'variableName' in distance && - distance.variableName && - distance.insertIndex !== undefined - ) { - const newBody = [...ast.body] - newBody.splice( - distance.insertIndex, - 0, - distance.variableDeclarationAst - ) - ast.body = newBody - } - const pathToNode = getNodePathFromSourceRange( - ast, - selection.codeBasedSelections[0].range - ) - const extrudeSketchRes = extrudeSketch( - ast, - pathToNode, - false, - 'variableName' in distance - ? distance.variableIdentifierAst - : distance.valueAst - ) - if (trap(extrudeSketchRes)) return - const { modifiedAst, pathToExtrudeArg } = extrudeSketchRes - - store.videoElement?.pause() - const updatedAst = await kclManager.updateAst(modifiedAst, true, { - focusPath: pathToExtrudeArg, - zoomToFit: true, - zoomOnRangeAndType: { - range: selection.codeBasedSelections[0].range, - type: 'path', - }, - }) - if (!engineCommandManager.engineConnection?.idleMode) { - store.videoElement?.play().catch((e) => { - console.warn('Video playing was prevented', e) - }) - } - if (updatedAst?.selections) { - editorManager.selectRange(updatedAst?.selections) - } - }, - 'AST delete selection': async ({ selectionRanges }) => { - let ast = kclManager.ast - - const modifiedAst = await deleteFromSelection( - ast, - selectionRanges.codeBasedSelections[0], - kclManager.programMemory, - getFaceDetails - ) - if (err(modifiedAst)) return - - const testExecute = await executeAst({ - ast: modifiedAst, - useFakeExecutor: true, - engineCommandManager, - }) - if (testExecute.errors.length) { - toast.error('Unable to delete part') - return - } - - await kclManager.updateAst(modifiedAst, true) - }, - 'AST fillet': async (_, event) => { - if (!event.data) return - - // Extract inputs - const { selection, radius } = event.data - - // Apply fillet to selection - const applyFilletToSelectionResult = applyFilletToSelection( - selection, - radius - ) - if (err(applyFilletToSelectionResult)) - return applyFilletToSelectionResult - }, - 'conditionally equip line tool': (_, { type }) => { - if (type === 'done.invoke.animate-to-face') { - sceneInfra.modelingSend({ - type: 'change tool', - data: { tool: 'line' }, - }) - } - }, - 'setup client side sketch segments': ({ - sketchDetails, - selectionRanges, - }) => { - if (!sketchDetails) return - ;(async () => { - if (Object.keys(sceneEntitiesManager.activeSegments).length > 0) { - await sceneEntitiesManager.tearDownSketch({ removeAxis: false }) - } - sceneInfra.resetMouseListeners() - await sceneEntitiesManager.setupSketch({ - sketchPathToNode: sketchDetails?.sketchPathToNode || [], - forward: sketchDetails.zAxis, - up: sketchDetails.yAxis, - position: sketchDetails.origin, - maybeModdedAst: kclManager.ast, - selectionRanges, - }) - sceneInfra.resetMouseListeners() - sceneEntitiesManager.setupSketchIdleCallbacks({ - pathToNode: sketchDetails?.sketchPathToNode || [], - forward: sketchDetails.zAxis, - up: sketchDetails.yAxis, - position: sketchDetails.origin, - }) - })() - }, - 'tear down client sketch': () => { - if (sceneEntitiesManager.activeSegments) { - sceneEntitiesManager.tearDownSketch({ removeAxis: false }) - } - }, - 'remove sketch grid': () => sceneEntitiesManager.removeSketchGrid(), - 'set up draft line': ({ sketchDetails }) => { - if (!sketchDetails) return - sceneEntitiesManager.setUpDraftSegment( - sketchDetails.sketchPathToNode, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin, - 'line' - ) - }, - 'set up draft arc': ({ sketchDetails }) => { - if (!sketchDetails) return - sceneEntitiesManager.setUpDraftSegment( - sketchDetails.sketchPathToNode, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin, - 'tangentialArcTo' - ) - }, - 'listen for rectangle origin': ({ sketchDetails }) => { - if (!sketchDetails) return - sceneEntitiesManager.setupNoPointsListener({ - sketchDetails, - afterClick: (args) => { - const twoD = args.intersectionPoint?.twoD - if (twoD) { - sceneInfra.modelingSend({ - type: 'Add rectangle origin', - data: [twoD.x, twoD.y], - }) - } else { - console.error('No intersection point found') - } - }, - }) - }, - 'set up draft rectangle': ({ sketchDetails }, { data }) => { - if (!sketchDetails || !data) return - sceneEntitiesManager.setupDraftRectangle( - sketchDetails.sketchPathToNode, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin, - data - ) - }, - 'set up draft line without teardown': ({ sketchDetails }) => { - if (!sketchDetails) return - sceneEntitiesManager.setUpDraftSegment( - sketchDetails.sketchPathToNode, - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin, - 'line', - false - ) - }, - 'show default planes': () => kclManager.showPlanes(), - 'setup noPoints onClick listener': ({ sketchDetails }) => { - if (!sketchDetails) return - - sceneEntitiesManager.setupNoPointsListener({ - sketchDetails, - afterClick: () => sceneInfra.modelingSend('Add start point'), - }) - }, - 'add axis n grid': ({ sketchDetails }) => { - if (!sketchDetails) return - if (localStorage.getItem('disableAxis')) return - sceneEntitiesManager.createSketchAxis( - sketchDetails.sketchPathToNode || [], - sketchDetails.zAxis, - sketchDetails.yAxis, - sketchDetails.origin - ) - }, - 'reset client scene mouse handlers': () => { - // when not in sketch mode we don't need any mouse listeners - // (note the orbit controls are always active though) - sceneInfra.resetMouseListeners() - }, - 'clientToEngine cam sync direction': () => { - sceneInfra.camControls.syncDirection = 'clientToEngine' - }, - 'engineToClient cam sync direction': () => { - sceneInfra.camControls.syncDirection = 'engineToClient' - }, - 'set selection filter to faces only': () => - engineCommandManager.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'set_selection_filter', - filter: ['face', 'object'], - }, - }), - 'set selection filter to defaults': () => - kclManager.defaultSelectionFilter(), - 'Delete segment': ({ sketchDetails }, { data: pathToNode }) => - deleteSegment({ pathToNode, sketchDetails }), - 'Reset Segment Overlays': () => sceneEntitiesManager.resetOverlays(), - 'Set context': assign({ - store: ({ store }, { data }) => { - if (data.streamDimensions) { - sceneInfra._streamDimensions = data.streamDimensions - } - - const result = { - ...store, - ...data, - } - const persistedContext: Partial = {} - for (const key of PersistedValues) { - persistedContext[key] = result[key] - } - if (typeof window !== 'undefined') { - window.localStorage.setItem( - PERSIST_MODELING_CONTEXT, - JSON.stringify(persistedContext) - ) - } - return result + } + ), + 'hide default planes': () => kclManager.hidePlanes(), + 'reset sketch metadata': assign({ + sketchDetails: null, + sketchEnginePathId: '', + sketchPlaneId: '', + }), + 'reset camera position': () => + engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_look_at', + center: { x: 0, y: 0, z: 0 }, + vantage: { x: 0, y: -1250, z: 580 }, + up: { x: 0, y: 0, z: 1 }, }, }), + 'set new sketch metadata': assign(({ event }) => { + if ( + event.type !== 'xstate.done.actor.animate-to-sketch' && + event.type !== 'xstate.done.actor.animate-to-face' + ) + return {} + return { + sketchDetails: event.output, + } + }), + 'AST extrude': async ({ context: { store }, event }) => { + if (event.type !== 'Extrude') return + if (!event.data) return + const { selection, distance } = event.data + let ast = kclManager.ast + if ( + 'variableName' in distance && + distance.variableName && + distance.insertIndex !== undefined + ) { + const newBody = [...ast.body] + newBody.splice(distance.insertIndex, 0, distance.variableDeclarationAst) + ast.body = newBody + } + const pathToNode = getNodePathFromSourceRange( + ast, + selection.codeBasedSelections[0].range + ) + const extrudeSketchRes = extrudeSketch( + ast, + pathToNode, + false, + 'variableName' in distance + ? distance.variableIdentifierAst + : distance.valueAst + ) + if (trap(extrudeSketchRes)) return + const { modifiedAst, pathToExtrudeArg } = extrudeSketchRes + + store.videoElement?.pause() + const updatedAst = await kclManager.updateAst(modifiedAst, true, { + focusPath: pathToExtrudeArg, + zoomToFit: true, + zoomOnRangeAndType: { + range: selection.codeBasedSelections[0].range, + type: 'path', + }, + }) + if (!engineCommandManager.engineConnection?.idleMode) { + store.videoElement?.play().catch((e) => { + console.warn('Video playing was prevented', e) + }) + } + if (updatedAst?.selections) { + editorManager.selectRange(updatedAst?.selections) + } }, - // end actions - services: { - 'do-constrain-remove-constraint': async ( - { selectionRanges, sketchDetails }, - { data } - ) => { + 'AST delete selection': async ({ context: { selectionRanges } }) => { + let ast = kclManager.ast + + const modifiedAst = await deleteFromSelection( + ast, + selectionRanges.codeBasedSelections[0], + kclManager.programMemory, + getFaceDetails + ) + if (err(modifiedAst)) return + + const testExecute = await executeAst({ + ast: modifiedAst, + useFakeExecutor: true, + engineCommandManager, + }) + if (testExecute.errors.length) { + toast.error('Unable to delete part') + return + } + + await kclManager.updateAst(modifiedAst, true) + }, + 'AST fillet': async ({ event }) => { + if (event.type !== 'Fillet') return + if (!event.data) return + + // Extract inputs + const { selection, radius } = event.data + + // Apply fillet to selection + const applyFilletToSelectionResult = applyFilletToSelection( + selection, + radius + ) + if (err(applyFilletToSelectionResult)) return applyFilletToSelectionResult + }, + 'conditionally equip line tool': ({ event: { type } }) => { + if (type === 'xstate.done.actor.animate-to-face') { + sceneInfra.modelingSend({ + type: 'change tool', + data: { tool: 'line' }, + }) + } + }, + 'setup client side sketch segments': ({ + context: { sketchDetails, selectionRanges }, + }) => { + if (!sketchDetails) return + ;(async () => { + if (Object.keys(sceneEntitiesManager.activeSegments).length > 0) { + await sceneEntitiesManager.tearDownSketch({ removeAxis: false }) + } + sceneInfra.resetMouseListeners() + await sceneEntitiesManager.setupSketch({ + sketchPathToNode: sketchDetails?.sketchPathToNode || [], + forward: sketchDetails.zAxis, + up: sketchDetails.yAxis, + position: sketchDetails.origin, + maybeModdedAst: kclManager.ast, + selectionRanges, + }) + sceneInfra.resetMouseListeners() + sceneEntitiesManager.setupSketchIdleCallbacks({ + pathToNode: sketchDetails?.sketchPathToNode || [], + forward: sketchDetails.zAxis, + up: sketchDetails.yAxis, + position: sketchDetails.origin, + }) + })() + }, + 'tear down client sketch': () => { + if (sceneEntitiesManager.activeSegments) { + sceneEntitiesManager.tearDownSketch({ removeAxis: false }) + } + }, + 'remove sketch grid': () => sceneEntitiesManager.removeSketchGrid(), + 'set up draft line': ({ context: { sketchDetails } }) => { + if (!sketchDetails) return + sceneEntitiesManager.setUpDraftSegment( + sketchDetails.sketchPathToNode, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin, + 'line' + ) + }, + 'set up draft arc': ({ context: { sketchDetails } }) => { + if (!sketchDetails) return + sceneEntitiesManager.setUpDraftSegment( + sketchDetails.sketchPathToNode, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin, + 'tangentialArcTo' + ) + }, + 'listen for rectangle origin': ({ context: { sketchDetails } }) => { + if (!sketchDetails) return + sceneEntitiesManager.setupNoPointsListener({ + sketchDetails, + afterClick: (args) => { + const twoD = args.intersectionPoint?.twoD + if (twoD) { + sceneInfra.modelingSend({ + type: 'Add rectangle origin', + data: [twoD.x, twoD.y], + }) + } else { + console.error('No intersection point found') + } + }, + }) + }, + 'set up draft rectangle': ({ context: { sketchDetails }, event }) => { + if (event.type !== 'Add rectangle origin') return + if (!sketchDetails || !event.data) return + sceneEntitiesManager.setupDraftRectangle( + sketchDetails.sketchPathToNode, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin, + event.data + ) + }, + 'set up draft line without teardown': ({ context: { sketchDetails } }) => { + if (!sketchDetails) return + sceneEntitiesManager.setUpDraftSegment( + sketchDetails.sketchPathToNode, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin, + 'line', + false + ) + }, + 'show default planes': () => kclManager.showPlanes(), + 'setup noPoints onClick listener': ({ context: { sketchDetails } }) => { + if (!sketchDetails) return + + sceneEntitiesManager.setupNoPointsListener({ + sketchDetails, + afterClick: () => sceneInfra.modelingSend({ type: 'Add start point' }), + }) + }, + 'add axis n grid': ({ context: { sketchDetails } }) => { + if (!sketchDetails) return + if (localStorage.getItem('disableAxis')) return + sceneEntitiesManager.createSketchAxis( + sketchDetails.sketchPathToNode || [], + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) + }, + 'reset client scene mouse handlers': () => { + // when not in sketch mode we don't need any mouse listeners + // (note the orbit controls are always active though) + sceneInfra.resetMouseListeners() + }, + 'clientToEngine cam sync direction': () => { + sceneInfra.camControls.syncDirection = 'clientToEngine' + }, + 'engineToClient cam sync direction': () => { + sceneInfra.camControls.syncDirection = 'engineToClient' + }, + 'set selection filter to faces only': () => + engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'set_selection_filter', + filter: ['face', 'object'], + }, + }), + 'set selection filter to defaults': () => + kclManager.defaultSelectionFilter(), + 'Delete segment': ({ context: { sketchDetails }, event }) => { + if (event.type !== 'Delete segment') return + if (!sketchDetails || !event.data) return + return deleteSegment({ + pathToNode: event.data, + sketchDetails, + }) + }, + 'Reset Segment Overlays': () => sceneEntitiesManager.resetOverlays(), + 'Set context': assign({ + store: ({ context: { store }, event }) => { + if (event.type !== 'Set context') return store + if (!event.data) return store + if (event.data.streamDimensions) { + sceneInfra._streamDimensions = event.data.streamDimensions + } + + const result = { + ...store, + ...event.data, + } + const persistedContext: Partial = {} + for (const key of PersistedValues) { + persistedContext[key] = result[key] + } + if (typeof window !== 'undefined') { + window.localStorage.setItem( + PERSIST_MODELING_CONTEXT, + JSON.stringify(persistedContext) + ) + } + return result + }, + }), + Make: () => {}, + 'enable copilot': () => {}, + 'disable copilot': () => {}, + 'Set selection': () => {}, + 'Set mouse state': () => {}, + 'Set Segment Overlays': () => {}, + 'Engine export': () => {}, + 'Submit to Text-to-CAD API': () => {}, + 'Set sketchDetails': () => {}, + 'sketch exit execute': () => {}, + }, + // end actions + actors: { + 'do-constrain-remove-constraint': fromPromise( + async ({ + input: { selectionRanges, sketchDetails, data }, + }: { + input: Pick< + ModelingMachineContext, + 'selectionRanges' | 'sketchDetails' + > & { data?: PathToNode } + }) => { const constraint = applyRemoveConstrainingValues({ selectionRanges, pathToNodes: data && [data], @@ -1367,10 +823,13 @@ export const modelingMachine = createMachine( updatedAst.newAst ), } - }, - 'do-constrain-horizontally': async ({ - selectionRanges, - sketchDetails, + } + ), + 'do-constrain-horizontally': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick }) => { const constraint = applyConstraintHorzVert( selectionRanges, @@ -1398,8 +857,14 @@ export const modelingMachine = createMachine( updatedAst.newAst ), } - }, - 'do-constrain-vertically': async ({ selectionRanges, sketchDetails }) => { + } + ), + 'do-constrain-vertically': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick + }) => { const constraint = applyConstraintHorzVert( selectionRanges, 'vertical', @@ -1426,10 +891,13 @@ export const modelingMachine = createMachine( updatedAst.newAst ), } - }, - 'do-constrain-horizontally-align': async ({ - selectionRanges, - sketchDetails, + } + ), + 'do-constrain-horizontally-align': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick }) => { const constraint = applyConstraintHorzVertAlign({ selectionRanges, @@ -1456,10 +924,13 @@ export const modelingMachine = createMachine( selectionType: 'completeSelection', selection: updatedSelectionRanges, } - }, - 'do-constrain-vertically-align': async ({ - selectionRanges, - sketchDetails, + } + ), + 'do-constrain-vertically-align': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick }) => { const constraint = applyConstraintHorzVertAlign({ selectionRanges, @@ -1486,8 +957,14 @@ export const modelingMachine = createMachine( selectionType: 'completeSelection', selection: updatedSelectionRanges, } - }, - 'do-constrain-snap-to-x': async ({ selectionRanges, sketchDetails }) => { + } + ), + 'do-constrain-snap-to-x': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick + }) => { const constraint = applyConstraintAxisAlign({ selectionRanges, constraint: 'snapToXAxis', @@ -1513,8 +990,14 @@ export const modelingMachine = createMachine( selectionType: 'completeSelection', selection: updatedSelectionRanges, } - }, - 'do-constrain-snap-to-y': async ({ selectionRanges, sketchDetails }) => { + } + ), + 'do-constrain-snap-to-y': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick + }) => { const constraint = applyConstraintAxisAlign({ selectionRanges, constraint: 'snapToYAxis', @@ -1540,8 +1023,14 @@ export const modelingMachine = createMachine( selectionType: 'completeSelection', selection: updatedSelectionRanges, } - }, - 'do-constrain-parallel': async ({ selectionRanges, sketchDetails }) => { + } + ), + 'do-constrain-parallel': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick + }) => { const constraint = applyConstraintEqualAngle({ selectionRanges, }) @@ -1571,10 +1060,13 @@ export const modelingMachine = createMachine( selectionType: 'completeSelection', selection: updatedSelectionRanges, } - }, - 'do-constrain-equal-length': async ({ - selectionRanges, - sketchDetails, + } + ), + 'do-constrain-equal-length': fromPromise( + async ({ + input: { selectionRanges, sketchDetails }, + }: { + input: Pick }) => { const constraint = applyConstraintEqualLength({ selectionRanges, @@ -1600,11 +1092,816 @@ export const modelingMachine = createMachine( selectionType: 'completeSelection', selection: updatedSelectionRanges, } + } + ), + 'Get vertical info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'Get ABS X info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'Get ABS Y info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'Get angle info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'Get perpendicular distance info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'AST-undo-startSketchOn': fromPromise( + async (_: { input: Pick }) => { + return undefined + } + ), + 'animate-to-face': fromPromise( + async (_: { input?: ExtrudeFacePlane | DefaultPlane }) => { + return {} as + | undefined + | { + sketchPathToNode: PathToNode + zAxis: [number, number, number] + yAxis: [number, number, number] + origin: [number, number, number] + } + } + ), + 'animate-to-sketch': fromPromise( + async (_: { input: Pick }) => { + return {} as { + sketchPathToNode: PathToNode + zAxis: [number, number, number] + yAxis: [number, number, number] + origin: [number, number, number] + } + } + ), + 'Get horizontal info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'Get length info': fromPromise( + async (_: { + input: Pick + }) => { + return {} as SetSelections + } + ), + 'Get convert to variable info': fromPromise( + async (_: { + input: Pick< + ModelingMachineContext, + 'sketchDetails' | 'selectionRanges' + > & { + data?: { + variableName: string + pathToNode: PathToNode + } + } + }) => { + return {} as SetSelections + } + ), + }, + // end services +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QFkD2EwBsCWA7KAxAMICGuAxlgNoAMAuoqAA6qzYAu2qujIAHogC0ANhoBWAHQAOAMwB2KQEY5AFgCcGqWqkAaEAE9Ew0RLEqa64TIBMKmTUXCAvk71oMOfAQDKYdgAJYLDByTm5aBiQQFjYwniiBBEEpYSkJOUUaOWsxeylrWzk9QwQClQkrVOsZNWExFItnVxB3LDxCXwCAW1QAVyDA9hJ2MAjeGI4ueNBE5KkaCWspZfMM+XE1YsQZMQWxNQtxao0ZeRc3dDavTv9ybhG+djGoibjeRJothBpzlsvPDp+fy+KBdMC4AIAeQAbmAAE6YEj6WDPZisSbcd6IT4GbG-VoAiTYCCYMAEACiEPhgQA1n5yAALVHRdFvBJCJYLHZKawHczVBxfeySRTmOpqMRyNSZapNC4edpEklkykjOG0+lMxSRNGxKZYpK2BbmWrqcQ0URiJZCkxyGgyYTKfZSK28sT4-6K4mkimPOG9DDM1769lJSUyCQSlTKGQqXKKF3CL6KaoRjKpORieM1RQehX4JU+gBi2EwpKe9HGrJDMyEWYjdhdYkcdsl1iKuIQiljwgkMakchsMmUDjzVyghZVfBYcIrOpZesxocEYjq6XbohoKVFUsUyZUjokNUzSmE1gT57HhO9ZOQJDpQerS9rCBU1gqWjfakH0ay+y+wgKOk3IqComZmLGUhXl6yoEAAIsEIyBMEoRTI+i7TPwQiOuU2hmCOCbRooYjJpK5Q1GevKKI41FStBBY3gQAAqYCPII7CoIIRAAIJwehGKYbMzbvmYKRZhK4YHsmqi9lY2Q5No1jCNGcj0RON4SAyxJgAACoiuBwAQ3GztgABmJChP4UBwiQTAMv4LBML0iIjBA-Fsi+ggHmoEjiDG35aIFDpfN+EYOKJA7dtRUgqGpk4SLADKoAA7npZCGcZnDmZZ1m2fZYBdEwnCQO5NZYUkYFyOkLqZkpgExWBJGdtRBR9lo1H2BaObWHF3h0uwjLEGQlCYH1mqlc+5Upqcix2G+dSOkptjJooBy+cpVhZnIUr2r1-WDQh5ZgMhoLgnOVYYQa22SGobpLOodrLDIXxxlVGjmPWuzKRae2agQdwYP4kAcP4EC9HC7QagNTKVi8T6CYg3a3aYYEBVoylmB2JRqDUvmrsstRZNRaixc0BKKmN0MSJTjIAJKwXedInWCEL+LCJnkCQmATQjXanGkw7LEpjgqOj+6tbY9TqPMQuyL9VM0wy9M+ozx1BKdrNJRDABe9xczzBrdue6SgfUOw45UKjJjYxrSla+xEzQo5k56BaK9T+1K7BRDcLA7A2Xg-ha9gusQlzoPYH7w2jLDuoCYb9iKH2ChLLIxEKLonZGpITtG2BOzp3Kfz5hO7uK8rZI+7gfsB7gbPwpwnOYBHUcUDH87BpNiRRb2W5PQ454SrGXwFPkEhgU7NAHLkw7y4yHuahXxC+-7JCB9xABC3j+AAGgbobdjYpg-jj0bno6TUlLyViRjQSkWPY1gOFBLslwvCue0vVc12vdeb9vABNfeL5D7vnkEoE0tR7ZW07PYb8FQ1gnhihnIu5M3ae3fnTb2K9a7+DIFAUkwCpo1D2KteYrY+T5BHs2XsT9QJ33tPdUCc8GSYK9j6b+q9A6knwOwGGHd4YJxiqYcKA5HSCzsCPM8VUMhWjfAUPuxEWFsK-jg3+Dl4RMHBBAbA5BnIkHVDo1ulAiHd3bJITavI+7tl5JnK++QIzGDbE-J26geqv3HGwlR2Dq5cLrsHUOQwyz6DwTgKAuBTGIzgZGEmB4ZD5GkVmUiORZothcQ6LcMhlHlx8T-QO7NG5c0wCErm2BwmRL5kpUwqQaoaGyMpZ6zUrDlFyKuAoEoDhDmyZ-XJfjAi4Fsv4Diu8KndhMDUAKqh9iZCoVnPyydRQHGWKtZQ3TF69NwbAAZTAhmoH8EA2OC544HwyYsFM19zxSkHI0q+xFezqDjLsVaOwp5rOhqo3xuCwAAEdejhx4VAPhozYwLDqH+JQ252nSVxvIRadgbA4TeVgjhajA5MAMUU6ghzO6827KoPsZ59iAUyKKbsyYJS9izFLBwyluyk3lJ4suPSUWfPUXCAqqBYS3FRRCFE2LBEHzdCbGKsY5BgvMcmWQPlajtlAnaaUw5cweMJEy9ZPoABKYBBCsRCL0EYozHRpG0NGcUhNvzJnNOPWQoEKXCBOO4hlKqME5J9ADY6wMAhgwhvgKGjJgXI0dNKZQA4H6rQtfaXyYrNqrhxlmJVjqKbOuZZXbgBTdlswMdgEgAAjQh-LLonI0NILICSSarSsNYIU58jyrhwk-fY9Li6MqTWqskjJ8HHQ4qgbm+bjkvhyHfaQVjboO1CpW5qxF3xZE3O02QVgX4JvQZqCQ3FkprwCAEvWzcjFDDbv4PAplUAEAgNwMARJcDQlQHSCQMB2CCE3WHTAggD2oAqdUZspgHFCyfjFOoI83xpClCkMCptE6NrQaXDBq711B1QDrLdLdd2UH3bgQ9BB4RwjgxIJgLlD1wi6Devw964Mhy3c+1Dr7e0eXKu+98A74mKQvpsLOJDIzAdsGeZY4hVLKsTcu6DIMCm6PDju6OKG0MnoMuey917b2CCE03cjh633m0WIODOMV5hnhHryd8+RMhipNJc5RAmAgKZE5HJDx0X3obhJhuE2HcNwYI3J8zT6X0qf2JGFSsLVzypgVfHISdqKijqg004Jm10g3-rvcTR7JNnrwDJs9cmc2wEEHwJTlGBEFv7Q6CMWggNZFjDake34k5bjPKsSr2RIswZizvOLtn7OOeGHhlzRG0sZayyp5S1TtBniJipMrKT7BysHOQnGPHF2Qf41FgIMWAFNYS9Jq9KXOvZvS-oHrVGyqJFlA2aUP5fy2HkEKad49xui1HQOOr0Wt77KaxhrDOG2vOcI3err22PO7a7ogWU5RHAOnTmBc8W4hQxl8sRUWdRZUtjuwEfBpJluntW7Jzr+BSQ7Zy32mjDpyh2AyHi8wsyr6rkkGImwEEVm2AR3gzH1mKPNZe05-DH3BBI61T9nH1H9sOgp+YTIxgHBir3FnK0vd9g1DjEpSUoo6cAr4SjqTSW1vs8VwybHF1cd87PNU2Q9SsxZHHVfO15QnbShNfITM02m1OrmzBjXT27Ms7e2zuTGutdw1y3jm+B2HTKVPqKKRH7RBp3kM2fIu1eNLqpqZjRcItG4B0XoxEhjLNiZsyt1X6O71aMT9o3R+i4SCFE23L3cdef-eHAsKedh7SZB2M2LG-3rVHmUFYgcW06f56Tyn4viHM9M+ew5177B2vs974X1PBjS8Z-L9z7XVfSg198iTewNem-KBHgoXsw5shLPArbiDXiAAyeBO2oG7f9BkHbdndrfaISlcYBwZBSDYEKSxTCy6nsL9szsZsz8L979MAJBaZcAOACA30lgIxvwbU6hMwUxZAAI1pCIxVThuwDcHU7c+MqZz8DIQCwCID2AoDtQl89t-sqJIwMh7plJCclIvgpRexvw7Ugdzx4kFBlF8DL9u0JAAA5PZHSVAPAdgWAIyCACAQYAxAIFgEQx-NaR+GoIcbQCUACbIceC+SUbaBSOiGPWbPA4ArtUAgQ-wIQkQsQ0gNuHtHnCgrsdGAlOdbjNsOxRADIY0N8EXeoLQLcZRcAyA0ZLqCoc+KPBoXfZMWwd8aMWMXYU4KwV5PQrxPwkgqgMg73HXRGKeHyO+GpRVJYO0JMZqTqYCWiQ4HIeJd0BI92JiDtCELNZuAxcgXZG-O-IwipdYPfOMcQO1J+MVLIRg+oCoCedArIJZCowA92XoZPPZKOWcRWSEXAY9VHHPM9bibwJiQQSYk9QQGY9gOYiJX7XFYHT9bQ0UeofYEmfo4LDGa7BQVGcDV2fQ+eTVUITnEA5o-AHg6w8gv7BAGvBYaHfIVQFMXfFvLsO+HyQ1QE3YJsVBB4rxZ43dAhT4ldebSGIIO4ZPblOEAyOEAgEsCAxKfwdlF4hnNo+RCoeMRoPyZsALRAAcMBWwEmYWTIEDZRBE14owlE9dSGEjKAPAcQyQ4kxE5HXkvAMkhwdIK0EmLMO1ewTMRgzISNI4JQDQC2bAk-d2dkhnQg-EyOBkSAfwLUpEqAg4w2O0BYN0C2Z0RwBgzsWoWAhMM1C0NxZRcgUkMgQIFyMkUZA8JOIE3Ic06oAoS+RGFMJOCUZ5MUJ2YHOnDEtNYZaETNHNH0bPC9NXOTOMhudiTiRMiGZMrnCjFTCWA8UHYWHGC0KtL-ULeFMVGhW6WM1NBudNXMrNXNMkEfVrcfd7DMxs2cbM+TJMtsivI5ZfVMKqcRQCBwKcis2BB0QDA-eaaKVae4t+d2ePDEvJOudlHoLlDcvxUQxYlXNM69LYvc2uQQbczlLVM83+c6NI0cmwJOKUk7bQDAgokoBMGaIndsaieoLcDIBs1lQOB9IJYpQ8xLY8s9U8nlYjeDR9YpFTUWYCXkcBGKJYZaQoxUwPA4KUGhU4wCzc+uDmIpfQcCtHKCziG8vAeTBuYTYJTze5dC9sLQWVWkrsEnI8DMbsXYZ+CLSoqDebblIC-xEjQJEi0JMpBY1M5LCQaC4S2C0jeC7bUpcpU00MVMCE5sXYEnOMNi4iZQaQPkQ1WwGqBdHA2PeedcnlIiwpYJCS8JMi5Y2SyimCtzYpDnMJfYmwn4oMp8g4aspQZsZMcRI8KeR0q0HYWMAivpLZQZYZHeRyyC5ywQKi3AbY7ZfsvgRCmRdMaUE4OtEeCwc3KoO1O2F5eNcyx41hKy4S-pOKvZABRKmSuSzc9K2yfs-QTzRxEtNxEVDfEebixYVIaiQCP86iaKr5X5f5cEQFBkJqtXFqvxbVKap9DXFTXGC0Y3OpLvEMrsbIHOU0K0HaU4aoCa9RdFGyMsLAeak8ly+Si6zFL4+82w99XuN8UXe0YwfOclMCNqR8xwO2ZpZRIgW-D4-wJiK-TAE07y3mOHKHU4B5OXO+G5DI6UYCVQb8XkDOWoYG0GmAcGyG0gmGg0KicoVQNxCwWwVaHIC1NG9TB6LGgmWE1cjBEGu-CG6-KgawYm0MYcdQPsLQp2U7UQHGWmv0+QBmtGEmXG9mwmqgGQHml8B0baCoCUF0X8CiHYMW9GyW7G5m5tTUfwXAPZbKMkXwUkSyDAcyZyGQ-Sdub43mQQJ+SIlC8tJsFU98owQcdIYwTGp2b9NQOKMgbALoYYSGYZV7AyG6s9YO0OkYfs02ipcMCoX-QoEWmpACQ8AdDTR0QcdUuE2OsOn1YZViSzNEz2aOiQQu+OjibYz2JOgys8ClY4ZQKZc7SQB0BVGhbIG0poZoY2jAeAKICDB2g0EQZYDQ7kW4vkZjD8qpX8eYJ+YNeYVcOKG8Ue5cb6Se+oaeiwWeuk3GUUF0dseQAuHINe5UTSbSNKAyIeyvWwlcZGOJHemKGei1FJJYYicEu0BQWrBIjSRKFKG+uADezyHYd8BQd6e0WQEY-esEj+wK7+ktP+8Yz2UBqacKIIxwEI0QMIrObsUwbQEtZ0HYbaJFdhMAdB7uTo8eBGrQuoUrZqJ+MKENMwA4OAuWfih3EGECizYxRnQ9KhxGO2Y+O1OXC2aXf9CUqiFMRG4cfW+3OPQStzQfPdF9IRrsW6HyS026EXRwO+HTAY78SeMiBSSUOnBrOLDR5ci0lZHqrGsrcwcebImUW6RvCxh7JbdR56n455CEugmUF0C0W6c7fFZQTAhMVQAcHYOnV47x++3xpkn20Qc8AcHGPCahAysVWoIDOdKoBXGapXeJkc2w55KqXIKMevZG+oKROMdGpQVaKlAAyqrxePKfZPIvNPVR5DYpnFQ2TpSMGwGHZYM2GmrOHaCoBaa1CRcQLgwwyGjR88XYRYKefLb8UKUEs4tTADYmc8H9OZggzkpIxZshFZiR9Z9TRgiUlqbBu2MK4-OE92bgwg42-DLmRZswcocROU1cYmMXEoNwy7JepBJZA55Ekwsw3lD5p2ceGnOoGKTIJJTsbaJOAoWUmSTu3kXw4g6xoXaqUUO+ZsW6J-FaNaZ06NbaTpZRaoj42o8OBo3ZDR+QWwXyToyqdYSrACXGYwM8R0dOClQOrhqmTY6YoYWYz2eY6xwcWSDIEskau0E3RAKlZx7aUWWQGAiwNkkIYUz4pli0JOSKMwHIZ2mckoSUWhe0WtBoU4aPVB5dI05HTk0zNEkIbgSQu4bE+EJlzIHyQ1t8K0BhT2hABp6Qd-Earo9QLJIVp47VjkyGrkyYH1UUzCPp3m0lCoUhv82Uv8BU2vVsT+1Uh0fOlm+12N7Uzk3UxKA0h1yhnx3mYcWMUwK0X8rMJacHO0i0aQO1RhbIQEuoV090uuWAL06x+hBBQrNsE1f5jI9Qg4ZpU+baGJ6N6qwSzM2cZswc0kaxgZi2OwYaiIgx2BdpRYOpbQ8+DGM6wOS83cnlUQ6x9qXyIcO+DcXIAoSVY2KbFIH8I4bsS9kSuC0CkoBJ3FB9xhR+F9oZ8I7QaQdA1UgG-J5dhNgIVKmyui4pe9tGsD592syD5qMUH2+0W6bIewDMP92DAD8SlSlNgVEBUDoccBTG+qUiTtkaiUcQKnI6sjtykpTyjDirejyB+pFwrsUQJ8nYZSQcJ2YwQcMj2KnZeK7diNNU5scBQloN5hujEmE8M8MbMYlptc1d6yuT9NABRT2AotlThQNTga+QNTKUEmU8HGKNu1pRmDFDn5P5ZuDXPjx9scwrJSBQaSTt2FZQQmUWFksjh6q6zARTujeYDAgcV-EJ5qWNZx41FeuoEjmWsGjmmLutkm+pFGHet-CtFaD9emzGqWhR3AxkI2k2iyWt4Dse41yZ0SVQBRGoW0koeaapMPRoU0M4BI6u8OvZSOxrkp3xm+RSUWdA8MAbACK0NjMFWMUhlBlp4b4uvZUuv2cuzUDR4wNTMomwXt1u4T+RgWh+aRGiHjFwIAA */ + id: 'Modeling', + + context: ({ input }) => ({ + ...modelingMachineDefaultContext, + ...input, + }), + + states: { + idle: { + on: { + 'Enter sketch': [ + { + target: 'animating to existing sketch', + guard: 'Selection is on face', + }, + 'Sketch no face', + ], + + Extrude: { + target: 'idle', + guard: 'has valid extrude selection', + actions: ['AST extrude'], + reenter: false, + }, + + Fillet: { + target: 'idle', + guard: 'has valid fillet selection', // TODO: fix selections + actions: ['AST fillet'], + reenter: false, + }, + + Export: { + target: 'idle', + reenter: false, + guard: 'Has exportable geometry', + actions: 'Engine export', + }, + + Make: { + target: 'idle', + reenter: false, + guard: 'Has exportable geometry', + actions: 'Make', + }, + + 'Delete selection': { + target: 'idle', + guard: 'has valid selection for deletion', + actions: ['AST delete selection'], + reenter: false, + }, + + 'Text-to-CAD': { + target: 'idle', + reenter: false, + actions: ['Submit to Text-to-CAD API'], + }, + }, + + entry: 'reset client scene mouse handlers', + + states: { + hidePlanes: { + on: { + 'Artifact graph populated': 'showPlanes', + }, + + entry: 'hide default planes', + }, + + showPlanes: { + on: { + 'Artifact graph emptied': 'hidePlanes', + }, + + entry: ['show default planes', 'reset camera position'], + }, + }, + + initial: 'hidePlanes', + }, + + Sketch: { + states: { + SketchIdle: { + on: { + 'Make segment vertical': { + guard: 'Can make selection vertical', + target: 'Await constrain vertically', + }, + + 'Make segment horizontal': { + guard: 'Can make selection horizontal', + target: 'Await constrain horizontally', + }, + + 'Constrain horizontal distance': { + target: 'Await horizontal distance info', + guard: 'Can constrain horizontal distance', + }, + + 'Constrain vertical distance': { + target: 'Await vertical distance info', + guard: 'Can constrain vertical distance', + }, + + 'Constrain ABS X': { + target: 'Await ABS X info', + guard: 'Can constrain ABS X', + }, + + 'Constrain ABS Y': { + target: 'Await ABS Y info', + guard: 'Can constrain ABS Y', + }, + + 'Constrain angle': { + target: 'Await angle info', + guard: 'Can constrain angle', + }, + + 'Constrain length': { + target: 'Await length info', + guard: 'Can constrain length', + }, + + 'Constrain perpendicular distance': { + target: 'Await perpendicular distance info', + guard: 'Can constrain perpendicular distance', + }, + + 'Constrain horizontally align': { + guard: 'Can constrain horizontally align', + target: 'Await constrain horizontally align', + }, + + 'Constrain vertically align': { + guard: 'Can constrain vertically align', + target: 'Await constrain vertically align', + }, + + 'Constrain snap to X': { + guard: 'Can constrain snap to X', + target: 'Await constrain snap to X', + }, + + 'Constrain snap to Y': { + guard: 'Can constrain snap to Y', + target: 'Await constrain snap to Y', + }, + + 'Constrain equal length': { + guard: 'Can constrain equal length', + target: 'Await constrain equal length', + }, + + 'Constrain parallel': { + target: 'Await constrain parallel', + guard: 'Can canstrain parallel', + }, + + 'Constrain remove constraints': { + guard: 'Can constrain remove constraints', + target: 'Await constrain remove constraints', + }, + + 'Re-execute': { + target: 'SketchIdle', + reenter: false, + actions: ['set sketchMetadata from pathToNode'], + }, + + 'code edit during sketch': 'clean slate', + + 'Convert to variable': { + target: 'Await convert to variable', + guard: 'Can convert to variable', + }, + + 'change tool': { + target: 'Change Tool', + }, + }, + + entry: 'setup client side sketch segments', + }, + + 'Await horizontal distance info': { + invoke: { + src: 'Get horizontal info', + id: 'get-horizontal-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Await vertical distance info': { + invoke: { + src: 'Get vertical info', + id: 'get-vertical-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Await ABS X info': { + invoke: { + src: 'Get ABS X info', + id: 'get-abs-x-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Await ABS Y info': { + invoke: { + src: 'Get ABS Y info', + id: 'get-abs-y-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Await angle info': { + invoke: { + src: 'Get angle info', + id: 'get-angle-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Await length info': { + invoke: { + src: 'Get length info', + id: 'get-length-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Await perpendicular distance info': { + invoke: { + src: 'Get perpendicular distance info', + id: 'get-perpendicular-distance-info', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + onError: 'SketchIdle', + }, + }, + + 'Line tool': { + exit: [], + + states: { + Init: { + always: [ + { + target: 'normal', + guard: 'has made first point', + actions: 'set up draft line', + }, + 'No Points', + ], + }, + + normal: {}, + + 'No Points': { + entry: 'setup noPoints onClick listener', + + on: { + 'Add start point': { + target: 'normal', + actions: 'set up draft line without teardown', + }, + + Cancel: '#Modeling.Sketch.undo startSketchOn', + }, + }, + }, + + initial: 'Init', + + on: { + 'change tool': { + target: 'Change Tool', + }, + }, + }, + + Init: { + always: [ + { + target: 'SketchIdle', + guard: 'is editing existing sketch', + }, + 'Line tool', + ], + }, + + 'Tangential arc to': { + entry: 'set up draft arc', + + on: { + 'change tool': { + target: 'Change Tool', + }, + }, + }, + + 'undo startSketchOn': { + invoke: { + src: 'AST-undo-startSketchOn', + id: 'AST-undo-startSketchOn', + input: ({ context: { sketchDetails } }) => ({ sketchDetails }), + onDone: { + target: '#Modeling.idle', + actions: 'enter modeling mode', + }, + }, + }, + + 'Rectangle tool': { + entry: ['listen for rectangle origin'], + + states: { + 'Awaiting second corner': { + on: { + 'Finish rectangle': 'Finished Rectangle', + }, + }, + + 'Awaiting origin': { + on: { + 'Add rectangle origin': { + target: 'Awaiting second corner', + actions: 'set up draft rectangle', + }, + }, + }, + + 'Finished Rectangle': { + always: '#Modeling.Sketch.SketchIdle', + }, + }, + + initial: 'Awaiting origin', + + on: { + 'change tool': { + target: 'Change Tool', + }, + }, + }, + + 'clean slate': { + always: 'SketchIdle', + }, + + 'Await convert to variable': { + invoke: { + src: 'Get convert to variable info', + id: 'get-convert-to-variable-info', + input: ({ context: { selectionRanges, sketchDetails }, event }) => { + if (event.type !== 'Convert to variable') { + return { + selectionRanges, + sketchDetails, + data: undefined, + } + } + return { + selectionRanges, + sketchDetails, + data: event.data, + } + }, + onError: 'SketchIdle', + onDone: { + target: 'SketchIdle', + actions: ['Set selection'], + }, + }, + }, + + 'Await constrain remove constraints': { + invoke: { + src: 'do-constrain-remove-constraint', + id: 'do-constrain-remove-constraint', + input: ({ context: { selectionRanges, sketchDetails }, event }) => { + return { + selectionRanges, + sketchDetails, + data: + event.type === 'Constrain remove constraints' + ? event.data + : undefined, + } + }, + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain horizontally': { + invoke: { + src: 'do-constrain-horizontally', + id: 'do-constrain-horizontally', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain vertically': { + invoke: { + src: 'do-constrain-vertically', + id: 'do-constrain-vertically', + input: ({ context: { selectionRanges, sketchDetails } }) => ({ + selectionRanges, + sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain horizontally align': { + invoke: { + src: 'do-constrain-horizontally-align', + id: 'do-constrain-horizontally-align', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain vertically align': { + invoke: { + src: 'do-constrain-vertically-align', + id: 'do-constrain-vertically-align', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain snap to X': { + invoke: { + src: 'do-constrain-snap-to-x', + id: 'do-constrain-snap-to-x', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain snap to Y': { + invoke: { + src: 'do-constrain-snap-to-y', + id: 'do-constrain-snap-to-y', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain equal length': { + invoke: { + src: 'do-constrain-equal-length', + id: 'do-constrain-equal-length', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Await constrain parallel': { + invoke: { + src: 'do-constrain-parallel', + id: 'do-constrain-parallel', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'SketchIdle', + actions: 'Set selection', + }, + }, + }, + + 'Change Tool': { + always: [ + { + target: 'SketchIdle', + guard: 'next is none', + }, + { + target: 'Line tool', + guard: 'next is line', + }, + { + target: 'Rectangle tool', + guard: 'next is rectangle', + }, + { + target: 'Tangential arc to', + guard: 'next is tangential arc', + }, + ], + + entry: 'assign tool in context', + }, + }, + + initial: 'Init', + + on: { + CancelSketch: '.SketchIdle', + + 'Delete segment': { + reenter: false, + actions: ['Delete segment', 'Set sketchDetails'], + }, + 'code edit during sketch': '.clean slate', + }, + + exit: [ + 'sketch exit execute', + 'tear down client sketch', + 'remove sketch grid', + 'engineToClient cam sync direction', + 'Reset Segment Overlays', + 'enable copilot', + ], + + entry: [ + 'add axis n grid', + 'conditionally equip line tool', + 'clientToEngine cam sync direction', + ], + }, + + 'Sketch no face': { + entry: [ + 'disable copilot', + 'show default planes', + 'set selection filter to faces only', + 'enter sketching mode', + ], + + exit: ['hide default planes', 'set selection filter to defaults'], + on: { + 'Select default plane': { + target: 'animating to plane', + actions: ['reset sketch metadata'], + }, }, }, - // end services - } -) + + 'animating to plane': { + invoke: { + src: 'animate-to-face', + id: 'animate-to-face', + input: ({ event }) => { + if (event.type !== 'Select default plane') return undefined + return event.data + }, + onDone: { + target: 'Sketch', + actions: 'set new sketch metadata', + }, + }, + }, + + 'animating to existing sketch': { + invoke: { + src: 'animate-to-sketch', + id: 'animate-to-sketch', + input: ({ context }) => ({ + selectionRanges: context.selectionRanges, + sketchDetails: context.sketchDetails, + }), + onDone: { + target: 'Sketch', + actions: [ + 'disable copilot', + 'set new sketch metadata', + 'enter sketching mode', + ], + }, + }, + }, + }, + + initial: 'idle', + + on: { + Cancel: { + target: '.idle', + // TODO what if we're existing extrude equipped, should these actions still be fired? + // maybe cancel needs to have a guard for if else logic? + actions: [ + 'reset sketch metadata', + 'enable copilot', + 'enter modeling mode', + ], + }, + + 'Set selection': { + reenter: false, + actions: 'Set selection', + }, + + 'Set mouse state': { + reenter: false, + actions: 'Set mouse state', + }, + 'Set context': { + reenter: false, + actions: 'Set context', + }, + 'Set Segment Overlays': { + reenter: false, + actions: 'Set Segment Overlays', + }, + }, +}) export function isEditingExistingSketch({ sketchDetails, diff --git a/src/machines/settingsMachine.ts b/src/machines/settingsMachine.ts index cccf68c7c..24ceaea2f 100644 --- a/src/machines/settingsMachine.ts +++ b/src/machines/settingsMachine.ts @@ -1,4 +1,4 @@ -import { assign, createMachine } from 'xstate' +import { assign, setup } from 'xstate' import { Themes, getSystemTheme, setThemeClass } from 'lib/theme' import { createSettings, settings } from 'lib/settings/initialSettings' import { @@ -9,177 +9,187 @@ import { WildcardSetEvent, } from 'lib/settings/settingsTypes' -export const settingsMachine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmusxPXx7bRt1DzMxI3UjD3UutwhAz4MyeHxiV5+AYRKIgGJzPCERZJFYpfDpLJgC6lK6VaqIExPMwWGwdGxBPRmAE9PSafCPMQ-EzWbQ6ELTOGzOJIxLLVbrTbbNKYKBpLaitAAUWgcGxMjk11uoBqVmBH0ZLKCrVs-xciCCwLCvhCjyMFhGHPh3IS5AASnB0AACZYI0SSS4KvF3AlafADRl1YZ2IxiRx6hBtIzPb7abQ+DxGaxmYKWrnzHnkGKO6jEYjOtN4OVlT03KrehAtOnm7Qaup6Ixm6mR6OaR4dAwjM1mVOxdM2lH8jZbXD4WBpRgAd2QAGMc2AAOIcIhF3Gl-EIRPA6yGcyh4whSnU0xGJ5GAat0OfFowma9xH9gBUK5LStUiECdMmfx+mg8hmNTY-PgMYQpoZoxh41g9q6+C0GAHDyLACL5nesBkBAzBgIQ2AAG6MAA1lhcEIZgSFWvMz4VGu5YALTbtYwEnj8HhxnooT1mG3QhmY-TmJ82gGCyjzaJEsLYAK8ClOReAelRr41HRJiMZYvysexdjUuohh+poBiGDuXzGKy0HWossmKmWyqIDR3zAZWLSahM2jWJ04YjDxHbDMmmhaYE3wmemxGIchLpxOZXpWQgNEjMB1h6WEYHqK8ZgJk2EL6N8wR1Cy-gJqJ4RAA */ - id: 'Settings', - predictableActionArguments: true, +export const settingsMachine = setup({ + types: { context: {} as ReturnType, - initial: 'idle', - states: { - idle: { - entry: ['setThemeClass', 'setClientSideSceneUnits'], + input: {} as ReturnType, + events: {} as + | WildcardSetEvent + | SetEventTypes + | { + type: 'set.app.theme' + data: { level: SettingsLevel; value: Themes } + } + | { + type: 'set.modeling.units' + data: { level: SettingsLevel; value: BaseUnit } + } + | { type: 'Reset settings'; defaultDirectory: string } + | { type: 'Set all settings'; settings: typeof settings }, + }, + actions: { + setEngineTheme: () => {}, + setClientTheme: () => {}, + 'Execute AST': () => {}, + toastSuccess: () => {}, + setEngineEdges: () => {}, + setEngineScaleGridVisibility: () => {}, + setClientSideSceneUnits: () => {}, + persistSettings: () => {}, + resetSettings: assign(({ context, event }) => { + if (!('defaultDirectory' in event)) return {} + // Reset everything except onboarding status, + // which should be preserved + const newSettings = createSettings() + if (context.app.onboardingStatus.user) { + newSettings.app.onboardingStatus.user = + context.app.onboardingStatus.user + } + // We instead pass in the default directory since it's asynchronous + // to re-initialize, and that can be done by the caller. + newSettings.app.projectDirectory.default = event.defaultDirectory - on: { - '*': { - target: 'persisting settings', - actions: ['setSettingAtLevel', 'toastSuccess'], - }, + return newSettings + }), + setAllSettings: assign(({ event }) => { + if (!('settings' in event)) return {} + return event.settings + }), + 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] - 'set.app.onboardingStatus': { - target: 'persisting settings', + // @ts-ignore + context[category][setting][level] = value - // No toast - actions: ['setSettingAtLevel'], - }, - - 'set.app.themeColor': { - target: 'persisting settings', - - // No toast - actions: ['setSettingAtLevel'], - }, - - 'set.modeling.defaultUnit': { - target: 'persisting settings', - - actions: [ - 'setSettingAtLevel', - 'toastSuccess', - 'setClientSideSceneUnits', - 'Execute AST', - ], - }, - - 'set.app.theme': { - target: 'persisting settings', - - actions: [ - 'setSettingAtLevel', - 'toastSuccess', - 'setThemeClass', - 'setEngineTheme', - 'setClientTheme', - ], - }, - - 'set.app.streamIdleMode': { - target: 'persisting settings', - - actions: ['setSettingAtLevel', 'toastSuccess'], - }, - - 'set.modeling.highlightEdges': { - target: 'persisting settings', - - actions: ['setSettingAtLevel', 'toastSuccess', 'setEngineEdges'], - }, - - 'Reset settings': { - target: 'persisting settings', - - actions: [ - 'resetSettings', - 'setThemeClass', - 'setEngineTheme', - 'setClientSideSceneUnits', - 'Execute AST', - 'setClientTheme', - ], - }, - - 'Set all settings': { - actions: [ - 'setAllSettings', - 'setThemeClass', - 'setEngineTheme', - 'setClientSideSceneUnits', - 'Execute AST', - 'setClientTheme', - ], - }, - - 'set.modeling.showScaleGrid': { - target: 'persisting settings', - actions: [ - 'setSettingAtLevel', - 'toastSuccess', - 'setEngineScaleGridVisibility', - ], - }, + const newContext = { + ...context, + [category]: { + ...context[category], + // @ts-ignore + [setting]: context[category][setting], }, - }, + } - 'persisting settings': { - invoke: { - src: 'Persist settings', - id: 'persistSettings', - onDone: 'idle', - }, - }, - }, - tsTypes: {} as import('./settingsMachine.typegen').Typegen0, - schema: { - events: {} as - | WildcardSetEvent - | SetEventTypes - | { - type: 'set.app.theme' - data: { level: SettingsLevel; value: Themes } - } - | { - type: 'set.modeling.units' - data: { level: SettingsLevel; value: BaseUnit } - } - | { type: 'Reset settings'; defaultDirectory: string } - | { type: 'Set all settings'; settings: typeof settings }, + return newContext + }), + setThemeClass: ({ context }) => { + const currentTheme = context.app.theme.current ?? Themes.System + setThemeClass( + currentTheme === Themes.System ? getSystemTheme() : currentTheme + ) }, }, - { - actions: { - resetSettings: assign((context, { defaultDirectory }) => { - // Reset everything except onboarding status, - // which should be preserved - const newSettings = createSettings() - if (context.app.onboardingStatus.user) { - newSettings.app.onboardingStatus.user = - context.app.onboardingStatus.user - } - // We instead pass in the default directory since it's asynchronous - // to re-initialize, and that can be done by the caller. - newSettings.app.projectDirectory.default = defaultDirectory +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmusxPXx7bRt1DzMxI3UjD3UutwhAz4MyeHxiV5+AYRKIgGJzPCERZJFYpfDpLJgC6lK6VaqIExPMwWGwdGxBPRmAE9PSafCPMQ-EzWbQ6ELTOGzOJIxLLVbrTbbNKYKBpLaitAAUWgcGxMjk11uoBqVmBH0ZLKCrVs-xciCCwLCvhCjyMFhGHPh3IS5AASnB0AACZYI0SSS4KvF3AlafADRl1YZ2IxiRx6hBtIzPb7abQ+DxGaxmYKWrnzHnkGKO6jEYjOtN4OVlT03KrehAtOnm7Qaup6Ixm6mR6OaR4dAwjM1mVOxdM2lH8jZbXD4WBpRgAd2QAGMc2AAOIcIhF3Gl-EIRPA6yGcyh4whSnU0xGJ5GAat0OfFowma9xH9gBUK5LStUiECdMmfx+mg8hmNTY-PgMYQpoZoxh41g9q6+C0GAHDyLACL5nesBkBAzBgIQ2AAG6MAA1lhcEIZgSFWvMz4VGu5YALTbtYwEnj8HhxnooT1mG3QhmY-TmJ82gGCyjzaJEsLYAK8ClOReAelRr41HRJiMZYvysexdjUuohh+poBiGDuXzGKy0HWossmKmWyqIDR3zAZWLSahM2jWJ04YjDxHbDMmmhaYE3wmemxGIchLpxOZXpWQgNEjMB1h6WEYHqK8ZgJk2EL6N8wR1Cy-gJqJ4RAA */ + id: 'Settings', + initial: 'idle', + context: ({ input }) => { + return { + ...createSettings(), + ...input, + } + }, + states: { + idle: { + entry: ['setThemeClass', 'setClientSideSceneUnits'], - return newSettings - }), - setAllSettings: assign((_, event) => { - return event.settings - }), - setSettingAtLevel: assign((context, event) => { - const { level, value } = event.data - const [category, setting] = event.type - .replace(/^set./, '') - .split('.') as [keyof typeof settings, string] + on: { + '*': { + target: 'persisting settings', + actions: ['setSettingAtLevel', 'toastSuccess'], + }, - // @ts-ignore - context[category][setting][level] = value + 'set.app.onboardingStatus': { + target: 'persisting settings', - const newContext = { - ...context, - [category]: { - ...context[category], - // @ts-ignore - [setting]: context[category][setting], - }, - } + // No toast + actions: ['setSettingAtLevel'], + }, - return newContext - }), - setThemeClass: (context) => { - const currentTheme = context.app.theme.current ?? Themes.System - setThemeClass( - currentTheme === Themes.System ? getSystemTheme() : currentTheme - ) + 'set.app.themeColor': { + target: 'persisting settings', + + // No toast + actions: ['setSettingAtLevel'], + }, + + 'set.modeling.defaultUnit': { + target: 'persisting settings', + + actions: [ + 'setSettingAtLevel', + 'toastSuccess', + 'setClientSideSceneUnits', + 'Execute AST', + ], + }, + + 'set.app.theme': { + target: 'persisting settings', + + actions: [ + 'setSettingAtLevel', + 'toastSuccess', + 'setThemeClass', + 'setEngineTheme', + 'setClientTheme', + ], + }, + + 'set.app.streamIdleMode': { + target: 'persisting settings', + + actions: ['setSettingAtLevel', 'toastSuccess'], + }, + + 'set.modeling.highlightEdges': { + target: 'persisting settings', + + actions: ['setSettingAtLevel', 'toastSuccess', 'setEngineEdges'], + }, + + 'Reset settings': { + target: 'persisting settings', + + actions: [ + 'resetSettings', + 'setThemeClass', + 'setEngineTheme', + 'setClientSideSceneUnits', + 'Execute AST', + 'setClientTheme', + ], + }, + + 'Set all settings': { + actions: [ + 'setAllSettings', + 'setThemeClass', + 'setEngineTheme', + 'setClientSideSceneUnits', + 'Execute AST', + 'setClientTheme', + ], + }, + + 'set.modeling.showScaleGrid': { + target: 'persisting settings', + actions: [ + 'setSettingAtLevel', + 'toastSuccess', + 'setEngineScaleGridVisibility', + ], + }, }, }, - } -) + + 'persisting settings': { + entry: ['persistSettings'], + always: 'idle', + }, + }, +}) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 69f44793e..ffb925527 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -14,7 +14,7 @@ import { type HomeLoaderData } from 'lib/types' import Loading from 'components/Loading' import { useMachine } from '@xstate/react' import { homeMachine } from '../machines/homeMachine' -import { ContextFrom, EventFrom } from 'xstate' +import { fromPromise } from 'xstate' import { PATHS } from 'lib/paths' import { getNextSearchParams, @@ -68,95 +68,102 @@ const Home = () => { ) const ref = useRef(null) - const [state, send, actor] = useMachine(homeMachine, { - context: { - projects: loadedProjects, - defaultProjectName: settings.projects.defaultProjectName.current, - defaultDirectory: settings.app.projectDirectory.current, - }, - actions: { - navigateToProject: ( - context: ContextFrom, - event: EventFrom - ) => { - if (event.data && 'name' in event.data) { - let projectPath = - context.defaultDirectory + - window.electron.path.sep + - event.data.name - onProjectOpen( - { - name: event.data.name, - path: projectPath, - }, - null - ) - commandBarSend({ type: 'Close' }) - navigate(`${PATHS.FILE}/${encodeURIComponent(projectPath)}`) - } - }, - toastSuccess: (_, event) => toast.success((event.data || '') + ''), - toastError: (_, event) => toast.error((event.data || '') + ''), - }, - services: { - readProjects: async (context: ContextFrom) => - listProjects(), - createProject: async ( - context: ContextFrom, - event: EventFrom - ) => { - let name = ( - event.data && 'name' in event.data - ? event.data.name - : settings.projects.defaultProjectName.current - ).trim() - - if (doesProjectNameNeedInterpolated(name)) { - const nextIndex = getNextProjectIndex(name, projects) - name = interpolateProjectNameWithIndex(name, nextIndex) - } - - await createNewProjectDirectory(name) - - return `Successfully created "${name}"` - }, - renameProject: async ( - context: ContextFrom, - event: EventFrom - ) => { - const { oldName, newName } = event.data - let name = newName ? newName : context.defaultProjectName - if (doesProjectNameNeedInterpolated(name)) { - const nextIndex = await getNextProjectIndex(name, projects) - name = interpolateProjectNameWithIndex(name, nextIndex) - } - - await renameProjectDirectory( - window.electron.path.join(context.defaultDirectory, oldName), - name - ) - return `Successfully renamed "${oldName}" to "${name}"` - }, - deleteProject: async ( - context: ContextFrom, - event: EventFrom - ) => { - await window.electron.rm( - window.electron.path.join(context.defaultDirectory, event.data.name), - { - recursive: true, + const [state, send, actor] = useMachine( + homeMachine.provide({ + actions: { + navigateToProject: ({ context, event }) => { + if ('data' in event && event.data && 'name' in event.data) { + let projectPath = + context.defaultDirectory + + window.electron.path.sep + + event.data.name + onProjectOpen( + { + name: event.data.name, + path: projectPath, + }, + null + ) + commandBarSend({ type: 'Close' }) + navigate(`${PATHS.FILE}/${encodeURIComponent(projectPath)}`) } - ) - return `Successfully deleted "${event.data.name}"` + }, + toastSuccess: ({ event }) => + toast.success( + ('data' in event && typeof event.data === 'string' && event.data) || + ('output' in event && + typeof event.output === 'string' && + event.output) || + '' + ), + toastError: ({ event }) => + toast.error( + ('data' in event && typeof event.data === 'string' && event.data) || + ('output' in event && + typeof event.output === 'string' && + event.output) || + '' + ), }, - }, - guards: { - 'Has at least 1 project': (_, event: EventFrom) => { - if (event.type !== 'done.invoke.read-projects') return false - return event?.data?.length ? event.data?.length >= 1 : false + actors: { + readProjects: fromPromise(() => listProjects()), + createProject: fromPromise(async ({ input }) => { + let name = ( + input && 'name' in input && input.name + ? input.name + : settings.projects.defaultProjectName.current + ).trim() + + if (doesProjectNameNeedInterpolated(name)) { + const nextIndex = getNextProjectIndex(name, projects) + name = interpolateProjectNameWithIndex(name, nextIndex) + } + + await createNewProjectDirectory(name) + + return `Successfully created "${name}"` + }), + renameProject: fromPromise(async ({ input }) => { + const { oldName, newName, defaultProjectName, defaultDirectory } = + input + let name = newName ? newName : defaultProjectName + if (doesProjectNameNeedInterpolated(name)) { + const nextIndex = await getNextProjectIndex(name, projects) + name = interpolateProjectNameWithIndex(name, nextIndex) + } + + await renameProjectDirectory( + window.electron.path.join(defaultDirectory, oldName), + name + ) + return `Successfully renamed "${oldName}" to "${name}"` + }), + deleteProject: fromPromise(async ({ input }) => { + await window.electron.rm( + window.electron.path.join(input.defaultDirectory, input.name), + { + recursive: true, + } + ) + return `Successfully deleted "${input.name}"` + }), }, - }, - }) + guards: { + 'Has at least 1 project': ({ event }) => { + if (event.type !== 'xstate.done.actor.read-projects') return false + console.log(`from has at least 1 project: ${event.output.length}`) + return event.output.length ? event.output.length >= 1 : false + }, + }, + }), + { + input: { + projects: loadedProjects, + defaultProjectName: settings.projects.defaultProjectName.current, + defaultDirectory: settings.app.projectDirectory.current, + }, + } + ) const { projects } = state.context const [searchParams, setSearchParams] = useSearchParams() const { searchResults, query, setQuery } = useProjectSearch(projects) @@ -197,14 +204,18 @@ const Home = () => { ) if (newProjectName !== project.name) { - send('Rename project', { - data: { oldName: project.name, newName: newProjectName }, + send({ + type: 'Rename project', + data: { oldName: project.name, newName: newProjectName as string }, }) } } async function handleDeleteProject(project: Project) { - send('Delete project', { data: { name: project.name || '' } }) + send({ + type: 'Delete project', + data: { name: project.name || '' }, + }) } return ( @@ -217,7 +228,9 @@ const Home = () => {

Your Projects

send('Create project')} + onClick={() => + send({ type: 'Create project', data: { name: '' } }) + } className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15" iconStart={{ icon: 'plus', diff --git a/yarn.lock b/yarn.lock index 25eee8acd..ecec2ad51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2952,13 +2952,13 @@ "@babel/types" "^7.21.4" recast "^0.23.1" -"@xstate/react@^3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.2.2.tgz#ddf0f9d75e2c19375b1e1b7335e72cb99762aed8" - integrity sha512-feghXWLedyq8JeL13yda3XnHPZKwYDN5HPBLykpLeuNpr9178tQd2/3d0NrH6gSd0sG5mLuLeuD+ck830fgzLQ== +"@xstate/react@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@xstate/react/-/react-4.1.1.tgz#2f580fc5f83d195f95b56df6cd8061c66660d9fa" + integrity sha512-pFp/Y+bnczfaZ0V8B4LOhx3d6Gd71YKAPbzerGqydC2nsYN/mp7RZu3q/w6/kvI2hwR/jeDeetM7xc3JFZH2NA== dependencies: use-isomorphic-layout-effect "^1.1.2" - use-sync-external-store "^1.0.0" + use-sync-external-store "^1.2.0" "@xstate/tools-shared@^4.1.0": version "4.1.0" @@ -8757,16 +8757,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8860,14 +8851,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9412,7 +9396,7 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" -use-sync-external-store@^1.0.0: +use-sync-external-store@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== @@ -9741,16 +9725,7 @@ word-wrap@^1.2.3, word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9793,11 +9768,16 @@ xmlbuilder@>=11.0.1, xmlbuilder@^15.1.1: resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.0.0-beta.54.tgz#d80f1a9e43ad883a65fc9b399161bd39633bd9bf" integrity sha512-BTnCPBQ2iTKe4uCnHEe1hNx6VTbXU+5mQGybSQHOjTLiBi4Ryi+tL9T6N1tmqagvM8rfl4XRfvndogfWCWcdpw== -xstate@^4.33.4, xstate@^4.38.2: +xstate@^4.33.4: version "4.38.3" resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075" integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw== +xstate@^5.17.4: + version "5.17.4" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.17.4.tgz#334ab2da123973634097f7ca48387ae1589c774e" + integrity sha512-KM2FYVOUJ04HlOO4TY3wEXqoYPR/XsDu+ewm+IWw0vilXqND0jVfvv04tEFwp8Mkk7I/oHXM8t1Ex9xJyUS4ZA== + xterm-addon-fit@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596"