diff --git a/electron/main/index.ts b/electron/main/index.ts index 6ef0b46e..8ccaa983 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -33,6 +33,11 @@ function createWindow(): void { mainWindow?.show() }) + // Keep the renderer's maximize/restore icon in sync — covers the toolbar + // button, double-clicking the title bar, and OS window-snap gestures. + mainWindow.on('maximize', () => mainWindow?.webContents.send('window:maximizeChanged', true)) + mainWindow.on('unmaximize', () => mainWindow?.webContents.send('window:maximizeChanged', false)) + mainWindow.webContents.on('before-input-event', (event, input) => { const isMacQuitShortcut = process.platform === 'darwin' && diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 695bda0f..a2a40772 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -238,6 +238,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe win.isMaximized() ? win.restore() : win.maximize() }) ipcMain.on('window:close', () => getWindow()?.close()) + ipcMain.handle('window:isMaximized', () => getWindow()?.isMaximized() ?? false) // Setup handlers — skipped in dev (uses .venv instead of python-embed) ipcMain.handle('setup:check', async () => { diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 6ced29c3..13c831b3 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -21,9 +21,14 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra return { // Window controls window: { - minimize: () => ipcRenderer.send('window:minimize'), - maximize: () => ipcRenderer.send('window:maximize'), - close: () => ipcRenderer.send('window:close'), + minimize: () => ipcRenderer.send('window:minimize'), + maximize: () => ipcRenderer.send('window:maximize'), + close: () => ipcRenderer.send('window:close'), + isMaximized: () => ipcRenderer.invoke('window:isMaximized') as Promise, + onMaximizeChange: (cb: (isMaximized: boolean) => void) => { + ipcRenderer.on('window:maximizeChanged', (_event, isMaximized) => cb(isMaximized as boolean)) + }, + offMaximizeChange: () => ipcRenderer.removeAllListeners('window:maximizeChanged'), }, // Renderer UI (zoom whole page — scales every px/rem consistently) diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index a542284f..80fde704 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -14,7 +14,7 @@ import { type Edge, type OnConnectStartParams, } from '@xyflow/react' -import { useWorkflowsStore, NODE_TYPES_WITHOUT_TARGET, NODE_TYPES_WITHOUT_SOURCE } from '@shared/stores/workflowsStore' +import { useWorkflowsStore, NODE_TYPES_WITHOUT_TARGET, NODE_TYPES_WITHOUT_SOURCE, FOLDER_COLORS } from '@shared/stores/workflowsStore' import { useExtensionsStore } from '@shared/stores/extensionsStore' import { useAppStore } from '@shared/stores/appStore' import type { Workflow, WFNode, WFEdge, WFNodeData } from '@shared/types/electron.d' @@ -74,6 +74,10 @@ function IoBadge({ type }: { type: 'image' | 'text' | 'mesh' | 'audio' }) { function newId(): string { return crypto.randomUUID() } +// Node clipboard (module-level so Ctrl+C in one workflow tab can be pasted in +// another — the canvas remounts per tab but the module survives). +const _nodeClipboard: { current: { nodes: Node[]; edges: Edge[]; pastes: number } | null } = { current: null } + function newWorkflow(): Workflow { const now = new Date().toISOString() return { id: newId(), name: 'New Workflow', description: '', nodes: [], edges: [], createdAt: now, updatedAt: now } @@ -540,13 +544,20 @@ function HelpModal({ onClose }: { onClose: () => void }) { useEffect(() => { window.electron.fs.readScreenshotDataUrl('workflow-helper.png').then(setHelperImg).catch(() => {}) }, []) + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) return createPortal(
{ if (e.target === e.currentTarget) onClose() }} + className="fixed inset-0 z-[9999] flex items-center justify-center bg-zinc-950/70 backdrop-blur-sm" + onMouseDown={onClose} > -
-
+
e.stopPropagation()} + > {/* Header */}
@@ -704,16 +715,14 @@ function getNodeInputType( // ─── Workflow canvas (inner, requires ReactFlowProvider) ────────────────────── function WorkflowCanvasInner({ - workflow, allExtensions, onSave, onDelete, onExport, panelOpen, onTogglePanel, onNew, onImport, + workflow, allExtensions, onSave, panelOpen, onTogglePanel, onOpen, onImport, }: { workflow: Workflow allExtensions: WorkflowExtension[] onSave: (w: Workflow) => void - onDelete: () => void - onExport: () => void panelOpen: boolean onTogglePanel: () => void - onNew: () => void + onOpen: () => void onImport: () => void }) { const { screenToFlowPosition, getNode } = useReactFlow() @@ -724,7 +733,6 @@ function WorkflowCanvasInner({ const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as Node[]) const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as Edge[]) - const [name, setName] = useState(workflow.name) const [paletteOpen, setPaletteOpen] = useState(false) const [helpOpen, setHelpOpen] = useState(false) @@ -738,8 +746,8 @@ function WorkflowCanvasInner({ const didMountRef = useRef(false) // ─── Undo / Redo ────────────────────────────────────────────────────────── - type Snapshot = { nodes: Node[]; edges: Edge[]; name: string } - const historyRef = useRef([{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[], name: workflow.name }]) + type Snapshot = { nodes: Node[]; edges: Edge[] } + const historyRef = useRef([{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[] }]) const histIdxRef = useRef(0) const [histIdx, setHistIdx] = useState(0) const skipPushRef = useRef(true) // skip the initial autosave-triggered push @@ -748,12 +756,11 @@ function WorkflowCanvasInner({ useEffect(() => { setNodes(workflow.nodes as Node[]) setEdges(workflow.edges as Edge[]) - setName(workflow.name) - historyRef.current = [{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[], name: workflow.name }] + historyRef.current = [{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[] }] histIdxRef.current = 0 setHistIdx(0) skipPushRef.current = true - // eslint-disable-next-line react-hooks/exhaustive-deps -- re-sync only when the workflow switches; adding name/nodes/edges would reset the editor on every keystroke + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-sync only when the workflow switches; adding nodes/edges would reset the editor on every change }, [workflow.id]) // Auto-save + history push debounced @@ -762,7 +769,6 @@ function WorkflowCanvasInner({ saveTimer.current = setTimeout(() => { const updated: Workflow = { ...workflow, - name, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString(), @@ -771,7 +777,7 @@ function WorkflowCanvasInner({ if (!skipPushRef.current) { const next = historyRef.current.slice(0, histIdxRef.current + 1) - next.push({ nodes, edges, name }) + next.push({ nodes, edges }) if (next.length > 50) next.shift() historyRef.current = next const newIdx = next.length - 1 @@ -782,18 +788,17 @@ function WorkflowCanvasInner({ }, 500) return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } // eslint-disable-next-line react-hooks/exhaustive-deps -- debounce on editable state; latest workflow/onSave read in the timeout - }, [nodes, edges, name]) + }, [nodes, edges]) const preflightIssues = useMemo(() => { const draft: Workflow = { ...workflow, - name, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: workflow.updatedAt, } return validateWorkflowPreflight(draft, allExtensions, { currentMeshUrl }) - }, [workflow, name, nodes, edges, allExtensions, currentMeshUrl]) + }, [workflow, nodes, edges, allExtensions, currentMeshUrl]) useEffect(() => { if (!didMountRef.current) { @@ -818,7 +823,6 @@ function WorkflowCanvasInner({ skipPushRef.current = true setNodes(snap.nodes) setEdges(snap.edges) - setName(snap.name) histIdxRef.current = newIdx setHistIdx(newIdx) }, [setNodes, setEdges]) @@ -831,7 +835,6 @@ function WorkflowCanvasInner({ skipPushRef.current = true setNodes(snap.nodes) setEdges(snap.edges) - setName(snap.name) histIdxRef.current = newIdx setHistIdx(newIdx) }, [setNodes, setEdges]) @@ -954,6 +957,68 @@ function WorkflowCanvasInner({ return () => window.removeEventListener('keydown', onKeyDown) }, [undo, redo]) + // Copy/paste selected nodes (Ctrl+C / Ctrl+V) — works across workflow tabs + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (!(e.ctrlKey || e.metaKey) || e.shiftKey) return + + if (e.key === 'c') { + const selected = nodes.filter((n) => n.selected) + if (selected.length === 0) return + const selIds = new Set(selected.map((n) => n.id)) + const copied = selected.map((n) => { + // Child copied without its container → detach to absolute coordinates + if (n.parentId && !selIds.has(n.parentId)) { + const parent = nodes.find((p) => p.id === n.parentId) + return { + ...structuredClone(n), + parentId: undefined, + position: { x: n.position.x + (parent?.position.x ?? 0), y: n.position.y + (parent?.position.y ?? 0) }, + } + } + return structuredClone(n) + }) + const copiedEdges = edges + .filter((ed) => selIds.has(ed.source) && selIds.has(ed.target)) + .map((ed) => structuredClone(ed)) + _nodeClipboard.current = { nodes: copied, edges: copiedEdges, pastes: 0 } + return + } + + if (e.key === 'v') { + const clip = _nodeClipboard.current + if (!clip || clip.nodes.length === 0) return + clip.pastes += 1 + const offset = 32 * clip.pastes + const idMap = new Map(clip.nodes.map((n) => [n.id, newId()])) + const pasted: Node[] = clip.nodes.map((n) => { + const keepParent = n.parentId != null && idMap.has(n.parentId) + return { + ...structuredClone(n), + id: idMap.get(n.id)!, + parentId: keepParent ? idMap.get(n.parentId!) : undefined, + // Children keep their parent-relative position; top-level nodes shift + // a bit more on every paste so repeated pastes don't stack. + position: keepParent ? n.position : { x: n.position.x + offset, y: n.position.y + offset }, + selected: true, + } + }) + const pastedEdges: Edge[] = clip.edges.map((ed) => ({ + ...structuredClone(ed), + id: `e-${newId()}`, + source: idMap.get(ed.source)!, + target: idMap.get(ed.target)!, + })) + setNodes((nds) => [...nds.map((n) => ({ ...n, selected: false })), ...pasted]) + setEdges((eds) => [...eds, ...pastedEdges]) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [nodes, edges, setNodes, setEdges]) + const addNodeFromPalette = useCallback((type: string, extensionId?: string) => { const position = screenToFlowPosition( pendingDropPos ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 } @@ -1036,7 +1101,7 @@ function WorkflowCanvasInner({ const newParentId = container?.id if (newParentId === dragged.parentId) return nds // no change - let next: Node[] = nds.map((n) => { + const next: Node[] = nds.map((n) => { if (n.id !== dragged.id) return n if (container) { // parentId (no extent) → child moves with the container but can still be dragged out @@ -1066,10 +1131,10 @@ function WorkflowCanvasInner({ showToast(preflightIssues[0].message) return } - const wf: Workflow = { ...workflow, name, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString() } + const wf: Workflow = { ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString() } onSave(wf) runWorkflow(wf, allExtensions) - }, [workflow, name, nodes, edges, onSave, allExtensions, isRunning, runWorkflow, cancel, preflightIssues, showToast]) + }, [workflow, nodes, edges, onSave, allExtensions, isRunning, runWorkflow, cancel, preflightIssues, showToast]) return (
@@ -1089,16 +1154,16 @@ function WorkflowCanvasInner({ {/* Header toolbar */}
- {/* New */} + {/* Open */} {/* Import */} @@ -1140,17 +1205,6 @@ function WorkflowCanvasInner({ -
- - {/* Name input */} - setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} - placeholder="Workflow name…" - className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700/80 rounded-md px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent/60" - /> -
@@ -1185,30 +1239,6 @@ function WorkflowCanvasInner({
)} - {/* Export */} - - - {/* Delete */} - - {/* Help */} + + + +
+
+
+

{wf.name || 'Untitled'}

+

{new Date(wf.updatedAt).toLocaleString()}

+
+
+ ) + } + + function renderFolder(folder: string): JSX.Element { + const inFolder = workflows.filter((w) => w.folder === folder).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + const collapsed = collapsedFolders.has(folder) + return ( +
+
setCollapsedFolders((s) => { + const next = new Set(s) + if (next.has(folder)) next.delete(folder); else next.add(folder) + return next + })} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(folder) }} + onDragLeave={() => setDragOverFolder(null)} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + setDragOverFolder(null) + const id = e.dataTransfer.getData('modly/workflow-id') + if (id) handleMoveToFolder(id, folder) + }} + className={`group flex items-center gap-2 px-4 py-2 cursor-pointer text-zinc-400 hover:text-zinc-200 transition-colors + ${dragOverFolder === folder ? 'bg-accent/10 text-accent-light' : ''}`} + > + + + + + + + {folder} + {inFolder.length} +
+ + + +
+ {colorPickerFolder === folder && ( +
+ {FOLDER_COLORS.map((c) => ( +
+ )} + {!collapsed && inFolder.length > 0 && ( +
+ {inFolder.map((wf) => renderWorkflowCard(wf))} +
+ )} + {!collapsed && inFolder.length === 0 && ( +

Empty — drag workflows here

+ )} +
+ ) + } + + // Closing the tab of an empty workflow (no nodes) deletes it too — a blank + // "New Workflow" the user closes is throwaway, don't let them pile up on disk. + // Reads the store directly so a keyboard shortcut never acts on a stale list. + function handleCloseTab(id: string) { + const wf = useWorkflowsStore.getState().workflows.find((w) => w.id === id) + if (wf && wf.nodes.length === 0) { remove(id); return } + closeWorkflow(id) + } + + async function handleToggleBookmark(id: string) { + const wf = workflows.find((w) => w.id === id) + if (!wf) return + // Not an edit — keep updatedAt so the recency sort doesn't reshuffle + await save({ ...wf, bookmarked: !wf.bookmarked }) + } + + async function handleMoveToFolder(id: string, folder?: string) { + const wf = workflows.find((w) => w.id === id) + if (!wf || (wf.folder ?? undefined) === folder) return + await save({ ...wf, folder }) + } + + async function handleDeleteFolder(name: string) { + for (const wf of workflows.filter((w) => w.folder === name)) { + await save({ ...wf, folder: undefined }) + } + removeFolder(name) + } + + function handleCreateFolder() { + const trimmed = (newFolderName ?? '').trim() + if (trimmed) addFolder(trimmed) + setNewFolderName(null) + } + + async function handleDuplicate(id: string) { + const src = workflows.find((w) => w.id === id) + if (!src) return + const now = new Date().toISOString() + const copy: Workflow = { + ...structuredClone(src), + id: newId(), + name: `${src.name || 'Untitled'} copy`, + bookmarked: undefined, // a copy isn't the favorite + createdAt: now, + updatedAt: now, + } + await save(copy) + openWorkflow(copy.id) } return ( @@ -1322,32 +1754,351 @@ export default function WorkflowsPage(): JSX.Element { {/* Tab bar */} {!loading && (
- {workflows.map((wf) => ( + {openWorkflows.map((wf) => (
{ e.dataTransfer.setData('modly/tab-id', wf.id); e.dataTransfer.effectAllowed = 'move' }} + onDragOver={(e) => { + if (!e.dataTransfer.types.includes('modly/tab-id')) return + e.preventDefault() + setDragOverTab(wf.id) + }} + onDragLeave={() => setDragOverTab((cur) => (cur === wf.id ? null : cur))} + onDrop={(e) => { + e.preventDefault() + setDragOverTab(null) + const dragId = e.dataTransfer.getData('modly/tab-id') + if (dragId) moveOpenTab(dragId, wf.id) + }} onClick={() => setActive(wf.id)} - onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); remove(wf.id) } }} + onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); handleCloseTab(wf.id) } }} + onContextMenu={(e) => { e.preventDefault(); setTabMenu({ id: wf.id, x: e.clientX, y: e.clientY }) }} className={`relative flex items-center gap-1.5 pl-3 pr-1.5 h-full text-[11px] font-medium shrink-0 transition-colors border-b-2 cursor-pointer group ${wf.id === activeId ? 'text-zinc-100 border-accent bg-zinc-900/50' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20 border-transparent' }`} > + {dragOverTab === wf.id && } + {/* Folder color dot; doubles as the running indicator (pulses) */} + {(workflowColor(wf) || runningWorkflowId === wf.id) && ( + + )} {wf.name || 'Untitled'}
))} +
)} + {/* Tab context menu */} + {tabMenu && ( +
e.stopPropagation()} + > + + + +
+ +
+ )} + + {/* Rename popup (above the Open popup, which can trigger it) */} + {renameTarget && createPortal( +
setRenameTarget(null)} + > +
e.stopPropagation()} + > +

Rename workflow

+ setRenameTarget({ ...renameTarget, value: e.target.value })} + onFocus={(e) => e.currentTarget.select()} + onKeyDown={(e) => { if (e.key === 'Enter') handleRename() }} + placeholder="Workflow name…" + className="w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent/60" + /> +
+ + +
+
+
, + document.body, + )} + + {/* Delete confirmation popup (topmost — reachable from the Open popup) */} + {deleteTarget && createPortal( +
setDeleteTarget(null)} + > +
e.stopPropagation()} + > +

Delete workflow?

+

+ “{workflows.find((w) => w.id === deleteTarget)?.name || 'Untitled'}” will be permanently deleted. This cannot be undone. +

+
+ + +
+
+
, + document.body, + )} + + {/* Open workflow popup */} + {openListVisible && createPortal( +
setOpenListVisible(false)} + > +
e.stopPropagation()} + > +
+

Open workflow

+
+ + +
+
+ + {/* Search */} +
+
+ + + + setOpenSearch(e.target.value)} + placeholder="Search workflows…" + className="flex-1 min-w-0 bg-transparent text-xs text-zinc-200 placeholder-zinc-600 focus:outline-none" + /> + {openSearch && ( + + )} +
+
+ + {/* New folder inline input */} + {newFolderName !== null && ( +
+ + + + setNewFolderName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder() }} + onBlur={() => setNewFolderName(null)} + placeholder="Folder name… (Enter to create)" + className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700/80 rounded-md px-2.5 py-1.5 text-xs text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent/60" + /> +
+ )} + +
e.preventDefault()} + onDrop={(e) => { + // Drop on the list background (not a folder) → move back to root + e.preventDefault() + setDragOverFolder(null) + const id = e.dataTransfer.getData('modly/workflow-id') + if (id) handleMoveToFolder(id, undefined) + }} + > + {workflows.length === 0 && folders.length === 0 && ( +

No saved workflows.

+ )} + + {/* Search results — flat list across all folders */} + {openSearch.trim() !== '' && (() => { + const q = openSearch.trim().toLowerCase() + const matches = workflows + .filter((w) => (w.name || 'Untitled').toLowerCase().includes(q)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + if (matches.length === 0) { + return

No workflow matches “{openSearch.trim()}”.

+ } + return ( +
+ {matches.map((wf) => renderWorkflowCard(wf))} +
+ ) + })()} + + {/* Bookmarks — pinned section: starred folders, then starred workflows */} + {openSearch.trim() === '' && (() => { + const markedFolders = [...bookmarkedFolders].filter((f) => folders.includes(f)).sort((a, b) => a.localeCompare(b)) + // Starred workflows already shown inside a starred folder aren't repeated + const marked = workflows + .filter((w) => w.bookmarked && !(w.folder && bookmarkedFolders.includes(w.folder))) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + if (marked.length === 0 && markedFolders.length === 0) return null + return ( +
+
+ + + + Bookmarks +
+ {markedFolders.map((folder) => renderFolder(folder))} + {marked.length > 0 && ( +
+ {marked.map((wf) => renderWorkflowCard(wf))} +
+ )} +
+ ) + })()} + + {/* Folders (bookmarked ones live in the section above) */} + {openSearch.trim() === '' && [...folders] + .filter((f) => !bookmarkedFolders.includes(f)) + .sort((a, b) => a.localeCompare(b)) + .map((folder) => renderFolder(folder))} + + {/* Root workflows */} + {openSearch.trim() === '' && (() => { + const root = workflows + .filter((w) => !w.folder || !folders.includes(w.folder)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + if (root.length === 0) return null + return ( +
+ {root.map((wf) => renderWorkflowCard(wf))} +
+ ) + })()} +
+
+
, + document.body, + )} + {/* Canvas + extensions panel */}
@@ -1358,11 +2109,9 @@ export default function WorkflowsPage(): JSX.Element { workflow={activeWorkflow} allExtensions={allExtensions} onSave={save} - onDelete={() => { remove(activeWorkflow.id) }} - onExport={() => exportFile(activeWorkflow)} panelOpen={panelOpen} onTogglePanel={() => setPanelOpen((o) => !o)} - onNew={handleCreateBlank} + onOpen={() => setOpenListVisible(true)} onImport={handleImport} /> @@ -1373,11 +2122,16 @@ export default function WorkflowsPage(): JSX.Element {
-

No workflows yet

-

Create one to get started

+

{workflows.length === 0 ? 'No workflows yet' : 'No workflow open'}

+

{workflows.length === 0 ? 'Create one to get started' : 'Open a saved workflow or create a new one'}

- + )} +