Skip to content
Merged
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
32 changes: 32 additions & 0 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,38 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
}
})

// List files (not directories) in a folder, optionally filtered by extension.
// `extensions` are lowercase without the dot (e.g. ['txt', 'png']).
ipcMain.handle('fs:listFiles', async (_, dirPath: string, extensions?: string[]) => {
try {
const wanted = extensions?.map(e => e.toLowerCase().replace(/^\./, ''))
const entries = await readdir(dirPath, { withFileTypes: true })
return entries
.filter(e => e.isFile())
.map(e => e.name)
.filter(name => {
if (!wanted || wanted.length === 0) return true
const ext = name.split('.').pop()?.toLowerCase() ?? ''
return wanted.includes(ext)
})
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
} catch {
return []
}
})

// Text-file picker for the standalone Load Prompt node.
ipcMain.handle('fs:selectTextFile', async () => {
const win = getWindow()
if (!win) return null
const result = await dialog.showOpenDialog(win, {
title: 'Select a prompt text file',
filters: [{ name: 'Text', extensions: ['txt', 'md', 'prompt'] }],
properties: ['openFile'],
})
return result.canceled ? null : result.filePaths[0]
})

ipcMain.handle('fs:moveDirectory', async (_, { src, dest }: { src: string; dest: string }) => {
try {
await mkdir(dest, { recursive: true })
Expand Down
4 changes: 4 additions & 0 deletions electron/preload/electron-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra
ipcRenderer.invoke('fs:savePath', args) as Promise<string | null>,
listDir: (dirPath: string): Promise<string[]> =>
ipcRenderer.invoke('fs:listDir', dirPath) as Promise<string[]>,
listFiles: (dirPath: string, extensions?: string[]): Promise<string[]> =>
ipcRenderer.invoke('fs:listFiles', dirPath, extensions) as Promise<string[]>,
selectTextFile: (): Promise<string | null> =>
ipcRenderer.invoke('fs:selectTextFile') as Promise<string | null>,
moveDirectory: (args: { src: string; dest: string }): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('fs:moveDirectory', args) as Promise<{ success: boolean; error?: string }>,
deleteDirectory: (dirPath: string): Promise<{ success: boolean; error?: string }> =>
Expand Down
28 changes: 18 additions & 10 deletions src/areas/workflows/WorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,19 @@ import Load3DMeshNode from './nodes/Load3DMeshNode'
import PreviewImageNode from './nodes/PreviewImageNode'
import WaitNode from './nodes/WaitNode'
import WhileNode from './nodes/WhileNode'
import ForEachNode from './nodes/ForEachNode'
import WorkflowEdge from './nodes/WorkflowEdge'

// ─── Constants ────────────────────────────────────────────────────────────────

const DRAG_KEY = 'modly/extension-id'
const DRAG_NODE_KEY = 'modly/node-type'
const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode, whileNode: WhileNode }
const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode, whileNode: WhileNode, forEachNode: ForEachNode }

// Loop-container node types: resizable frames whose children form a loop body.
// (For Each iterators are plain source nodes, not containers.)
const CONTAINER_TYPES = new Set(['whileNode'])
const isContainerType = (type: string | undefined): boolean => !!type && CONTAINER_TYPES.has(type)
const EDGE_TYPES = { workflowEdge: WorkflowEdge }

const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' }
Expand All @@ -45,7 +51,7 @@ const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' }
// auto-parent nodes dropped (or created) inside a While so they join its loop body.
function findWhileContainerAt(nodes: Node[], pos: { x: number; y: number }): Node | undefined {
return nodes.find((n) => {
if (n.type !== 'whileNode') return false
if (!isContainerType(n.type)) return false
const gw = (n.measured?.width ?? n.width ?? (n.style?.width as number)) || 0
const gh = (n.measured?.height ?? n.height ?? (n.style?.height as number)) || 0
return pos.x >= n.position.x && pos.x <= n.position.x + gw
Expand Down Expand Up @@ -92,6 +98,7 @@ const PANEL_BUILTIN_NODES = [
{ type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></> },
{ type: 'waitNode', label: 'Wait', color: '#71717a', icon: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></> },
{ type: 'whileNode', label: 'While', color: '#f59e0b', icon: <><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></> },
{ type: 'forEachNode', label: 'For Each', color: '#38bdf8', icon: <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></> },
]

function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) {
Expand Down Expand Up @@ -337,6 +344,7 @@ const BUILTIN_NODES = [
{ type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' },
{ type: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' },
{ type: 'whileNode', label: 'While', color: '#f59e0b', description: 'Container: wrap nodes to loop them N times or with Continue/Retry' },
{ type: 'forEachNode', label: 'For Each', color: '#38bdf8', description: 'Iterates a folder (image / text / mesh) alphabetically, one item per run of the downstream nodes' },
]

type PaletteItem =
Expand Down Expand Up @@ -879,8 +887,8 @@ function WorkflowCanvasInner({
// bail only on a real node or a handle.
const target = event.target as Element
const nodeEl = target.closest('.react-flow__node')
const onWhile = nodeEl?.classList.contains('react-flow__node-whileNode') ?? false
if (target.closest('.react-flow__handle') || (nodeEl && !onWhile)) {
const onContainer = !!nodeEl && [...CONTAINER_TYPES].some((t) => nodeEl.classList.contains(`react-flow__node-${t}`))
if (target.closest('.react-flow__handle') || (nodeEl && !onContainer)) {
pendingConnectionRef.current = null
return
}
Expand All @@ -900,7 +908,7 @@ function WorkflowCanvasInner({

const nodeType = e.dataTransfer.getData(DRAG_NODE_KEY)
if (nodeType) {
const isContainer = nodeType === 'whileNode'
const isContainer = isContainerType(nodeType)
setNodes((nds) => {
const parent = isContainer ? undefined : findWhileContainerAt(nds, position)
const node: Node = {
Expand Down Expand Up @@ -959,7 +967,7 @@ function WorkflowCanvasInner({
pendingDropPos ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
)
const newNodeId = newId()
const isContainer = type === 'whileNode'
const isContainer = isContainerType(type)
setNodes((nds) => {
const parent = isContainer ? undefined : findWhileContainerAt(nds, position)
const node: Node = {
Expand Down Expand Up @@ -1001,7 +1009,7 @@ function WorkflowCanvasInner({
// When a While container is deleted (button or keyboard), detach its children
// to absolute coordinates so they don't get orphaned to the canvas origin.
const onNodesDelete = useCallback((deleted: Node[]) => {
const removedContainers = deleted.filter((n) => n.type === 'whileNode')
const removedContainers = deleted.filter((n) => isContainerType(n.type))
if (removedContainers.length === 0) return
setNodes((nds) => nds.map((n) => {
const container = removedContainers.find((c) => c.id === n.parentId)
Expand All @@ -1014,9 +1022,9 @@ function WorkflowCanvasInner({
// When a node is dropped, attach/detach it to a While container based on overlap.
// Children get a parentId + parent-relative position (no extent, so they can be dragged back out).
const onNodeDragStop = useCallback((_e: unknown, dragged: Node) => {
if (dragged.type === 'whileNode') return
if (isContainerType(dragged.type)) return
setNodes((nds) => {
const containers = nds.filter((n) => n.type === 'whileNode')
const containers = nds.filter((n) => isContainerType(n.type))
if (containers.length === 0 && !dragged.parentId) return nds

const parent = dragged.parentId ? nds.find((n) => n.id === dragged.parentId) : undefined
Expand All @@ -1036,7 +1044,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
Expand Down
139 changes: 139 additions & 0 deletions src/areas/workflows/nodes/ForEachNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { WFNodeData } from '@shared/types/electron.d'
import BaseNode from './BaseNode'
import { useWorkflowRunStore } from '../workflowRunStore'

// For Each — an iterator source. It walks a folder alphabetically and emits one
// file per loop pass; every node wired downstream re-runs for each file. Runs to
// completion unless the user hits Stop, with an optional Pause (then Continue →
// next file / Retry → same file). A dropdown picks what it emits: image / text / mesh.
type Mode = 'image' | 'text' | 'mesh'

const MODES: Record<Mode, { title: string; color: string; hint: string }> = {
image: { title: 'For Each Image', color: '#38bdf8', hint: 'images' },
text: { title: 'For Each Text', color: '#fbbf24', hint: 'text files' },
mesh: { title: 'For Each Mesh', color: '#a78bfa', hint: 'meshes' },
}

export default function ForEachNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) {
const { updateNodeData } = useReactFlow()
const mode = ((data.params.mode as Mode | undefined) ?? 'image')
const variant = MODES[mode] ?? MODES.image

const status = useWorkflowRunStore((s) => s.runState.status)
const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId)
const continueWhile = useWorkflowRunStore((s) => s.continueWhile)
const retryWhile = useWorkflowRunStore((s) => s.retryWhile)
const pauseWhile = useWorkflowRunStore((s) => s.pauseWhile)
const progress = useWorkflowRunStore((s) => s.whileProgress[id])
const pausedGroup = useWorkflowRunStore((s) => s.pausedGroup)
const isPaused = status === 'paused' && (activeNodeId === id || pausedGroup.includes(id))
const isLooping = status === 'running' && progress != null && !isPaused
const locked = status === 'running' || status === 'paused'

const ioRowRef = useRef<HTMLDivElement>(null)
const [handleTop, setHandleTop] = useState('50%')
useLayoutEffect(() => {
if (ioRowRef.current) setHandleTop(`${ioRowRef.current.offsetTop + ioRowRef.current.offsetHeight / 2}px`)
}, [])

const dir = (data.params.dir as string | undefined) ?? ''
const dirLabel = dir ? dir.split(/[\\/]/).filter(Boolean).pop() : 'Pick folder…'

const pickDir = async (): Promise<void> => {
const picked = await window.electron.fs.selectDirectory(dir || undefined)
if (picked) updateNodeData(id, { params: { ...data.params, dir: picked } })
}

return (
<BaseNode
id={id}
selected={selected}
running={isLooping || isPaused}
title={variant.title}
showInGenerate={data.showInGenerate ?? false}
minWidth={200}
autoHeight
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={variant.color} strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
}
subheader={
<div ref={ioRowRef} className="flex items-center justify-end px-3 py-2">
{progress && (
<span className="mr-auto px-1.5 py-0.5 rounded bg-zinc-700/60 text-zinc-300 tabular-nums text-[9px]">
{progress.total != null ? `${progress.current}/${progress.total}` : progress.current}
</span>
)}
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium border"
style={{ borderColor: `${variant.color}55`, background: `${variant.color}18`, color: variant.color }}>
{mode}
</span>
</div>
}
handles={
<Handle type="source" position={Position.Right}
style={{ background: variant.color, width: 14, height: 14, border: '2.5px solid #18181b', top: handleTop }} />
}
>
<div className="px-3 pb-3 pt-2.5 flex flex-col gap-2">
{/* Iterated type */}
<select
value={mode}
disabled={locked}
onChange={(e) => updateNodeData(id, { params: { ...data.params, mode: e.target.value as Mode, dir: undefined } })}
className={`nodrag w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-2 text-[11px] text-zinc-200 focus:outline-none focus:border-zinc-600 ${locked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<option value="image">Image</option>
<option value="text">Text</option>
<option value="mesh">Mesh</option>
</select>

<button
onClick={pickDir}
disabled={locked}
className={`nodrag w-full flex items-center gap-1.5 bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-2 text-[11px] text-zinc-200 ${locked ? 'opacity-50 cursor-not-allowed' : 'hover:border-zinc-600'}`}
title={dir || `Folder of ${variant.hint}`}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0 text-zinc-500">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span className="truncate">{dirLabel}</span>
</button>

{/* Run controls */}
{isLooping && (
<button
onClick={pauseWhile}
className="nodrag flex items-center justify-center gap-1 px-2 py-1 rounded bg-amber-500/20 border border-amber-500/40 text-amber-300 hover:bg-amber-500/30 transition-colors text-[10px] font-medium"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
Pause
</button>
)}
{isPaused && (
<div className="nodrag flex items-center gap-1.5">
<button
onClick={continueWhile}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1 rounded bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30 transition-colors text-[10px] font-medium"
>
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Continue
</button>
<button
onClick={retryWhile}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1 rounded bg-sky-500/20 border border-sky-500/40 text-sky-300 hover:bg-sky-500/30 transition-colors text-[10px] font-medium"
>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Retry
</button>
</div>
)}
</div>
</BaseNode>
)
}
16 changes: 16 additions & 0 deletions src/areas/workflows/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ function nodeLabel(node: WFNode, allExtensions: WorkflowExtension[]): string {
if (node.type === 'meshNode') return 'Load 3D Mesh'
if (node.type === 'outputNode') return 'Add to Scene'
if (node.type === 'previewNode') return 'Preview Views'
if (node.type === 'forEachNode') {
const mode = (node.data.params?.mode as string) ?? 'image'
return mode === 'text' ? 'For Each Text' : mode === 'mesh' ? 'For Each Mesh' : 'For Each Image'
}
if (node.type === 'extensionNode') {
return getWorkflowExtension(node.data.extensionId ?? '', allExtensions)?.name ?? 'Extension'
}
Expand All @@ -40,6 +44,10 @@ function getNodeOutputType(node: WFNode, allExtensions: WorkflowExtension[]): Da
if (node.type === 'textNode') return 'text'
if (node.type === 'meshNode' || node.type === 'outputNode') return 'mesh'
if (node.type === 'previewNode') return 'image'
if (node.type === 'forEachNode') {
const mode = (node.data.params?.mode as DataType | undefined) ?? 'image'
return mode === 'text' || mode === 'mesh' ? mode : 'image'
}
if (node.type === 'extensionNode') {
return getWorkflowExtension(node.data.extensionId ?? '', allExtensions)?.output
}
Expand Down Expand Up @@ -78,6 +86,14 @@ export function validateWorkflowPreflight(
})
}

if (node.type === 'forEachNode' && !((node.data.params?.dir as string | undefined)?.trim())) {
pushIssue(issues, {
key: `${node.id}:foreach-no-folder`,
nodeId: node.id,
message: `${nodeLabel(node, allExtensions)} needs a folder selected.`,
})
}

// A node fed by two different Wait branches can't be scheduled into a single
// branch — it would run before either branch produces its mesh.
if (
Expand Down
Loading