From 248ed242e0cdbe4e1024a71f1e870d73133f7570 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:07:39 +0000 Subject: [PATCH 1/2] Initial plan From 99e1dde77bdcdd42e31c82ee3a95cae4414fab08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:23:27 +0000 Subject: [PATCH 2/2] Save canvas state and workflow after editing via platformAPI and localStorage Co-authored-by: Shellishack <40737228+Shellishack@users.noreply.github.com> --- package-lock.json | 46 +++++++------- web/components/views/project/project-view.tsx | 11 +++- web/lib/hooks/use-canvas-workflow.ts | 10 +++ web/lib/hooks/use-workflow-persistence.ts | 63 +++++++++++++++++++ web/lib/platform-api/abstract-platform-api.ts | 53 +++++++++++++++- .../platform-api/capacitor/capacitor-api.ts | 38 ++++++++++- web/lib/platform-api/electron/electron-api.ts | 37 +++++++++++ 7 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 web/lib/hooks/use-workflow-persistence.ts diff --git a/package-lock.json b/package-lock.json index 4737c381..4c7bb83d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2529,15 +2529,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2594,20 +2594,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -2642,9 +2642,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -16522,25 +16522,25 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -16559,7 +16559,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -30668,7 +30668,7 @@ }, "npm-packages/react-api": { "name": "@pulse-editor/react-api", - "version": "0.1.1-beta.82", + "version": "0.1.1-beta.83", "dependencies": { "@module-federation/runtime": "^2.0.1", "use-debounce": "^10.1.0" @@ -30695,7 +30695,7 @@ "typescript-eslint": "^8.56.0" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-beta.82", + "@pulse-editor/shared-utils": "0.1.1-beta.83", "react": "19.2.4", "react-dom": "19.2.4" } @@ -31024,7 +31024,7 @@ }, "npm-packages/shared-utils": { "name": "@pulse-editor/shared-utils", - "version": "0.1.1-beta.82", + "version": "0.1.1-beta.83", "devDependencies": { "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.0", diff --git a/web/components/views/project/project-view.tsx b/web/components/views/project/project-view.tsx index 1001133e..1aa8ff9c 100644 --- a/web/components/views/project/project-view.tsx +++ b/web/components/views/project/project-view.tsx @@ -2,6 +2,7 @@ import Icon from "@/components/misc/icon"; import { EditorContext } from "@/components/providers/editor-context-provider"; import { useEditorAIAssistantHint } from "@/lib/hooks/use-editor-ai-assistant-hint"; import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; +import { useWorkflowPersistence } from "@/lib/hooks/use-workflow-persistence"; import { createCanvasViewId } from "@/lib/views/view-helpers"; import { Button, Input } from "@heroui/react"; import { useCallback, useContext } from "react"; @@ -10,11 +11,19 @@ export default function ProjectView() { const editorContext = useContext(EditorContext); const { createCanvasTabView } = useTabViewManager(); + const { loadWorkflow } = useWorkflowPersistence(); const { hint: inputPlaceholder } = useEditorAIAssistantHint(); const createNewCanvas = useCallback(async () => { + const savedContent = await loadWorkflow(); + await createCanvasTabView({ viewId: createCanvasViewId(), + appConfigs: + savedContent?.nodes + .filter((node) => node?.data?.config != null) + .map((node) => node.data.config) ?? [], + initialWorkflowContent: savedContent ?? undefined, }); // Open explorer for canvas views @@ -22,7 +31,7 @@ export default function ProjectView() { ...prev, isSideMenuOpen: true, })); - }, []); + }, [loadWorkflow, createCanvasTabView, editorContext]); const openMarketplace = useCallback(() => { editorContext?.setEditorStates((prev) => ({ diff --git a/web/lib/hooks/use-canvas-workflow.ts b/web/lib/hooks/use-canvas-workflow.ts index e8bdf2cd..90311d32 100644 --- a/web/lib/hooks/use-canvas-workflow.ts +++ b/web/lib/hooks/use-canvas-workflow.ts @@ -11,12 +11,14 @@ import { useCallback, useContext, useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import { AppNodeData, WorkflowContent } from "../types"; import useWorkflowExecutor from "./use-workflow-executor"; +import { useWorkflowPersistence } from "./use-workflow-persistence"; export default function useCanvasWorkflow( initialWorkflowContent?: WorkflowContent, ) { const editorContext = useContext(EditorContext); const imcContext = useContext(IMCContext); + const { saveWorkflow } = useWorkflowPersistence(); const [entryPoint, setEntryPoint] = useState< ReactFlowNode | undefined @@ -66,6 +68,13 @@ export default function useCanvasWorkflow( })); }, 500); + const debouncePersistCanvasState = useDebouncedCallback(() => { + saveWorkflow({ + nodes: localNodes, + edges: localEdges, + }); + }, 1000); + const updateWorkflowNodeData = useCallback( (nodeViewId: string, data: Partial) => { setLocalNodes((prev) => { @@ -140,6 +149,7 @@ export default function useCanvasWorkflow( useEffect(() => { debounceSaveNodesAndEdges(); + debouncePersistCanvasState(); }, [localNodes, localEdges]); // Restore snapshot states upon loading a workflow diff --git a/web/lib/hooks/use-workflow-persistence.ts b/web/lib/hooks/use-workflow-persistence.ts new file mode 100644 index 00000000..6b3b7a54 --- /dev/null +++ b/web/lib/hooks/use-workflow-persistence.ts @@ -0,0 +1,63 @@ +import { useContext } from "react"; +import { EditorContext } from "@/components/providers/editor-context-provider"; +import { WorkflowContent } from "@/lib/types"; +import { usePlatformApi } from "./use-platform-api"; + +/** + * Hook that provides canvas workflow persistence. + * Saves and loads workflow content via the platform API (file system on + * desktop/mobile, localStorage on cloud/web) so that in-progress edits + * survive page reloads and app restarts. + */ +export function useWorkflowPersistence() { + const { platformApi } = usePlatformApi(); + const editorContext = useContext(EditorContext); + + /** + * Returns a stable storage key for the current project. + * Combines projectHomePath and projectName so each project has its own + * saved canvas state. + */ + function getProjectUri(): string | undefined { + const project = editorContext?.editorStates.project; + if (!project) return undefined; + const homePath = editorContext?.persistSettings?.projectHomePath; + return homePath ? `${homePath}/${project}` : project; + } + + /** + * Persist the given workflow content for the active project. + * Silently no-ops when no project is open or the platform API is unavailable. + */ + async function saveWorkflow(content: WorkflowContent): Promise { + if (!platformApi) return; + const projectUri = getProjectUri(); + if (!projectUri) return; + try { + await platformApi.saveCanvasState(projectUri, content); + } catch (err) { + console.error("Failed to save canvas state:", err); + } + } + + /** + * Load the previously saved workflow content for the active project. + * Returns undefined when no saved state exists or the load fails. + */ + async function loadWorkflow(): Promise { + if (!platformApi) return undefined; + const projectUri = getProjectUri(); + if (!projectUri) return undefined; + try { + return await platformApi.loadCanvasState(projectUri); + } catch (err) { + console.error("Failed to load canvas state:", err); + return undefined; + } + } + + return { + saveWorkflow, + loadWorkflow, + }; +} diff --git a/web/lib/platform-api/abstract-platform-api.ts b/web/lib/platform-api/abstract-platform-api.ts index 1f3ee889..e898c932 100644 --- a/web/lib/platform-api/abstract-platform-api.ts +++ b/web/lib/platform-api/abstract-platform-api.ts @@ -1,5 +1,5 @@ import { FileSystemObject, ListPathOptions } from "@pulse-editor/shared-utils"; -import { PersistentSettings, ProjectInfo } from "../types"; +import { PersistentSettings, ProjectInfo, WorkflowContent } from "../types"; export abstract class AbstractPlatformAPI { // Show a selection dialogue to pick a directory. @@ -69,4 +69,55 @@ export abstract class AbstractPlatformAPI { // Create a new terminal and get socket abstract createTerminal(): Promise; + + // Canvas state persistence + /** + * Save the canvas workflow state for the given project. + * The default implementation persists to localStorage for offline support. + * + * @param projectUri A stable identifier for the project + * (e.g. the full file-system path or the project name for cloud environments). + * @param content The workflow content to persist. + */ + async saveCanvasState( + projectUri: string, + content: WorkflowContent, + ): Promise { + if (typeof window !== "undefined") { + try { + localStorage.setItem( + `pulse-canvas-${projectUri}`, + JSON.stringify(content), + ); + } catch (err) { + console.error("Failed to save canvas state to localStorage:", err); + } + } + } + + /** + * Load the previously saved canvas workflow state for the given project. + * The default implementation reads from localStorage. + * + * @param projectUri A stable identifier for the project. + * @returns The saved workflow content, or undefined if nothing was saved. + */ + async loadCanvasState( + projectUri: string, + ): Promise { + if (typeof window !== "undefined") { + try { + const stored = localStorage.getItem(`pulse-canvas-${projectUri}`); + if (!stored) return undefined; + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed?.nodes) || !Array.isArray(parsed?.edges)) { + return undefined; + } + return parsed as WorkflowContent; + } catch (err) { + console.error("Failed to load canvas state from localStorage:", err); + } + } + return undefined; + } } diff --git a/web/lib/platform-api/capacitor/capacitor-api.ts b/web/lib/platform-api/capacitor/capacitor-api.ts index f3541db8..20591914 100644 --- a/web/lib/platform-api/capacitor/capacitor-api.ts +++ b/web/lib/platform-api/capacitor/capacitor-api.ts @@ -1,4 +1,4 @@ -import { PersistentSettings, ProjectInfo } from "@/lib/types"; +import { PersistentSettings, ProjectInfo, WorkflowContent } from "@/lib/types"; import { Directory, Encoding, Filesystem } from "@capacitor/filesystem"; import { FilePicker } from "@capawesome/capacitor-file-picker"; import { FileSystemObject, ListPathOptions } from "@pulse-editor/shared-utils"; @@ -332,6 +332,42 @@ export class CapacitorAPI extends AbstractPlatformAPI { ); } + async saveCanvasState( + projectUri: string, + content: WorkflowContent, + ): Promise { + const canvasFile = new File( + [JSON.stringify(content, null, 2)], + "canvas.json", + { type: "application/json" }, + ); + try { + await this.writeFile(canvasFile, `${projectUri}/canvas.json`); + } catch (err) { + console.error("Failed to save canvas state to file:", err); + await super.saveCanvasState(projectUri, content); + } + } + + async loadCanvasState( + projectUri: string, + ): Promise { + try { + const hasCanvas = await this.hasPath(`${projectUri}/canvas.json`); + if (!hasCanvas) return undefined; + const file = await this.readFile(`${projectUri}/canvas.json`); + const text = await file.text(); + const parsed = JSON.parse(text); + if (!Array.isArray(parsed?.nodes) || !Array.isArray(parsed?.edges)) { + return undefined; + } + return parsed as WorkflowContent; + } catch (err) { + console.error("Failed to load canvas state from file:", err); + return super.loadCanvasState(projectUri); + } + } + private getStoragePathAndDir(uri: string): { path: string; directory: Directory; diff --git a/web/lib/platform-api/electron/electron-api.ts b/web/lib/platform-api/electron/electron-api.ts index 009bd937..6298329f 100644 --- a/web/lib/platform-api/electron/electron-api.ts +++ b/web/lib/platform-api/electron/electron-api.ts @@ -1,6 +1,7 @@ import { PersistentSettings, ProjectInfo, + WorkflowContent, } from "@/lib/types"; import { AbstractPlatformAPI } from "../abstract-platform-api"; import { FileSystemObject, ListPathOptions } from "@pulse-editor/shared-utils"; @@ -112,4 +113,40 @@ export class ElectronAPI extends AbstractPlatformAPI { async createTerminal(): Promise { return await this.electronAPI?.createTerminal(); } + + async saveCanvasState( + projectUri: string, + content: WorkflowContent, + ): Promise { + const canvasFile = new File( + [JSON.stringify(content, null, 2)], + "canvas.json", + { type: "application/json" }, + ); + try { + await this.writeFile(canvasFile, `${projectUri}/canvas.json`); + } catch (err) { + console.error("Failed to save canvas state to file:", err); + await super.saveCanvasState(projectUri, content); + } + } + + async loadCanvasState( + projectUri: string, + ): Promise { + try { + const hasCanvas = await this.hasPath(`${projectUri}/canvas.json`); + if (!hasCanvas) return undefined; + const file = await this.readFile(`${projectUri}/canvas.json`); + const text = await file.text(); + const parsed = JSON.parse(text); + if (!Array.isArray(parsed?.nodes) || !Array.isArray(parsed?.edges)) { + return undefined; + } + return parsed as WorkflowContent; + } catch (err) { + console.error("Failed to load canvas state from file:", err); + return super.loadCanvasState(projectUri); + } + } }