mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user