diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index eba280f..57d1c3a 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -68,6 +68,13 @@ export function App(): JSX.Element { }, [setUpdateInfo]); const [paletteOpen, setPaletteOpen] = useState(false); + const [sidebarVisible, setSidebarVisible] = useState(() => { + if (typeof window === 'undefined') return true; + return localStorage.getItem('sidebar:visible') !== 'false'; + }); + useEffect(() => { + localStorage.setItem('sidebar:visible', String(sidebarVisible)); + }, [sidebarVisible]); const shortcuts = useMemo( () => [ @@ -97,6 +104,11 @@ export function App(): JSX.Element { handler: () => activeTabId && duplicateTab(activeTabId), }, { combo: 'mod+l', description: 'Focus URL bar', handler: () => focusUrl() }, + { + combo: 'mod+b', + description: 'Toggle sidebar', + handler: () => setSidebarVisible((v) => !v), + }, { combo: 'mod+shift+f', description: 'Focus collection search', @@ -168,6 +180,7 @@ export function App(): JSX.Element { tabs, isRepo, paletteOpen, + sidebarVisible, ], ); useShortcuts(shortcuts); @@ -234,11 +247,14 @@ export function App(): JSX.Element {
+ {workspace && ( - )} - {running && ( - - )} - {finished && ( - - )} - -
- - - {/* Config form — hidden once run is in flight */} - {!running && !finished && ( -
- - - setTotal(Math.max(1, parseInt(e.target.value, 10) || 1)) - } - className="field w-full" - /> - - - - setConcurrency( - Math.max(1, parseInt(e.target.value, 10) || 1), - ) - } - className="field w-full" - /> - - - - setDelay(Math.max(0, parseInt(e.target.value, 10) || 0)) - } - className="field w-full" - /> - - - setExpectStatus(e.target.value)} - placeholder="200" - className="field w-full font-mono" - /> - -
- - setExpectBody(e.target.value)} - placeholder='{"success":true}' - className="field w-full font-mono" - /> - -
-
- )} - - {/* Metrics */} - {progress && ( -
-
-
-
-
- - = 95 - ? 'ok' - : parseFloat(successRate) >= 80 - ? 'warn' - : 'err' - } - /> - - - - - - - - - - -
- - {(statusEntries.length > 0 || errorEntries.length > 0) && ( -
- {statusEntries.map(([status, count]) => ( - - {status} · {count} - - ))} - {errorEntries.map(([kind, count]) => ( - - {kind} · {count} - - ))} -
- )} -
- )} - - {/* Console */} -
- {events.length === 0 && !progress && ( -
- Configure the run above and press Start. -
- )} - {events.map((ev) => ( - - ))} - {finished && ( -
- — Done in {(progress!.elapsedMs / 1000).toFixed(2)}s ·{' '} - {progress!.succeeded} ok / {progress!.sent} sent ·{' '} - {successRate}% success -
- )} -
- - - - ); -} - -function EventLine({ event }: { event: LoadEvent }): JSX.Element { - const prefix = event.valid - ? '✓' - : event.errorKind - ? '✗' - : '!'; - const color = event.valid - ? 'text-status-ok' - : event.errorKind - ? 'text-method-delete' - : 'text-status-clientError'; - return ( -
- {prefix} - #{String(event.iteration).padStart(5, '0')} - - {event.status === 0 ? '---' : event.status} - {' '} - {event.durationMs}ms - {event.errorMessage && ( - {event.errorMessage} - )} - {!event.valid && !event.errorKind && ( - validation failed - )} -
- ); -} - -function Field({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}): JSX.Element { - return ( -
- - {children} - {hint &&
{hint}
} -
- ); -} - -function Metric({ - label, - value, - tone, -}: { - label: string; - value: string; - tone?: 'ok' | 'warn' | 'err'; -}): JSX.Element { - const color = - tone === 'ok' - ? 'text-status-ok' - : tone === 'warn' - ? 'text-status-clientError' - : tone === 'err' - ? 'text-method-delete' - : 'text-ink-1'; - return ( -
-
{label}
-
{value}
-
- ); -} - -function buildRequestFromBuilder(builder: BuilderState, name: string) { - const headers: Record = {}; - for (const row of builder.headers) { - if (row.enabled && row.key.trim()) headers[row.key.trim()] = row.value; - } - const request = { - scrapeman: FORMAT_VERSION, - meta: { name }, - method: builder.method, - url: builder.url, - } as const; - const out: Record = { ...request }; - if (Object.keys(headers).length > 0) out['headers'] = headers; - if (builder.bodyType !== 'none' && builder.body.trim().length > 0) { - out['body'] = { type: builder.bodyType, content: builder.body }; - } - if (builder.auth.type !== 'none') out['auth'] = builder.auth; - if (builder.settings.proxy.enabled && builder.settings.proxy.url.trim()) { - out['proxy'] = builder.settings.proxy; - } - if (builder.settings.scrapeDo.enabled && builder.settings.scrapeDo.token.trim()) { - out['scrapeDo'] = builder.settings.scrapeDo; - } - return out as never; -} diff --git a/apps/desktop/src/renderer/src/components/LoadTestPanel.tsx b/apps/desktop/src/renderer/src/components/LoadTestPanel.tsx new file mode 100644 index 0000000..c74b890 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/LoadTestPanel.tsx @@ -0,0 +1,418 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { FORMAT_VERSION } from '@scrapeman/shared-types'; +import type { LoadEvent, LoadProgress } from '@scrapeman/shared-types'; +import { bridge } from '../bridge.js'; +import { useAppStore, type BuilderState } from '../store.js'; + +/** + * Inline load test panel rendered inside the Request Builder tab bar. + * Replaces the former modal LoadTestDialog — all IPC logic is identical. + */ +export function LoadTestPanel(): JSX.Element { + const activeTab = useAppStore((s) => s.tabs.find((t) => t.id === s.activeTabId) ?? null); + const workspace = useAppStore((s) => s.workspace); + + const [total, setTotal] = useState(100); + const [concurrency, setConcurrency] = useState(10); + const [delay, setDelay] = useState(0); + const [expectStatus, setExpectStatus] = useState('200'); + const [expectBody, setExpectBody] = useState(''); + + const [runId, setRunId] = useState(null); + const [progress, setProgress] = useState(null); + const [events, setEvents] = useState([]); + const [starting, setStarting] = useState(false); + + const consoleRef = useRef(null); + + // Reset run state when the active request tab changes so stale load + // test data from a previous request doesn't bleed through. + const activeTabId = activeTab?.id ?? null; + useEffect(() => { + setRunId(null); + setProgress(null); + setEvents([]); + setStarting(false); + }, [activeTabId]); + + useEffect(() => { + return bridge.onLoadProgress((p) => { + if (!runId || p.runId !== runId) return; + setProgress(p); + if (p.lastEvent) { + setEvents((prev) => { + const next = [...prev, p.lastEvent!]; + return next.length > 500 ? next.slice(-500) : next; + }); + } + }); + }, [runId]); + + // Auto-scroll console to bottom on new events. + useEffect(() => { + if (consoleRef.current) { + consoleRef.current.scrollTop = consoleRef.current.scrollHeight; + } + }, [events.length]); + + const start = async (): Promise => { + if (!activeTab) return; + setStarting(true); + setEvents([]); + setProgress(null); + try { + const request = buildRequestFromBuilder(activeTab.builder, activeTab.name); + const id = await bridge.loadStart({ + request, + ...(workspace?.path ? { workspacePath: workspace.path } : {}), + total, + concurrency, + ...(delay > 0 ? { perIterDelayMs: delay } : {}), + validator: { + ...(expectStatus.trim() + ? { + expectStatus: expectStatus + .split(',') + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)), + } + : {}), + ...(expectBody.trim() ? { expectBodyContains: expectBody } : {}), + }, + }); + setRunId(id); + } finally { + setStarting(false); + } + }; + + const stop = async (): Promise => { + if (runId) await bridge.loadStop(runId); + }; + + const reset = (): void => { + setRunId(null); + setProgress(null); + setEvents([]); + }; + + const running = runId !== null && progress !== null && !progress.done; + const finished = progress !== null && progress.done; + + const successRate = + progress && progress.sent > 0 + ? ((progress.succeeded / progress.sent) * 100).toFixed(1) + : '0.0'; + + const progressPct = progress + ? Math.min(100, (progress.sent / progress.totalTarget) * 100) + : 0; + + const statusEntries = useMemo( + () => + progress + ? Object.entries(progress.statusHistogram).sort((a, b) => + a[0].localeCompare(b[0]), + ) + : [], + [progress], + ); + + const errorEntries = useMemo( + () => (progress ? Object.entries(progress.errorKinds) : []), + [progress], + ); + + return ( +
+ {/* Header */} +
+
+
+ Load test · {activeTab?.name ?? 'current request'} +
+
+ Fires the current request N times with bounded concurrency. + Validates each response locally and tracks the success rate. +
+
+
+ {!running && !finished && ( + + )} + {running && ( + + )} + {finished && ( + + )} +
+
+ + {/* Config form — hidden once run is in flight */} + {!running && !finished && ( +
+ + + setTotal(Math.max(1, parseInt(e.target.value, 10) || 1)) + } + className="field w-full" + /> + + + + setConcurrency( + Math.max(1, parseInt(e.target.value, 10) || 1), + ) + } + className="field w-full" + /> + + + + setDelay(Math.max(0, parseInt(e.target.value, 10) || 0)) + } + className="field w-full" + /> + + + setExpectStatus(e.target.value)} + placeholder="200" + className="field w-full font-mono" + /> + +
+ + setExpectBody(e.target.value)} + placeholder='{"success":true}' + className="field w-full font-mono" + /> + +
+
+ )} + + {/* Metrics */} + {progress && ( +
+
+
+
+
+ + = 95 + ? 'ok' + : parseFloat(successRate) >= 80 + ? 'warn' + : 'err' + } + /> + + + + + + + + + + +
+ + {(statusEntries.length > 0 || errorEntries.length > 0) && ( +
+ {statusEntries.map(([status, count]) => ( + + {status} · {count} + + ))} + {errorEntries.map(([kind, count]) => ( + + {kind} · {count} + + ))} +
+ )} +
+ )} + + {/* Console */} +
+ {events.length === 0 && !progress && ( +
+ Configure the run above and press Start. +
+ )} + {events.map((ev) => ( + + ))} + {finished && ( +
+ — Done in {(progress!.elapsedMs / 1000).toFixed(2)}s ·{' '} + {progress!.succeeded} ok / {progress!.sent} sent ·{' '} + {successRate}% success +
+ )} +
+
+ ); +} + +function EventLine({ event }: { event: LoadEvent }): JSX.Element { + const prefix = event.valid + ? '✓' + : event.errorKind + ? '✗' + : '!'; + const color = event.valid + ? 'text-status-ok' + : event.errorKind + ? 'text-method-delete' + : 'text-status-clientError'; + return ( +
+ {prefix} + #{String(event.iteration).padStart(5, '0')} + + {event.status === 0 ? '---' : event.status} + {' '} + {event.durationMs}ms + {event.errorMessage && ( + {event.errorMessage} + )} + {!event.valid && !event.errorKind && ( + validation failed + )} +
+ ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}): JSX.Element { + return ( +
+ + {children} + {hint &&
{hint}
} +
+ ); +} + +function Metric({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: 'ok' | 'warn' | 'err'; +}): JSX.Element { + const color = + tone === 'ok' + ? 'text-status-ok' + : tone === 'warn' + ? 'text-status-clientError' + : tone === 'err' + ? 'text-method-delete' + : 'text-ink-1'; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function buildRequestFromBuilder(builder: BuilderState, name: string) { + const headers: Record = {}; + for (const row of builder.headers) { + if (row.enabled && row.key.trim()) headers[row.key.trim()] = row.value; + } + const request = { + scrapeman: FORMAT_VERSION, + meta: { name }, + method: builder.method, + url: builder.url, + } as const; + const out: Record = { ...request }; + if (Object.keys(headers).length > 0) out['headers'] = headers; + if (builder.bodyType !== 'none' && builder.body.trim().length > 0) { + out['body'] = { type: builder.bodyType, content: builder.body }; + } + if (builder.auth.type !== 'none') out['auth'] = builder.auth; + if (builder.settings.proxy.enabled && builder.settings.proxy.url.trim()) { + out['proxy'] = builder.settings.proxy; + } + if (builder.settings.scrapeDo.enabled && builder.settings.scrapeDo.token.trim()) { + out['scrapeDo'] = builder.settings.scrapeDo; + } + return out as never; +} diff --git a/apps/desktop/src/renderer/src/components/RequestBuilder.tsx b/apps/desktop/src/renderer/src/components/RequestBuilder.tsx index dced161..88e24cc 100644 --- a/apps/desktop/src/renderer/src/components/RequestBuilder.tsx +++ b/apps/desktop/src/renderer/src/components/RequestBuilder.tsx @@ -8,13 +8,13 @@ import { SettingsTab as SettingsTabPanel } from './SettingsTab.js'; import { AuthTab } from './AuthTab.js'; import { CodePanel } from './CodePanel.js'; import { ImportCurlDialog } from './ImportCurlDialog.js'; -import { LoadTestDialog } from './LoadTestDialog.js'; +import { LoadTestPanel } from './LoadTestPanel.js'; import { HighlightedInput } from '../ui/HighlightedInput.js'; import { CellContextMenu } from '../ui/CellContextMenu.js'; import { PromptDialog } from '../ui/Dialog.js'; import { shortcutLabel } from '../hooks/useShortcuts.js'; -type Tab = 'params' | 'headers' | 'auth' | 'body' | 'settings' | 'code'; +type Tab = 'params' | 'headers' | 'auth' | 'body' | 'settings' | 'code' | 'load'; export function RequestBuilder(): JSX.Element { const activeTab = useAppStore((s) => s.tabs.find((t) => t.id === s.activeTabId) ?? null); @@ -42,7 +42,6 @@ export function RequestBuilder(): JSX.Element { const [tab, setTab] = useState('params'); const [importOpen, setImportOpen] = useState(false); - const [loadTestOpen, setLoadTestOpen] = useState(false); const urlInputRef = useRef(null); const focusUrlTick = useAppStore((s) => s.focusUrlTick); @@ -61,7 +60,7 @@ export function RequestBuilder(): JSX.Element { const loadTestTick = useAppStore((s) => s.loadTestTick); useEffect(() => { if (loadTestTick === 0) return; - setLoadTestOpen(true); + setTab('load'); }, [loadTestTick]); if (!activeTab) { @@ -128,7 +127,7 @@ export function RequestBuilder(): JSX.Element { )}
@@ -291,6 +293,10 @@ export function RequestBuilder(): JSX.Element { />
)} + {/* Load test panel uses display:none to preserve run state across tab switches */} +
+ +
- setLoadTestOpen(false)} - /> -