Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion web/components/views/project/project-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -10,19 +11,27 @@ 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
editorContext?.setEditorStates((prev) => ({
...prev,
isSideMenuOpen: true,
}));
}, []);
}, [loadWorkflow, createCanvasTabView, editorContext]);

const openMarketplace = useCallback(() => {
editorContext?.setEditorStates((prev) => ({
Expand Down
10 changes: 10 additions & 0 deletions web/lib/hooks/use-canvas-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppNodeData> | undefined
Expand Down Expand Up @@ -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<AppNodeData>) => {
setLocalNodes((prev) => {
Expand Down Expand Up @@ -140,6 +149,7 @@ export default function useCanvasWorkflow(

useEffect(() => {
debounceSaveNodesAndEdges();
debouncePersistCanvasState();
}, [localNodes, localEdges]);

// Restore snapshot states upon loading a workflow
Expand Down
63 changes: 63 additions & 0 deletions web/lib/hooks/use-workflow-persistence.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<WorkflowContent | undefined> {
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,
};
}
53 changes: 52 additions & 1 deletion web/lib/platform-api/abstract-platform-api.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -69,4 +69,55 @@ export abstract class AbstractPlatformAPI {

// Create a new terminal and get socket
abstract createTerminal(): Promise<string>;

// 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<void> {
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<WorkflowContent | undefined> {
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;
}
}
38 changes: 37 additions & 1 deletion web/lib/platform-api/capacitor/capacitor-api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -332,6 +332,42 @@ export class CapacitorAPI extends AbstractPlatformAPI {
);
}

async saveCanvasState(
projectUri: string,
content: WorkflowContent,
): Promise<void> {
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<WorkflowContent | undefined> {
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;
Expand Down
Loading
Loading