diff --git a/.changeset/fuzzy-sheep-pay.md b/.changeset/fuzzy-sheep-pay.md new file mode 100644 index 0000000..e9a1507 --- /dev/null +++ b/.changeset/fuzzy-sheep-pay.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Make action return type any diff --git a/.changeset/pre.json b/.changeset/pre.json index e531879..acb7817 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -32,6 +32,7 @@ "free-ears-swim", "fruity-goats-look", "full-beans-stop", + "fuzzy-sheep-pay", "hot-symbols-fry", "hot-windows-march", "large-moose-tap", @@ -56,6 +57,7 @@ "tough-aliens-appear", "true-suits-fly", "vast-places-rhyme", + "weak-beers-watch", "wicked-spoons-fry" ] } diff --git a/.changeset/weak-beers-watch.md b/.changeset/weak-beers-watch.md new file mode 100644 index 0000000..19087df --- /dev/null +++ b/.changeset/weak-beers-watch.md @@ -0,0 +1,6 @@ +--- +"@pulse-editor/shared-utils": patch +"@pulse-editor/react-api": patch +--- + +Update action register hook diff --git a/npm-packages/react-api/CHANGELOG.md b/npm-packages/react-api/CHANGELOG.md index 0445fce..6475d79 100644 --- a/npm-packages/react-api/CHANGELOG.md +++ b/npm-packages/react-api/CHANGELOG.md @@ -1,5 +1,21 @@ # @pulse-editor/react-api +## 0.1.1-alpha.45 + +### Patch Changes + +- Make action return type any +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.45 + +## 0.1.1-alpha.44 + +### Patch Changes + +- Update action register hook +- Updated dependencies + - @pulse-editor/shared-utils@0.1.1-alpha.44 + ## 0.1.1-alpha.43 ### Patch Changes diff --git a/npm-packages/react-api/package.json b/npm-packages/react-api/package.json index ed9cc19..130094d 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.43", + "version": "0.1.1-alpha.45", "main": "dist/main.js", "files": [ "dist" @@ -38,7 +38,7 @@ "typescript-eslint": "^8.30.1" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-alpha.43", + "@pulse-editor/shared-utils": "0.1.1-alpha.45", "react": "^19.0.0", "react-dom": "^19.0.0" } diff --git a/npm-packages/react-api/src/hooks/editor/use-register-action.ts b/npm-packages/react-api/src/hooks/editor/use-register-action.ts index 34e8d12..f1ff634 100644 --- a/npm-packages/react-api/src/hooks/editor/use-register-action.ts +++ b/npm-packages/react-api/src/hooks/editor/use-register-action.ts @@ -5,7 +5,7 @@ import { ReceiverHandler, TypedVariable, } from "@pulse-editor/shared-utils"; -import { useEffect, useRef, useState } from "react"; +import { DependencyList, useEffect, useRef, useState } from "react"; import useIMC from "../../lib/use-imc"; /** @@ -17,7 +17,8 @@ import useIMC from "../../lib/use-imc"; * @param parameters Parameters of the command. * @param returns Return values of the command. * @param callbackHandler Callback handler function to handle the command. - * @param isExtReady Whether the extension is ready to receive commands. + * @param deps Dependency list to re-register the action when changed. + * @param isExtReady Whether the extension is ready to receive commands. * Useful for actions that need to wait for some certain app state to be ready. * */ @@ -28,7 +29,8 @@ export default function useRegisterAction( parameters?: Record; returns?: Record; }, - callbackHandler?: (args: any) => Promise, + callbackHandler: (args: any) => Promise, + deps: DependencyList, isExtReady: boolean = true ) { const { isReady, imc } = useIMC(getReceiverHandlerMap()); @@ -85,8 +87,9 @@ export default function useRegisterAction( description: actionInfo.description, parameters: actionInfo.parameters ?? {}, returns: actionInfo.returns ?? {}, + handler: callbackHandler, })); - }, [callbackHandler]); + }, [...deps]); async function executeAction(args: any) { if (!action.handler) return; diff --git a/npm-packages/shared-utils/CHANGELOG.md b/npm-packages/shared-utils/CHANGELOG.md index db4b6f3..247b5e3 100644 --- a/npm-packages/shared-utils/CHANGELOG.md +++ b/npm-packages/shared-utils/CHANGELOG.md @@ -1,5 +1,17 @@ # @pulse-editor/shared-utils +## 0.1.1-alpha.45 + +### Patch Changes + +- Make action return type any + +## 0.1.1-alpha.44 + +### Patch Changes + +- Update action register hook + ## 0.1.1-alpha.43 ### Patch Changes diff --git a/npm-packages/shared-utils/package.json b/npm-packages/shared-utils/package.json index 6127331..34c3fdd 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.43", + "version": "0.1.1-alpha.45", "main": "dist/main.js", "files": [ "dist" diff --git a/npm-packages/shared-utils/src/imc/inter-module-communication.ts b/npm-packages/shared-utils/src/imc/inter-module-communication.ts index 9acca00..d807102 100644 --- a/npm-packages/shared-utils/src/imc/inter-module-communication.ts +++ b/npm-packages/shared-utils/src/imc/inter-module-communication.ts @@ -72,10 +72,7 @@ export class InterModuleCommunication { } const message = event.data; - if ( - process.env.NODE_ENV === "development" && - message.from !== undefined - ) { + if (message.from !== undefined) { console.log( `Module ${this.thisWindowId} received message from module ${ message.from diff --git a/package-lock.json b/package-lock.json index fb8ce6f..ad24965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25828,7 +25828,7 @@ }, "npm-packages/react-api": { "name": "@pulse-editor/react-api", - "version": "0.1.1-alpha.43", + "version": "0.1.1-alpha.45", "devDependencies": { "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", @@ -25852,7 +25852,7 @@ "typescript-eslint": "^8.30.1" }, "peerDependencies": { - "@pulse-editor/shared-utils": "0.1.1-alpha.43", + "@pulse-editor/shared-utils": "0.1.1-alpha.45", "react": "^19.0.0", "react-dom": "^19.0.0" } @@ -26117,7 +26117,7 @@ }, "npm-packages/shared-utils": { "name": "@pulse-editor/shared-utils", - "version": "0.1.1-alpha.43", + "version": "0.1.1-alpha.45", "devDependencies": { "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", @@ -26667,7 +26667,7 @@ "@langchain/core": "^0.3.66", "@langchain/openai": "^0.6.3", "@pulse-editor/capacitor-plugin": "file:../capacitor-plugin", - "@pulse-editor/shared-utils": "^0.1.1-alpha.43", + "@pulse-editor/shared-utils": "^0.1.1-alpha.45", "@ricky0123/vad-web": "^0.0.28", "@vercel/analytics": "^1.5.0", "@xyflow/react": "^12.8.5", diff --git a/web/components/app-loaders/sandbox-app-loader.tsx b/web/components/app-loaders/sandbox-app-loader.tsx index 2846cbd..2aed76e 100644 --- a/web/components/app-loaders/sandbox-app-loader.tsx +++ b/web/components/app-loaders/sandbox-app-loader.tsx @@ -141,7 +141,7 @@ export default function SandboxAppLoader({ } }, [isConnected]); - // Send theme update to the extension + // Send theme update to the extension when theme changes useEffect(() => { if (currentViewId && imcContext?.polyIMC?.hasChannel(currentViewId)) { imcContext?.polyIMC?.sendMessage( @@ -166,7 +166,7 @@ export default function SandboxAppLoader({ getHandlerMap(viewModel), ); } - }, [viewModel, isConnected]); + }, [viewModel]); function getHandlerMap(model: ViewModel) { const newMap = new Map(); @@ -188,6 +188,16 @@ export default function SandboxAppLoader({ if (onInitialLoaded) { onInitialLoaded(); } + + // Update with current theme + // TODO: pass theme directly to app along with config when creating a new view + if (currentViewId && imcContext?.polyIMC?.hasChannel(currentViewId)) { + imcContext?.polyIMC?.sendMessage( + currentViewId, + IMCMessageTypeEnum.EditorThemeUpdate, + resolvedTheme, + ); + } }, ); diff --git a/web/components/explorer/app/app-explorer.tsx b/web/components/explorer/app/app-explorer.tsx index 2adac79..0a96502 100644 --- a/web/components/explorer/app/app-explorer.tsx +++ b/web/components/explorer/app/app-explorer.tsx @@ -33,7 +33,7 @@ export default function AppExplorer() { onPress={(ext) => { const config: AppViewConfig = { app: ext.config.id, - viewId: v4(), + viewId: `${ext.config.id}-${v4()}`, recommendedHeight: ext.config.recommendedHeight, recommendedWidth: ext.config.recommendedWidth, }; diff --git a/web/components/explorer/file-system/fs-explorer.tsx b/web/components/explorer/file-system/fs-explorer.tsx index 376422c..b630f51 100644 --- a/web/components/explorer/file-system/fs-explorer.tsx +++ b/web/components/explorer/file-system/fs-explorer.tsx @@ -38,8 +38,7 @@ export default function FileSystemExplorer({ function viewFile(uri: string, viewMode: ViewModeEnum) { platformApi?.readFile(uri).then((file) => { - const viewId = v4(); - openFileInView(viewId, file, viewMode).then(() => { + openFileInView(file, viewMode).then(() => { if (platform === PlatformEnum.Capacitor) { setIsMenuOpen(false); } diff --git a/web/components/interface/editor-toolbar.tsx b/web/components/interface/editor-toolbar.tsx index 977b189..e5de288 100644 --- a/web/components/interface/editor-toolbar.tsx +++ b/web/components/interface/editor-toolbar.tsx @@ -2,11 +2,10 @@ import Icon from "@/components/misc/icon"; import AppSettingsModal from "@/components/modals/app-settings-modal"; +import { useMenuActions } from "@/lib/hooks/menu-actions/use-menu-actions"; import usePlatformAIAssistant from "@/lib/hooks/use-platform-ai-assistant"; import useRecorder from "@/lib/hooks/use-recorder"; -import useScopedActions from "@/lib/hooks/use-scoped-actions"; -import { AppNodeData } from "@/lib/types"; -import { addToast, Button, Divider, Tooltip } from "@heroui/react"; +import { Button, Divider, Tooltip } from "@heroui/react"; import { AnimatePresence, motion } from "framer-motion"; import { useContext, useState } from "react"; import AgentConfigModal from "../modals/agent-config-modal"; @@ -18,7 +17,7 @@ export default function EditorToolbar() { const { chatWithAssistant } = usePlatformAIAssistant(); const { isRecording, record } = useRecorder(); - const { runAction } = useScopedActions(); + const { runMenuActionByName } = useMenuActions(); const [isAgentListModalOpen, setIsAgentListModalOpen] = useState(false); const [isAppSettingsModalOpen, setAppIsSettingsModalOpen] = useState(false); @@ -87,38 +86,7 @@ export default function EditorToolbar() { isIconOnly className="text-default-foreground h-8 w-8 min-w-8 px-1 py-1" onPress={() => { - const node = editorContext?.editorStates.selectedNode; - - if (!node) { - addToast({ - title: "No Node Selected", - description: - "Please select a node as a starting point to run the workflow.", - color: "danger", - }); - return; - } - - const { selectedAction } = node.data as AppNodeData; - - if (!selectedAction) { - addToast({ - title: "No Action Selected", - description: - "Please select an action for the node to run.", - color: "danger", - }); - return; - } - - runAction( - { - action: selectedAction, - viewId: node.id, - type: "app", - }, - {}, - ); + runMenuActionByName("Run Workflow", "view"); }} > diff --git a/web/components/interface/navigation/menu-dropdown/file-menu.tsx b/web/components/interface/navigation/menu-dropdown/file-menu.tsx index d5304f3..062e9d7 100644 --- a/web/components/interface/navigation/menu-dropdown/file-menu.tsx +++ b/web/components/interface/navigation/menu-dropdown/file-menu.tsx @@ -1,35 +1,51 @@ -import { useMenuActions } from "@/lib/hooks/use-menu-actions"; -import NavMenuDropdown from "../nav-menu-dropdown"; -import { MenuAction } from "@/lib/types"; -import { useEffect } from "react"; +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 { ViewModeEnum } from "@pulse-editor/shared-utils"; +import { useEffect, useState } from "react"; import { v4 } from "uuid"; +import NavMenuDropdown from "../nav-menu-dropdown"; export default function FileMenuDropDown() { - const { menuActions, registerMenuAction } = useMenuActions("file"); - const { createTabView } = useTabViewManager(); + const { menuActions } = useMenuActions("file"); + const { createCanvasTabView, activeTabView, closeTabView } = + useTabViewManager(); - const defaultMenuActions: MenuAction[] = [ + useRegisterMenuAction( { name: "New Workflow", - actionFunc: async () => { - // Trigger new Workflow creation logic - await createTabView(ViewModeEnum.Canvas, { viewId: v4() }); - }, menuCategory: "file", shortcut: "Ctrl+N", icon: "add", description: "Create a new Workflow", }, - ]; + async () => { + // Trigger new Workflow creation logic + await createCanvasTabView({ viewId: "canvas-" + v4() }); + }, + [], + ); + + const [isCloseWorkflowEnabled, setIsCloseWorkflowEnabled] = useState(false); + useRegisterMenuAction( + { + name: "Close Workflow", + menuCategory: "file", + shortcut: "Ctrl+C", + icon: "close", + description: "Close the current workflow", + }, + async () => { + if (activeTabView) { + closeTabView(activeTabView); + } + }, + [activeTabView], + isCloseWorkflowEnabled, + ); - // Register default menu actions if not already registered useEffect(() => { - defaultMenuActions.forEach((action) => { - registerMenuAction(action); - }); - }, []); + setIsCloseWorkflowEnabled(activeTabView !== undefined); + }, [activeTabView]); return ; } diff --git a/web/components/interface/navigation/menu-dropdown/view-menu.tsx b/web/components/interface/navigation/menu-dropdown/view-menu.tsx index 51eaca3..768cc0f 100644 --- a/web/components/interface/navigation/menu-dropdown/view-menu.tsx +++ b/web/components/interface/navigation/menu-dropdown/view-menu.tsx @@ -1,64 +1,107 @@ -import { useMenuActions } from "@/lib/hooks/use-menu-actions"; -import NavMenuDropdown from "../nav-menu-dropdown"; -import { MenuAction } from "@/lib/types"; -import { useContext, useEffect } from "react"; 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 { useContext, useEffect, useState } from "react"; +import { v4 } from "uuid"; +import NavMenuDropdown from "../nav-menu-dropdown"; export default function ViewMenuDropDown() { const editorContext = useContext(EditorContext); - const { menuActions, registerMenuAction, unregisterMenuAction } = - useMenuActions("view"); + const { menuActions } = useMenuActions("view"); - const defaultMenuActions: MenuAction[] = []; + // Command Viewer + const [isCommandViewerOpen, setIsCommandViewerOpen] = useState(false); - // Register default menu actions if not already registered - useEffect(() => { - console.log("Registering default menu actions"); - defaultMenuActions.forEach((action) => { - registerMenuAction(action); - }); - }, []); - - useEffect(() => { - const closeAction: MenuAction = { + useRegisterMenuAction( + { name: "Close Command Viewer", - actionFunc: async () => { - console.log("Closing command viewer"); - editorContext?.setEditorStates((prev) => ({ - ...prev, - isCommandViewerOpen: false, - })); - }, menuCategory: "view", shortcut: "F1", icon: "terminal", description: "Close the command viewer", - }; + }, + async () => { + console.log("Closing command viewer"); + editorContext?.setEditorStates((prev) => ({ + ...prev, + isCommandViewerOpen: false, + })); + }, + [], + isCommandViewerOpen, + ); - const openAction: MenuAction = { + useRegisterMenuAction( + { name: "View Command Viewer", - actionFunc: async () => { - console.log("Opening command viewer"); - editorContext?.setEditorStates((prev) => ({ - ...prev, - isCommandViewerOpen: true, - })); - }, menuCategory: "view", shortcut: "F1", icon: "terminal", description: "View all commands and shortcuts", - }; - if (editorContext?.editorStates.isCommandViewerOpen) { - // Register the close action and unregister the open action - unregisterMenuAction(openAction); - registerMenuAction(closeAction); - } else { - // Register the open action and unregister the close action - unregisterMenuAction(closeAction); - registerMenuAction(openAction); - } + }, + async () => { + console.log("Opening command viewer"); + editorContext?.setEditorStates((prev) => ({ + ...prev, + isCommandViewerOpen: true, + })); + }, + [], + !isCommandViewerOpen, + ); + + useEffect(() => { + setIsCommandViewerOpen( + editorContext?.editorStates.isCommandViewerOpen ?? false, + ); }, [editorContext?.editorStates.isCommandViewerOpen]); + // Workflow + const { createCanvasTabView } = useTabViewManager(); + + useRegisterMenuAction( + { + name: "Import Workflow", + menuCategory: "file", + description: "Import a workflow from a JSON file", + shortcut: "Ctrl+Alt+I", + icon: "upload", + }, + async () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json"; + input.onchange = (e: any) => { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = async (event) => { + try { + const workflow = JSON.parse( + event.target?.result as string, + ) as Workflow; + if (workflow) { + // Create a new tab view with the imported workflow + const viewId = "canvas-" + v4(); + await createCanvasTabView({ + viewId, + appConfigs: workflow.nodes.map((node) => node.data.config), + workflow, + } as CanvasViewConfig); + } else { + alert("Invalid workflow file"); + } + } catch (err) { + alert("Error reading workflow file"); + } + }; + reader.readAsText(file); + }; + input.click(); + }, + [], + ); + return ; } diff --git a/web/components/interface/navigation/nav-top-bar.tsx b/web/components/interface/navigation/nav-top-bar.tsx index 4d176a4..3c05959 100644 --- a/web/components/interface/navigation/nav-top-bar.tsx +++ b/web/components/interface/navigation/nav-top-bar.tsx @@ -1,9 +1,8 @@ import { PlatformEnum } from "@/lib/enums"; import { useAuth } from "@/lib/hooks/use-auth"; -import { useMenuActions } from "@/lib/hooks/use-menu-actions"; +import { useMenuActions } from "@/lib/hooks/menu-actions/use-menu-actions"; import { useWorkspace } from "@/lib/hooks/use-workspace"; import { getPlatform } from "@/lib/platform-api/platform-checker"; -import { MenuAction } from "@/lib/types"; import { Button, Dropdown, @@ -46,39 +45,12 @@ export default function NavTopBar({ // Use the 'app' query parameter to load specific extension app upon loading page const app = params.get("app"); - const { menuActions } = useMenuActions(); + const { menuActions, runMenuActionByKeyboardShortcut } = useMenuActions(); // Handle menu action shortcuts useEffect(() => { - async function runAction(action: MenuAction, event: KeyboardEvent) { - if (action.shortcut) { - // Parse shortcut like "Ctrl+Shift+X" - const keys = action.shortcut - .toLowerCase() - .split("+") - .map((k) => k.trim()); - const ctrl = keys.includes("ctrl") || keys.includes("cmd"); - const shift = keys.includes("shift"); - const alt = keys.includes("alt"); - const key = keys.find( - (k) => !["ctrl", "cmd", "shift", "alt"].includes(k), - ); - if ( - (ctrl ? event.ctrlKey || event.metaKey : true) && - (shift ? event.shiftKey : true) && - (alt ? event.altKey : true) && - event.key.toLowerCase() === key - ) { - event.preventDefault(); - await action.actionFunc(); - } - } - } - async function handleKeyDown(event: KeyboardEvent) { - for (const action of menuActions ?? []) { - await runAction(action, event); - } + await runMenuActionByKeyboardShortcut(event); } window.addEventListener("keydown", handleKeyDown); diff --git a/web/components/providers/imc-provider.tsx b/web/components/providers/imc-provider.tsx index 183e98e..4766b1b 100644 --- a/web/components/providers/imc-provider.tsx +++ b/web/components/providers/imc-provider.tsx @@ -96,7 +96,6 @@ export default function InterModuleCommunicationProvider({ } function markActionRegistered(action: Action) { - console.log(`Action registered: ${action.name}`); actionRegisteredMapRef.current.set(action.name, true); if (actionRegisteredResolvePromisesRef.current[action.name]) { actionRegisteredResolvePromisesRef.current[action.name](); diff --git a/web/components/views/base/base-app-view.tsx b/web/components/views/base/base-app-view.tsx index d13b1af..04d27b3 100644 --- a/web/components/views/base/base-app-view.tsx +++ b/web/components/views/base/base-app-view.tsx @@ -36,7 +36,7 @@ export default function BaseAppView({ ext.config.version, ); const viewModel: ViewModel = { - viewId: ext.config.id + "-" + viewId, + viewId: viewId, appConfig: ext.config, }; setPulseAppViewModel(viewModel); diff --git a/web/components/views/canvas/canvas-view.tsx b/web/components/views/canvas/canvas-view.tsx index 8caaf88..0f6eb0e 100644 --- a/web/components/views/canvas/canvas-view.tsx +++ b/web/components/views/canvas/canvas-view.tsx @@ -1,10 +1,11 @@ +import { useRegisterMenuAction } from "@/lib/hooks/menu-actions/use-register-menu-action"; import { useAppInfo } from "@/lib/hooks/use-app-info"; -import { useMenuActions } from "@/lib/hooks/use-menu-actions"; +import useCanvasWorkflow from "@/lib/hooks/use-workflow"; import { AppInfoModalContent, AppNodeData, + AppViewConfig, CanvasViewConfig, - MenuAction, } from "@/lib/types"; import { Button } from "@heroui/react"; import { Action } from "@pulse-editor/shared-utils"; @@ -25,7 +26,7 @@ import { useViewport, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import Icon from "../../misc/icon"; import AppNode from "./nodes/app-node/app-node"; import "./theme.css"; @@ -48,41 +49,46 @@ Pulse Editor is a modular, cross-platform, AI-powered creativity platform with f `, }; -export default function CanvasView({ config }: { config?: CanvasViewConfig }) { +export default function CanvasView({ config }: { config: CanvasViewConfig }) { const { openAppInfoModal } = useAppInfo(); - const { registerMenuAction, unregisterMenuAction } = useMenuActions(); + + const { + workflow, + entryPoint, + startWorkflow, + updateWorkflowEdges, + updateWorkflowNodes, + exportWorkflow, + updateWorkflowNodeData, + } = useCanvasWorkflow(config.viewId); const viewport = useViewport(); const { screenToFlowPosition } = useReactFlow(); const containerRef = useRef(null); - const [nodes, setNodes] = useState[]>([]); - const [edges, setEdges] = useState([]); - + // this is called when new node is added below using updateWorkflowNodes const onNodesChange = useCallback( - (changes: NodeChange>[]) => { - console.log("Node changes:", changes); - setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)); - }, - [], + (changes: NodeChange>[]) => + updateWorkflowNodes((oldNodes) => applyNodeChanges(changes, oldNodes)), + [updateWorkflowNodes], ); const onEdgesChange = useCallback( - (changes: EdgeChange<{ id: string; source: string; target: string }>[]) => { - console.log("Edge changes:", changes); - setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)); - }, - [], + (changes: EdgeChange<{ id: string; source: string; target: string }>[]) => + updateWorkflowEdges((oldEdges) => applyEdgeChanges(changes, oldEdges)), + [updateWorkflowEdges], ); const onConnect = useCallback( (params: any) => - setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)), - [], + updateWorkflowEdges((oldEdges) => addEdge(params, oldEdges)), + [updateWorkflowEdges], ); const onReconnect = useCallback( (oldEdge: ReactFlowEdge, newConnection: Connection) => - setEdges((els) => reconnectEdge(oldEdge, newConnection, els)), - [], + updateWorkflowEdges((oldEdges) => + reconnectEdge(oldEdge, newConnection, oldEdges), + ), + [updateWorkflowEdges], ); /* Node creator functions */ @@ -91,166 +97,128 @@ export default function CanvasView({ config }: { config?: CanvasViewConfig }) { }, []); // Register menu actions + useRegisterMenuAction( + { + name: "Export Workflow", + menuCategory: "file", + description: "Export the current workflow as a JSON file", + shortcut: "Ctrl+Alt+E", + + icon: "download", + }, + + async () => exportWorkflow(), + [exportWorkflow], + ); + useRegisterMenuAction( + { + name: "Run Workflow", + menuCategory: "view", + description: + "Run the current workflow from the selected or default entry point", + shortcut: "Ctrl+Alt+R", + icon: "play_arrow", + }, + async () => { + await startWorkflow(); + }, + [entryPoint], + ); + + // Promote nodes to workflow nodes, + // or remove workflow nodes that are no longer in the config useEffect(() => { - console.log("CanvasView rendered: registering menu actions"); + function promoteToWorkflowNode(newApps: AppViewConfig[]) { + const newNodes: ReactFlowNode[] = + newApps?.map((appConfig) => { + const flowCenter = getViewCenter(appConfig); - const menuActions: MenuAction[] = [ - { - name: "Export Workflow", - menuCategory: "file", - description: "Export the current workflow as a JSON file", - shortcut: "Ctrl+Alt+E", - actionFunc: async () => { - await exportWorkflow(); - }, - icon: "download", - }, - { - name: "Import Workflow", - menuCategory: "file", - description: "Import a workflow from a JSON file", - shortcut: "Ctrl+Alt+I", - actionFunc: async () => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = "application/json"; - input.onchange = (e: any) => { - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = (event) => { - try { - const workflow = JSON.parse(event.target?.result as string); - if (workflow.nodes && workflow.edges) { - setNodes(workflow.nodes); - setEdges(workflow.edges); - } else { - alert("Invalid workflow file"); - } - } catch (err) { - alert("Error reading workflow file"); - } - }; - reader.readAsText(file); + const newAppNodeData: AppNodeData = { + config: appConfig, + selectedAction: undefined, + setSelectedAction: async (action: Action | undefined) => { + await updateWorkflowNodeData(appConfig.viewId, { + selectedAction: action, + }); + }, + isRunning: false, }; - input.click(); - }, - icon: "upload", - }, - ]; - menuActions.forEach((action) => { - registerMenuAction(action); - }); + return { + id: appConfig.viewId, + position: flowCenter, + data: newAppNodeData, + type: "appNode", + height: appConfig.recommendedHeight ?? 360, + width: appConfig.recommendedWidth ?? 640, + }; + }) ?? []; - return () => { - // Unregister menu actions on unmount - menuActions.forEach((action) => { - unregisterMenuAction(action); - }); - }; - }, []); + // Add new nodes to the workflow + updateWorkflowNodes((oldNodes) => oldNodes.concat(newNodes)); + } - // Add or remove nodes when config changes - useEffect(() => { - if (config) { - // Added nodes - const newNodes = config.nodes?.filter( - (newNode) => !nodes.find((node) => node.id === newNode.viewId), - ); + function removeWorkflowNode(removedNodes: ReactFlowNode[]) { + updateWorkflowNodes((oldNodes) => { + return oldNodes.filter( + (node) => + !removedNodes.find((removedNode) => removedNode.id === node.id), + ); + }); + } - if (newNodes && newNodes.length > 0) { - const newAppNodes: ReactFlowNode[] = - newNodes?.map((appConfig) => { - const containerBounds = - containerRef.current?.getBoundingClientRect(); + function getViewCenter(appConfig: AppViewConfig) { + const containerBounds = containerRef.current?.getBoundingClientRect(); - if (!containerBounds) throw new Error("Container bounds not found"); + if (!containerBounds) throw new Error("Container bounds not found"); - const screenCenter = { - x: - containerBounds.left + - containerBounds.width / 2 - - ((appConfig.recommendedWidth ?? 640) / 2) * viewport.zoom, - y: - containerBounds.top + - containerBounds.height / 2 - - ((appConfig.recommendedHeight ?? 360) / 2) * viewport.zoom, - }; + const screenCenter = { + x: + containerBounds.left + + containerBounds.width / 2 - + ((appConfig.recommendedWidth ?? 640) / 2) * viewport.zoom, + y: + containerBounds.top + + containerBounds.height / 2 - + ((appConfig.recommendedHeight ?? 360) / 2) * viewport.zoom, + }; - const flowCenter = screenToFlowPosition(screenCenter); + const flowCenter = screenToFlowPosition(screenCenter); + return flowCenter; + } - return { - id: appConfig.viewId, - position: flowCenter, - data: { - label: appConfig.app, - config: appConfig, - selectedAction: undefined, - setSelectedAction: async (action: Action | undefined) => { - // Update the node's data - setNodes((nds) => - nds.map((node) => { - if (node.id === appConfig.viewId) { - return { - ...node, - data: { - ...node.data, - selectedAction: action, - }, - }; - } - return node; - }), - ); - }, - }, - type: "appNode", - height: appConfig.recommendedHeight ?? 360, - width: appConfig.recommendedWidth ?? 640, - }; - }) ?? []; + if (config) { + // Added apps + const addedApps = config.appConfigs?.filter( + (newNode) => + !workflow?.nodes.find((node) => node.id === newNode.viewId), + ); - setNodes((nds) => nds.concat(newAppNodes)); + if (addedApps && addedApps.length > 0) { + promoteToWorkflowNode(addedApps); } - // Removed nodes - const removedNodes = nodes.filter( - (node) => !config.nodes?.find((newNode) => newNode.viewId === node.id), + // Removed apps + const removedApps = workflow?.nodes.filter( + (node) => + !config.appConfigs?.find((newNode) => newNode.viewId === node.id), ); - if (removedNodes && removedNodes.length > 0) { - setNodes((nds) => - nds.filter( - (node) => - !removedNodes.find((removedNode) => removedNode.id === node.id), - ), - ); + if (removedApps && removedApps.length > 0) { + removeWorkflowNode(removedApps); } } - }, [config, viewport]); - - async function exportWorkflow() { - const workflow = { nodes, edges }; - const blob = new Blob([JSON.stringify(workflow, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "workflow.json"; - a.click(); - URL.revokeObjectURL(url); - } + }, [config, viewport, screenToFlowPosition, updateWorkflowNodes]); return (
- +