From 856de35a52a4e17c18b337a296112d529b1a9406 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 20 Apr 2026 13:57:40 -0400 Subject: [PATCH] feat(search): add global Ctrl+K command palette (#711) * feat(search): add global command palette with cross-node stacks Press Ctrl+K from anywhere to open a search palette that jumps to pages, switches nodes, or opens stacks on any online node in the fleet. Trigger lives as an icon in the top bar. Replaces the former sidebar-scoped Ctrl+K handler. The cross-node stack search fan-out is extracted into a shared hook so the sidebar and palette stay in sync on the same debounce + abort shape. * fix(search): drop render-time ref write and effect-based query reset Replace `nodesRef.current = nodes` during render with direct use of `nodes` in the stack select callback, and fold the query-reset logic into a single `onOpenChange` handler so it no longer runs via an effect. Both changes resolve react-hooks rule violations that broke frontend lint in CI. --- docs/docs.json | 1 + docs/features/global-search.mdx | 58 +++++ docs/images/global-search/palette-stacks.png | Bin 0 -> 16664 bytes frontend/src/components/EditorLayout.tsx | 103 ++------ .../src/components/GlobalCommandPalette.tsx | 227 ++++++++++++++++++ frontend/src/components/TopBar.tsx | 3 + .../src/components/sidebar/SidebarSearch.tsx | 10 +- frontend/src/hooks/useCrossNodeStackSearch.ts | 98 ++++++++ 8 files changed, 414 insertions(+), 86 deletions(-) create mode 100644 docs/features/global-search.mdx create mode 100644 docs/images/global-search/palette-stacks.png create mode 100644 frontend/src/components/GlobalCommandPalette.tsx create mode 100644 frontend/src/hooks/useCrossNodeStackSearch.ts diff --git a/docs/docs.json b/docs/docs.json index d9b33c9d..f3b16ca5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -95,6 +95,7 @@ "pages": [ "features/overview", "features/dashboard", + "features/global-search", "features/sidebar", "features/stack-management", "features/editor", diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx new file mode 100644 index 00000000..2edd8732 --- /dev/null +++ b/docs/features/global-search.mdx @@ -0,0 +1,58 @@ +--- +title: Global Search +description: Jump to any page, node, or stack from anywhere in the app with a single keystroke. +--- + +The **global search palette** lets you move around Sencho without reaching for the mouse. It covers top-level navigation, every configured node, and every stack on every online node in your fleet. + + + Sencho global search palette filtering stacks across nodes + + +## Opening the palette + +Three ways to open it: + +- Press Ctrl + K (or Cmd + K on macOS) from anywhere in the app. +- Click the search icon in the top bar, left of the notification bell. +- Press Esc to close it at any time. + +The shortcut works from the dashboard, editor, fleet view, resources, and every other screen. + +## What you can find + +The palette groups results into three sections: + +| Group | What it contains | What happens when you pick one | +|-------|------------------|--------------------------------| +| **Pages** | Every top-level navigation destination (Home, Fleet, Resources, App Store, Logs, Auto-Update, Console, Audit, Schedules) | Navigates to that page | +| **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline | Switches the active node without leaving the current page | +| **Stacks** | Every compose stack on every online node, matched by filename | Switches to the stack's node and opens it in the editor | + +Each stack row shows a status dot (running, exited, or unknown) on the left and the node name as a tag on the right. That way you always know which host a match lives on before you pick it. + +## Typing to filter + +Start typing and the results narrow in real time. Matching is substring-based, so you can type a fragment from anywhere in a stack's filename or a node's name. Examples: + +- `fleet` — jumps to the Fleet page. +- `opsix` — selects the Opsix node and switches the active context to it. +- `db` — lists every stack with "db" in its name, showing which node each one lives on. + +When a query matches stacks on a remote node, selecting one switches the active node and opens the editor on that stack in a single action. + +## Cross-node search + +Stack search fans out across every online node in your fleet. Offline nodes are skipped so one unreachable host never slows the palette down. Results from the active node and remote nodes appear together, sorted by the order nodes were added. + +The palette caps the Stacks group at 50 results. If you have more matches than that, keep typing to narrow the query and the extras will come into view. + +## Keyboard navigation + +Once the palette is open: + +- / — move through the results. +- Enter — pick the highlighted result. +- Esc — close the palette. + +Offline nodes show as greyed-out and are not selectable. diff --git a/docs/images/global-search/palette-stacks.png b/docs/images/global-search/palette-stacks.png new file mode 100644 index 0000000000000000000000000000000000000000..88f63d872a21949ef6caf22f5dd46ed0c1ac2872 GIT binary patch literal 16664 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 z>>({}); - const [remoteSearchLoading, setRemoteSearchLoading] = useState(false); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); @@ -417,19 +421,6 @@ export default function EditorLayout() { localStorage.setItem('sencho-theme', theme); }, [isDarkMode, theme]); - // ⌘K / Ctrl+K — focus stack search input - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - const input = document.querySelector('[cmdk-input]'); - input?.focus(); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, []); - // Listen for cross-component navigation (e.g., NodeManager → Schedules) useEffect(() => { const handler = (e: Event) => { @@ -443,69 +434,19 @@ 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; + // Fan out stack search across other online nodes so the sidebar can surface matches from the whole fleet. + const { hits: remoteSearchHits, loading: remoteSearchLoading } = useCrossNodeStackSearch({ + query: searchQuery, + enabled: true, + excludeNodeId: activeNode?.id, + }); + const remoteStackResults = useMemo(() => { + const out: Record> = {}; + for (const hit of remoteSearchHits) { + (out[hit.nodeId] ??= []).push({ file: hit.file, status: hit.status }); } - 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]); + return out; + }, [remoteSearchHits]); // 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 @@ -2143,7 +2084,13 @@ export default function EditorLayout() { ) : null; return ( +
+ {/* Left Sidebar (Stacks) */} } notifications={ setStackMisconfigScanId(null)} />
+
); } diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx new file mode 100644 index 00000000..a3ce7c70 --- /dev/null +++ b/frontend/src/components/GlobalCommandPalette.tsx @@ -0,0 +1,227 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { Search } from 'lucide-react'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { DialogDescription, DialogTitle } from '@/components/ui/dialog'; +import { useNodes, type Node } from '@/context/NodeContext'; +import { cn } from '@/lib/utils'; +import { + useCrossNodeStackSearch, + type StackHit, + type StackStatus, +} from '@/hooks/useCrossNodeStackSearch'; +import type { TopBarNavItem } from './TopBar'; + +const MAX_STACK_HITS = 50; + +const statusDot: Record = { + running: 'bg-success', + exited: 'bg-muted-foreground', + unknown: 'bg-muted-foreground/60', +}; + +interface PaletteState { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +const PaletteContext = createContext(null); + +export function GlobalCommandPaletteProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + // Let cmdk's own Ctrl+K handling take precedence when focus is already inside a palette + const target = e.target as HTMLElement | null; + if (target?.closest('[cmdk-root]')) return; + e.preventDefault(); + setOpen(prev => !prev); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + const value = useMemo( + () => ({ open, setOpen, toggle: () => setOpen(prev => !prev) }), + [open], + ); + + return {children}; +} + +function usePaletteState(): PaletteState { + const ctx = useContext(PaletteContext); + if (!ctx) throw new Error('usePaletteState must be used within GlobalCommandPaletteProvider'); + return ctx; +} + +export function GlobalCommandPaletteTrigger() { + const { setOpen } = usePaletteState(); + return ( + + ); +} + +interface GlobalCommandPaletteProps { + navItems: TopBarNavItem[]; + onNavigate: (value: string) => void; + onSelectStack: (node: Node, filename: string) => void; +} + +export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: GlobalCommandPaletteProps) { + const { open, setOpen } = usePaletteState(); + const { nodes, activeNode, setActiveNode } = useNodes(); + const [query, setQuery] = useState(''); + + const { hits: remoteHits, loading: stacksLoading } = useCrossNodeStackSearch({ + query, + enabled: open, + }); + const stackHits = useMemo(() => remoteHits.slice(0, MAX_STACK_HITS), [remoteHits]); + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next); + if (!next) setQuery(''); + }, [setOpen]); + + const handleSelectNav = useCallback((value: string) => { + handleOpenChange(false); + onNavigate(value); + }, [handleOpenChange, onNavigate]); + + const handleSelectNode = useCallback((node: Node) => { + handleOpenChange(false); + setActiveNode(node); + }, [handleOpenChange, setActiveNode]); + + const handleSelectStack = useCallback((hit: StackHit) => { + const node = nodes.find(n => n.id === hit.nodeId); + if (!node) return; + handleOpenChange(false); + onSelectStack(node, hit.file); + }, [handleOpenChange, nodes, onSelectStack]); + + const visibleNodes = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return nodes; + return nodes.filter(n => n.name.toLowerCase().includes(q)); + }, [nodes, query]); + + return ( + + + Search + Jump to a page, node, or stack + + + + + + {stacksLoading ? 'Searching...' : 'No results.'} + + + + + {navItems.map(({ value, label, icon: Icon }) => ( + handleSelectNav(value)} + > + + {label} + + ))} + + + {visibleNodes.length > 0 && ( + + {visibleNodes.map(node => { + const isActive = node.id === activeNode?.id; + const offline = node.status === 'offline'; + return ( + handleSelectNode(node)} + disabled={offline} + > + + {node.name} + {isActive && ( + + Active + + )} + + ); + })} + + )} + + {stackHits.length > 0 && ( + + {stackHits.map(hit => ( + handleSelectStack(hit)} + > + + {hit.file} + + {hit.nodeName} + + + ))} + {remoteHits.length > MAX_STACK_HITS && ( +
+ Showing first {MAX_STACK_HITS} of {remoteHits.length} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 8d30c5c7..19d332a4 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -17,6 +17,7 @@ interface TopBarProps { onNavigate: (value: string) => void; mobileNavOpen: boolean; onMobileNavOpenChange: (open: boolean) => void; + search?: ReactNode; notifications: ReactNode; userMenu: ReactNode; } @@ -27,6 +28,7 @@ export function TopBar({ onNavigate, mobileNavOpen, onMobileNavOpenChange, + search, notifications, userMenu, }: TopBarProps) { @@ -73,6 +75,7 @@ export function TopBar({ {/* RIGHT ZONE: Utilities + identity pin */}
+ {search} {notifications} {userMenu} diff --git a/frontend/src/components/sidebar/SidebarSearch.tsx b/frontend/src/components/sidebar/SidebarSearch.tsx index b376fe0f..993ed6b4 100644 --- a/frontend/src/components/sidebar/SidebarSearch.tsx +++ b/frontend/src/components/sidebar/SidebarSearch.tsx @@ -5,23 +5,15 @@ interface SidebarSearchProps { onValueChange: (v: string) => void; } -function kbdHint(): string { - if (typeof navigator === 'undefined') return 'Ctrl+K'; - return /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K'; -} - export function SidebarSearch({ value, onValueChange }: SidebarSearchProps) { return ( -
+
- - {kbdHint()} -
); } diff --git a/frontend/src/hooks/useCrossNodeStackSearch.ts b/frontend/src/hooks/useCrossNodeStackSearch.ts new file mode 100644 index 00000000..e198ccb2 --- /dev/null +++ b/frontend/src/hooks/useCrossNodeStackSearch.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNodes } from '@/context/NodeContext'; +import { fetchForNode } from '@/lib/api'; + +export type StackStatus = 'running' | 'exited' | 'unknown'; + +export interface StackStatusInfo { + status: StackStatus; +} + +export interface StackHit { + nodeId: number; + nodeName: string; + file: string; + status: StackStatus; +} + +interface Options { + query: string; + enabled: boolean; + excludeNodeId?: number; +} + +const DEBOUNCE_MS = 250; + +export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) { + const { nodes } = useNodes(); + const [hits, setHits] = useState([]); + const [loading, setLoading] = useState(false); + + // Ref avoids re-running the effect on every NodeContext status tick + const nodesRef = useRef(nodes); + nodesRef.current = nodes; + + useEffect(() => { + const q = query.trim().toLowerCase(); + if (!enabled || !q) { + setHits([]); + setLoading(false); + return; + } + const targets = nodesRef.current.filter( + n => n.status !== 'offline' && n.id !== excludeNodeId, + ); + if (targets.length === 0) { + setHits([]); + return; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + setLoading(true); + try { + const perNode = await Promise.all(targets.map(async (node) => { + 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 [] as StackHit[]; + const rawList = await listRes.json(); + const files: string[] = Array.isArray(rawList) ? rawList : []; + 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 StackStatus; + } else if (val && typeof val === 'object' && 'status' in val) { + statuses[key] = (val as StackStatusInfo).status; + } + } + } + return files + .filter(f => f.toLowerCase().includes(q)) + .map(file => ({ + nodeId: node.id, + nodeName: node.name, + file, + status: statuses[file] ?? 'unknown', + })); + } catch { + return [] as StackHit[]; + } + })); + if (controller.signal.aborted) return; + setHits(perNode.flat()); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, DEBOUNCE_MS); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [enabled, query, excludeNodeId]); + + return { hits, loading }; +}