Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,24 +49,35 @@ export function App(): JSX.Element {
return unsubscribe;
}, [workspace, refreshTree, loadEnvironments]);

const [paletteOpen, setPaletteOpen] = useState(false);

const shortcuts = useMemo<Shortcut[]>(
() => [
{ 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);

Expand All @@ -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 (
<div className="flex h-screen flex-col bg-bg-canvas text-ink-1">
<header
Expand Down Expand Up @@ -145,6 +168,11 @@ export function App(): JSX.Element {
</header>
<CookiesPanel open={cookiesOpen} onClose={() => setCookiesOpen(false)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
commands={commands}
/>
<div className="flex-1 overflow-hidden">
<SplitPane
orientation="horizontal"
Expand Down
114 changes: 114 additions & 0 deletions apps/desktop/src/renderer/src/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { useAppStore } from './store.js';

export interface Command {
id: string;
title: string;
section?: string;
shortcut?: string;
run: () => void | Promise<void>;
}

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<Command[]>(
() => [
{
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,
],
);
}
166 changes: 166 additions & 0 deletions apps/desktop/src/renderer/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);

useEffect(() => {
if (open) {
setQuery('');
setCursor(0);
}
}, [open]);

const filtered = useMemo<Scored[]>(() => {
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<HTMLElement>(
`[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 (
<RadixDialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 z-50 bg-ink-1/20 backdrop-blur-[2px] animate-fade-in" />
<RadixDialog.Content
onOpenAutoFocus={(e) => {
// Focus the input, not the first list item.
e.preventDefault();
const input = (e.currentTarget as HTMLElement).querySelector<HTMLInputElement>(
'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"
>
<RadixDialog.Title className="sr-only">Command palette</RadixDialog.Title>
<input
type="text"
value={query}
onChange={(e) => 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"
/>
<div
ref={listRef}
className="max-h-[50vh] overflow-y-auto py-1"
role="listbox"
>
{filtered.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-ink-4">
No commands found
</div>
) : (
filtered.map(({ command }, idx) => {
const active = idx === cursor;
return (
<div
key={command.id}
data-idx={idx}
role="option"
aria-selected={active}
onMouseMove={() => 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'
}`}
>
<div className="flex items-center gap-2">
{command.section && (
<span className="text-[10px] uppercase tracking-wide text-ink-4">
{command.section}
</span>
)}
<span>{command.title}</span>
</div>
{command.shortcut && (
<span className="font-mono text-[11px] text-ink-4">
{shortcutLabel(command.shortcut)}
</span>
)}
</div>
);
})
)}
</div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/components/RequestBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex h-full items-center justify-center">
Expand Down
Loading
Loading