diff --git a/README.md b/README.md index 6a1d8bb..e72148d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ http://127.0.0.1:5173 Notes: - The frontend dev server proxies API and WebSocket requests to the backend. +- `npm run dev` now clears Vite's local cache and stale Python bytecode first, then starts Vite with `--force`. - If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`. - If you want the frontend accessible from other devices on your LAN, run: @@ -95,14 +96,9 @@ npm run dev -- --host 0.0.0.0 ## Running the Local Desktop Version The desktop launcher starts the Python server internally and opens a native window with `pywebview`. +`npm run desktop` now rebuilds the frontend first so the native app always uses a fresh `frontend/dist`. -Build the frontend first: - -```powershell -npm run build -``` - -Then launch the desktop app from source: +Launch the desktop app from source: ```powershell npm run desktop @@ -110,8 +106,7 @@ npm run desktop Notes: -- `npm run desktop` uses the built frontend from `frontend/dist`. -- If you change frontend code, run `npm run build` again before starting the desktop version. +- `npm run build` clears stale frontend output, Vite cache, and Python bytecode before producing `frontend/dist`. ## Building the Windows `.exe` diff --git a/backend/nodes/helpers.py b/backend/nodes/helpers.py index 756bc46..72b9333 100644 --- a/backend/nodes/helpers.py +++ b/backend/nodes/helpers.py @@ -180,6 +180,20 @@ def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int] return text_image +def _import_ibw_loader(): + """Import igor's binary wave loader with NumPy 2 compatibility.""" + if not hasattr(np, "complex"): + # igor 0.3 still references np.complex at import time. + setattr(np, "complex", complex) + + try: + from igor.binarywave import load as load_ibw + except ImportError: + raise ImportError("Install 'igor' package to load .ibw files: pip install igor") + + return load_ibw + + # --------------------------------------------------------------------------- # Markup helpers (from display.py — used by Markup) # --------------------------------------------------------------------------- @@ -508,7 +522,7 @@ def list_channels(filepath: str) -> list[dict]: if ext == ".ibw": try: - from igor.binarywave import load as load_ibw + load_ibw = _import_ibw_loader() wave = load_ibw(str(path)) raw = wave["wave"]["wData"] labels = wave["wave"].get("labels", None) diff --git a/backend/nodes/image.py b/backend/nodes/image.py index 4cf8e18..2d46d34 100644 --- a/backend/nodes/image.py +++ b/backend/nodes/image.py @@ -5,7 +5,7 @@ from pathlib import Path from backend.node_registry import register_node from backend.data_types import COLORMAPS, DataField, resolve_colormap_input -from backend.nodes.helpers import _resolve_path, _SPM_EXTENSIONS +from backend.nodes.helpers import _resolve_path, _SPM_EXTENSIONS, _import_ibw_loader @register_node(display_name="Image") @@ -149,11 +149,7 @@ class Image: @staticmethod def _load_ibw_all(path: Path) -> list[DataField]: - try: - from igor.binarywave import load as load_ibw - except ImportError: - raise ImportError("Install 'igor' package to load .ibw files: pip install igor") - + load_ibw = _import_ibw_loader() wave = load_ibw(str(path)) wdata = wave["wave"] header = wdata["wave_header"] diff --git a/frontend/package.json b/frontend/package.json index ab03857..61b0016 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,8 @@ "npm": ">=9.0.0" }, "scripts": { - "dev": "vite", - "build": "vite build", + "dev": "vite --force", + "build": "vite build --emptyOutDir", "preview": "vite preview", "test": "node --test tests/**/*.test.mjs" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 127629f..c2d358b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata'; import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture'; import { hydrateWorkflowState } from './workflowHydration'; import { serializeWorkflowState } from './workflowSerialization'; +import { sortNodesForParentOrder } from './nodeHierarchy.js'; import { buildNodeClipboardPayload, buildNodeClipboardPayloadForIds, @@ -182,6 +183,18 @@ function getNodeCenter(node, nodeMap) { }; } +function getNodeRect(node, nodeMap) { + const pos = getNodeAbsolutePosition(node, nodeMap); + const width = Number(getNodeDimension(node, 'width')) || 200; + const height = Number(getNodeDimension(node, 'height')) || 120; + return { + left: pos.x, + top: pos.y, + right: pos.x + width, + bottom: pos.y + height, + }; +} + function rectContainsPoint(rect, point) { return point.x >= rect.left && point.x <= rect.right @@ -189,13 +202,60 @@ function rectContainsPoint(rect, point) { && point.y <= rect.bottom; } -function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId) { +function getEventClientPosition(event) { + if (!event) return null; + const point = 'changedTouches' in event && event.changedTouches?.[0] + ? event.changedTouches[0] + : ('touches' in event && event.touches?.[0] ? event.touches[0] : event); + if (!Number.isFinite(point?.clientX) || !Number.isFinite(point?.clientY)) return null; + return { x: point.clientX, y: point.clientY }; +} + +function getEventFlowPosition(event, reactFlow) { + const clientPosition = getEventClientPosition(event); + if (!clientPosition || typeof reactFlow?.screenToFlowPosition !== 'function') return null; + return reactFlow.screenToFlowPosition(clientPosition); +} + +function getDragIntent(event, reactFlow, dragState) { + if (!dragState?.pointerOffset || !dragState?.anchorStartAbsolute) return null; + const pointerFlowPos = getEventFlowPosition(event, reactFlow); + if (!pointerFlowPos) return null; + + const anchorAbsolute = { + x: pointerFlowPos.x - dragState.pointerOffset.x, + y: pointerFlowPos.y - dragState.pointerOffset.y, + }; + const delta = { + x: anchorAbsolute.x - (Number(dragState.anchorStartAbsolute.x) || 0), + y: anchorAbsolute.y - (Number(dragState.anchorStartAbsolute.y) || 0), + }; + const absolutePositions = new Map( + Object.entries(dragState.absolutePositions || {}).map(([id, pos]) => [ + id, + { + x: (Number(pos?.x) || 0) + delta.x, + y: (Number(pos?.y) || 0) + delta.y, + }, + ]), + ); + + return { + pointerFlowPos, + anchorAbsolute, + absolutePositions, + }; +} + +function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId, anchorPoint = null) { const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node])); const anchorNode = nodeMap.get(String(anchorNodeId)); if (!anchorNode) return null; const draggedIdSet = new Set((draggedNodeIds || []).map((id) => String(id))); - const anchorCenter = getNodeCenter(anchorNode, nodeMap); + const anchorCenter = anchorPoint && Number.isFinite(anchorPoint.x) && Number.isFinite(anchorPoint.y) + ? anchorPoint + : getNodeCenter(anchorNode, nodeMap); return (nodes || []) .filter((node) => ( @@ -712,6 +772,7 @@ function Flow() { const lastPastedClipboardTextRef = useRef(''); const pasteRepeatCountRef = useRef(0); const duplicateDragRef = useRef(null); + const dragStateRef = useRef(null); const activeDragNodeIdRef = useRef(null); const reactFlow = useReactFlow(); @@ -999,8 +1060,9 @@ function Flow() { groupNode, ]; - setNodes(nextNodes); - setTimeout(() => refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()), 0); + const orderedNodes = sortNodesForParentOrder(nextNodes); + setNodes(orderedNodes); + setTimeout(() => refreshGroupNode(groupId, orderedNodes, reactFlow.getEdges()), 0); }, [reactFlow, refreshGroupNode, setNodes]); const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => { @@ -1639,10 +1701,10 @@ function Flow() { nextIdRef.current = pasted.nextNodeId; - setNodes((existing) => [ + setNodes((existing) => sortNodesForParentOrder([ ...existing.map((node) => ({ ...node, selected: false })), ...pasted.nodes, - ]); + ])); setEdges((existing) => [ ...existing.map((edge) => ({ ...edge, selected: false })), ...pasted.edges, @@ -1682,7 +1744,7 @@ function Flow() { const applyWorkflowData = useCallback((data) => { const hydrated = hydrateWorkflowState(data, nodeDefsRef.current); - setNodes(hydrated.nodes); + setNodes(sortNodesForParentOrder(hydrated.nodes)); setEdges(hydrated.edges); nextIdRef.current = hydrated.nextNodeId; initializeDynamicNodes(hydrated.nodes); @@ -1912,11 +1974,40 @@ function Flow() { const onNodeDragStart = useCallback((event, node) => { activeDragNodeIdRef.current = String(node.id); + dragStateRef.current = null; if (!(event.ctrlKey || event.metaKey)) { duplicateDragRef.current = null; + const currentNodes = reactFlow.getNodes(); + const draggedNodes = node.data?.className === 'Group' + ? [] + : ( + node.selected + ? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group') + : currentNodes.filter((candidate) => candidate.id === node.id) + ); + const pointerFlowPos = getEventFlowPosition(event, reactFlow); + if (draggedNodes.length > 0 && pointerFlowPos) { + const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate])); + const absolutePositions = Object.fromEntries( + draggedNodes.map((candidate) => [ + String(candidate.id), + getNodeAbsolutePosition(candidate, nodeMap), + ]), + ); + const anchorAbsolute = absolutePositions[String(node.id)] || getNodeAbsolutePosition(node, nodeMap); + dragStateRef.current = { + anchorId: String(node.id), + anchorStartAbsolute: anchorAbsolute, + absolutePositions, + pointerOffset: { + x: pointerFlowPos.x - anchorAbsolute.x, + y: pointerFlowPos.y - anchorAbsolute.y, + }, + }; + } if (node.data?.className === 'Group') { - const descendantIds = collectGroupDescendantIds(reactFlow.getNodes(), node.id); + const descendantIds = collectGroupDescendantIds(currentNodes, node.id); if (descendantIds.size > 0) { setNodes((existing) => existing.map((candidate) => ( descendantIds.has(String(candidate.id)) @@ -1974,10 +2065,10 @@ function Flow() { duplicateSourceById, }; - setNodes((existing) => [ + setNodes((existing) => sortNodesForParentOrder([ ...existing.map((candidate) => ({ ...candidate, selected: false })), ...duplicated.nodes, - ]); + ])); setEdges((existing) => [ ...existing.map((edge) => ({ ...edge, selected: false })), ...duplicated.edges, @@ -2036,6 +2127,8 @@ function Flow() { if (String(node.id) !== activeDragNodeIdRef.current) return; activeDragNodeIdRef.current = null; + const dragState = dragStateRef.current; + dragStateRef.current = null; const duplicateState = duplicateDragRef.current; duplicateDragRef.current = null; if (duplicateState) { @@ -2089,6 +2182,7 @@ function Flow() { } const currentNodes = reactFlow.getNodes(); + const dragIntent = getDragIntent(event, reactFlow, dragState); const touchedGroupIds = new Set(); let nextNodes = currentNodes; let changed = false; @@ -2103,9 +2197,23 @@ function Flow() { if (draggedNodes.length > 0) { const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id))); - const targetGroup = findExpandedGroupDropTarget(nextNodes, Array.from(draggedIdSet), node.id); + const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate])); + const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id)); + const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id)) + || (anchorNode ? getNodeAbsolutePosition(anchorNode, nodeMap) : null); + const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute + ? { + x: intendedAnchorAbsolute.x + (Number(getNodeDimension(anchorNode, 'width')) || 200) / 2, + y: intendedAnchorAbsolute.y + (Number(getNodeDimension(anchorNode, 'height')) || 120) / 2, + } + : null; + const targetGroup = findExpandedGroupDropTarget( + nextNodes, + Array.from(draggedIdSet), + node.id, + intendedAnchorCenter, + ); if (targetGroup) { - const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate])); const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap); const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap); let joinedCount = 0; @@ -2113,10 +2221,15 @@ function Flow() { nextNodes = nextNodes.map((candidate) => { if (!draggedIdSet.has(String(candidate.id))) return candidate; - const center = getNodeCenter(candidate, nodeMap); + const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id)); + const width = Number(getNodeDimension(candidate, 'width')) || 200; + const height = Number(getNodeDimension(candidate, 'height')) || 120; + const center = intendedAbsolute + ? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 } + : getNodeCenter(candidate, nodeMap); if (!rectContainsPoint(targetRect, center)) return candidate; - const absolute = getNodeAbsolutePosition(candidate, nodeMap); + const absolute = intendedAbsolute || getNodeAbsolutePosition(candidate, nodeMap); const nextPosition = { x: absolute.x - targetAbs.x, y: absolute.y - targetAbs.y, @@ -2147,12 +2260,47 @@ function Flow() { level: 'info', }); } + } else { + const pointerFlowPos = dragIntent?.pointerFlowPos || getEventFlowPosition(event, reactFlow); + let removedCount = 0; + + nextNodes = nextNodes.map((candidate) => { + if (!draggedIdSet.has(String(candidate.id)) || !candidate.parentId) return candidate; + + const parentId = String(candidate.parentId); + const parentNode = nodeMap.get(parentId); + if (!parentNode || parentNode.data?.className !== 'Group') return candidate; + if (!pointerFlowPos) return candidate; + if (rectContainsPoint(getNodeRect(parentNode, nodeMap), pointerFlowPos)) { + return candidate; + } + + const absolute = dragIntent?.absolutePositions.get(String(candidate.id)) + || getNodeAbsolutePosition(candidate, nodeMap); + touchedGroupIds.add(parentId); + removedCount += 1; + changed = true; + return { + ...candidate, + parentId: undefined, + extent: undefined, + hidden: false, + position: absolute, + }; + }); + + if (removedCount > 0) { + setStatus({ + text: `Removed ${removedCount} node${removedCount === 1 ? '' : 's'} from group.`, + level: 'info', + }); + } } } if (!changed) return; - setNodes(nextNodes); + setNodes(sortNodesForParentOrder(nextNodes)); setTimeout(() => { touchedGroupIds.forEach((groupId) => { if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()); diff --git a/frontend/src/nodeClipboard.js b/frontend/src/nodeClipboard.js index 1726fb1..4216f3d 100644 --- a/frontend/src/nodeClipboard.js +++ b/frontend/src/nodeClipboard.js @@ -1,3 +1,5 @@ +import { sortNodesForParentOrder } from './nodeHierarchy.js'; + export const NODE_CLIPBOARD_KIND = 'argonode/node-selection'; export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection'; @@ -151,9 +153,12 @@ export function instantiateNodeClipboardPayload( const idMap = new Map(); let currentId = Number(nextNodeId) || 1; - const nodes = payload.nodes.map((node) => { - const newId = String(currentId++); - idMap.set(String(node.id), newId); + payload.nodes.forEach((node) => { + idMap.set(String(node.id), String(currentId++)); + }); + + const nodes = sortNodesForParentOrder(payload.nodes.map((node) => { + const newId = idMap.get(String(node.id)); const className = node.data?.className || ''; const definition = className ? defs[className] || null : null; @@ -187,7 +192,7 @@ export function instantiateNodeClipboardPayload( warning: null, }, }; - }); + })); const edges = payload.edges .filter((edge) => ( diff --git a/frontend/src/nodeHierarchy.js b/frontend/src/nodeHierarchy.js new file mode 100644 index 0000000..c3742c8 --- /dev/null +++ b/frontend/src/nodeHierarchy.js @@ -0,0 +1,28 @@ +export function sortNodesForParentOrder(nodes) { + const list = Array.isArray(nodes) ? nodes.filter(Boolean) : []; + const entries = list.map((node) => ({ id: String(node.id), node })); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered = []; + + function visit(entry) { + if (!entry) return; + const { id, node } = entry; + if (visited.has(id) || visiting.has(id)) return; + + visiting.add(id); + + const parentId = node?.parentId ? String(node.parentId) : null; + if (parentId) { + visit(byId.get(parentId)); + } + + visiting.delete(id); + visited.add(id); + ordered.push(node); + } + + entries.forEach((entry) => visit(entry)); + return ordered; +} diff --git a/frontend/src/workflowHydration.js b/frontend/src/workflowHydration.js index bdc1e55..8d1d070 100644 --- a/frontend/src/workflowHydration.js +++ b/frontend/src/workflowHydration.js @@ -1,3 +1,5 @@ +import { sortNodesForParentOrder } from './nodeHierarchy.js'; + function mergeDefinition(nodeData, defs) { const savedData = nodeData || {}; const registryDefinition = savedData.className ? defs[savedData.className] : null; @@ -34,7 +36,7 @@ export function hydrateWorkflowState(data, defs = {}) { const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : []; const loadedEdges = Array.isArray(data?.edges) ? data.edges : []; - const nodes = loadedNodes.map((node) => { + const nodes = sortNodesForParentOrder(loadedNodes.map((node) => { const definition = mergeDefinition(node.data, defs); return { @@ -62,7 +64,7 @@ export function hydrateWorkflowState(data, defs = {}) { warning: null, }, }; - }); + })); const edges = loadedEdges.map((edge) => ({ ...edge })); diff --git a/frontend/tests/nodeHierarchy.test.mjs b/frontend/tests/nodeHierarchy.test.mjs new file mode 100644 index 0000000..7dffff8 --- /dev/null +++ b/frontend/tests/nodeHierarchy.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { sortNodesForParentOrder } from '../src/nodeHierarchy.js'; +import { hydrateWorkflowState } from '../src/workflowHydration.js'; +import { instantiateNodeClipboardPayload, NODE_CLIPBOARD_KIND } from '../src/nodeClipboard.js'; + +test('sortNodesForParentOrder places parents before descendants', () => { + const nodes = [ + { id: '2', parentId: '1', position: { x: 80, y: 60 }, data: { className: 'Preview' } }, + { id: '3', position: { x: 300, y: 20 }, data: { className: 'Image' } }, + { id: '1', className: 'group-shell', position: { x: 0, y: 0 }, data: { className: 'Group' } }, + { id: '4', parentId: '2', position: { x: 30, y: 24 }, data: { className: 'Save' } }, + ]; + + const ordered = sortNodesForParentOrder(nodes); + + assert.deepEqual(ordered.map((node) => node.id), ['1', '2', '3', '4']); +}); + +test('hydrateWorkflowState reorders group parents ahead of children', () => { + const saved = { + nodes: [ + { + id: '11', + type: 'custom', + position: { x: 48, y: 72 }, + parentId: '10', + extent: 'parent', + data: { + label: 'preview', + className: 'Preview', + widgetValues: {}, + }, + }, + { + id: '10', + type: 'custom', + className: 'group-shell', + position: { x: 12, y: 24 }, + style: { width: 320, height: 220 }, + data: { + label: 'group', + className: 'Group', + widgetValues: {}, + }, + }, + ], + edges: [], + }; + + const hydrated = hydrateWorkflowState(saved, {}); + + assert.deepEqual(hydrated.nodes.map((node) => node.id), ['10', '11']); + assert.equal(hydrated.nodes[1].parentId, '10'); +}); + +test('instantiateNodeClipboardPayload remaps parent ids before sorting grouped nodes', () => { + const payload = { + kind: NODE_CLIPBOARD_KIND, + version: 1, + nodes: [ + { + id: 'child', + type: 'custom', + position: { x: 48, y: 72 }, + parentId: 'group', + extent: 'parent', + data: { + label: 'preview', + className: 'Preview', + widgetValues: {}, + }, + }, + { + id: 'group', + type: 'custom', + className: 'group-shell', + position: { x: 12, y: 24 }, + style: { width: 320, height: 220 }, + data: { + label: 'group', + className: 'Group', + widgetValues: {}, + }, + }, + ], + edges: [], + }; + + const instantiated = instantiateNodeClipboardPayload(payload, {}, 20); + + assert.deepEqual(instantiated.nodes.map((node) => node.id), ['21', '20']); + assert.equal(instantiated.nodes[1].parentId, '21'); + assert.equal(instantiated.nextNodeId, 22); +}); diff --git a/package.json b/package.json index 0618d35..58f1308 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ }, "scripts": { "postinstall": "npm --prefix frontend install", - "dev": "npm --prefix frontend run dev", - "build": "npm --prefix frontend run build", + "clean:dev": "node scripts/clean-build-artifacts.mjs", + "clean:build": "node scripts/clean-build-artifacts.mjs", + "clean:native": "node scripts/clean-build-artifacts.mjs --mode=native", + "dev": "npm run clean:dev && npm --prefix frontend run dev", + "build": "npm run clean:build && npm --prefix frontend run build", "preview": "npm --prefix frontend run preview", "test:frontend": "npm --prefix frontend test", "backend": "python -m backend.main", - "desktop": "python desktop.py", + "desktop": "npm run build && python desktop.py", "build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1", "build:mac": "bash scripts/build-mac.sh", "build:linux": "bash scripts/build-linux.sh" diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 5da3c78..3ff8951 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -38,6 +38,10 @@ $pythonExe = if (Test-Path ".\.venv\Scripts\python.exe") { $frontendDist = Join-Path $repoRoot "frontend\dist" $demoDir = Join-Path $repoRoot "demo" +Write-Host "Removing cached frontend and desktop build artifacts..." +node scripts\clean-build-artifacts.mjs --mode=native +Assert-LastExitCode "Artifact cleanup" + Write-Host "Building frontend bundle..." npm run build Assert-LastExitCode "Frontend build" diff --git a/scripts/clean-build-artifacts.mjs b/scripts/clean-build-artifacts.mjs new file mode 100644 index 0000000..cc0ae9f --- /dev/null +++ b/scripts/clean-build-artifacts.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const args = new Set(process.argv.slice(2)); +const mode = args.has('--mode=native') || args.has('--native') ? 'native' : 'frontend'; + +const removed = []; + +function removePath(targetPath) { + if (!fs.existsSync(targetPath)) return; + fs.rmSync(targetPath, { recursive: true, force: true }); + removed.push(path.relative(repoRoot, targetPath) || '.'); +} + +function removePythonCaches(rootPath) { + const stack = [rootPath]; + const skipDirs = new Set(['.git', '.venv', 'node_modules']); + + while (stack.length > 0) { + const current = stack.pop(); + let entries = []; + + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + + if (entry.isDirectory()) { + if (entry.name === '__pycache__') { + removePath(fullPath); + continue; + } + if (skipDirs.has(entry.name)) continue; + stack.push(fullPath); + continue; + } + + if (entry.isFile() && (entry.name.endsWith('.pyc') || entry.name.endsWith('.pyo'))) { + try { + fs.rmSync(fullPath, { force: true }); + removed.push(path.relative(repoRoot, fullPath) || '.'); + } catch { + // Ignore files held open by another process; the rest of the clean can still continue. + } + } + } + } +} + +removePath(path.join(repoRoot, 'frontend', 'dist')); +removePath(path.join(repoRoot, 'frontend', 'node_modules', '.vite')); +removePythonCaches(repoRoot); + +if (mode === 'native') { + removePath(path.join(repoRoot, 'desktop-build')); + removePath(path.join(repoRoot, 'desktop-dist')); +} + +if (removed.length === 0) { + console.log(`[clean] No cached build artifacts found (${mode}).`); +} else { + console.log(`[clean] Removed ${removed.length} artifact${removed.length === 1 ? '' : 's'} (${mode}).`); +}