import classNames from 'classnames'; import { search } from 'fast-fuzzy'; import type { KeyboardEvent, ReactNode } from 'react'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { useActiveCookieJar } from '../hooks/useActiveCookieJar'; import { useActiveEnvironment } from '../hooks/useActiveEnvironment'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useAppRoutes } from '../hooks/useAppRoutes'; import { useCreateEnvironment } from '../hooks/useCreateEnvironment'; import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest'; import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest'; import { useCreateWorkspace } from '../hooks/useCreateWorkspace'; import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDeleteRequest } from '../hooks/useDeleteRequest'; import { useEnvironments } from '../hooks/useEnvironments'; import type { HotkeyAction } from '../hooks/useHotKey'; import { useHotKey } from '../hooks/useHotKey'; import { useHttpRequestActions } from '../hooks/useHttpRequestActions'; import { useOpenWorkspace } from '../hooks/useOpenWorkspace'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useRecentRequests } from '../hooks/useRecentRequests'; import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces'; import { useRenameRequest } from '../hooks/useRenameRequest'; import { useRequests } from '../hooks/useRequests'; import { useScrollIntoView } from '../hooks/useScrollIntoView'; import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useWorkspaces } from '../hooks/useWorkspaces'; import { fallbackRequestName } from '../lib/fallbackRequestName'; import { invokeCmd } from '../lib/tauri'; import { CookieDialog } from './CookieDialog'; import { Button } from './core/Button'; import { Heading } from './core/Heading'; import { HotKey } from './core/HotKey'; import { HttpMethodTag } from './core/HttpMethodTag'; import { Icon } from './core/Icon'; import { PlainInput } from './core/PlainInput'; import { HStack } from './core/Stacks'; import { useDialog } from './DialogContext'; import { EnvironmentEditDialog } from './EnvironmentEditDialog'; interface CommandPaletteGroup { key: string; label: ReactNode; items: CommandPaletteItem[]; } type CommandPaletteItem = { key: string; onSelect: () => void; action?: HotkeyAction; } & ({ searchText: string; label: ReactNode } | { label: string }); const MAX_PER_GROUP = 8; export function CommandPalette({ onClose }: { onClose: () => void }) { const [command, setCommand] = useDebouncedState('', 150); const [selectedItemKey, setSelectedItemKey] = useState(null); const [activeEnvironment, setActiveEnvironmentId] = useActiveEnvironment(); const httpRequestActions = useHttpRequestActions(); const routes = useAppRoutes(); const workspaces = useWorkspaces(); const environments = useEnvironments(); const recentEnvironments = useRecentEnvironments(); const recentWorkspaces = useRecentWorkspaces(); const requests = useRequests(); const activeRequest = useActiveRequest(); const recentRequests = useRecentRequests(); const openWorkspace = useOpenWorkspace(); const createWorkspace = useCreateWorkspace(); const createHttpRequest = useCreateHttpRequest(); const [activeCookieJar] = useActiveCookieJar(); const createGrpcRequest = useCreateGrpcRequest(); const createEnvironment = useCreateEnvironment(); const dialog = useDialog(); const workspace = useActiveWorkspace(); const sendRequest = useSendAnyHttpRequest(); const renameRequest = useRenameRequest(activeRequest?.id ?? null); const deleteRequest = useDeleteRequest(activeRequest?.id ?? null); const [, setSidebarHidden] = useSidebarHidden(); const workspaceCommands = useMemo(() => { const commands: CommandPaletteItem[] = [ { key: 'settings.open', label: 'Open Settings', action: 'settings.show', onSelect: async () => { if (workspace == null) return; await invokeCmd('cmd_new_nested_window', { url: routes.paths.workspaceSettings({ workspaceId: workspace.id }), label: 'settings', title: 'Yaak Settings', }); }, }, { key: 'app.create', label: 'Create Workspace', onSelect: createWorkspace.mutate, }, { key: 'http_request.create', label: 'Create HTTP Request', onSelect: () => createHttpRequest.mutate({}), }, { key: 'cookies.show', label: 'Show Cookies', onSelect: async () => { dialog.show({ id: 'cookies', title: 'Manage Cookies', size: 'full', render: () => , }); }, }, { key: 'grpc_request.create', label: 'Create GRPC Request', onSelect: () => createGrpcRequest.mutate({}), }, { key: 'environment.edit', label: 'Edit Environment', action: 'environmentEditor.toggle', onSelect: () => { dialog.toggle({ id: 'environment-editor', noPadding: true, size: 'lg', className: 'h-[80vh]', render: () => , }); }, }, { key: 'environment.create', label: 'Create Environment', onSelect: createEnvironment.mutate, }, { key: 'sidebar.toggle', label: 'Toggle Sidebar', action: 'sidebar.focus', onSelect: () => setSidebarHidden((h) => !h), }, ]; if (activeRequest?.model === 'http_request') { commands.push({ key: 'http_request.send', action: 'http_request.send', label: 'Send Request', onSelect: () => sendRequest.mutateAsync(activeRequest.id), }); for (const a of httpRequestActions) { commands.push({ key: a.key, label: a.label, onSelect: () => a.call(activeRequest), }); } } if (activeRequest != null) { commands.push({ key: 'http_request.rename', label: 'Rename Request', onSelect: renameRequest.mutate, }); commands.push({ key: 'http_request.delete', label: 'Delete Request', onSelect: deleteRequest.mutate, }); } return commands.sort((a, b) => ('searchText' in a ? a.searchText : a.label).localeCompare( 'searchText' in b ? b.searchText : b.label, ), ); }, [ activeCookieJar?.id, activeEnvironment, activeRequest, createEnvironment.mutate, createGrpcRequest, createHttpRequest, createWorkspace.mutate, deleteRequest.mutate, dialog, httpRequestActions, renameRequest.mutate, routes.paths, sendRequest, setSidebarHidden, workspace, ]); 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 sortedEnvironments = useMemo(() => { return [...environments].sort((a, b) => { const aRecentIndex = recentEnvironments.indexOf(a.id); const bRecentIndex = recentEnvironments.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); } }); }, [environments, recentEnvironments]); 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 actionsGroup: CommandPaletteGroup = { key: 'actions', label: 'Actions', items: workspaceCommands, }; const requestGroup: CommandPaletteGroup = { key: 'requests', label: 'Requests', items: [], }; for (const r of sortedRequests) { requestGroup.items.push({ key: `switch-request-${r.id}`, searchText: fallbackRequestName(r), label: (
{fallbackRequestName(r)}
), onSelect: () => { return routes.navigate('request', { workspaceId: r.workspaceId, requestId: r.id, environmentId: activeEnvironment?.id, }); }, }); } const environmentGroup: CommandPaletteGroup = { key: 'environments', label: 'Environments', items: [], }; for (const e of sortedEnvironments) { if (e.id === activeEnvironment?.id) { continue; } environmentGroup.items.push({ key: `switch-environment-${e.id}`, label: e.name, onSelect: () => setActiveEnvironmentId(e.id), }); } const workspaceGroup: CommandPaletteGroup = { key: 'workspaces', label: 'Workspaces', items: [], }; for (const w of sortedWorkspaces) { workspaceGroup.items.push({ key: `switch-workspace-${w.id}`, label: w.name, onSelect: () => openWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }), }); } return [actionsGroup, requestGroup, environmentGroup, workspaceGroup]; }, [ workspaceCommands, sortedRequests, routes, activeEnvironment, sortedEnvironments, setActiveEnvironmentId, sortedWorkspaces, openWorkspace, ]); const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]); useEffect(() => { setSelectedItemKey(null); }, [command]); const { filteredGroups, filteredAllItems } = useMemo(() => { const result = command ? search(command, allItems, { threshold: 0.5, keySelector: (v) => ('searchText' in v ? v.searchText : v.label), }) : allItems; const filteredGroups = groups .map((g) => { g.items = result.filter((i) => g.items.includes(i)).slice(0, MAX_PER_GROUP); return g; }) .filter((g) => g.items.length > 0); const filteredAllItems = filteredGroups.flatMap((g) => g.items); return { filteredAllItems, filteredGroups }; }, [allItems, command, groups]); const handleSelectAndClose = useCallback( (cb: () => void) => { onClose(); cb(); }, [onClose], ); const selectedItem = useMemo(() => { let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null; if (selectedItem == null) { selectedItem = filteredAllItems[0] ?? null; } return selectedItem; }, [filteredAllItems, selectedItemKey]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key); if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { const next = filteredAllItems[index + 1] ?? filteredAllItems[0]; setSelectedItemKey(next?.key ?? null); } else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) { const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1]; setSelectedItemKey(prev?.key ?? null); } else if (e.key === 'Enter') { const selected = filteredAllItems[index]; setSelectedItemKey(selected?.key ?? null); if (selected) { handleSelectAndClose(selected.onSelect); } } }, [filteredAllItems, handleSelectAndClose, selectedItem?.key], ); return (
} name="command" label="Command" placeholder="Search or type a command" className="font-sans !text-base" defaultValue={command} onChange={setCommand} onKeyDownCapture={handleKeyDown} />
{filteredGroups.map((g) => (
{g.label} {g.items.map((v) => ( handleSelectAndClose(v.onSelect)} rightSlot={ v.action && } > {v.label} ))}
))}
); } function CommandPaletteItem({ children, active, onClick, rightSlot, }: { children: ReactNode; active: boolean; onClick: () => void; rightSlot?: ReactNode; }) { const ref = useRef(null); useScrollIntoView(ref.current, active); return ( ); } function CommandPaletteAction({ action, onAction, }: { action: HotkeyAction; onAction: () => void; }) { useHotKey(action, onAction); return ; }