From bd94ef9e1549eea85a1e6f0e0389eb7ac5d6db4d Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 18 Apr 2026 19:20:08 -0400 Subject: [PATCH] feat(sidebar): global multi-node stack search with status (#685) Sidebar search now fans out to every online node and surfaces matches from the whole fleet under an "Other nodes" section, with UP/DN status badges fetched in parallel via the bulk /stacks/statuses endpoint. Clicking a remote result switches the active node and opens the stack. Also fixes a cmdk reconciliation crash ("Failed to execute 'appendChild' on 'Node'") that fired when typing in the search input, by disabling cmdk's internal filtering since filtering is already controlled. --- frontend/src/components/EditorLayout.tsx | 142 ++++++++++++++++++++++- 1 file changed, 137 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 5da9965c..8e46d388 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; @@ -158,6 +158,7 @@ export default function EditorLayout() { const [creatingFromGit, setCreatingFromGit] = useState(false); const [stackToDelete, setStackToDelete] = useState(null); const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); + const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); const [isLoading, setIsLoading] = useState(false); const [stackActions, setStackActions] = useState>({}); const stackActionsRef = useRef>({}); @@ -212,6 +213,8 @@ export default function EditorLayout() { const [filterNodeId, setFilterNodeId] = useState(null); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [remoteStackResults, setRemoteStackResults] = useState>>({}); + const [remoteSearchLoading, setRemoteSearchLoading] = useState(false); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); @@ -340,6 +343,70 @@ export default function EditorLayout() { return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); }, []); + // Global stack search: when the user types a query, fan out to every other online + // node and fetch its stack list so the sidebar can surface matches from the whole + // fleet. Debounced 250ms; cleared as soon as the query is empty. + useEffect(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) { + setRemoteStackResults({}); + setRemoteSearchLoading(false); + return; + } + const otherNodes = nodes.filter(n => n.id !== activeNode?.id && n.status !== 'offline'); + if (otherNodes.length === 0) { + setRemoteStackResults({}); + return; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + setRemoteSearchLoading(true); + try { + const entries = await Promise.all(otherNodes.map(async (node) => { + const empty = [] as Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>; + try { + const [listRes, statusRes] = await Promise.all([ + fetchForNode('/stacks', node.id, { signal: controller.signal }), + fetchForNode('/stacks/statuses', node.id, { signal: controller.signal }), + ]); + if (!listRes.ok) return [node.id, empty] as const; + const listData = await listRes.json(); + const list: string[] = Array.isArray(listData) ? listData : []; + const statuses: Record = {}; + if (statusRes.ok) { + const raw = await statusRes.json(); + for (const [key, val] of Object.entries(raw)) { + if (typeof val === 'string') { + statuses[key] = val as 'running' | 'exited' | 'unknown'; + } else if (val && typeof val === 'object' && 'status' in val) { + statuses[key] = (val as StackStatusInfo).status; + } + } + } + const matches = list + .filter(f => f.toLowerCase().includes(query)) + .map(file => ({ file, status: statuses[file] ?? 'unknown' as const })); + return [node.id, matches] as const; + } catch { + return [node.id, empty] as const; + } + })); + if (controller.signal.aborted) return; + const next: Record> = {}; + for (const [id, matches] of entries) { + if (matches.length > 0) next[id] = matches; + } + setRemoteStackResults(next); + } finally { + if (!controller.signal.aborted) setRemoteSearchLoading(false); + } + }, 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [searchQuery, activeNode?.id, nodes]); + // Force Monaco to re-measure its container after the tab switch DOM settles. // Monaco's internal child is position:static with an explicit pixel height that // creates a circular CSS dependency (Monaco drives card height → grid height → Monaco). @@ -863,6 +930,22 @@ export default function EditorLayout() { const hasUnsavedChanges = () => content !== originalContent || envContent !== originalEnvContent; + // Global-search result click: switch the active node, clear the query so the + // sidebar snaps back to the new node's full stack list, then open the stack. + // setActiveNode writes to localStorage synchronously, so the next apiFetch + // picks up the new node-id header without waiting for a re-render. + const loadFileOnNode = async (node: Node, filename: string) => { + if (!filename) return; + if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) { + setPendingUnsavedNode(node); + setPendingUnsavedLoad(filename); + return; + } + setActiveNode(node); + setSearchQuery(''); + await loadFile(filename); + }; + const loadFile = async (filename: string) => { if (!filename) return; // Guard: if there are unsaved changes and we're switching to a different stack, confirm first @@ -1897,7 +1980,10 @@ export default function EditorLayout() { } {/* Search Input & Stack List */} - + {/* shouldFilter disabled: we do controlled filtering via filteredFiles. cmdk's + internal filter otherwise reconciles against items wrapped in ContextMenu and + throws "appendChild: parameter 1 is not of type 'Node'". */} +
+ + {/* Remote node matches: surfaced only when the user is actively searching. */} + {searchQuery.trim() && (remoteSearchLoading || Object.keys(remoteStackResults).length > 0) && ( +
+

+ Other nodes + {remoteSearchLoading && } +

+ {Object.entries(remoteStackResults).map(([nodeIdStr, files]) => { + const node = nodes.find(n => n.id === Number(nodeIdStr)); + if (!node || files.length === 0) return null; + return ( +
+
+ + {node.name} +
+ {files.map(({ file, status }) => ( + + ))} +
+ ); + })} +
+ )}
@@ -2814,7 +2941,7 @@ export default function EditorLayout() { - { if (!open) setPendingUnsavedLoad(null); }}> + { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }}> Unsaved Changes @@ -2823,14 +2950,19 @@ export default function EditorLayout() { - setPendingUnsavedLoad(null)}>Cancel + { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); }}>Cancel { const target = pendingUnsavedLoad; + const targetNode = pendingUnsavedNode; // Reset content to original so the guard doesn't re-trigger setContent(originalContent); setEnvContent(originalEnvContent); setPendingUnsavedLoad(null); - if (target) loadFile(target); + setPendingUnsavedNode(null); + if (target) { + if (targetNode) loadFileOnNode(targetNode, target); + else loadFile(target); + } }}>Discard Changes