diff --git a/.changeset/forty-cows-smell.md b/.changeset/forty-cows-smell.md new file mode 100644 index 0000000..56f73d7 --- /dev/null +++ b/.changeset/forty-cows-smell.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Add app state snapshot during import & export diff --git a/.changeset/pre.json b/.changeset/pre.json index acb7817..fd367c3 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -29,6 +29,7 @@ "early-pumas-listen", "few-wasps-beam", "fluffy-poems-cover", + "forty-cows-smell", "free-ears-swim", "fruity-goats-look", "full-beans-stop", diff --git a/npm-packages/react-api/CHANGELOG.md b/npm-packages/react-api/CHANGELOG.md index 6475d79..b039a94 100644 --- a/npm-packages/react-api/CHANGELOG.md +++ b/npm-packages/react-api/CHANGELOG.md @@ -1,5 +1,13 @@ # @pulse-editor/react-api +## 0.1.1-alpha.46 + +### Patch Changes + +- Add app state snapshot during import & export +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.46 + ## 0.1.1-alpha.45 ### Patch Changes diff --git a/npm-packages/react-api/package.json b/npm-packages/react-api/package.json index 130094d..336d6f2 100644 --- a/npm-packages/react-api/package.json +++ b/npm-packages/react-api/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-editor/react-api", - "version": "0.1.1-alpha.45", + "version": "0.1.1-alpha.46", "main": "dist/main.js", "files": [ "dist" @@ -38,7 +38,7 @@ "typescript-eslint": "^8.30.1" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-alpha.45", + "@pulse-editor/shared-utils": "0.1.1-alpha.46", "react": "^19.0.0", "react-dom": "^19.0.0" } diff --git a/npm-packages/react-api/src/hooks/editor/use-loading.ts b/npm-packages/react-api/src/hooks/editor/use-loading.ts index 0410667..22b8d79 100644 --- a/npm-packages/react-api/src/hooks/editor/use-loading.ts +++ b/npm-packages/react-api/src/hooks/editor/use-loading.ts @@ -1,11 +1,11 @@ +import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; import { useEffect, useState } from "react"; import useIMC from "../../lib/use-imc"; -import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; export default function useLoading() { const receiverHandlerMap = new Map< IMCMessageTypeEnum, - (senderWindow: Window, message: IMCMessage) => Promise + (senderWindow: Window, message: IMCMessage) => Promise >(); const { imc, isReady } = useIMC(receiverHandlerMap); diff --git a/npm-packages/react-api/src/hooks/editor/use-snapshot-state.ts b/npm-packages/react-api/src/hooks/editor/use-snapshot-state.ts new file mode 100644 index 0000000..94df3f5 --- /dev/null +++ b/npm-packages/react-api/src/hooks/editor/use-snapshot-state.ts @@ -0,0 +1,64 @@ +import { useContext, useEffect, useState } from "react"; +import { SnapshotContext } from "../../providers/snapshot-provider"; + +export default function useSnapShotState( + key: string, + initialValue?: T, + onRestore?: (value: T) => void +) { + const snapshotContext = useContext(SnapshotContext); + + if (!snapshotContext) { + throw new Error("useSnapShotState must be used within a SnapshotProvider"); + } + + const { states, setStates } = snapshotContext; + + // Initialize state with the value from context or the initial value + const [state, setState] = useState( + states[key] !== undefined ? states[key] : initialValue + ); + + // Update context whenever state changes + const setSnapshotState: React.Dispatch> = (value) => { + setState((prev) => { + const newValue = + typeof value === "function" ? (value as (prev: T) => T)(prev) : value; + + // Defer the setStates call to next microtask, outside render phase + Promise.resolve().then(() => { + setStates((prevStates) => ({ + ...prevStates, + [key]: newValue, + })); + }); + + return newValue; + }); + }; + + // Set the initial value in context if not already set + useEffect(() => { + // Only set if the key does not exist in the context + if (states[key] === undefined && initialValue !== undefined) { + setStates((prevStates) => ({ + ...prevStates, + [key]: initialValue, + })); + } + }, []); + + // Restore state from context when key or states change + useEffect(() => { + console.log("Restoring state for key:", key, states[key]); + + if (states[key] !== undefined && states[key] !== state) { + setState(states[key]); + if (onRestore) { + onRestore(states[key]); + } + } + }, [states[key]]); + + return [state, setSnapshotState] as const; +} diff --git a/npm-packages/react-api/src/lib/use-imc.tsx b/npm-packages/react-api/src/lib/use-imc.tsx index 0da6133..2f0dcab 100644 --- a/npm-packages/react-api/src/lib/use-imc.tsx +++ b/npm-packages/react-api/src/lib/use-imc.tsx @@ -1,6 +1,6 @@ -import { InterModuleCommunication } from "@pulse-editor/shared-utils"; import { IMCMessageTypeEnum, + InterModuleCommunication, ReceiverHandlerMap, } from "@pulse-editor/shared-utils"; import { useEffect, useState } from "react"; @@ -35,9 +35,8 @@ export default function useIMC(handlerMap: ReceiverHandlerMap) { await newImc.initOtherWindow(targetWindow); setImc(newImc); - newImc.sendMessage(IMCMessageTypeEnum.AppReady).then(() => { - setIsReady(true); - }); + await newImc.sendMessage(IMCMessageTypeEnum.AppReady); + setIsReady(true); } initIMC(); diff --git a/npm-packages/react-api/src/main.ts b/npm-packages/react-api/src/main.ts index 1bcd5b6..2b2e7b0 100644 --- a/npm-packages/react-api/src/main.ts +++ b/npm-packages/react-api/src/main.ts @@ -1,9 +1,9 @@ import useAgentTools from "./hooks/agent/use-agent-tools"; import useAgents from "./hooks/agent/use-agents"; -import useRegisterAction from "./hooks/editor/use-register-action"; import useFileView from "./hooks/editor/use-file-view"; import useLoading from "./hooks/editor/use-loading"; import useNotification from "./hooks/editor/use-notification"; +import useRegisterAction from "./hooks/editor/use-register-action"; import useTheme from "./hooks/editor/use-theme"; import useToolbar from "./hooks/editor/use-toolbar"; @@ -14,12 +14,14 @@ import useSTT from "./hooks/ai-modality/use-stt"; import useTTS from "./hooks/ai-modality/use-tts"; import useVideoGen from "./hooks/ai-modality/use-video-gen"; import usePulseEnv from "./hooks/editor/use-env"; +import useSnapshotState from "./hooks/editor/use-snapshot-state"; import useTerminal from "./hooks/terminal/use-terminal"; +import SnapshotProvider from "./providers/snapshot-provider"; export { + SnapshotProvider, useAgentTools, useAgents, - useRegisterAction, useFileView, useImageGen, useLLM, @@ -27,7 +29,9 @@ export { useNotification, useOCR, usePulseEnv, + useRegisterAction, useSTT, + useSnapshotState, useTTS, useTerminal, useTheme, diff --git a/npm-packages/react-api/src/providers/snapshot-provider.tsx b/npm-packages/react-api/src/providers/snapshot-provider.tsx new file mode 100644 index 0000000..a4126f5 --- /dev/null +++ b/npm-packages/react-api/src/providers/snapshot-provider.tsx @@ -0,0 +1,63 @@ +import { IMCMessage, IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; +import React, { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useEffect, + useState, +} from "react"; +import useIMC from "../lib/use-imc"; + +export const SnapshotContext = createContext( + undefined +); + +export type SnapshotContextType = { + states: { [key: string]: any }; + setStates: Dispatch>; +}; + +export default function SnapshotProvider({ + children, +}: { + children: ReactNode; +}) { + const receiverHandlerMap = new Map< + IMCMessageTypeEnum, + (senderWindow: Window, message: IMCMessage) => Promise + >([ + [ + IMCMessageTypeEnum.EditorAppStateSnapshotRestore, + async (senderWindow: Window, message: IMCMessage) => { + const { states } = message.payload; + + // Update all states in the context + setStates((prev) => ({ ...states })); + }, + ], + [ + IMCMessageTypeEnum.EditorAppStateSnapshotSave, + async (senderWindow: Window, message: IMCMessage) => { + // Return current states in the context + return { states }; + }, + ], + ]); + + const { imc, isReady } = useIMC(receiverHandlerMap); + + const [states, setStates] = useState<{ [key: string]: any }>({}); + + useEffect(() => { + if (isReady) { + imc?.updateReceiverHandlerMap(receiverHandlerMap); + } + }, [isReady, states]); + + return ( + + {children} + + ); +} diff --git a/npm-packages/shared-utils/CHANGELOG.md b/npm-packages/shared-utils/CHANGELOG.md index 247b5e3..4bf88b2 100644 --- a/npm-packages/shared-utils/CHANGELOG.md +++ b/npm-packages/shared-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @pulse-editor/shared-utils +## 0.1.1-alpha.46 + +### Patch Changes + +- Add app state snapshot during import & export + ## 0.1.1-alpha.45 ### Patch Changes diff --git a/npm-packages/shared-utils/package.json b/npm-packages/shared-utils/package.json index 34c3fdd..50861d4 100644 --- a/npm-packages/shared-utils/package.json +++ b/npm-packages/shared-utils/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-editor/shared-utils", - "version": "0.1.1-alpha.45", + "version": "0.1.1-alpha.46", "main": "dist/main.js", "files": [ "dist" diff --git a/npm-packages/shared-utils/src/imc/poly-imc.ts b/npm-packages/shared-utils/src/imc/poly-imc.ts index 68802cf..417524b 100644 --- a/npm-packages/shared-utils/src/imc/poly-imc.ts +++ b/npm-packages/shared-utils/src/imc/poly-imc.ts @@ -145,10 +145,18 @@ export class ConnectionListener { private listener: InterModuleCommunication; + /** + * + * @param polyIMC The polyIMC instance. + * @param newConnectionReceiverHandlerMap Receiver handler map for newly established poly-IMC channel. + * @param onConnection Callback function to be called when a new connection is established. + * @param expectedOtherWindowId Optional expected other window ID to validate incoming connections. + */ constructor( polyIMC: PolyIMC, newConnectionReceiverHandlerMap: ReceiverHandlerMap, - onConnection?: (senderWindow: Window, message: IMCMessage) => void + onConnection?: (senderWindow: Window, message: IMCMessage) => void, + expectedOtherWindowId?: string ) { this.polyIMC = polyIMC; this.newConnectionReceiverHandlerMap = newConnectionReceiverHandlerMap; @@ -156,7 +164,7 @@ export class ConnectionListener { const listener = new InterModuleCommunication(); this.listener = listener; - listener.initThisWindow(window); + listener.initThisWindow(window, expectedOtherWindowId); listener.updateReceiverHandlerMap( new Map([ diff --git a/npm-packages/shared-utils/src/types/types.ts b/npm-packages/shared-utils/src/types/types.ts index deb8584..e7e7f08 100644 --- a/npm-packages/shared-utils/src/types/types.ts +++ b/npm-packages/shared-utils/src/types/types.ts @@ -36,6 +36,9 @@ export enum IMCMessageTypeEnum { EditorShowNotification = "editor-show-notification", // Get environment variables EditorGetEnv = "editor-get-env", + // App state snapshot upon importing & exporting + EditorAppStateSnapshotRestore = "editor-app-state-snapshot-restore", + EditorAppStateSnapshotSave = "editor-app-state-snapshot-save", // #endregion // #region Platform API interaction messages (require OS-like environment) diff --git a/web/components/app-loaders/sandbox-app-loader.tsx b/web/components/app-loaders/sandbox-app-loader.tsx index d37344e..0107110 100644 --- a/web/components/app-loaders/sandbox-app-loader.tsx +++ b/web/components/app-loaders/sandbox-app-loader.tsx @@ -113,7 +113,7 @@ export default function SandboxAppLoader({ if (currentViewId) { // Listen for an incoming extension connection - console.log("Listening for extension connection..."); + console.log(`[${currentViewId}]: Listening for app connection...`); listenForExtensionConnection(); setIsLookingForExtension(true); @@ -136,6 +136,8 @@ export default function SandboxAppLoader({ // When IMC is connected, remove the connection listener useEffect(() => { if (isConnected && clRef.current) { + console.log(`[${currentViewId}]: App connected.`); + // Close the connection listener clRef.current.close(); clRef.current = null; } @@ -184,6 +186,7 @@ export default function SandboxAppLoader({ }: { isLoading: boolean; } = message.payload; + console.log(`[${model.viewId}]: App is loading: `, isLoading); setIsLoadingExtension((prev) => isLoading); if (onInitialLoaded) { onInitialLoaded(); @@ -296,6 +299,7 @@ export default function SandboxAppLoader({ (senderWindow: Window, message: IMCMessage) => { setIsConnected((prev) => true); }, + viewModel.viewId, ); clRef.current = cl; } diff --git a/web/components/explorer/app/app-explorer.tsx b/web/components/explorer/app/app-explorer.tsx index 4bb1940..27c4624 100644 --- a/web/components/explorer/app/app-explorer.tsx +++ b/web/components/explorer/app/app-explorer.tsx @@ -57,17 +57,19 @@ export default function AppExplorer() {
{previews}
- +
+ +
); } diff --git a/web/components/interface/navigation/menu-dropdown/view-menu.tsx b/web/components/interface/navigation/menu-dropdown/view-menu.tsx index 3018f03..77a8d81 100644 --- a/web/components/interface/navigation/menu-dropdown/view-menu.tsx +++ b/web/components/interface/navigation/menu-dropdown/view-menu.tsx @@ -2,7 +2,7 @@ import { EditorContext } from "@/components/providers/editor-context-provider"; import { useMenuActions } from "@/lib/hooks/menu-actions/use-menu-actions"; import { useRegisterMenuAction } from "@/lib/hooks/menu-actions/use-register-menu-action"; import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; -import { CanvasViewConfig, Workflow } from "@/lib/types"; +import { CanvasViewConfig, WorkflowContent } from "@/lib/types"; import { useContext, useEffect, useState } from "react"; import { v4 } from "uuid"; import NavMenuDropdown from "../nav-menu-dropdown"; @@ -78,16 +78,18 @@ export default function ViewMenuDropDown() { const reader = new FileReader(); reader.onload = async (event) => { try { - const workflow = JSON.parse( + const workflowContent = JSON.parse( event.target?.result as string, - ) as Workflow; - if (workflow) { + ) as WorkflowContent; + if (workflowContent) { // Create a new tab view with the imported workflow const viewId = "canvas-" + v4(); await createCanvasTabView({ viewId, - appConfigs: workflow.content.nodes.map((node) => node.data.config), - initialWorkflow: workflow, + appConfigs: workflowContent.nodes.map( + (node) => node.data.config, + ), + initialWorkflowContent: workflowContent, } as CanvasViewConfig); } else { alert("Invalid workflow file"); diff --git a/web/components/marketplace/workflow/workflow-gallery.tsx b/web/components/marketplace/workflow/workflow-gallery.tsx index 626595c..6f52346 100644 --- a/web/components/marketplace/workflow/workflow-gallery.tsx +++ b/web/components/marketplace/workflow/workflow-gallery.tsx @@ -60,7 +60,7 @@ export default function WorkflowGallery() { return (
- +
); }, diff --git a/web/components/marketplace/workflow/workflow-preview-card.tsx b/web/components/marketplace/workflow/workflow-preview-card.tsx index 26cae55..f356434 100644 --- a/web/components/marketplace/workflow/workflow-preview-card.tsx +++ b/web/components/marketplace/workflow/workflow-preview-card.tsx @@ -41,7 +41,7 @@ export default function WorkflowPreviewCard({ await createCanvasTabView({ viewId: `canvas-${v4()}`, appConfigs: workflow.content.nodes.map((node) => node.data.config), - initialWorkflow: workflow, + initialWorkflowContent: workflow.content, }); editorContext?.setEditorStates((prev) => ({ @@ -105,9 +105,7 @@ export default function WorkflowPreviewCard({ color="primary" size="sm" onPress={() => { - if (isPressable) { - openWorkflow(); - } + openWorkflow(); }} > Use diff --git a/web/components/modals/publish-workflow-modal.tsx b/web/components/modals/publish-workflow-modal.tsx index 2b11cea..3064c19 100644 --- a/web/components/modals/publish-workflow-modal.tsx +++ b/web/components/modals/publish-workflow-modal.tsx @@ -13,6 +13,7 @@ export default function PublishWorkflowModal({ localNodes, localEdges, entryPoint, + saveAppsSnapshotStates, }: { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; @@ -20,43 +21,58 @@ export default function PublishWorkflowModal({ localNodes: ReactFlowNode[]; localEdges: ReactFlowEdge[]; entryPoint: ReactFlowNode | undefined; + saveAppsSnapshotStates: () => Promise<{ + [key: string]: any; + }>; }) { const [name, setName] = useState(""); const [version, setVersion] = useState(""); async function publishWorkflow() { - if (!workflowCanvas) { - console.error("Workflow canvas is not available"); - return; - } + try { + if (!workflowCanvas) { + console.error("Workflow canvas is not available"); + return; + } - setIsOpen(false); + setIsOpen(false); - const res = await captureWorkflowCanvas(workflowCanvas); - const dataUrl = res.toDataURL("image/png"); + const res = await captureWorkflowCanvas(workflowCanvas); + const dataUrl = res.toDataURL("image/png"); - const workflow: Workflow = { - name: name, - thumbnail: dataUrl, - content: { - nodes: localNodes ?? [], - edges: localEdges ?? [], - defaultEntryPoint: entryPoint, - }, - version: version, - visibility: "public", - }; + const snapshotStates = await saveAppsSnapshotStates(); - await fetchAPI("/api/workflow/publish", { - method: "POST", - body: JSON.stringify({ ...workflow }), - }); + const workflow: Workflow = { + name: name, + thumbnail: dataUrl, + content: { + nodes: localNodes ?? [], + edges: localEdges ?? [], + defaultEntryPoint: entryPoint, + snapshotStates: snapshotStates, + }, + version: version, + visibility: "public", + }; - addToast({ - title: "Workflow Published", - description: "Your workflow has been published successfully.", - color: "success", - }); + await fetchAPI("/api/workflow/publish", { + method: "POST", + body: JSON.stringify({ ...workflow }), + }); + + addToast({ + title: "Workflow Published", + description: "Your workflow has been published successfully.", + color: "success", + }); + } catch (error) { + console.error("Error publishing workflow:", error); + addToast({ + title: "Error", + description: "There was an error publishing your workflow.", + color: "danger", + }); + } } async function handlePress() { diff --git a/web/components/providers/imc-provider.tsx b/web/components/providers/imc-provider.tsx index 4766b1b..337e1a8 100644 --- a/web/components/providers/imc-provider.tsx +++ b/web/components/providers/imc-provider.tsx @@ -27,7 +27,7 @@ import { STTConfig, TTSConfig, } from "@pulse-editor/shared-utils"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { createContext, useContext, useEffect, useRef } from "react"; import { EditorContext } from "./editor-context-provider"; export const IMCContext = createContext(undefined); @@ -39,7 +39,7 @@ export default function InterModuleCommunicationProvider({ }) { const editorContext = useContext(EditorContext); - const [polyIMC, setPolyIMC] = useState(undefined); + const polyIMCRef = useRef(undefined); const imcInitializedMapRef = useRef>(new Map()); const imcInitializedResolvePromisesRef = useRef<{ [key: string]: () => void; @@ -53,29 +53,23 @@ export default function InterModuleCommunicationProvider({ useEffect(() => { // @ts-expect-error set window viewId window.viewId = "Pulse Editor Main"; + polyIMCRef.current = new PolyIMC(getHandlerMap()); return () => { // Cleanup the polyIMC instance when the component unmounts - if (polyIMC) { - polyIMC.close(); - setPolyIMC(undefined); + if (polyIMCRef) { + polyIMCRef.current?.close(); + polyIMCRef.current = undefined; } }; }, []); - useEffect(() => { - if (!polyIMC) { - const newPolyIMC = new PolyIMC(getHandlerMap()); - setPolyIMC(newPolyIMC); - } - }, [polyIMC, setPolyIMC]); - // Update the base handler map as editor context changes useEffect(() => { - if (polyIMC) { - polyIMC.updateBaseReceiverHandlerMap(getHandlerMap()); + if (polyIMCRef.current) { + polyIMCRef.current?.updateBaseReceiverHandlerMap(getHandlerMap()); } - }, [polyIMC, editorContext]); + }, [editorContext]); function markIMCInitialized(viewId: string) { imcInitializedMapRef.current.set(viewId, true); @@ -489,7 +483,7 @@ export default function InterModuleCommunicationProvider({ return ( n.id === appConfig.viewId, )?.data.isShowingWorkflowConnector ?? false, }; @@ -285,6 +286,7 @@ export default function CanvasView({ localNodes={localNodes} localEdges={localEdges} entryPoint={entryPoint} + saveAppsSnapshotStates={saveAppsSnapshotStates} /> ); diff --git a/web/lib/hooks/use-canvas-workflow.ts b/web/lib/hooks/use-canvas-workflow.ts index 039272a..86baf41 100644 --- a/web/lib/hooks/use-canvas-workflow.ts +++ b/web/lib/hooks/use-canvas-workflow.ts @@ -1,5 +1,6 @@ -import { EditorContext } from "@/components/providers/editor-context-provider"; +import { IMCContext } from "@/components/providers/imc-provider"; import { addToast } from "@heroui/react"; +import { IMCMessageTypeEnum } from "@pulse-editor/shared-utils"; import { Edge as ReactFlowEdge, Node as ReactFlowNode, @@ -8,11 +9,13 @@ import { } from "@xyflow/react"; import { useCallback, useContext, useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; -import { AppNodeData, Workflow } from "../types"; +import { AppNodeData, WorkflowContent } from "../types"; import useScopedActions from "./use-scoped-actions"; -export default function useCanvasWorkflow(initialWorkflow?: Workflow) { - const editorContext = useContext(EditorContext); +export default function useCanvasWorkflow( + initialWorkflowContent?: WorkflowContent, +) { + const imcContext = useContext(IMCContext); const { runAction } = useScopedActions(); @@ -26,20 +29,33 @@ export default function useCanvasWorkflow(initialWorkflow?: Workflow) { >(undefined); const [localNodes, setLocalNodes] = useNodesState( - initialWorkflow?.content.nodes ?? [], + initialWorkflowContent?.nodes ?? [], ); const [localEdges, setLocalEdges] = useEdgesState( - initialWorkflow?.content.edges ?? [], + initialWorkflowContent?.edges ?? [], ); const [defaultEntryPoint, setDefaultEntryPoint] = useState< ReactFlowNode | undefined - >(initialWorkflow?.content.defaultEntryPoint); + >(initialWorkflowContent?.defaultEntryPoint); // Update entry points useEffect(() => { debouncedGetEntryPoint(); }, [localNodes]); + // Restore snapshot states upon loading a workflow + useEffect(() => { + async function restore() { + if (!imcContext) return; + + if (initialWorkflowContent?.snapshotStates) { + await restoreAppsSnapshotStates(initialWorkflowContent); + } + } + + restore(); + }, [initialWorkflowContent, imcContext]); + async function startWorkflow() { // DAG traversal using Kahn's algorithm (topological sort) function getExecutionSequence(entryPoint: ReactFlowNode) { @@ -337,7 +353,7 @@ export default function useCanvasWorkflow(initialWorkflow?: Workflow) { [localEdges], ); - const exportWorkflow = useCallback(() => { + const exportWorkflow = useCallback(async () => { const blob = new Blob( [ JSON.stringify( @@ -345,7 +361,8 @@ export default function useCanvasWorkflow(initialWorkflow?: Workflow) { nodes: localNodes, edges: localEdges, defaultEntryPoint: defaultEntryPoint, - }, + snapshotStates: await saveAppsSnapshotStates(), + } as WorkflowContent, null, 2, ), @@ -362,6 +379,62 @@ export default function useCanvasWorkflow(initialWorkflow?: Workflow) { URL.revokeObjectURL(url); }, [localNodes, localEdges, defaultEntryPoint]); + async function saveAppsSnapshotStates() { + const apps = localNodes.map((node) => node.data.config); + + const appStates = await Promise.all( + apps.map(async (app) => { + if (!app.viewId) return null; + + // Do a time out because the app may not use snapshot feature + return await Promise.race([ + new Promise((resolve) => setTimeout(() => resolve(null), 2000)), + (async () => { + const { states } = await imcContext?.polyIMC?.sendMessage( + app.viewId, + IMCMessageTypeEnum.EditorAppStateSnapshotSave, + ); + return { appId: app.viewId, states: states }; + })(), + ]); + }), + ); + + const appStatesMap = appStates + .filter((s) => s && s.states) + .reduce( + (acc, curr) => { + if (curr) { + acc[curr.appId] = curr.states; + } + return acc; + }, + {} as { [key: string]: any }, + ); + + return appStatesMap; + } + + async function restoreAppsSnapshotStates(content: WorkflowContent) { + if (!imcContext) return; + else if (!content.snapshotStates) return; + + const apps = content.nodes.map((node) => node.data.config); + for (const app of apps) { + if (!app.viewId) continue; + if (content.snapshotStates[app.viewId]) { + // Wait until the view is initialized + await imcContext?.resolveWhenViewInitialized(app.viewId); + // Send snapshot restore message + await imcContext?.polyIMC?.sendMessage( + app.viewId, + IMCMessageTypeEnum.EditorAppStateSnapshotRestore, + { states: content.snapshotStates[app.viewId] }, + ); + } + } + } + return { localNodes, localEdges, @@ -373,5 +446,7 @@ export default function useCanvasWorkflow(initialWorkflow?: Workflow) { updateWorkflowEdges, exportWorkflow, updateWorkflowNodeData, + saveAppsSnapshotStates, + restoreAppsSnapshotStates, }; } diff --git a/web/lib/hooks/use-tab-view-manager.ts b/web/lib/hooks/use-tab-view-manager.ts index 4b8c8a4..46ff09b 100644 --- a/web/lib/hooks/use-tab-view-manager.ts +++ b/web/lib/hooks/use-tab-view-manager.ts @@ -1,5 +1,6 @@ import { EditorContext } from "@/components/providers/editor-context-provider"; import { IMCContext } from "@/components/providers/imc-provider"; +import { addToast } from "@heroui/react"; import { ViewModeEnum } from "@pulse-editor/shared-utils"; import { useContext, useEffect, useState } from "react"; import { v4 } from "uuid"; @@ -206,6 +207,15 @@ export function useTabViewManager() { tabIndex: newIdx, }; }); + + if (view.type === ViewModeEnum.Canvas) { + // Remove the app nodes' view IDs from IMC context + (view.config as CanvasViewConfig).appConfigs?.forEach((appConfig) => { + imcContext?.polyIMC?.removeChannel(appConfig.viewId); + }); + } else if (view.type === ViewModeEnum.App) { + imcContext?.polyIMC?.removeChannel((view.config as AppViewConfig).viewId); + } } /** @@ -222,6 +232,19 @@ export function useTabViewManager() { tabIndex: -1, }; }); + + // For all tab views, remove their view IDs from IMC context + tabViews.forEach((view) => { + if (view.type === ViewModeEnum.Canvas) { + (view.config as CanvasViewConfig).appConfigs?.forEach((appConfig) => { + imcContext?.polyIMC?.removeChannel(appConfig.viewId); + }); + } else if (view.type === ViewModeEnum.App) { + imcContext?.polyIMC?.removeChannel( + (view.config as AppViewConfig).viewId, + ); + } + }); } function viewCount(): number { @@ -265,6 +288,20 @@ export function useTabViewManager() { throw new Error("IMC context is not available"); } + // Prohibit creating canvas if any app's view ID in the canvas already exists + const existViewId = canvasConfig.appConfigs?.find((appConfig) => + imcContext?.polyIMC?.hasChannel(appConfig.viewId), + ); + + if (existViewId) { + addToast({ + title: "Error creating canvas", + description: `Same app nodes already exist. Your workflow might already be opened in another tab.`, + color: "danger", + }); + return undefined; + } + const newTabView: TabView = { type: ViewModeEnum.Canvas, config: canvasConfig, @@ -297,6 +334,10 @@ export function useTabViewManager() { currentTab = await createCanvasTabView({ viewId: `canvas-${v4()}`, } as CanvasViewConfig); + if (!currentTab) { + console.error("Failed to create a new canvas tab"); + return; + } } const newCanvasConfig: CanvasViewConfig = { diff --git a/web/lib/types.ts b/web/lib/types.ts index 868af0e..6213688 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -228,7 +228,7 @@ export type CanvasViewConfig = { // App configurations. // This does not change once the canvas view is created. appConfigs?: AppViewConfig[]; - initialWorkflow?: Workflow; + initialWorkflowContent?: WorkflowContent; }; export type TabView = { @@ -350,15 +350,18 @@ export type AppMetaData = { export type Workflow = { name: string; version: string; - content: { - nodes: ReactFlowNode[]; - edges: ReactFlowEdge[]; - defaultEntryPoint?: ReactFlowNode; - }; + content: WorkflowContent; thumbnail?: string; visibility: "private" | "public" | "unlisted"; }; +export type WorkflowContent = { + nodes: ReactFlowNode[]; + edges: ReactFlowEdge[]; + defaultEntryPoint?: ReactFlowNode; + snapshotStates?: { [key: string]: any }; +}; + export type AppNodeData = { config: AppViewConfig; selectedAction: Action | undefined;