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.
This commit is contained in:
Anso
2026-04-20 13:57:40 -04:00
committed by GitHub
parent a41af47ff3
commit 856de35a52
8 changed files with 414 additions and 86 deletions
+1
View File
@@ -95,6 +95,7 @@
"pages": [
"features/overview",
"features/dashboard",
"features/global-search",
"features/sidebar",
"features/stack-management",
"features/editor",
+58
View File
@@ -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.
<Frame>
<img src="/images/global-search/palette-stacks.png" alt="Sencho global search palette filtering stacks across nodes" />
</Frame>
## Opening the palette
Three ways to open it:
- Press <kbd>Ctrl</kbd> + <kbd>K</kbd> (or <kbd>Cmd</kbd> + <kbd>K</kbd> on macOS) from anywhere in the app.
- Click the search icon in the top bar, left of the notification bell.
- Press <kbd>Esc</kbd> 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:
- <kbd>↑</kbd> / <kbd>↓</kbd> — move through the results.
- <kbd>Enter</kbd> — pick the highlighted result.
- <kbd>Esc</kbd> — close the palette.
Offline nodes show as greyed-out and are not selectable.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+26 -77
View File
@@ -51,6 +51,12 @@ import { SecurityHistoryView } from './SecurityHistoryView';
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
import type { SenchoNavigateDetail } from './NodeManager';
import { NodeSwitcher } from './NodeSwitcher';
import {
GlobalCommandPalette,
GlobalCommandPaletteProvider,
GlobalCommandPaletteTrigger,
} from './GlobalCommandPalette';
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
import type { SenchoOpenLogsDetail } from '@/lib/events';
import { useNodes } from '@/context/NodeContext';
@@ -314,8 +320,6 @@ export default function EditorLayout() {
const [isEditing, setIsEditing] = useState(false);
const [editingCompose, setEditingCompose] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [remoteStackResults, setRemoteStackResults] = useState<Record<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>>>({});
const [remoteSearchLoading, setRemoteSearchLoading] = useState(false);
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
@@ -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<HTMLInputElement>('[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<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
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<string, 'running' | 'exited' | 'unknown'> = {};
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<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
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 (
<GlobalCommandPaletteProvider>
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
<GlobalCommandPalette
navItems={navItems}
onNavigate={handleNavigate}
onSelectStack={loadFileOnNode}
/>
{/* Left Sidebar (Stacks) */}
<StackSidebar
isDarkMode={isDarkMode}
@@ -2196,6 +2143,7 @@ export default function EditorLayout() {
onNavigate={handleNavigate}
mobileNavOpen={mobileNavOpen}
onMobileNavOpenChange={setMobileNavOpen}
search={<GlobalCommandPaletteTrigger />}
notifications={
<NotificationPanel
notifications={notifications}
@@ -2911,5 +2859,6 @@ export default function EditorLayout() {
onClose={() => setStackMisconfigScanId(null)}
/>
</div>
</GlobalCommandPaletteProvider>
);
}
@@ -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<StackStatus, string> = {
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<PaletteState | null>(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<PaletteState>(
() => ({ open, setOpen, toggle: () => setOpen(prev => !prev) }),
[open],
);
return <PaletteContext.Provider value={value}>{children}</PaletteContext.Provider>;
}
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 (
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open search (Ctrl+K)"
title="Search (Ctrl+K)"
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-lg',
'text-foreground/80 hover:bg-accent hover:text-foreground transition-colors',
)}
>
<Search className="h-4 w-4" strokeWidth={1.5} />
</button>
);
}
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 (
<CommandDialog open={open} onOpenChange={handleOpenChange}>
<VisuallyHidden>
<DialogTitle>Search</DialogTitle>
<DialogDescription>Jump to a page, node, or stack</DialogDescription>
</VisuallyHidden>
<CommandInput
placeholder="Search the app..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
<span aria-live="polite">
{stacksLoading ? 'Searching...' : 'No results.'}
</span>
</CommandEmpty>
<CommandGroup heading="Pages">
{navItems.map(({ value, label, icon: Icon }) => (
<CommandItem
key={`nav-${value}`}
value={`nav ${label} ${value}`}
onSelect={() => handleSelectNav(value)}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
<span>{label}</span>
</CommandItem>
))}
</CommandGroup>
{visibleNodes.length > 0 && (
<CommandGroup heading="Nodes">
{visibleNodes.map(node => {
const isActive = node.id === activeNode?.id;
const offline = node.status === 'offline';
return (
<CommandItem
key={`node-${node.id}`}
value={`node ${node.name}`}
onSelect={() => handleSelectNode(node)}
disabled={offline}
>
<span
aria-hidden
className={cn(
'h-2 w-2 rounded-full',
offline ? 'bg-muted-foreground' : 'bg-success',
)}
/>
<span className="flex-1">{node.name}</span>
{isActive && (
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
Active
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
)}
{stackHits.length > 0 && (
<CommandGroup heading="Stacks">
{stackHits.map(hit => (
<CommandItem
key={`stack-${hit.nodeId}-${hit.file}`}
value={`stack ${hit.file} ${hit.nodeName}`}
onSelect={() => handleSelectStack(hit)}
>
<span aria-hidden className={cn('h-2 w-2 rounded-full', statusDot[hit.status])} />
<span className="flex-1 truncate">{hit.file}</span>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
{hit.nodeName}
</span>
</CommandItem>
))}
{remoteHits.length > MAX_STACK_HITS && (
<div className="px-2 py-1.5 text-center font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
Showing first {MAX_STACK_HITS} of {remoteHits.length}
</div>
)}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
);
}
+3
View File
@@ -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 */}
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
{search}
{notifications}
{userMenu}
@@ -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 (
<div className="px-4 py-2 flex-none relative">
<div className="px-4 py-2 flex-none">
<CommandInput
placeholder="Search stacks..."
value={value}
onValueChange={onValueChange}
className="h-9 border-none"
/>
<kbd className="pointer-events-none absolute right-6 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-glass-border bg-glass-highlight px-1.5 font-mono text-[10px] text-muted-foreground">
{kbdHint()}
</kbd>
</div>
);
}
@@ -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<StackHit[]>([]);
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<string, StackStatus> = {};
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<StackHit>(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 };
}