diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index dcecfeb..2b641ad 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; +import { CommandPalette } from './components/CommandPalette.js'; +import { useCommands } from './commands.js'; import { Sidebar } from './components/Sidebar.js'; import { RequestBuilder } from './components/RequestBuilder.js'; import { ResponseViewer } from './components/ResponseViewer.js'; @@ -47,24 +49,35 @@ export function App(): JSX.Element { return unsubscribe; }, [workspace, refreshTree, loadEnvironments]); + const [paletteOpen, setPaletteOpen] = useState(false); + const shortcuts = useMemo( () => [ - { combo: 'mod+t', description: 'New tab', handler: () => newTab() }, - { - combo: 'mod+w', - description: 'Close tab', - handler: () => activeTabId && closeTab(activeTabId), - }, { - combo: 'mod+d', - description: 'Duplicate tab', - handler: () => activeTabId && duplicateTab(activeTabId), + combo: 'mod+k', + description: 'Command palette', + handler: () => setPaletteOpen((v) => !v), }, - { combo: 'mod+l', description: 'Focus URL bar', handler: () => focusUrl() }, - { combo: 'mod+enter', description: 'Send request', handler: () => void send() }, - { combo: 'mod+s', description: 'Save request', handler: () => void saveOrPrompt() }, + ...(paletteOpen + ? [] + : [ + { combo: 'mod+t', description: 'New tab', handler: () => newTab() }, + { + combo: 'mod+w', + description: 'Close tab', + handler: () => activeTabId && closeTab(activeTabId), + }, + { + combo: 'mod+d', + description: 'Duplicate tab', + handler: () => activeTabId && duplicateTab(activeTabId), + }, + { combo: 'mod+l', description: 'Focus URL bar', handler: () => focusUrl() }, + { combo: 'mod+enter', description: 'Send request', handler: () => void send() }, + { combo: 'mod+s', description: 'Save request', handler: () => void saveOrPrompt() }, + ]), ], - [newTab, closeTab, duplicateTab, activeTabId, send, saveOrPrompt, focusUrl], + [newTab, closeTab, duplicateTab, activeTabId, send, saveOrPrompt, focusUrl, paletteOpen], ); useShortcuts(shortcuts); @@ -89,6 +102,16 @@ export function App(): JSX.Element { localStorage.setItem('split:orientation', splitOrientation); }, [splitOrientation]); + const commandExtras = useMemo( + () => ({ + toggleTheme, + toggleSplit: () => + setSplitOrientation((o) => (o === 'horizontal' ? 'vertical' : 'horizontal')), + }), + [toggleTheme], + ); + const commands = useCommands(commandExtras); + return (
setCookiesOpen(false)} /> setSettingsOpen(false)} /> + setPaletteOpen(false)} + commands={commands} + />
void | Promise; +} + +export interface CommandExtras { + toggleTheme: () => void; + toggleSplit: () => void; +} + +export function useCommands(extras: CommandExtras): Command[] { + const send = useAppStore((s) => s.send); + const saveOrPrompt = useAppStore((s) => s.saveOrPrompt); + const newTab = useAppStore((s) => s.newTab); + const closeTab = useAppStore((s) => s.closeTab); + const duplicateTab = useAppStore((s) => s.duplicateTab); + const activeTabId = useAppStore((s) => s.activeTabId); + const focusUrl = useAppStore((s) => s.focusUrl); + const openImportCurl = useAppStore((s) => s.openImportCurl); + const openLoadTest = useAppStore((s) => s.openLoadTest); + + return useMemo( + () => [ + { + id: 'request.send', + title: 'Send request', + section: 'Request', + shortcut: 'mod+enter', + run: () => void send(), + }, + { + id: 'request.save', + title: 'Save / Save as', + section: 'Request', + shortcut: 'mod+s', + run: () => void saveOrPrompt(), + }, + { + id: 'request.import-curl', + title: 'Import curl', + section: 'Request', + run: () => openImportCurl(), + }, + { + id: 'request.load-test', + title: 'Run load test', + section: 'Request', + run: () => openLoadTest(), + }, + { + id: 'tab.new', + title: 'New tab', + section: 'Tabs', + shortcut: 'mod+t', + run: () => newTab(), + }, + { + id: 'tab.close', + title: 'Close tab', + section: 'Tabs', + shortcut: 'mod+w', + run: () => { + if (activeTabId) closeTab(activeTabId); + }, + }, + { + id: 'tab.duplicate', + title: 'Duplicate tab', + section: 'Tabs', + shortcut: 'mod+d', + run: () => { + if (activeTabId) duplicateTab(activeTabId); + }, + }, + { + id: 'view.focus-url', + title: 'Focus URL bar', + section: 'View', + shortcut: 'mod+l', + run: () => focusUrl(), + }, + { + id: 'view.toggle-theme', + title: 'Toggle theme (light / dark)', + section: 'View', + run: () => extras.toggleTheme(), + }, + { + id: 'view.toggle-split', + title: 'Toggle split orientation', + section: 'View', + run: () => extras.toggleSplit(), + }, + ], + [ + send, + saveOrPrompt, + newTab, + closeTab, + duplicateTab, + activeTabId, + focusUrl, + openImportCurl, + openLoadTest, + extras, + ], + ); +} diff --git a/apps/desktop/src/renderer/src/components/CommandPalette.tsx b/apps/desktop/src/renderer/src/components/CommandPalette.tsx new file mode 100644 index 0000000..368d60d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/CommandPalette.tsx @@ -0,0 +1,166 @@ +import * as RadixDialog from '@radix-ui/react-dialog'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { Command } from '../commands.js'; +import { shortcutLabel } from '../hooks/useShortcuts.js'; + +interface Props { + open: boolean; + onClose: () => void; + commands: Command[]; +} + +interface Scored { + command: Command; + score: number; +} + +// All query characters must appear in order inside the title (case-insensitive). +// Higher score = better match. Exact prefix > word prefix > earlier position. +function score(title: string, query: string): number | null { + if (!query) return 0; + const t = title.toLowerCase(); + const q = query.toLowerCase(); + if (t.startsWith(q)) return 1000 - t.length; + const wordStart = ` ${t}`.indexOf(` ${q}`); + if (wordStart >= 0) return 800 - wordStart; + + let ti = 0; + let firstIdx = -1; + for (let qi = 0; qi < q.length; qi++) { + const ch = q[qi]!; + const found = t.indexOf(ch, ti); + if (found < 0) return null; + if (firstIdx < 0) firstIdx = found; + ti = found + 1; + } + if (t.includes(q)) return 500 - t.indexOf(q); + return 200 - firstIdx; +} + +export function CommandPalette({ open, onClose, commands }: Props): JSX.Element { + const [query, setQuery] = useState(''); + const [cursor, setCursor] = useState(0); + const listRef = useRef(null); + + useEffect(() => { + if (open) { + setQuery(''); + setCursor(0); + } + }, [open]); + + const filtered = useMemo(() => { + const out: Scored[] = []; + for (const command of commands) { + const s = score(command.title, query); + if (s === null) continue; + out.push({ command, score: s }); + } + out.sort((a, b) => b.score - a.score); + return out; + }, [commands, query]); + + useEffect(() => { + setCursor(0); + }, [query]); + + useEffect(() => { + const el = listRef.current?.querySelector( + `[data-idx="${cursor}"]`, + ); + el?.scrollIntoView({ block: 'nearest' }); + }, [cursor]); + + const runAt = (idx: number): void => { + const hit = filtered[idx]; + if (!hit) return; + onClose(); + void hit.command.run(); + }; + + const onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'ArrowDown' || e.key === 'Tab') { + e.preventDefault(); + setCursor((c) => (filtered.length === 0 ? 0 : (c + 1) % filtered.length)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setCursor((c) => + filtered.length === 0 ? 0 : (c - 1 + filtered.length) % filtered.length, + ); + } else if (e.key === 'Enter') { + e.preventDefault(); + runAt(cursor); + } + }; + + return ( + !next && onClose()}> + + + { + // Focus the input, not the first list item. + e.preventDefault(); + const input = (e.currentTarget as HTMLElement).querySelector( + 'input', + ); + input?.focus(); + }} + className="fixed left-1/2 top-[20%] z-50 w-[560px] -translate-x-1/2 overflow-hidden rounded-lg border border-line bg-bg-canvas shadow-popover animate-slide-down-fade" + > + Command palette + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Type a command..." + className="w-full border-0 border-b border-line bg-transparent px-4 py-3 text-sm text-ink-1 outline-none placeholder:text-ink-4" + /> +
+ {filtered.length === 0 ? ( +
+ No commands found +
+ ) : ( + filtered.map(({ command }, idx) => { + const active = idx === cursor; + return ( +
setCursor(idx)} + onClick={() => runAt(idx)} + className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${ + active ? 'bg-bg-hover text-ink-1' : 'text-ink-2' + }`} + > +
+ {command.section && ( + + {command.section} + + )} + {command.title} +
+ {command.shortcut && ( + + {shortcutLabel(command.shortcut)} + + )} +
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/RequestBuilder.tsx b/apps/desktop/src/renderer/src/components/RequestBuilder.tsx index 411b225..e8881d6 100644 --- a/apps/desktop/src/renderer/src/components/RequestBuilder.tsx +++ b/apps/desktop/src/renderer/src/components/RequestBuilder.tsx @@ -48,6 +48,18 @@ export function RequestBuilder(): JSX.Element { urlInputRef.current?.select(); }, [focusUrlTick]); + const importCurlTick = useAppStore((s) => s.importCurlTick); + useEffect(() => { + if (importCurlTick === 0) return; + setImportOpen(true); + }, [importCurlTick]); + + const loadTestTick = useAppStore((s) => s.loadTestTick); + useEffect(() => { + if (loadTestTick === 0) return; + setLoadTestOpen(true); + }, [loadTestTick]); + if (!activeTab) { return (
diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 59a1dc3..035c8a6 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -120,6 +120,12 @@ interface AppState { focusUrlTick: number; focusUrl: () => void; + // Ticks bumped by the command palette to open dialogs owned by RequestBuilder. + importCurlTick: number; + openImportCurl: () => void; + loadTestTick: number; + openLoadTest: () => void; + newTab: () => void; closeTab: (id: string) => void; duplicateTab: (id: string) => void; @@ -442,6 +448,8 @@ export const useAppStore = create((set, get) => { activeTabId: null, saveDialogOpen: false, focusUrlTick: 0, + importCurlTick: 0, + loadTestTick: 0, loadRecents: async () => { const recents = await bridge.workspaceList(); @@ -477,6 +485,8 @@ export const useAppStore = create((set, get) => { }, focusUrl: () => set({ focusUrlTick: get().focusUrlTick + 1 }), + openImportCurl: () => set({ importCurlTick: get().importCurlTick + 1 }), + openLoadTest: () => set({ loadTestTick: get().loadTestTick + 1 }), newTab: () => { const tab = emptyDraftTab();