import classNames from 'classnames'; import type { KeyboardEvent, ReactNode } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useActiveRequestId } from '../hooks/useActiveRequestId'; import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId'; import { useAppRoutes } from '../hooks/useAppRoutes'; import { useOpenWorkspace } from '../hooks/useOpenWorkspace'; import { useRecentRequests } from '../hooks/useRecentRequests'; import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces'; import { useRequests } from '../hooks/useRequests'; import { useWorkspaces } from '../hooks/useWorkspaces'; import { fallbackRequestName } from '../lib/fallbackRequestName'; import { Heading } from './core/Heading'; import { HttpMethodTag } from './core/HttpMethodTag'; import { Icon } from './core/Icon'; import { PlainInput } from './core/PlainInput'; import { HStack } from './core/Stacks'; interface CommandPaletteGroup { key: string; label: ReactNode; items: CommandPaletteItem[]; } type CommandPaletteItem = { key: string; onSelect: () => void; } & ({ searchText: string; label: ReactNode } | { label: string }); export function CommandPalette({ onClose }: { onClose: () => void }) { const [selectedItemKey, setSelectedItemKey] = useState(null); const routes = useAppRoutes(); const activeEnvironmentId = useActiveEnvironmentId(); const activeRequestId = useActiveRequestId(); const activeWorkspaceId = useActiveWorkspaceId(); const workspaces = useWorkspaces(); const recentWorkspaces = useRecentWorkspaces(); const requests = useRequests(); const recentRequests = useRecentRequests(); const [command, setCommand] = useState(''); const openWorkspace = useOpenWorkspace(); const sortedRequests = useMemo(() => { return [...requests].sort((a, b) => { const aRecentIndex = recentRequests.indexOf(a.id); const bRecentIndex = recentRequests.indexOf(b.id); if (aRecentIndex >= 0 && bRecentIndex >= 0) { return aRecentIndex - bRecentIndex; } else if (aRecentIndex >= 0 && bRecentIndex === -1) { return -1; } else if (aRecentIndex === -1 && bRecentIndex >= 0) { return 1; } else { return a.createdAt.localeCompare(b.createdAt); } }); }, [recentRequests, requests]); const sortedWorkspaces = useMemo(() => { return [...workspaces].sort((a, b) => { const aRecentIndex = recentWorkspaces.indexOf(a.id); const bRecentIndex = recentWorkspaces.indexOf(b.id); if (aRecentIndex >= 0 && bRecentIndex >= 0) { return aRecentIndex - bRecentIndex; } else if (aRecentIndex >= 0 && bRecentIndex === -1) { return -1; } else if (aRecentIndex === -1 && bRecentIndex >= 0) { return 1; } else { return a.createdAt.localeCompare(b.createdAt); } }); }, [recentWorkspaces, workspaces]); const groups = useMemo(() => { const requestGroup: CommandPaletteGroup = { key: 'requests', label: 'Requests', items: [], }; for (const r of sortedRequests.slice(0, 4)) { if (r.id === activeRequestId) { continue; } requestGroup.items.push({ key: `switch-request-${r.id}`, searchText: `${r.method} ${r.name}`, label: (
{fallbackRequestName(r)}
), onSelect: () => { return routes.navigate('request', { workspaceId: r.workspaceId, requestId: r.id, environmentId: activeEnvironmentId ?? undefined, }); }, }); } const workspaceGroup: CommandPaletteGroup = { key: 'workspaces', label: 'Workspaces', items: [], }; for (const w of sortedWorkspaces.slice(0, 4)) { if (w.id === activeWorkspaceId) { continue; } workspaceGroup.items.push({ key: `switch-workspace-${w.id}`, label: w.name, onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }), }); } return [requestGroup, workspaceGroup]; }, [ activeEnvironmentId, activeRequestId, activeWorkspaceId, openWorkspace, routes, sortedRequests, sortedWorkspaces, ]); const filteredGroups = useMemo( () => groups .map((g) => { g.items = g.items.filter((v) => { const s = 'searchText' in v ? v.searchText : v.label; return s.toLowerCase().includes(command.toLowerCase()); }); return g; }) .filter((g) => g.items.length > 0), [command, groups], ); const handleSelectAndClose = useCallback( (cb: () => void) => { onClose(); cb(); }, [onClose], ); const { allItems, selectedItem } = useMemo(() => { const allItems = filteredGroups.flatMap((g) => g.items); let selectedItem = allItems.find((i) => i.key === selectedItemKey) ?? null; if (selectedItem == null) { selectedItem = allItems[0] ?? null; } return { selectedItem, allItems }; }, [filteredGroups, selectedItemKey]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { const index = allItems.findIndex((v) => v.key === selectedItem?.key); if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { const next = allItems[index + 1]; setSelectedItemKey(next?.key ?? null); } else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) { const prev = allItems[index - 1]; setSelectedItemKey(prev?.key ?? null); } else if (e.key === 'Enter') { const selected = allItems[index]; setSelectedItemKey(selected?.key ?? null); if (selected) { handleSelectAndClose(selected.onSelect); } } }, [allItems, handleSelectAndClose, selectedItem?.key], ); return (
} name="command" label="Command" placeholder="Search or type a command" className="font-sans !text-base" defaultValue="" onChange={setCommand} onKeyDownCapture={handleKeyDown} />
{filteredGroups.map((g) => (
{g.label} {g.items.map((v) => ( handleSelectAndClose(v.onSelect)} > {v.label} ))}
))}
); } function CommandPaletteItem({ children, active, onClick, }: { children: ReactNode; active: boolean; onClick: () => void; }) { return ( ); }