diff --git a/src/App.tsx b/src/App.tsx index dae4e34b3..be7fda9fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,6 +95,9 @@ export function App() { didDragInStream, setStreamDimensions, streamDimensions, + setIsExecuting, + defferedCode, + defferedSetCode, } = useStore((s) => ({ editorView: s.editorView, setEditorView: s.setEditorView, @@ -103,7 +106,9 @@ export function App() { setGuiMode: s.setGuiMode, addLog: s.addLog, code: s.code, + defferedCode: s.defferedCode, setCode: s.setCode, + defferedSetCode: s.defferedSetCode, setAst: s.setAst, setError: s.setError, setProgramMemory: s.setProgramMemory, @@ -132,6 +137,7 @@ export function App() { didDragInStream: s.didDragInStream, setStreamDimensions: s.setStreamDimensions, streamDimensions: s.streamDimensions, + setIsExecuting: s.setIsExecuting, })) const { @@ -182,7 +188,7 @@ export function App() { // const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => { const onChange = (value: string, viewUpdate: ViewUpdate) => { - setCode(value) + defferedSetCode(value) if (isTauri() && pathParams.id) { // Save the file to disk // Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files @@ -287,16 +293,17 @@ export function App() { let unsubFn: any[] = [] const asyncWrap = async () => { try { - if (!code) { + if (!defferedCode) { setAst(null) return } - const _ast = await asyncParser(code) + const _ast = await asyncParser(defferedCode) setAst(_ast) resetLogs() resetKCLErrors() engineCommandManager.endSession() engineCommandManager.startNewSession() + setIsExecuting(true) const programMemory = await _executor( _ast, { @@ -328,6 +335,7 @@ export function App() { const { artifactMap, sourceRangeMap } = await engineCommandManager.waitForAllCommands() + setIsExecuting(false) setArtifactMap({ artifactMap, sourceRangeMap }) const unSubHover = engineCommandManager.subscribeToUnreliable({ @@ -362,6 +370,7 @@ export function App() { setError() } catch (e: any) { + setIsExecuting(false) if (e instanceof KCLError) { addKCLError(e) } else { @@ -375,7 +384,7 @@ export function App() { return () => { unsubFn.forEach((fn) => fn()) } - }, [code, isStreamReady, engineCommandManager]) + }, [defferedCode, isStreamReady, engineCommandManager]) const debounceSocketSend = throttle((message) => { engineCommandManager?.sendSceneCommand(message) diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index 8ec3e5cec..0a7363b57 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -21,6 +21,7 @@ export const Stream = ({ className = '' }) => { didDragInStream, setDidDragInStream, streamDimensions, + isExecuting, } = useStore((s) => ({ mediaStream: s.mediaStream, engineCommandManager: s.engineCommandManager, @@ -30,6 +31,7 @@ export const Stream = ({ className = '' }) => { didDragInStream: s.didDragInStream, setDidDragInStream: s.setDidDragInStream, streamDimensions: s.streamDimensions, + isExecuting: s.isExecuting, })) useEffect(() => { @@ -155,7 +157,8 @@ export const Stream = ({ className = '' }) => { onWheel={handleScroll} onPlay={() => setIsLoading(false)} onMouseMoveCapture={handleMouseMove} - className="w-full h-full" + className={`w-full h-full ${isExecuting && 'blur-md'}`} + style={{ transitionDuration: '200ms', transitionProperty: 'filter' }} /> {isLoading && (
diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 2cf5bf07a..33605d3e9 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -10,11 +10,16 @@ import { exportSave } from 'lib/exportSave' import { v4 as uuidv4 } from 'uuid' import * as Sentry from '@sentry/react' -interface ResultCommand { +interface CommandInfo { + commandType: CommandTypes + range: SourceRange + parentId?: string +} +interface ResultCommand extends CommandInfo { type: 'result' data: any } -interface PendingCommand { +interface PendingCommand extends CommandInfo { type: 'pending' promise: Promise resolve: (val: any) => void @@ -546,6 +551,8 @@ export class EngineConnection { export type EngineCommand = Models['WebSocketRequest_type'] type ModelTypes = Models['OkModelingCmdResponse_type']['type'] +type CommandTypes = Models['ModelingCmd_type']['type'] + type UnreliableResponses = Extract< Models['OkModelingCmdResponse_type'], { type: 'highlight_set_entity' } @@ -687,15 +694,22 @@ export class EngineCommandManager { const resolve = command.resolve this.artifactMap[id] = { type: 'result', + range: command.range, + commandType: command.commandType, + parentId: command.parentId ? command.parentId : undefined, data: modelingResponse, } resolve({ id, + commandType: command.commandType, + range: command.range, data: modelingResponse, }) } else { this.artifactMap[id] = { type: 'result', + commandType: command?.commandType, + range: command?.range, data: modelingResponse, } } @@ -747,8 +761,29 @@ export class EngineCommandManager { delete this.unreliableSubscriptions[event][id] } endSession() { - // this.websocket?.close() - // socket.off('command') + // TODO: instead of sending a single command with `object_ids: Object.keys(this.artifactMap)` + // we need to loop over them each individualy because if the engine doesn't recognise a single + // id the whole command fails. + Object.entries(this.artifactMap).forEach(([id, artifact]) => { + const artifactTypesToDelete: ArtifactMap[string]['commandType'][] = [ + // 'start_path' creates a new scene object for the path, which is why it needs to be deleted, + // however all of the segments in the path are its children so there don't need to be deleted. + // this fact is very opaque in the api and docs (as to what should can be deleted). + // Using an array is the list is likely to grow. + 'start_path', + ] + if (!artifactTypesToDelete.includes(artifact.commandType)) return + + const deletCmd: EngineCommand = { + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'remove_scene_objects', + object_ids: [id], + }, + } + this.engineConnection?.send(deletCmd) + }) } cusorsSelected(selections: { otherSelections: Selections['otherSelections'] @@ -801,11 +836,20 @@ export class EngineCommandManager { JSON.stringify(command) ) return Promise.resolve() + } else if ( + cmd.type === 'mouse_move' && + this.engineConnection.unreliableDataChannel + ) { + cmd.sequence = this.outSequence + this.outSequence++ + this.engineConnection?.unreliableDataChannel?.send( + JSON.stringify(command) + ) + return Promise.resolve() } - console.log('sending command', command) // since it's not mouse drag or highlighting send over TCP and keep track of the command this.engineConnection?.send(command) - return this.handlePendingCommand(command.cmd_id) + return this.handlePendingCommand(command.cmd_id, command.cmd) } sendModelingCommand({ id, @@ -823,15 +867,35 @@ export class EngineCommandManager { return Promise.resolve() } this.engineConnection?.send(command) - return this.handlePendingCommand(id) + if (typeof command !== 'string' && command.type === 'modeling_cmd_req') { + return this.handlePendingCommand(id, command?.cmd, range) + } else if (typeof command === 'string') { + const parseCommand: EngineCommand = JSON.parse(command) + if (parseCommand.type === 'modeling_cmd_req') + return this.handlePendingCommand(id, parseCommand?.cmd, range) + } + throw 'shouldnt reach here' } - handlePendingCommand(id: string) { + handlePendingCommand( + id: string, + command: Models['ModelingCmd_type'], + range?: SourceRange + ) { let resolve: (val: any) => void = () => {} const promise = new Promise((_resolve, reject) => { resolve = _resolve }) + const getParentId = (): string | undefined => { + if (command.type === 'extend_path') { + return command.path + } + // TODO handle other commands that have a parent + } this.artifactMap[id] = { + range: range || [0, 0], type: 'pending', + commandType: command.type, + parentId: getParentId(), promise, resolve, } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 37732e0b8..7268e091a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -56,6 +56,27 @@ export function throttle( return throttled } +// takes a function and executes it after the wait time, if the function is called again before the wait time is up, the timer is reset +export function defferExecution(func: (args: T) => any, wait: number) { + let timeout: ReturnType | null + let latestArgs: T + + function later() { + timeout = null + func(latestArgs) + } + + function deffered(args: T) { + latestArgs = args + if (timeout) { + clearTimeout(timeout) + } + timeout = setTimeout(later, wait) + } + + return deffered +} + export function getNormalisedCoordinates({ clientX, clientY, diff --git a/src/useStore.ts b/src/useStore.ts index 4b1b97fb1..9da2f1645 100644 --- a/src/useStore.ts +++ b/src/useStore.ts @@ -19,6 +19,7 @@ import { EngineCommandManager, } from './lang/std/engineConnection' import { KCLError } from './lang/errors' +import { defferExecution } from 'lib/utils' export type Selection = { type: 'default' | 'line-end' | 'line-mid' @@ -132,7 +133,9 @@ export interface StoreState { ) => void updateAstAsync: (ast: Program, focusPath?: PathToNode) => void code: string + defferedCode: string setCode: (code: string) => void + defferedSetCode: (code: string) => void formatCode: () => void errorState: { isError: boolean @@ -168,6 +171,8 @@ export interface StoreState { streamWidth: number streamHeight: number }) => void + isExecuting: boolean + setIsExecuting: (isExecuting: boolean) => void showHomeMenu: boolean setHomeShowMenu: (showMenu: boolean) => void @@ -186,187 +191,201 @@ let pendingAstUpdates: number[] = [] export const useStore = create()( persist( - (set, get) => ({ - editorView: null, - setEditorView: (editorView) => { - set({ editorView }) - }, - highlightRange: [0, 0], - setHighlightRange: (selection) => { - set({ highlightRange: selection }) - const editorView = get().editorView - if (editorView) { - editorView.dispatch({ effects: addLineHighlight.of(selection) }) - } - }, - setCursor: (selections) => { - const { editorView } = get() - if (!editorView) return - const ranges: ReturnType[] = [] - const selectionRangeTypeMap: { [key: number]: Selection['type'] } = {} - set({ selectionRangeTypeMap }) - selections.codeBasedSelections.forEach(({ range, type }) => { - if (range?.[1]) { - ranges.push(EditorSelection.cursor(range[1])) - selectionRangeTypeMap[range[1]] = type + (set, get) => { + const setDefferedCode = defferExecution( + (code: string) => set({ defferedCode: code }), + 600 + ) + return { + editorView: null, + setEditorView: (editorView) => { + set({ editorView }) + }, + highlightRange: [0, 0], + setHighlightRange: (selection) => { + set({ highlightRange: selection }) + const editorView = get().editorView + if (editorView) { + editorView.dispatch({ effects: addLineHighlight.of(selection) }) } - }) - setTimeout(() => { - editorView.dispatch({ - selection: EditorSelection.create( - ranges, - selections.codeBasedSelections.length - 1 - ), + }, + setCursor: (selections) => { + const { editorView } = get() + if (!editorView) return + const ranges: ReturnType[] = [] + const selectionRangeTypeMap: { [key: number]: Selection['type'] } = {} + set({ selectionRangeTypeMap }) + selections.codeBasedSelections.forEach(({ range, type }) => { + if (range?.[1]) { + ranges.push(EditorSelection.cursor(range[1])) + selectionRangeTypeMap[range[1]] = type + } }) - }) - }, - setCursor2: (codeSelections) => { - const currestSelections = get().selectionRanges - const code = get().code - if (!codeSelections) { - get().setCursor({ - otherSelections: currestSelections.otherSelections, - codeBasedSelections: [ - { range: [0, code.length - 1], type: 'default' }, - ], - }) - return - } - const selections: Selections = { - ...currestSelections, - codeBasedSelections: get().isShiftDown - ? [...currestSelections.codeBasedSelections, codeSelections] - : [codeSelections], - } - get().setCursor(selections) - }, - selectionRangeTypeMap: {}, - selectionRanges: { - otherSelections: [], - codeBasedSelections: [], - }, - setSelectionRanges: (selectionRanges) => - set({ selectionRanges, selectionRangeTypeMap: {} }), - guiMode: { mode: 'default' }, - lastGuiMode: { mode: 'default' }, - setGuiMode: (guiMode) => { - set({ guiMode }) - }, - logs: [], - addLog: (log) => { - if (Array.isArray(log)) { - const cleanLog: any = log.map(({ __geoMeta, ...rest }) => rest) - set((state) => ({ logs: [...state.logs, cleanLog] })) - } else { - set((state) => ({ logs: [...state.logs, log] })) - } - }, - resetLogs: () => { - set({ logs: [] }) - }, - kclErrors: [], - addKCLError: (e) => { - set((state) => ({ kclErrors: [...state.kclErrors, e] })) - }, - resetKCLErrors: () => { - set({ kclErrors: [] }) - }, - ast: null, - setAst: (ast) => { - set({ ast }) - }, - updateAst: async (ast, { focusPath, callBack = () => {} } = {}) => { - const newCode = recast(ast) - const astWithUpdatedSource = parser_wasm(newCode) - callBack(astWithUpdatedSource) - - set({ ast: astWithUpdatedSource, code: newCode }) - if (focusPath) { - const { node } = getNodeFromPath(astWithUpdatedSource, focusPath) - const { start, end } = node - if (!start || !end) return setTimeout(() => { - get().setCursor({ - codeBasedSelections: [ - { - type: 'default', - range: [start, end], - }, - ], - otherSelections: [], + editorView.dispatch({ + selection: EditorSelection.create( + ranges, + selections.codeBasedSelections.length - 1 + ), }) }) - } - }, - updateAstAsync: async (ast, focusPath) => { - // clear any pending updates - pendingAstUpdates.forEach((id) => clearTimeout(id)) - pendingAstUpdates = [] - // setup a new update - pendingAstUpdates.push( - setTimeout(() => { - get().updateAst(ast, { focusPath }) - }, 100) as unknown as number - ) - }, - code: '', - setCode: (code) => { - set({ code }) - }, - formatCode: async () => { - const code = get().code - const ast = parser_wasm(code) - const newCode = recast(ast) - set({ code: newCode, ast }) - }, - errorState: { - isError: false, - error: '', - }, - setError: (error = '') => { - set({ errorState: { isError: !!error, error } }) - }, - programMemory: { root: {}, pendingMemory: {} }, - setProgramMemory: (programMemory) => set({ programMemory }), - isShiftDown: false, - setIsShiftDown: (isShiftDown) => set({ isShiftDown }), - artifactMap: {}, - sourceRangeMap: {}, - setArtifactNSourceRangeMaps: (maps) => set({ ...maps }), - setEngineCommandManager: (engineCommandManager) => - set({ engineCommandManager }), - setMediaStream: (mediaStream) => set({ mediaStream }), - isStreamReady: false, - setIsStreamReady: (isStreamReady) => set({ isStreamReady }), - isLSPServerReady: false, - setIsLSPServerReady: (isLSPServerReady) => set({ isLSPServerReady }), - isMouseDownInStream: false, - setIsMouseDownInStream: (isMouseDownInStream) => { - set({ isMouseDownInStream }) - }, - didDragInStream: false, - setDidDragInStream: (didDragInStream) => { - set({ didDragInStream }) - }, - // For stream event handling - fileId: '', - setFileId: (fileId) => set({ fileId }), - streamDimensions: { streamWidth: 1280, streamHeight: 720 }, - setStreamDimensions: (streamDimensions) => set({ streamDimensions }), + }, + setCursor2: (codeSelections) => { + const currestSelections = get().selectionRanges + const code = get().code + if (!codeSelections) { + get().setCursor({ + otherSelections: currestSelections.otherSelections, + codeBasedSelections: [ + { range: [0, code.length - 1], type: 'default' }, + ], + }) + return + } + const selections: Selections = { + ...currestSelections, + codeBasedSelections: get().isShiftDown + ? [...currestSelections.codeBasedSelections, codeSelections] + : [codeSelections], + } + get().setCursor(selections) + }, + selectionRangeTypeMap: {}, + selectionRanges: { + otherSelections: [], + codeBasedSelections: [], + }, + setSelectionRanges: (selectionRanges) => + set({ selectionRanges, selectionRangeTypeMap: {} }), + guiMode: { mode: 'default' }, + lastGuiMode: { mode: 'default' }, + setGuiMode: (guiMode) => { + set({ guiMode }) + }, + logs: [], + addLog: (log) => { + if (Array.isArray(log)) { + const cleanLog: any = log.map(({ __geoMeta, ...rest }) => rest) + set((state) => ({ logs: [...state.logs, cleanLog] })) + } else { + set((state) => ({ logs: [...state.logs, log] })) + } + }, + resetLogs: () => { + set({ logs: [] }) + }, + kclErrors: [], + addKCLError: (e) => { + set((state) => ({ kclErrors: [...state.kclErrors, e] })) + }, + resetKCLErrors: () => { + set({ kclErrors: [] }) + }, + ast: null, + setAst: (ast) => { + set({ ast }) + }, + updateAst: async (ast, { focusPath, callBack = () => {} } = {}) => { + const newCode = recast(ast) + const astWithUpdatedSource = parser_wasm(newCode) + callBack(astWithUpdatedSource) - // tauri specific app settings - defaultDir: { - dir: '', - }, - isBannerDismissed: false, - setBannerDismissed: (isBannerDismissed) => set({ isBannerDismissed }), - openPanes: ['code'], - setOpenPanes: (openPanes) => set({ openPanes }), - showHomeMenu: true, - setHomeShowMenu: (showHomeMenu) => set({ showHomeMenu }), - homeMenuItems: [], - setHomeMenuItems: (homeMenuItems) => set({ homeMenuItems }), - }), + set({ ast: astWithUpdatedSource, code: newCode }) + if (focusPath) { + const { node } = getNodeFromPath( + astWithUpdatedSource, + focusPath + ) + const { start, end } = node + if (!start || !end) return + setTimeout(() => { + get().setCursor({ + codeBasedSelections: [ + { + type: 'default', + range: [start, end], + }, + ], + otherSelections: [], + }) + }) + } + }, + updateAstAsync: async (ast, focusPath) => { + // clear any pending updates + pendingAstUpdates.forEach((id) => clearTimeout(id)) + pendingAstUpdates = [] + // setup a new update + pendingAstUpdates.push( + setTimeout(() => { + get().updateAst(ast, { focusPath }) + }, 100) as unknown as number + ) + }, + code: '', + defferedCode: '', + setCode: (code) => set({ code, defferedCode: code }), + defferedSetCode: (code) => { + set({ code }) + setDefferedCode(code) + }, + formatCode: async () => { + const code = get().code + const ast = parser_wasm(code) + const newCode = recast(ast) + set({ code: newCode, ast }) + }, + errorState: { + isError: false, + error: '', + }, + setError: (error = '') => { + set({ errorState: { isError: !!error, error } }) + }, + programMemory: { root: {}, pendingMemory: {} }, + setProgramMemory: (programMemory) => set({ programMemory }), + isShiftDown: false, + setIsShiftDown: (isShiftDown) => set({ isShiftDown }), + artifactMap: {}, + sourceRangeMap: {}, + setArtifactNSourceRangeMaps: (maps) => set({ ...maps }), + setEngineCommandManager: (engineCommandManager) => + set({ engineCommandManager }), + setMediaStream: (mediaStream) => set({ mediaStream }), + isStreamReady: false, + setIsStreamReady: (isStreamReady) => set({ isStreamReady }), + isLSPServerReady: false, + setIsLSPServerReady: (isLSPServerReady) => set({ isLSPServerReady }), + isMouseDownInStream: false, + setIsMouseDownInStream: (isMouseDownInStream) => { + set({ isMouseDownInStream }) + }, + didDragInStream: false, + setDidDragInStream: (didDragInStream) => { + set({ didDragInStream }) + }, + // For stream event handling + fileId: '', + setFileId: (fileId) => set({ fileId }), + streamDimensions: { streamWidth: 1280, streamHeight: 720 }, + setStreamDimensions: (streamDimensions) => set({ streamDimensions }), + isExecuting: false, + setIsExecuting: (isExecuting) => set({ isExecuting }), + + // tauri specific app settings + defaultDir: { + dir: '', + }, + isBannerDismissed: false, + setBannerDismissed: (isBannerDismissed) => set({ isBannerDismissed }), + openPanes: ['code'], + setOpenPanes: (openPanes) => set({ openPanes }), + showHomeMenu: true, + setHomeShowMenu: (showHomeMenu) => set({ showHomeMenu }), + homeMenuItems: [], + setHomeMenuItems: (homeMenuItems) => set({ homeMenuItems }), + } + }, { name: 'store', partialize: (state) =>