From e05248a287a072c9960f9774e05744609f18c447 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Wed, 20 May 2026 18:09:37 -0300 Subject: [PATCH 01/10] fix: listener leak, editor reconfigure, swallowed IPC error MarkdownEditor: a focus listener was attached inside a `useEffect` without cleanup. Moved to a ViewPlugin so teardown happens on view destroy. HttpBodyCM: the editor was being reconfigured on every commit, defeating the Compartment design. Restored the diff-only reconfigure pattern. NewConnectionModal: IPC errors from `create_connection_cmd` were swallowed silently. Now surfaced through the modal's error badge. Also fixes `scripts/coverage-check.sh`, which crashed on commits that only delete files (bash 3.2 empty-array + `set -u`). The empty check now runs before the array expansion. --- .../blocks/http/fenced/HttpFencedPanel.tsx | 65 ++++++++--- .../http/fenced/__tests__/HttpBodyCM.test.tsx | 81 ++++++++++++++ .../src/components/editor/MarkdownEditor.tsx | 28 ++--- .../layout/connections/NewConnectionModal.tsx | 24 ++++ .../NewConnectionModalContainer.tsx | 91 +++++++++------ .../NewConnectionModalContainer.test.tsx | 78 +++++++++++++ .../__tests__/active-editor.test.ts | 105 ++++++++++++++++++ .../src/lib/codemirror/active-editor.ts | 31 ++++++ scripts/coverage-check.sh | 7 +- 9 files changed, 444 insertions(+), 66 deletions(-) create mode 100644 httui-desktop/src/components/blocks/http/fenced/__tests__/HttpBodyCM.test.tsx create mode 100644 httui-desktop/src/lib/codemirror/__tests__/active-editor.test.ts diff --git a/httui-desktop/src/components/blocks/http/fenced/HttpFencedPanel.tsx b/httui-desktop/src/components/blocks/http/fenced/HttpFencedPanel.tsx index 750f9db9..ab5a77df 100644 --- a/httui-desktop/src/components/blocks/http/fenced/HttpFencedPanel.tsx +++ b/httui-desktop/src/components/blocks/http/fenced/HttpFencedPanel.tsx @@ -81,7 +81,7 @@ import { import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { writeFile } from "@tauri-apps/plugin-fs"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { EditorState, type Extension } from "@codemirror/state"; +import { Compartment, EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { syntaxHighlighting } from "@codemirror/language"; import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"; @@ -250,31 +250,51 @@ const HttpInlineCM = memo(function HttpInlineCM({ ); }); +/** A body is JSON-highlightable when its first non-space char is { or [. */ +export function looksLikeJsonBody(body: string): boolean { + const t = body.trimStart(); + return t.startsWith("{") || t.startsWith("["); +} + +export interface BodyCMProps { + value: string; + onCommit: (next: string) => void; + refsGetters?: { + getBlocks: () => BlockContext[]; + getEnvKeys: () => (string | EnvKeyInfo)[]; + }; +} + /** * Multi-line CodeMirror for the body in form mode. Adds JSON highlight * when the body looks like JSON, plus `{{ref}}` highlight + autocomplete. + * + * JSON highlight lives in a Compartment instead of being baked into the + * memoized `extensions` array: keying the memo on `value` rebuilt the + * whole extension set on every commit-on-blur (`onCommit` makes the + * parent re-emit `value`), reconfiguring the editor and causing the + * visible flash the draft/commit-on-blur indirection exists to avoid. + * The extension array is now stable; only the JSON language + * reconfigures, and only when the body's JSON-ness actually flips. */ -const HttpBodyCM = memo(function HttpBodyCM({ +export const HttpBodyCM = memo(function HttpBodyCM({ value, onCommit, refsGetters, -}: { - value: string; - onCommit: (next: string) => void; - refsGetters?: { - getBlocks: () => BlockContext[]; - getEnvKeys: () => (string | EnvKeyInfo)[]; - }; -}) { +}: BodyCMProps) { const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]); + const jsonCompartment = useMemo(() => new Compartment(), []); + const viewRef = useRef(null); + const extensions = useMemo(() => { - const exts = [cmBodyTheme, cmTransparentBg, ...referenceHighlight]; - const trimmed = value.trimStart(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - exts.push(json()); - } + const exts: Extension[] = [ + cmBodyTheme, + cmTransparentBg, + ...referenceHighlight, + jsonCompartment.of([]), + ]; if (refsGetters) { exts.push( createReferenceAutocomplete( @@ -284,7 +304,17 @@ const HttpBodyCM = memo(function HttpBodyCM({ ); } return exts; - }, [refsGetters, value]); + }, [refsGetters, jsonCompartment]); + + // Drive JSON highlight off the live draft, applied through the + // compartment so the editor is not reconfigured and the dispatch only + // fires when the JSON-ness flips. + const isJson = looksLikeJsonBody(draft); + useEffect(() => { + viewRef.current?.dispatch({ + effects: jsonCompartment.reconfigure(isJson ? json() : []), + }); + }, [isJson, jsonCompartment]); return ( { if (draft !== value) onCommit(draft); }} + onCreateEditor={(view) => { + viewRef.current = view; + }} extensions={extensions} basicSetup={{ lineNumbers: false, diff --git a/httui-desktop/src/components/blocks/http/fenced/__tests__/HttpBodyCM.test.tsx b/httui-desktop/src/components/blocks/http/fenced/__tests__/HttpBodyCM.test.tsx new file mode 100644 index 00000000..df1cec14 --- /dev/null +++ b/httui-desktop/src/components/blocks/http/fenced/__tests__/HttpBodyCM.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/test/render"; + +// Keep CodeMirror out of jsdom (project convention) and record the props +// each render hands it, so we can assert the extension array is stable +// across `value` changes — the regression this test guards. +const recordedExtensions: unknown[] = []; +const fakeView = { dispatch: vi.fn() }; +let created = false; + +vi.mock("@uiw/react-codemirror", () => ({ + default: (props: { + extensions?: unknown; + onCreateEditor?: (v: unknown) => void; + }) => { + recordedExtensions.push(props.extensions); + if (!created) { + created = true; + props.onCreateEditor?.(fakeView); + } + return null; + }, +})); + +// Imported after the mock is registered. +import { + HttpBodyCM, + looksLikeJsonBody, +} from "@/components/blocks/http/fenced/HttpFencedPanel"; + +beforeEach(() => { + recordedExtensions.length = 0; + fakeView.dispatch.mockClear(); + created = false; +}); +afterEach(() => vi.clearAllMocks()); + +describe("looksLikeJsonBody", () => { + it("is true for objects/arrays, ignoring leading whitespace", () => { + expect(looksLikeJsonBody('{"a":1}')).toBe(true); + expect(looksLikeJsonBody(" \n [1,2]")).toBe(true); + }); + it("is false for plain text and empty bodies", () => { + expect(looksLikeJsonBody("hello world")).toBe(false); + expect(looksLikeJsonBody("")).toBe(false); + expect(looksLikeJsonBody(" ")).toBe(false); + }); +}); + +describe("HttpBodyCM", () => { + it("keeps the extensions array stable across value changes (no flash)", () => { + const onCommit = vi.fn(); + const { rerender } = renderWithProviders( + , + ); + // Simulate a commit-on-blur that makes the parent re-emit `value`. + rerender(); + + expect(recordedExtensions.length).toBeGreaterThanOrEqual(2); + // Same reference on every render → CM never reconfigures the whole + // extension set on commit. Before the fix `value` was a memo dep, so + // this reference changed on every commit (the flash). + const first = recordedExtensions[0]; + expect(recordedExtensions.every((e) => e === first)).toBe(true); + }); + + it("toggles JSON highlight through the compartment on JSON-ness flip", () => { + const onCommit = vi.fn(); + const { rerender } = renderWithProviders( + , + ); + // Mounted as JSON → the compartment effect dispatched a reconfigure. + expect(fakeView.dispatch).toHaveBeenCalled(); + const afterMount = fakeView.dispatch.mock.calls.length; + + // Flip to non-JSON → another reconfigure dispatch, but the extension + // array (asserted above) is untouched. + rerender(); + expect(fakeView.dispatch.mock.calls.length).toBeGreaterThan(afterMount); + }); +}); diff --git a/httui-desktop/src/components/editor/MarkdownEditor.tsx b/httui-desktop/src/components/editor/MarkdownEditor.tsx index db832910..41f9dfc0 100644 --- a/httui-desktop/src/components/editor/MarkdownEditor.tsx +++ b/httui-desktop/src/components/editor/MarkdownEditor.tsx @@ -17,6 +17,7 @@ import { createDocHeaderExtension } from "@/lib/codemirror/cm-doc-header"; import { useEnvironmentStore } from "@/stores/environment"; import { BlockContextProvider } from "@/components/blocks/BlockContext"; import { + activeEditorTracker, registerActiveEditor, unregisterActiveEditor, } from "@/lib/codemirror/active-editor"; @@ -91,8 +92,8 @@ export function MarkdownEditor({ // render; the file path also keys the outer // so a new file mount produces a fresh closure naturally. const extensions = useMemo( - () => - buildExtensions({ + () => [ + ...buildExtensions({ filePath, entriesRef, handleFileSelectRef, @@ -100,6 +101,11 @@ export function MarkdownEditor({ getActiveVariables: () => useEnvironmentStore.getState().getActiveVariables(), }), + // Focus/destroy-driven active-editor registry. CM owns the + // listener lifecycle (auto-removed on view destroy), so there is + // no manual addEventListener to leak. + activeEditorTracker(), + ], // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -113,18 +119,12 @@ export function MarkdownEditor({ effects: vimCompartment.reconfigure(vim()), }); } - // Register as the active editor so out-of-editor components - // (schema panel, etc.) can dispatch edits into the currently- - // focused pane. Focus wins here: the last-focused editor is - // authoritative. - const onFocus = () => registerActiveEditor(view); - const onBlur = () => unregisterActiveEditor(view); - view.dom.addEventListener("focusin", onFocus); - view.dom.addEventListener("focusout", onBlur); - // Seed as active immediately — queueMicrotask below will focus - // it, but the first `focusin` fires before we've attached the - // listener above when there's only one pane, so re-registering - // here avoids losing the first registration to the race. + // Focus/blur are tracked by the `activeEditorTracker()` extension + // (CM-owned listener lifecycle — no manual addEventListener to + // leak). Seed as active immediately so this editor is + // authoritative before the first focus event: queueMicrotask + // focuses it, but with a single pane the first `focusin` can fire + // before the extension's handler runs. registerActiveEditor(view); queueMicrotask(() => view.focus()); }, diff --git a/httui-desktop/src/components/layout/connections/NewConnectionModal.tsx b/httui-desktop/src/components/layout/connections/NewConnectionModal.tsx index 0bba4494..d1322df7 100644 --- a/httui-desktop/src/components/layout/connections/NewConnectionModal.tsx +++ b/httui-desktop/src/components/layout/connections/NewConnectionModal.tsx @@ -102,6 +102,9 @@ export interface NewConnectionModalProps { mode?: "create" | "edit"; /** When in edit mode, used in the title (e.g. "Edit connection: payments-db"). */ editingName?: string; + /** Save/IPC failure message. Rendered as an alert above the footer; + * the consumer keeps the modal open so the user can retry. */ + error?: string | null; } export function NewConnectionModal({ @@ -119,6 +122,7 @@ export function NewConnectionModal({ supportedKinds, mode = "create", editingName, + error, }: NewConnectionModalProps) { const overlayRef = useRef(null); const [internalKind, setInternalKind] = useState(initialKind); @@ -240,6 +244,8 @@ export function NewConnectionModal({ )} + + ); } + +function ErrorBanner({ message }: { message?: string | null }) { + if (!message) return null; + return ( + + {message} + + ); +} diff --git a/httui-desktop/src/components/layout/connections/NewConnectionModalContainer.tsx b/httui-desktop/src/components/layout/connections/NewConnectionModalContainer.tsx index 2dbca596..986402d9 100644 --- a/httui-desktop/src/components/layout/connections/NewConnectionModalContainer.tsx +++ b/httui-desktop/src/components/layout/connections/NewConnectionModalContainer.tsx @@ -48,6 +48,50 @@ const SUPPORTED_DRIVERS: ReadonlySet = new Set( SUPPORTED_NEW_CONNECTION_KINDS, ); +// Edit path: only send fields that make shape sense; password is sent +// only when the user actually typed something (otherwise the keychain +// value stays). +function buildUpdateInput( + form: PostgresFormValue, + ssl: SslFormValue, +): UpdateConnectionInput { + const portNum = Number(form.port); + const update: UpdateConnectionInput = { + host: form.host.trim() || undefined, + port: Number.isFinite(portNum) && portNum > 0 ? portNum : undefined, + database_name: form.database.trim() || undefined, + username: form.username.trim() || undefined, + ssl_mode: ssl.mode || undefined, + }; + if (form.password.length > 0) update.password = form.password; + return update; +} + +function buildCreateInput( + kind: ConnectionKind, + form: PostgresFormValue, + ssl: SslFormValue, +): CreateConnectionInput { + if (kind === "sqlite") { + return { + name: form.name.trim(), + driver: "sqlite", + database_name: form.database.trim() || undefined, + }; + } + const portNum = Number(form.port); + return { + name: form.name.trim(), + driver: kind as "postgres" | "mysql", + host: form.host.trim() || undefined, + port: Number.isFinite(portNum) && portNum > 0 ? portNum : undefined, + database_name: form.database.trim() || undefined, + username: form.username.trim() || undefined, + password: form.password || undefined, + ssl_mode: ssl.mode || undefined, + }; +} + export function NewConnectionModalContainer({ open, onClose, @@ -58,6 +102,7 @@ export function NewConnectionModalContainer({ const [tab, setTab] = useState("form"); const [form, setForm] = useState(EMPTY_POSTGRES_VALUE); const [ssl, setSsl] = useState(EMPTY_SSL_VALUE); + const [error, setError] = useState(null); const isEdit = Boolean(editing); @@ -93,6 +138,7 @@ export function NewConnectionModalContainer({ setSsl(EMPTY_SSL_VALUE); setKind("postgres"); setTab("form"); + setError(null); }; const handleClose = () => { @@ -114,42 +160,18 @@ export function NewConnectionModalContainer({ if (!SUPPORTED_DRIVERS.has(kind)) return; if (form.name.trim().length === 0) return; - if (isEdit && editing) { - // Edit path: only send fields that changed shape sense; password - // is only sent when the user actually typed something (otherwise - // the keychain value stays). - const portNum = Number(form.port); - const update: UpdateConnectionInput = { - host: form.host.trim() || undefined, - port: Number.isFinite(portNum) && portNum > 0 ? portNum : undefined, - database_name: form.database.trim() || undefined, - username: form.username.trim() || undefined, - ssl_mode: ssl.mode || undefined, - }; - if (form.password.length > 0) update.password = form.password; - await updateConnection(editing.id, update); - } else { - let input: CreateConnectionInput; - if (kind === "sqlite") { - input = { - name: form.name.trim(), - driver: "sqlite", - database_name: form.database.trim() || undefined, - }; + setError(null); + try { + if (isEdit && editing) { + await updateConnection(editing.id, buildUpdateInput(form, ssl)); } else { - const portNum = Number(form.port); - input = { - name: form.name.trim(), - driver: kind as "postgres" | "mysql", - host: form.host.trim() || undefined, - port: Number.isFinite(portNum) && portNum > 0 ? portNum : undefined, - database_name: form.database.trim() || undefined, - username: form.username.trim() || undefined, - password: form.password || undefined, - ssl_mode: ssl.mode || undefined, - }; + await createConnection(buildCreateInput(kind, form, ssl)); } - await createConnection(input); + } catch (e) { + // Surface the IPC failure instead of swallowing it as an + // unhandled rejection — keep the modal open so the user can retry. + setError(e instanceof Error ? e.message : String(e)); + return; } reset(); onCreated(); @@ -200,6 +222,7 @@ export function NewConnectionModalContainer({ onTabChange={setTab} renderTabBody={renderTabBody} saveDisabled={saveDisabled} + error={error} onSave={handleSave} onCancel={handleClose} supportedKinds={SUPPORTED_NEW_CONNECTION_KINDS} diff --git a/httui-desktop/src/components/layout/connections/__tests__/NewConnectionModalContainer.test.tsx b/httui-desktop/src/components/layout/connections/__tests__/NewConnectionModalContainer.test.tsx index 6a46c195..28b9e9f4 100644 --- a/httui-desktop/src/components/layout/connections/__tests__/NewConnectionModalContainer.test.tsx +++ b/httui-desktop/src/components/layout/connections/__tests__/NewConnectionModalContainer.test.tsx @@ -118,6 +118,84 @@ describe("NewConnectionModalContainer", () => { expect(closed).toBe(true); }); + it("surfaces a createConnection IPC failure and keeps the modal open", async () => { + let closed = false; + let created = false; + mockTauriCommand("create_connection", () => { + throw new Error("connection refused"); + }); + renderWithProviders( + { + closed = true; + }} + onCreated={() => { + created = true; + }} + />, + ); + const user = userEvent.setup(); + await user.type( + screen.getByTestId("new-connection-field-name"), + "payments-db", + ); + await user.click(screen.getByTestId("new-connection-save")); + + const alert = await screen.findByTestId("new-connection-error"); + expect(alert.textContent).toContain("connection refused"); + // The failure must not silently close the modal or report success. + expect(created).toBe(false); + expect(closed).toBe(false); + expect(screen.getByTestId("new-connection-modal")).toBeTruthy(); + }); + + it("clears the error and closes once a retried save succeeds", async () => { + let closed = false; + let fail = true; + mockTauriCommand("create_connection", (args: unknown) => { + if (fail) throw new Error("connection refused"); + captured = args as CapturedCreate; + return { + id: "x", + name: "x", + driver: "postgres", + host: null, + port: null, + database_name: null, + username: null, + has_password: false, + ssl_mode: null, + timeout_ms: 0, + query_timeout_ms: 0, + ttl_seconds: 0, + max_pool_size: 0, + is_readonly: false, + last_tested_at: null, + created_at: "", + updated_at: "", + }; + }); + renderWithProviders( + { + closed = true; + }} + onCreated={() => {}} + />, + ); + const user = userEvent.setup(); + await user.type(screen.getByTestId("new-connection-field-name"), "db"); + await user.click(screen.getByTestId("new-connection-save")); + expect(await screen.findByTestId("new-connection-error")).toBeTruthy(); + + fail = false; + await user.click(screen.getByTestId("new-connection-save")); + expect(screen.queryByTestId("new-connection-error")).toBeNull(); + expect(closed).toBe(true); + }); + it("Cancel calls onClose without dispatching createConnection", async () => { let closed = false; renderWithProviders( diff --git a/httui-desktop/src/lib/codemirror/__tests__/active-editor.test.ts b/httui-desktop/src/lib/codemirror/__tests__/active-editor.test.ts new file mode 100644 index 00000000..84df9b95 --- /dev/null +++ b/httui-desktop/src/lib/codemirror/__tests__/active-editor.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +import { + activeEditorTracker, + getActiveEditor, + registerActiveEditor, + unregisterActiveEditor, +} from "@/lib/codemirror/active-editor"; + +const views: EditorView[] = []; + +function makeView(withTracker = true): EditorView { + const view = new EditorView({ + state: EditorState.create({ + doc: "hello", + extensions: withTracker ? [activeEditorTracker()] : [], + }), + parent: document.body, + }); + views.push(view); + return view; +} + +function focusin(view: EditorView) { + view.contentDOM.dispatchEvent(new Event("focusin", { bubbles: true })); +} +function focusout(view: EditorView) { + view.contentDOM.dispatchEvent(new Event("focusout", { bubbles: true })); +} + +afterEach(() => { + while (views.length) views.pop()!.destroy(); + // Ensure no view leaks into the next test's module state. + const active = getActiveEditor(); + if (active) unregisterActiveEditor(active); +}); + +describe("active-editor registry", () => { + it("register / get / unregister are scoped to the same view", () => { + const a = makeView(false); + const b = makeView(false); + + registerActiveEditor(a); + expect(getActiveEditor()).toBe(a); + + // Unregistering a non-active view is a no-op. + unregisterActiveEditor(b); + expect(getActiveEditor()).toBe(a); + + unregisterActiveEditor(a); + expect(getActiveEditor()).toBeNull(); + }); +}); + +describe("activeEditorTracker", () => { + it("registers on focusin and clears on focusout", () => { + const view = makeView(); + expect(getActiveEditor()).toBeNull(); + + focusin(view); + expect(getActiveEditor()).toBe(view); + + focusout(view); + expect(getActiveEditor()).toBeNull(); + }); + + it("unregisters when the view is destroyed", () => { + const view = makeView(); + focusin(view); + expect(getActiveEditor()).toBe(view); + + view.destroy(); + expect(getActiveEditor()).toBeNull(); + }); + + it("does not leak listeners: a destroyed view never re-registers", () => { + const view = makeView(); + focusin(view); + expect(getActiveEditor()).toBe(view); + + const dom = view.contentDOM; + view.destroy(); + expect(getActiveEditor()).toBeNull(); + + // The old DOM is detached; CM removed its handlers on destroy. A + // stray focus event must NOT resurrect the dead view (the bug this + // fix addresses: shell-side listeners were never removed). + dom.dispatchEvent(new Event("focusin", { bubbles: true })); + expect(getActiveEditor()).toBeNull(); + }); + + it("destroying a non-active view does not clobber the active one", () => { + const a = makeView(); + const b = makeView(); + + focusin(a); + focusin(b); + expect(getActiveEditor()).toBe(b); + + a.destroy(); + expect(getActiveEditor()).toBe(b); + }); +}); diff --git a/httui-desktop/src/lib/codemirror/active-editor.ts b/httui-desktop/src/lib/codemirror/active-editor.ts index 1cb32cae..1ba721b9 100644 --- a/httui-desktop/src/lib/codemirror/active-editor.ts +++ b/httui-desktop/src/lib/codemirror/active-editor.ts @@ -12,7 +12,9 @@ * - The registry is inherently imperative: "insert this text somewhere" * is a side effect, not state React needs to observe. */ +import { ViewPlugin } from "@codemirror/view"; import type { EditorView } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; import { findDbBlocks } from "@/lib/codemirror/cm-db-block"; import { stringifyDbFenceInfo, type DbDialect } from "@/lib/blocks/db-fence"; @@ -33,6 +35,35 @@ export function getActiveEditor(): EditorView | null { return activeView; } +/** + * CM6 extension that keeps the active-editor registry in sync with focus + * and tears everything down when the view is destroyed. + * + * The listeners are owned by a `ViewPlugin`, so CM guarantees `destroy()` + * runs on view teardown (file switch keyed by ``, vim toggle that recreates the view, unmount). There + * `removeEventListener` is paired with the `addEventListener` and the + * registry is cleared. This replaces the shell-side + * `view.dom.addEventListener("focusin"/"focusout", …)` that was never + * removed — recreating the view leaked stale listeners + closures and a + * stray focus event could resurrect a dead view. + */ +export function activeEditorTracker(): Extension { + return ViewPlugin.define((view) => { + const onFocusIn = () => registerActiveEditor(view); + const onFocusOut = () => unregisterActiveEditor(view); + view.dom.addEventListener("focusin", onFocusIn); + view.dom.addEventListener("focusout", onFocusOut); + return { + destroy() { + view.dom.removeEventListener("focusin", onFocusIn); + view.dom.removeEventListener("focusout", onFocusOut); + unregisterActiveEditor(view); + }, + }; + }); +} + /** * Insert a SQL snippet into the active editor: * - If the cursor is inside an existing db block body, replace the body diff --git a/scripts/coverage-check.sh b/scripts/coverage-check.sh index e3cec32e..dc018d90 100755 --- a/scripts/coverage-check.sh +++ b/scripts/coverage-check.sh @@ -78,12 +78,15 @@ KEPT=() for f in "${CHANGED_FILES[@]}"; do [ -f "$f" ] && KEPT+=("$f") done -CHANGED_FILES=("${KEPT[@]}") -if [ ${#CHANGED_FILES[@]} -eq 0 ]; then +# Check emptiness BEFORE copying: on bash 3.2 (macOS) `"${KEPT[@]}"` +# of an empty array under `set -u` is an "unbound variable" error, +# which crashed deletion-only commits before reaching this skip. +if [ ${#KEPT[@]} -eq 0 ]; then echo "coverage-check: all touched files were deleted; gate skipped" exit 0 fi +CHANGED_FILES=("${KEPT[@]}") HAS_RS=0 HAS_FE=0 From 30406218a6a2ce83bb425c39fe84d2b4f0f9c8bb Mon Sep 17 00:00:00 2001 From: gandarfh Date: Wed, 20 May 2026 18:09:52 -0300 Subject: [PATCH 02/10] chore(dead-code): remove 7 unreachable feature islands + 8 unused deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes code with no reachable entry point: - ToolUseBlock + usePromptDialog — unreachable. - 7 dead feature islands (~10046 L across 82 files; the body is large because the islands cross-import each other): `src/components/ dev-tools/`, the legacy E2E block, the old TipTap-based vim implementation under `src/components/editor/vim/`, the unused `cm-scroll.browser.test`, the prior `` body renderer, the orphan promptDialog glue, and ~39 orphan test files surfaced by `tsc` + the suite sweep after each island was removed. - The dead E2E pipeline in `cm-block-widgets.tsx` (497 to 242 L), removed surgically — the surviving DiffViewer paths are preserved. - 8 unused npm dependencies (lockfile authoritative = repo root). - Documentation drift: a stale TipTap label + `CLAUDE.md` context and size claims that no longer matched the post-CM6 reality. --- CLAUDE.md | 8 +- httui-desktop/package.json | 8 - .../blocks/assertions/AssertionsBadge.tsx | 70 ---- .../blocks/assertions/AssertionsTab.tsx | 127 ------ .../blocks/assertions/RunAllReport.tsx | 109 ----- .../__tests__/AssertionsBadge.test.tsx | 75 ---- .../__tests__/AssertionsTab.test.tsx | 162 -------- .../__tests__/RunAllReport.test.tsx | 119 ------ .../blocks/captures/CapturesFooter.tsx | 182 --------- .../__tests__/CapturesFooter.test.tsx | 174 -------- .../components/blocks/db/DbExplainSection.tsx | 107 ----- .../src/components/blocks/db/ExplainPlan.tsx | 199 ---------- .../db/__tests__/DbExplainSection.test.tsx | 96 ----- .../blocks/db/__tests__/ExplainPlan.test.tsx | 143 ------- .../blocks/db/explain-plan-types.ts | 33 -- .../src/components/blocks/db/index.ts | 27 -- .../blocks/run-diff/RunDiffPanel.tsx | 340 ---------------- .../blocks/run-diff/RunHistoryMenu.tsx | 170 -------- .../run-diff/__tests__/RunDiffPanel.test.tsx | 189 --------- .../__tests__/RunHistoryMenu.test.tsx | 132 ------- .../src/components/chat/ToolUseBlock.tsx | 202 ---------- .../__tests__/markdown-extensions.test.ts | 3 - .../components/editor/markdown-extensions.ts | 2 - .../src/components/layout/AddBlockMenu.tsx | 213 ---------- .../layout/__tests__/AddBlockMenu.test.tsx | 83 ---- .../connections/NewConnectionEnvBinder.tsx | 121 ------ .../connections/NewConnectionTestBanner.tsx | 152 ------- .../__tests__/NewConnectionEnvBinder.test.tsx | 124 ------ .../NewConnectionTestBanner.test.tsx | 95 ----- .../layout/docheader/DocHeaderStatusBadge.tsx | 96 ----- .../__tests__/DocHeaderStatusBadge.test.tsx | 95 ----- .../layout/editor-toolbar/EditorToolbar.tsx | 157 -------- .../__tests__/EditorToolbar.test.tsx | 143 ------- .../__tests__/blockCount.test.ts | 69 ---- .../layout/editor-toolbar/blockCount.ts | 53 --- .../components/layout/git/CommitChangelog.tsx | 200 ---------- .../components/layout/git/GitAuditHeader.tsx | 49 --- .../git/__tests__/CommitChangelog.test.tsx | 104 ----- .../git/__tests__/GitAuditHeader.test.tsx | 31 -- .../layout/settings/AboutSection.tsx | 2 +- .../layout/topbar/SegmentedEnvSwitcher.tsx | 93 ----- .../__tests__/SegmentedEnvSwitcher.test.tsx | 116 ------ .../session-override-resolver.test.ts | 77 ---- .../variables/session-override-resolver.ts | 38 -- .../__tests__/useAssertionResult.test.ts | 66 ---- .../__tests__/useFileAutoCapture.test.ts | 132 ------- .../__tests__/useFileCapturesHydrate.test.ts | 110 ------ .../useFileCapturesPersistence.test.ts | 186 --------- .../__tests__/useFileFirstAuthor.test.ts | 122 ------ .../src/hooks/__tests__/useTemplates.test.ts | 107 ----- httui-desktop/src/hooks/useAssertionResult.ts | 29 -- httui-desktop/src/hooks/useFileAutoCapture.ts | 75 ---- .../src/hooks/useFileCapturesHydrate.ts | 48 --- .../src/hooks/useFileCapturesPersistence.ts | 62 --- httui-desktop/src/hooks/useFileFirstAuthor.ts | 68 ---- httui-desktop/src/hooks/usePromptDialog.tsx | 107 ----- httui-desktop/src/hooks/useTemplates.ts | 64 --- httui-desktop/src/hooks/useTheme.ts | 10 - .../__tests__/assertions-aggregate.test.ts | 121 ------ .../lib/blocks/__tests__/assertions.test.ts | 362 ----------------- .../src/lib/blocks/__tests__/captures.test.ts | 189 --------- .../blocks/__tests__/explain-support.test.ts | 54 --- .../src/lib/blocks/__tests__/run-diff.test.ts | 202 ---------- .../blocks/__tests__/run-history-trim.test.ts | 172 -------- .../__tests__/serialize-explain-plan.test.ts | 66 ---- .../src/lib/blocks/assertions-aggregate.ts | 67 ---- httui-desktop/src/lib/blocks/assertions.ts | 373 ------------------ httui-desktop/src/lib/blocks/captures.ts | 114 ------ .../src/lib/blocks/explain-support.ts | 38 -- httui-desktop/src/lib/blocks/run-diff.ts | 235 ----------- .../src/lib/blocks/run-history-trim.ts | Bin 3174 -> 0 bytes .../src/lib/blocks/serialize-explain-plan.ts | 44 --- .../__tests__/classify-block-changes.test.ts | 244 ------------ .../changelog/__tests__/find-blocks.test.ts | 145 ------- .../changelog/__tests__/parse-diff.test.ts | 217 ---------- .../lib/changelog/classify-block-changes.ts | 186 --------- .../src/lib/changelog/find-blocks.ts | 124 ------ httui-desktop/src/lib/changelog/parse-diff.ts | 152 ------- .../__tests__/cm-numbered-headings.test.ts | 97 ----- .../__tests__/cm-scroll.browser.test.tsx | 107 ----- .../src/lib/codemirror/cm-block-widgets.tsx | 282 +------------ .../lib/codemirror/cm-numbered-headings.tsx | 96 ----- .../src/lib/share/__tests__/share-url.test.ts | 170 -------- httui-desktop/src/lib/share/share-url.ts | 142 ------- .../tauri/__tests__/captures-cache.test.ts | 56 --- .../lib/tauri/__tests__/run-bodies.test.ts | 92 ----- httui-desktop/src/lib/tauri/captures-cache.ts | 34 -- httui-desktop/src/lib/tauri/run-bodies.ts | 95 ----- httui-desktop/src/lib/tauri/templates.ts | 33 -- .../src/stores/__tests__/captureStore.test.ts | 252 ------------ httui-desktop/src/stores/captureStore.ts | 232 ----------- 91 files changed, 11 insertions(+), 10756 deletions(-) delete mode 100644 httui-desktop/src/components/blocks/assertions/AssertionsBadge.tsx delete mode 100644 httui-desktop/src/components/blocks/assertions/AssertionsTab.tsx delete mode 100644 httui-desktop/src/components/blocks/assertions/RunAllReport.tsx delete mode 100644 httui-desktop/src/components/blocks/assertions/__tests__/AssertionsBadge.test.tsx delete mode 100644 httui-desktop/src/components/blocks/assertions/__tests__/AssertionsTab.test.tsx delete mode 100644 httui-desktop/src/components/blocks/assertions/__tests__/RunAllReport.test.tsx delete mode 100644 httui-desktop/src/components/blocks/captures/CapturesFooter.tsx delete mode 100644 httui-desktop/src/components/blocks/captures/__tests__/CapturesFooter.test.tsx delete mode 100644 httui-desktop/src/components/blocks/db/DbExplainSection.tsx delete mode 100644 httui-desktop/src/components/blocks/db/ExplainPlan.tsx delete mode 100644 httui-desktop/src/components/blocks/db/__tests__/DbExplainSection.test.tsx delete mode 100644 httui-desktop/src/components/blocks/db/__tests__/ExplainPlan.test.tsx delete mode 100644 httui-desktop/src/components/blocks/db/explain-plan-types.ts delete mode 100644 httui-desktop/src/components/blocks/db/index.ts delete mode 100644 httui-desktop/src/components/blocks/run-diff/RunDiffPanel.tsx delete mode 100644 httui-desktop/src/components/blocks/run-diff/RunHistoryMenu.tsx delete mode 100644 httui-desktop/src/components/blocks/run-diff/__tests__/RunDiffPanel.test.tsx delete mode 100644 httui-desktop/src/components/blocks/run-diff/__tests__/RunHistoryMenu.test.tsx delete mode 100644 httui-desktop/src/components/chat/ToolUseBlock.tsx delete mode 100644 httui-desktop/src/components/layout/AddBlockMenu.tsx delete mode 100644 httui-desktop/src/components/layout/__tests__/AddBlockMenu.test.tsx delete mode 100644 httui-desktop/src/components/layout/connections/NewConnectionEnvBinder.tsx delete mode 100644 httui-desktop/src/components/layout/connections/NewConnectionTestBanner.tsx delete mode 100644 httui-desktop/src/components/layout/connections/__tests__/NewConnectionEnvBinder.test.tsx delete mode 100644 httui-desktop/src/components/layout/connections/__tests__/NewConnectionTestBanner.test.tsx delete mode 100644 httui-desktop/src/components/layout/docheader/DocHeaderStatusBadge.tsx delete mode 100644 httui-desktop/src/components/layout/docheader/__tests__/DocHeaderStatusBadge.test.tsx delete mode 100644 httui-desktop/src/components/layout/editor-toolbar/EditorToolbar.tsx delete mode 100644 httui-desktop/src/components/layout/editor-toolbar/__tests__/EditorToolbar.test.tsx delete mode 100644 httui-desktop/src/components/layout/editor-toolbar/__tests__/blockCount.test.ts delete mode 100644 httui-desktop/src/components/layout/editor-toolbar/blockCount.ts delete mode 100644 httui-desktop/src/components/layout/git/CommitChangelog.tsx delete mode 100644 httui-desktop/src/components/layout/git/GitAuditHeader.tsx delete mode 100644 httui-desktop/src/components/layout/git/__tests__/CommitChangelog.test.tsx delete mode 100644 httui-desktop/src/components/layout/git/__tests__/GitAuditHeader.test.tsx delete mode 100644 httui-desktop/src/components/layout/topbar/SegmentedEnvSwitcher.tsx delete mode 100644 httui-desktop/src/components/layout/topbar/__tests__/SegmentedEnvSwitcher.test.tsx delete mode 100644 httui-desktop/src/components/layout/variables/__tests__/session-override-resolver.test.ts delete mode 100644 httui-desktop/src/components/layout/variables/session-override-resolver.ts delete mode 100644 httui-desktop/src/hooks/__tests__/useAssertionResult.test.ts delete mode 100644 httui-desktop/src/hooks/__tests__/useFileAutoCapture.test.ts delete mode 100644 httui-desktop/src/hooks/__tests__/useFileCapturesHydrate.test.ts delete mode 100644 httui-desktop/src/hooks/__tests__/useFileCapturesPersistence.test.ts delete mode 100644 httui-desktop/src/hooks/__tests__/useFileFirstAuthor.test.ts delete mode 100644 httui-desktop/src/hooks/__tests__/useTemplates.test.ts delete mode 100644 httui-desktop/src/hooks/useAssertionResult.ts delete mode 100644 httui-desktop/src/hooks/useFileAutoCapture.ts delete mode 100644 httui-desktop/src/hooks/useFileCapturesHydrate.ts delete mode 100644 httui-desktop/src/hooks/useFileCapturesPersistence.ts delete mode 100644 httui-desktop/src/hooks/useFileFirstAuthor.ts delete mode 100644 httui-desktop/src/hooks/usePromptDialog.tsx delete mode 100644 httui-desktop/src/hooks/useTemplates.ts delete mode 100644 httui-desktop/src/hooks/useTheme.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/assertions-aggregate.test.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/assertions.test.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/captures.test.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/explain-support.test.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/run-diff.test.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/run-history-trim.test.ts delete mode 100644 httui-desktop/src/lib/blocks/__tests__/serialize-explain-plan.test.ts delete mode 100644 httui-desktop/src/lib/blocks/assertions-aggregate.ts delete mode 100644 httui-desktop/src/lib/blocks/assertions.ts delete mode 100644 httui-desktop/src/lib/blocks/captures.ts delete mode 100644 httui-desktop/src/lib/blocks/explain-support.ts delete mode 100644 httui-desktop/src/lib/blocks/run-diff.ts delete mode 100644 httui-desktop/src/lib/blocks/run-history-trim.ts delete mode 100644 httui-desktop/src/lib/blocks/serialize-explain-plan.ts delete mode 100644 httui-desktop/src/lib/changelog/__tests__/classify-block-changes.test.ts delete mode 100644 httui-desktop/src/lib/changelog/__tests__/find-blocks.test.ts delete mode 100644 httui-desktop/src/lib/changelog/__tests__/parse-diff.test.ts delete mode 100644 httui-desktop/src/lib/changelog/classify-block-changes.ts delete mode 100644 httui-desktop/src/lib/changelog/find-blocks.ts delete mode 100644 httui-desktop/src/lib/changelog/parse-diff.ts delete mode 100644 httui-desktop/src/lib/codemirror/__tests__/cm-numbered-headings.test.ts delete mode 100644 httui-desktop/src/lib/codemirror/__tests__/cm-scroll.browser.test.tsx delete mode 100644 httui-desktop/src/lib/codemirror/cm-numbered-headings.tsx delete mode 100644 httui-desktop/src/lib/share/__tests__/share-url.test.ts delete mode 100644 httui-desktop/src/lib/share/share-url.ts delete mode 100644 httui-desktop/src/lib/tauri/__tests__/captures-cache.test.ts delete mode 100644 httui-desktop/src/lib/tauri/__tests__/run-bodies.test.ts delete mode 100644 httui-desktop/src/lib/tauri/captures-cache.ts delete mode 100644 httui-desktop/src/lib/tauri/run-bodies.ts delete mode 100644 httui-desktop/src/lib/tauri/templates.ts delete mode 100644 httui-desktop/src/stores/__tests__/captureStore.test.ts delete mode 100644 httui-desktop/src/stores/captureStore.ts diff --git a/CLAUDE.md b/CLAUDE.md index 32b67574..73e87f89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Notes — desktop markdown editor with executable blocks (HTTP client, DB query > **Repo layout (post epic 00):** the desktop app lives in `httui-desktop/` (`httui-desktop/src/` for the React frontend, `httui-desktop/src-tauri/` for the Rust backend). The marketing landing is `httui-web/`, the Claude sidecar is `httui-sidecar/`. The shared Rust crate is `httui-core/`, the terminal binary `httui-tui/`, the MCP server `httui-mcp/`. **Path references like `src/components/...` in this doc are relative to `httui-desktop/`** unless otherwise prefixed. -> **Recent migrations:** TipTap and the E2E block were removed (commits `7aa97e8`, `0aa2868`, `9124ad4`). The editor is now CodeMirror 6 only. State is managed by Zustand stores, not React Contexts (one legacy context remains: `WorkspaceContext`). Older docs may still reference the old architecture. +> **Recent migrations:** TipTap and the E2E block were removed (commits `7aa97e8`, `0aa2868`, `9124ad4`). The editor is now CodeMirror 6 only. State is managed by Zustand stores, not React Contexts (the only legacy *domain* context left is `WorkspaceContext`; small CM6/UI-scoped contexts like `BlockContext` and the doc-header context also exist by design). Older docs may still reference the old architecture. ## Commands @@ -68,7 +68,7 @@ Full details in `docs/ARCHITECTURE.md` (some sections may be outdated — code i **Frontend layers:** - **CM6 fenced-block extensions** — each block type has a CM6 extension (`src/lib/codemirror/cm-http-block.tsx`, `cm-db-block.tsx`) that scans the doc for its fence (```http, ```db-*), produces decorations with widget DOM containing portal slots (toolbar / form / result / statusbar), and provides a transactionFilter to keep fences atomic-on-edges. - **Portal mounts** (`src/components/editor/HttpWidgetPortals.tsx`, `DbWidgetPortals.tsx`) subscribe to the CM6 extension's portal registry and `createPortal` the React panels into each slot. -- **Block panels** (`HttpFencedPanel.tsx`, `DbFencedPanel.tsx`) — each is a single large component holding toolbar, form/raw mode, result tabs, status bar, and settings drawer. ⚠️ Both are monoliths (3.876 L and 2.200 L respectively) — pending split. Avoid adding new features inline; prefer extracting sub-components first. +- **Block panels** (`HttpFencedPanel.tsx`, `DbFencedPanel.tsx`) — each is a single large component holding toolbar, form/raw mode, result tabs, status bar, and settings drawer. ⚠️ Both are monoliths (~2.6k L and ~780 L respectively) — pending split. Avoid adding new features inline; prefer extracting sub-components first. - **`ExecutableBlockShell`** (`src/components/blocks/ExecutableBlockShell.tsx`) — shared shell with display modes (input/split/output), run button, status badge. Currently only consumed by `StandaloneBlock` (the diff-viewer block). HTTP/DB panels reimplement toolbar/status inline because they live outside the editor's document flow (mounted via Portal into CM6 widget DOM). **Backend layers:** @@ -193,7 +193,7 @@ Info-string tokens: `alias`, `timeout`, `display`, `mode` (`raw|form`). Canonica **Architecture:** - `src/lib/blocks/http-fence.ts` — parser/serializer for both info string and HTTP-message body. `parseHttpMessageBody` / `stringifyHttpMessageBody` are idempotent (canonical reformat). `parseLegacyHttpBody` + `legacyToHttpMessage` handle the JSON shim. - `src/lib/codemirror/cm-http-block.tsx` — CM6 extension: scanner, decorations, atomic-on-fences-only, transactionFilter, method coloring on the first body line, keymap (⌘↵ run, ⌘. cancel, ⌘⇧C copy as cURL). Holds a portal registry (toolbar / form / result / statusbar slots) so React mounts inside the widget DOM. -- `src/components/blocks/http/fenced/HttpFencedPanel.tsx` — React panel mounted via `createPortal` into each registered slot. Toolbar (badge / alias / method / host / `[raw│form]` toggle / ▶ / ⚙), result tabs (Body / Headers / Cookies / Timing / Raw with `pretty│raw` sub-toggle), status bar (status dot, host, elapsed, size, "ran X ago", `⤓` Send-as menu), settings drawer (Chakra `Portal` + `Box`, NEVER `Dialog` — preserves CM6 focus). Form mode replaces the body lines with a tabular Params/Headers/Body editor; each input uses local state + commit-on-blur to avoid the round-trip lag of re-emitting raw on every keystroke. **Single file: 3.876 L. Pending split.** +- `src/components/blocks/http/fenced/HttpFencedPanel.tsx` — React panel mounted via `createPortal` into each registered slot. Toolbar (badge / alias / method / host / `[raw│form]` toggle / ▶ / ⚙), result tabs (Body / Headers / Cookies / Timing / Raw with `pretty│raw` sub-toggle), status bar (status dot, host, elapsed, size, "ran X ago", `⤓` Send-as menu), settings drawer (Chakra `Portal` + `Box`, NEVER `Dialog` — preserves CM6 focus). Form mode replaces the body lines with a tabular Params/Headers/Body editor; each input uses local state + commit-on-blur to avoid the round-trip lag of re-emitting raw on every keystroke. **Single file: ~2.6k L. Pending split.** - `src/components/editor/HttpWidgetPortals.tsx` — subscribes to the portal registry and renders panels. **Execution:** @@ -215,7 +215,7 @@ Info-string tokens: `alias`, `timeout`, `display`, `mode` (`raw|form`). Canonica ## DB block - Block type `db-*` (where `*` is the connection id) in `src/components/blocks/db/`. Like the HTTP block, it is a CM6 fenced-code implementation. -- `src/components/blocks/db/fenced/DbFencedPanel.tsx` — React panel (2.200 L, **pending split**). Connection picker, SQL editor, mutation warning for DELETE/UPDATE, result tabs. +- `src/components/blocks/db/fenced/DbFencedPanel.tsx` — React panel (~780 L, **pending split**). Connection picker, SQL editor, mutation warning for DELETE/UPDATE, result tabs. - `src/components/blocks/db/ResultTable.tsx` (528 L) — virtualized result grid (`@tanstack/react-virtual`). - Streamed via `executeDbStreamed` (`src/lib/tauri/streamedExecution.ts`). - SQL safety: `{{...}}` references are converted to bind parameters (`$1`, `?`) before dispatch — never string-interpolated. diff --git a/httui-desktop/package.json b/httui-desktop/package.json index d7ce3de7..38c847e5 100644 --- a/httui-desktop/package.json +++ b/httui-desktop/package.json @@ -51,8 +51,6 @@ "@codemirror/theme-one-dark": "^6.1.3", "@dnd-kit/core": "^6.3.1", "@emotion/react": "^11.14.0", - "@floating-ui/react": "^0.27.19", - "@lezer/markdown": "^1.6.3", "@replit/codemirror-vim": "^6.3.0", "@tanstack/react-virtual": "^3.13.24", "@tauri-apps/api": "^2.10.1", @@ -63,13 +61,8 @@ "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-sql": "^2.4.0", "@tauri-apps/plugin-updater": "^2.10.1", - "@types/katex": "^0.16.8", "@uiw/react-codemirror": "^4.25.9", - "highlight.js": "^11.11.1", - "immer": "^11.1.8", - "katex": "^0.16.47", "lowlight": "^3.3.0", - "marked": "^18.0.3", "mermaid": "^11.14.0", "next-themes": "^0.4.6", "react": "^19.2.6", @@ -78,7 +71,6 @@ "react-markdown": "^10.1.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", - "sql-formatter": "^15.7.4", "zustand": "^5.0.13" } } diff --git a/httui-desktop/src/components/blocks/assertions/AssertionsBadge.tsx b/httui-desktop/src/components/blocks/assertions/AssertionsBadge.tsx deleted file mode 100644 index 825b86ac..00000000 --- a/httui-desktop/src/components/blocks/assertions/AssertionsBadge.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// Block-status assertions badge. -// -// Compact `N/M` chip mounted next to the run badge in the panel -// status bar. Color shifts on failure. Renders nothing when there -// are no assertions to evaluate (the consumer can short-circuit on -// `total === 0` without an outer guard). - -import { chakra } from "@chakra-ui/react"; - -import type { AssertionResult } from "@/lib/blocks/assertions"; - -export interface AssertionsBadgeProps { - /** Total number of parsed assertions in the block. 0 hides the badge. */ - total: number; - /** Aggregate result from `useAssertionResult`. Null = block hasn't - * run yet → badge shows the spec'd-but-pending state. */ - result: AssertionResult | null; - onClick?: () => void; -} - -export function AssertionsBadge({ - total, - result, - onClick, -}: AssertionsBadgeProps) { - if (total === 0) return null; - - const passed = result ? total - result.failures.length : null; - const allPass = result?.pass === true; - const someFail = result?.pass === false; - - const bg = someFail ? "error" : allPass ? "brand.fg" : "bg"; - const fg = someFail || allPass ? "brand.contrast" : "fg.muted"; - const border = someFail || allPass ? "transparent" : "border"; - - const label = result === null ? `0/${total}` : `${passed}/${total}`; - - const interactive = !!onClick; - const Comp = interactive ? chakra.button : chakra.span; - - return ( - - ✓ {label} - - ); -} diff --git a/httui-desktop/src/components/blocks/assertions/AssertionsTab.tsx b/httui-desktop/src/components/blocks/assertions/AssertionsTab.tsx deleted file mode 100644 index c4f8ff7b..00000000 --- a/httui-desktop/src/components/blocks/assertions/AssertionsTab.tsx +++ /dev/null @@ -1,127 +0,0 @@ -// Block-result Tests tab. -// -// Presentational. Lists each assertion with ✓ / ✗ icon + the original -// raw expression + actual vs expected when failed. Pure consumer of -// `AssertionResult` produced by `useAssertionResult`. The HTTP/DB -// panels mount this between Body and Raw tabs at the consumer site. - -import { Box, Flex, Text } from "@chakra-ui/react"; - -import type { - AssertionFailure, - AssertionResult, - ParsedAssertion, -} from "@/lib/blocks/assertions"; - -export interface AssertionsTabProps { - /** All parsed assertions in document order. Drives the row list. */ - assertions: ReadonlyArray; - /** Aggregate result from `useAssertionResult`. Null when no run yet. */ - result: AssertionResult | null; -} - -export function AssertionsTab({ assertions, result }: AssertionsTabProps) { - if (assertions.length === 0) { - return ( - - No assertions in this block. Add a{" "} - - # expect: - {" "} - section after the body. - - ); - } - - if (!result) { - return ( - - Run the block to evaluate {assertions.length}{" "} - {assertions.length === 1 ? "assertion" : "assertions"}. - - ); - } - - const failureByLine = new Map(); - for (const f of result.failures) failureByLine.set(f.line, f); - - return ( - - {assertions.map((a) => { - const failure = failureByLine.get(a.line); - const passed = !failure; - return ( - - - {passed ? "✓" : "✗"} - - - - {a.raw} - - {failure && ( - - actual {formatValue(failure.actual)} · expected{" "} - {formatValue(failure.expected)} - {failure.reason ? ` — ${failure.reason}` : ""} - - )} - - - ); - })} - - ); -} - -function formatValue(v: unknown): string { - if (v === undefined) return "undefined"; - if (v === null) return "null"; - if (typeof v === "string") return JSON.stringify(v); - if (typeof v === "number" || typeof v === "boolean") return String(v); - try { - return JSON.stringify(v); - } catch { - return String(v); - } -} diff --git a/httui-desktop/src/components/blocks/assertions/RunAllReport.tsx b/httui-desktop/src/components/blocks/assertions/RunAllReport.tsx deleted file mode 100644 index 2238fb66..00000000 --- a/httui-desktop/src/components/blocks/assertions/RunAllReport.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// Run-all assertion summary. -// -// Presentational. Consumes the pure `aggregateAssertionResults` -// output. Mounted at the bottom of the run-all stream when the runs -// finish (or stop on first failure when shift-click was NOT held). - -import { Box, Flex, Text, chakra } from "@chakra-ui/react"; - -import type { RunAllAssertionSummary } from "@/lib/blocks/assertions-aggregate"; - -export interface RunAllReportProps { - summary: RunAllAssertionSummary; - /** Click handler invoked when the user clicks a failed block in - * the report; the consumer scrolls + selects that block. */ - onJumpToBlock?: (blockAlias: string) => void; -} - -export function RunAllReport({ summary, onJumpToBlock }: RunAllReportProps) { - const { blocks, assertions, passed, failed, failedBlocks, allPass } = summary; - - if (assertions === 0) { - return ( - - - {blocks} {blocks === 1 ? "block" : "blocks"} ran. No assertions - defined. - - - ); - } - - return ( - - 0 ? 2 : 0}> - - {allPass ? "✓" : "✗"} - - - {blocks} {blocks === 1 ? "block" : "blocks"}, {assertions}{" "} - {assertions === 1 ? "assertion" : "assertions"}, {passed} passed,{" "} - {failed} failed - - - - {failedBlocks.length > 0 && ( - - {failedBlocks.map((alias) => ( - - ))} - - )} - - ); -} - -function FailedBlockRow({ - alias, - onJump, -}: { - alias: string; - onJump?: (a: string) => void; -}) { - const interactive = !!onJump; - const Comp = interactive ? chakra.button : chakra.div; - return ( - onJump?.(alias) : undefined} - fontFamily="mono" - fontSize="11px" - color="error" - textAlign="left" - cursor={interactive ? "pointer" : "default"} - bg="transparent" - borderWidth={0} - px={0} - _hover={interactive ? { textDecoration: "underline" } : undefined} - > - ✗ {alias} - - ); -} diff --git a/httui-desktop/src/components/blocks/assertions/__tests__/AssertionsBadge.test.tsx b/httui-desktop/src/components/blocks/assertions/__tests__/AssertionsBadge.test.tsx deleted file mode 100644 index 5559c825..00000000 --- a/httui-desktop/src/components/blocks/assertions/__tests__/AssertionsBadge.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { AssertionsBadge } from "@/components/blocks/assertions/AssertionsBadge"; -import type { AssertionResult } from "@/lib/blocks/assertions"; -import { renderWithProviders, screen } from "@/test/render"; - -describe("AssertionsBadge", () => { - it("renders nothing when total is 0", () => { - renderWithProviders(); - expect(screen.queryByTestId("assertions-badge")).not.toBeInTheDocument(); - }); - - it("renders 0/N with pending state when result is null", () => { - renderWithProviders(); - const badge = screen.getByTestId("assertions-badge"); - expect(badge.textContent).toMatch(/0\/3/); - expect(badge.getAttribute("data-pending")).toBe("true"); - expect(badge.getAttribute("data-pass")).toBeNull(); - expect(badge.getAttribute("data-fail")).toBeNull(); - }); - - it("renders N/N with pass state when every assertion passed", () => { - const result: AssertionResult = { pass: true, failures: [] }; - renderWithProviders(); - const badge = screen.getByTestId("assertions-badge"); - expect(badge.textContent).toMatch(/3\/3/); - expect(badge.getAttribute("data-pass")).toBe("true"); - expect(badge.getAttribute("data-fail")).toBeNull(); - expect(badge.getAttribute("title")).toMatch(/all assertions passed/); - }); - - it("renders (N-K)/N with fail state when some assertions failed", () => { - const result: AssertionResult = { - pass: false, - failures: [ - { line: 2, raw: "x", actual: 1, expected: 2, reason: "" }, - { line: 3, raw: "y", actual: 1, expected: 2, reason: "" }, - ], - }; - renderWithProviders(); - const badge = screen.getByTestId("assertions-badge"); - expect(badge.textContent).toMatch(/3\/5/); - expect(badge.getAttribute("data-fail")).toBe("true"); - expect(badge.getAttribute("data-pass")).toBeNull(); - expect(badge.getAttribute("title")).toMatch(/2 assertions failed/); - }); - - it("uses singular 'assertion failed' wording when one failure", () => { - const result: AssertionResult = { - pass: false, - failures: [{ line: 2, raw: "x", actual: 1, expected: 2, reason: "" }], - }; - renderWithProviders(); - expect( - screen.getByTestId("assertions-badge").getAttribute("title"), - ).toMatch(/1 assertion failed/); - }); - - it("is a non-interactive span when onClick is omitted", () => { - renderWithProviders(); - expect(screen.getByTestId("assertions-badge").tagName).toBe("SPAN"); - }); - - it("becomes a button and fires onClick when handler is supplied", async () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - const badge = screen.getByTestId("assertions-badge"); - expect(badge.tagName).toBe("BUTTON"); - await userEvent.setup().click(badge); - expect(onClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/httui-desktop/src/components/blocks/assertions/__tests__/AssertionsTab.test.tsx b/httui-desktop/src/components/blocks/assertions/__tests__/AssertionsTab.test.tsx deleted file mode 100644 index 053bf8a4..00000000 --- a/httui-desktop/src/components/blocks/assertions/__tests__/AssertionsTab.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AssertionsTab } from "@/components/blocks/assertions/AssertionsTab"; -import type { AssertionResult, ParsedAssertion } from "@/lib/blocks/assertions"; -import { renderWithProviders, screen } from "@/test/render"; - -function pa(line: number, raw: string): ParsedAssertion { - return { - line, - raw, - lhs: raw.split(/[<>=!]/)[0].trim(), - op: "===" as const, - rhs: - raw - .split(/[<>=!]/) - .pop() - ?.trim() ?? "", - }; -} - -describe("AssertionsTab", () => { - it("renders the empty hint when no assertions are defined", () => { - renderWithProviders(); - expect(screen.getByTestId("assertions-tab-empty")).toBeInTheDocument(); - }); - - it("renders the pending hint when assertions exist but no run yet", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("assertions-tab-pending").textContent).toMatch( - /Run the block.*2 assertions/, - ); - }); - - it("uses singular wording when only one assertion is pending", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("assertions-tab-pending").textContent).toMatch( - /1 assertion\b/, - ); - }); - - it("renders one row per assertion when result is present", () => { - const result: AssertionResult = { pass: true, failures: [] }; - renderWithProviders( - , - ); - expect(screen.getByTestId("assertions-tab-row-1")).toBeInTheDocument(); - expect(screen.getByTestId("assertions-tab-row-2")).toBeInTheDocument(); - }); - - it("marks rows as passed when no failure matches their line", () => { - const result: AssertionResult = { pass: true, failures: [] }; - renderWithProviders( - , - ); - expect( - screen.getByTestId("assertions-tab-row-1").getAttribute("data-passed"), - ).toBe("true"); - expect(screen.getByTestId("assertions-tab-row-1-icon").textContent).toBe( - "✓", - ); - }); - - it("marks rows as failed and renders actual + expected + reason", () => { - const result: AssertionResult = { - pass: false, - failures: [ - { - line: 1, - raw: "status === 200", - actual: 404, - expected: 200, - reason: "values not equal", - }, - ], - }; - renderWithProviders( - , - ); - expect( - screen.getByTestId("assertions-tab-row-1").getAttribute("data-passed"), - ).toBeNull(); - expect(screen.getByTestId("assertions-tab-row-1-icon").textContent).toBe( - "✗", - ); - const failure = screen.getByTestId( - "assertions-tab-row-1-failure", - ).textContent; - expect(failure).toMatch(/actual 404/); - expect(failure).toMatch(/expected 200/); - expect(failure).toMatch(/values not equal/); - }); - - it("formats string actuals with quotes", () => { - const result: AssertionResult = { - pass: false, - failures: [ - { - line: 1, - raw: "$.body.name === 'alice'", - actual: "bob", - expected: "alice", - reason: "values not equal", - }, - ], - }; - renderWithProviders( - , - ); - expect( - screen.getByTestId("assertions-tab-row-1-failure").textContent, - ).toMatch(/actual "bob".*expected "alice"/); - }); - - it("renders undefined actual cleanly (no '[object Undefined]')", () => { - const result: AssertionResult = { - pass: false, - failures: [ - { - line: 1, - raw: "$.body.missing === 1", - actual: undefined, - expected: 1, - reason: "values not equal", - }, - ], - }; - renderWithProviders( - , - ); - expect( - screen.getByTestId("assertions-tab-row-1-failure").textContent, - ).toMatch(/actual undefined/); - }); - - it("data-pass on the wrapper reflects the aggregate result", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("assertions-tab").getAttribute("data-pass")).toBe( - "true", - ); - }); -}); diff --git a/httui-desktop/src/components/blocks/assertions/__tests__/RunAllReport.test.tsx b/httui-desktop/src/components/blocks/assertions/__tests__/RunAllReport.test.tsx deleted file mode 100644 index cdb04269..00000000 --- a/httui-desktop/src/components/blocks/assertions/__tests__/RunAllReport.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { RunAllReport } from "@/components/blocks/assertions/RunAllReport"; -import type { RunAllAssertionSummary } from "@/lib/blocks/assertions-aggregate"; -import { renderWithProviders, screen } from "@/test/render"; - -function summary( - over: Partial = {}, -): RunAllAssertionSummary { - return { - blocks: 7, - assertions: 23, - passed: 22, - failed: 1, - failedBlocks: ["b"], - allPass: false, - ...over, - }; -} - -describe("RunAllReport", () => { - it("renders the empty path with sing/plural agreement", () => { - renderWithProviders( - , - ); - const empty = screen.getByTestId("run-all-report"); - expect(empty.getAttribute("data-empty")).toBe("true"); - expect(empty.textContent).toMatch(/1 block ran/); - }); - - it("renders the spec'd '7 blocks, 23 assertions, 22 passed, 1 failed' summary", () => { - renderWithProviders(); - expect(screen.getByTestId("run-all-report-summary").textContent).toMatch( - /7 blocks, 23 assertions, 22 passed, 1 failed/, - ); - }); - - it("encodes pass / fail via data attributes", () => { - const { rerender } = renderWithProviders( - , - ); - expect(screen.getByTestId("run-all-report").getAttribute("data-fail")).toBe( - "true", - ); - rerender( - , - ); - expect(screen.getByTestId("run-all-report").getAttribute("data-pass")).toBe( - "true", - ); - }); - - it("lists each failedBlocks alias as a row", () => { - renderWithProviders( - , - ); - expect( - screen.getByTestId("run-all-report-failed-block-b1"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("run-all-report-failed-block-b2"), - ).toBeInTheDocument(); - }); - - it("failed-block rows are non-interactive divs without onJumpToBlock", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("run-all-report-failed-block-b").tagName).toBe( - "DIV", - ); - }); - - it("failed-block rows become buttons and fire onJumpToBlock(alias)", async () => { - const onJump = vi.fn(); - renderWithProviders( - , - ); - const a = screen.getByTestId("run-all-report-failed-block-a"); - expect(a.tagName).toBe("BUTTON"); - await userEvent.setup().click(a); - expect(onJump).toHaveBeenCalledWith("a"); - }); - - it("uses singular 'block' / 'assertion' wording when counts are 1", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("run-all-report-summary").textContent).toMatch( - /1 block, 1 assertion, 1 passed/, - ); - }); -}); diff --git a/httui-desktop/src/components/blocks/captures/CapturesFooter.tsx b/httui-desktop/src/components/blocks/captures/CapturesFooter.tsx deleted file mode 100644 index 70f5bdaf..00000000 --- a/httui-desktop/src/components/blocks/captures/CapturesFooter.tsx +++ /dev/null @@ -1,182 +0,0 @@ -// Block-status captures footer. -// -// Compact `↗ N captured` summary that expands to a list of `key = -// value` rows. Secret-named values mask in the panel until the user -// clicks (clipboard copy still works on the masked row). Pure -// presentational consumer — the consumer feeds it the -// `Record` from `useCaptureStore.getBlockCaptures`. - -import { Box, Text, chakra } from "@chakra-ui/react"; -import { useState } from "react"; - -import type { CaptureEntry } from "@/stores/captureStore"; - -const SECRET_MASK = "••••••••"; -const VALUE_MAX_CHARS = 80; - -export interface CapturesFooterProps { - /** All captures for the current block. Empty object hides the footer. */ - captures: Readonly>; - /** Per-row copy handler. When omitted, clicking a row is a no-op - * (the consumer typically wires `navigator.clipboard.writeText`). */ - onCopy?: (key: string, value: string) => void; - /** Initial open state for the expand panel. Default closed. */ - defaultOpen?: boolean; -} - -export function CapturesFooter({ - captures, - onCopy, - defaultOpen = false, -}: CapturesFooterProps) { - const entries = Object.entries(captures); - const [open, setOpen] = useState(defaultOpen); - - if (entries.length === 0) return null; - - return ( - - setOpen((v) => !v)} - display="flex" - alignItems="center" - gap={2} - px={4} - py={2} - bg="transparent" - borderWidth={0} - textAlign="left" - w="full" - cursor="pointer" - _hover={{ bg: "bg.subtle" }} - > - - ↗ - - - {entries.length} captured - - - - {open ? "▾" : "▸"} - - - - {open && ( - - {entries.map(([key, entry]) => ( - - ))} - - )} - - ); -} - -function CaptureRow({ - name, - entry, - onCopy, -}: { - name: string; - entry: CaptureEntry; - onCopy?: (key: string, value: string) => void; -}) { - const stringValue = - entry.value === null - ? "" - : typeof entry.value === "string" - ? entry.value - : String(entry.value); - - const display = entry.isSecret - ? SECRET_MASK - : stringValue.length > VALUE_MAX_CHARS - ? `${stringValue.slice(0, VALUE_MAX_CHARS)}…` - : stringValue; - - const interactive = !!onCopy; - - const Comp = interactive ? chakra.button : chakra.div; - return ( - onCopy?.(name, stringValue) : undefined} - display="flex" - alignItems="baseline" - gap={2} - px={4} - py={1} - bg="transparent" - borderWidth={0} - textAlign="left" - w="full" - cursor={interactive ? "pointer" : "default"} - title={interactive ? "Click to copy" : undefined} - _hover={interactive ? { bg: "bg.subtle" } : undefined} - > - - {name} - - - = - - - {display} - - {entry.isSecret && ( - - 🔒 - - )} - - ); -} diff --git a/httui-desktop/src/components/blocks/captures/__tests__/CapturesFooter.test.tsx b/httui-desktop/src/components/blocks/captures/__tests__/CapturesFooter.test.tsx deleted file mode 100644 index 44a29a12..00000000 --- a/httui-desktop/src/components/blocks/captures/__tests__/CapturesFooter.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { CapturesFooter } from "@/components/blocks/captures/CapturesFooter"; -import type { CaptureEntry } from "@/stores/captureStore"; -import { renderWithProviders, screen } from "@/test/render"; - -function captures( - ...entries: Array<[string, CaptureEntry]> -): Record { - return Object.fromEntries(entries); -} - -const e = (value: CaptureEntry["value"], isSecret = false): CaptureEntry => ({ - value, - isSecret, -}); - -describe("CapturesFooter", () => { - it("renders nothing when captures is empty", () => { - renderWithProviders(); - expect(screen.queryByTestId("captures-footer")).not.toBeInTheDocument(); - }); - - it("renders summary `N captured` and starts collapsed", () => { - renderWithProviders( - , - ); - expect( - screen.getByTestId("captures-footer-summary-label").textContent, - ).toBe("2 captured"); - expect( - screen.getByTestId("captures-footer").getAttribute("data-open"), - ).toBeNull(); - expect( - screen.queryByTestId("captures-footer-list"), - ).not.toBeInTheDocument(); - }); - - it("expands when the summary is clicked", async () => { - renderWithProviders(); - await userEvent - .setup() - .click(screen.getByTestId("captures-footer-summary")); - expect( - screen.getByTestId("captures-footer").getAttribute("data-open"), - ).toBe("true"); - expect(screen.getByTestId("captures-footer-list")).toBeInTheDocument(); - }); - - it("collapses again on a second click", async () => { - renderWithProviders( - , - ); - expect( - screen.getByTestId("captures-footer").getAttribute("data-open"), - ).toBe("true"); - await userEvent - .setup() - .click(screen.getByTestId("captures-footer-summary")); - expect( - screen.getByTestId("captures-footer").getAttribute("data-open"), - ).toBeNull(); - }); - - it("respects defaultOpen=true", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("captures-footer-list")).toBeInTheDocument(); - }); - - it("renders one row per capture in iteration order", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("captures-footer-row-token")).toBeInTheDocument(); - expect( - screen.getByTestId("captures-footer-row-user_id"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("captures-footer-row-status"), - ).toBeInTheDocument(); - }); - - it("masks secret values with bullets and shows the lock chip", () => { - renderWithProviders( - , - ); - const row = screen.getByTestId("captures-footer-row-api_token"); - expect(row.textContent).toMatch(/••••••••/); - expect(row.textContent).not.toMatch(/real-secret-here/); - expect( - screen.getByTestId("captures-footer-row-api_token-secret-chip"), - ).toBeInTheDocument(); - expect(row.getAttribute("data-secret")).toBe("true"); - }); - - it("truncates long non-secret values to 80 chars + ellipsis", () => { - const long = "x".repeat(120); - renderWithProviders( - , - ); - const row = screen.getByTestId("captures-footer-row-text"); - expect(row.textContent).toMatch(new RegExp(`${"x".repeat(80)}…`)); - }); - - it("renders empty string for null values", () => { - renderWithProviders( - , - ); - const row = screen.getByTestId("captures-footer-row-nothing"); - // No exception, no garbage; row exists with empty value side - expect(row).toBeInTheDocument(); - }); - - it("rows are non-interactive divs when onCopy is omitted", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("captures-footer-row-a").tagName).toBe("DIV"); - }); - - it("rows become buttons and fire onCopy(key, value) when handler is supplied", async () => { - const onCopy = vi.fn(); - renderWithProviders( - , - ); - const row = screen.getByTestId("captures-footer-row-token"); - expect(row.tagName).toBe("BUTTON"); - await userEvent.setup().click(row); - expect(onCopy).toHaveBeenCalledWith("token", "abc"); - }); - - it("clipboard receives the full value even when the row is masked", async () => { - const onCopy = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("captures-footer-row-api_secret")); - expect(onCopy).toHaveBeenCalledWith("api_secret", "real-value"); - }); - - it("number values are stringified for the row display", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("captures-footer-row-count").textContent).toMatch( - /count.*=.*42/, - ); - }); -}); diff --git a/httui-desktop/src/components/blocks/db/DbExplainSection.tsx b/httui-desktop/src/components/blocks/db/DbExplainSection.tsx deleted file mode 100644 index 7377bd36..00000000 --- a/httui-desktop/src/components/blocks/db/DbExplainSection.tsx +++ /dev/null @@ -1,107 +0,0 @@ -// DbFencedPanel EXPLAIN section. -// -// Presentational composition of the canvas-spec header row + -// `` tree. The consumer (DbFencedPanel) chooses when -// to mount; this component owns only the visual contract. -// -// Hide-entirely contract when `plan === undefined` -// AND `unsupported` is not set, the section returns `null` so a block -// that hasn't run with `explain=true` shows nothing. `plan === null` -// is the loading state (request in flight); `plan: PlanNode` is the -// ready state; `unsupported` overrides everything with the -// driver-not-supported copy. - -import { Box, Flex, Text } from "@chakra-ui/react"; - -import { ExplainPlan } from "./ExplainPlan"; -import type { PlanNode } from "./explain-plan-types"; - -export interface DbExplainSectionProps { - /** - * Root plan node, `null` while a request is in flight, or - * `undefined` to hide the section entirely (block hasn't been run - * with `explain=true`). - */ - plan: PlanNode | null | undefined; - /** - * Render the unsupported state regardless of `plan`. Driver name - * surfaces via `driverLabel`. - */ - unsupported?: boolean; - driverLabel?: string; - /** - * Optional summary annotation (e.g. `"uses idx_route_provider"`). - * Renders mono in `fg.ok` to the right of the header. - */ - summary?: string; - /** - * Sub-label below the header. Defaults to `"buffers · timing"` - * matching the canvas mock. - */ - subLabel?: string; -} - -const DEFAULT_SUB_LABEL = "buffers · timing"; - -export function DbExplainSection({ - plan, - unsupported, - driverLabel, - summary, - subLabel = DEFAULT_SUB_LABEL, -}: DbExplainSectionProps) { - if (plan === undefined && !unsupported) { - return null; - } - return ( - - - - EXPLAIN ANALYZE - - - {subLabel} - - - {summary && ( - - {summary} - - )} - - - - ); -} diff --git a/httui-desktop/src/components/blocks/db/ExplainPlan.tsx b/httui-desktop/src/components/blocks/db/ExplainPlan.tsx deleted file mode 100644 index a98c7bbc..00000000 --- a/httui-desktop/src/components/blocks/db/ExplainPlan.tsx +++ /dev/null @@ -1,199 +0,0 @@ -// recursive `PlanNode` tree renderer. -// -// Pure presentational. Consumer (DbFencedPanel) fetches -// the parsed plan via Tauri and passes the root node. Each child -// row is indented + connected with vertical / horizontal stub -// lines; a click on a node toggles its subtree. The cost bar runs -// 0..100% with `accent` for normal nodes and `error` when `warn` -// is set. - -import { useState } from "react"; -import { Box, Flex, Text } from "@chakra-ui/react"; - -import { type PlanNode, formatRows } from "./explain-plan-types"; - -export interface ExplainPlanProps { - /** Root node of the plan tree. `null` while loading or when no - * EXPLAIN data is available; the consumer hides the section - * in that case. */ - plan: PlanNode | null; - /** When set, blocks the consumer from showing the plan card — - * e.g. the driver doesn't support EXPLAIN. */ - unsupported?: boolean; - /** Optional driver-name copy for the unsupported message. */ - driverLabel?: string; -} - -export function ExplainPlan({ - plan, - unsupported, - driverLabel, -}: ExplainPlanProps) { - if (unsupported) { - return ( - - - EXPLAIN unavailable for{" "} - {driverLabel ? {driverLabel} : "this driver"}. - - - ); - } - if (plan === null) { - return ( - - - Loading plan… - - - ); - } - return ( - - - - ); -} - -function Node({ - node, - depth, - isLast, -}: { - node: PlanNode; - depth: number; - isLast: boolean; -}) { - const [open, setOpen] = useState(true); - const hasChildren = node.children.length > 0; - const nodeId = `${depth}-${node.op}-${node.target}`; - return ( - - 0 ? "1px" : 0} - borderLeftColor="border" - pl={depth > 0 ? 2 : 0} - cursor={hasChildren ? "pointer" : undefined} - _hover={hasChildren ? { bg: "bg.muted" } : undefined} - onClick={hasChildren ? () => setOpen((v) => !v) : undefined} - > - {hasChildren && ( - - {open ? "▾" : "▸"} - - )} - - {node.op} - - {node.target && ( - - {node.target} - - )} - - {node.cost} - - - {formatRows(node.rows)} - - - - - {node.warn && ( - - ⚠ - - )} - - {hasChildren && open && ( - - {node.children.map((child, i) => ( - - ))} - - )} - - ); -} - -function CostBar({ pct, warn }: { pct: number; warn: boolean }) { - const clamped = Math.max(0, Math.min(100, pct)); - return ( - - - - ); -} diff --git a/httui-desktop/src/components/blocks/db/__tests__/DbExplainSection.test.tsx b/httui-desktop/src/components/blocks/db/__tests__/DbExplainSection.test.tsx deleted file mode 100644 index eddd14a1..00000000 --- a/httui-desktop/src/components/blocks/db/__tests__/DbExplainSection.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DbExplainSection } from "@/components/blocks/db/DbExplainSection"; -import type { PlanNode } from "@/components/blocks/db/explain-plan-types"; -import { renderWithProviders, screen } from "@/test/render"; - -function leaf(over: Partial = {}): PlanNode { - return { - op: "Limit", - target: "(rows=50)", - cost: "0.42..18.7", - rows: 50, - pct: 100, - warn: false, - children: [], - ...over, - }; -} - -describe("DbExplainSection", () => { - it("returns null when plan is undefined and not unsupported (hide-entirely)", () => { - const { container } = renderWithProviders( - , - ); - expect(container.firstChild).toBeNull(); - expect(screen.queryByTestId("db-explain-section")).not.toBeInTheDocument(); - }); - - it("renders the header with default sub-label and the loading ExplainPlan when plan is null", () => { - renderWithProviders(); - expect(screen.getByTestId("db-explain-section-label").textContent).toBe( - "EXPLAIN ANALYZE", - ); - expect(screen.getByTestId("db-explain-section-sub").textContent).toBe( - "buffers · timing", - ); - expect(screen.getByTestId("explain-plan").getAttribute("data-state")).toBe( - "loading", - ); - }); - - it("renders a custom subLabel when supplied", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("db-explain-section-sub").textContent).toBe( - "cost · timing", - ); - }); - - it("renders the ready ExplainPlan and the summary annotation when plan is a node", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("explain-plan").getAttribute("data-state")).toBe( - "ready", - ); - const summary = screen.getByTestId("db-explain-section-summary"); - expect(summary.textContent).toBe("uses idx_route_provider"); - expect(summary.getAttribute("title")).toBe("uses idx_route_provider"); - expect(screen.getByTestId("explain-plan-op").textContent).toBe( - "Index Scan", - ); - }); - - it("hides the summary annotation when not supplied", () => { - renderWithProviders(); - expect( - screen.queryByTestId("db-explain-section-summary"), - ).not.toBeInTheDocument(); - }); - - it("renders the unsupported state with driver label even when plan is undefined", () => { - renderWithProviders( - , - ); - // Section is visible (the unsupported branch overrides the hide rule). - expect(screen.getByTestId("db-explain-section")).toBeInTheDocument(); - expect(screen.getByTestId("explain-plan").getAttribute("data-state")).toBe( - "unsupported", - ); - expect(screen.getByTestId("explain-plan").textContent).toMatch(/SQLite/); - }); - - it("forwards unsupported state when plan is null too (consumer sets both during a transition)", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("explain-plan").getAttribute("data-state")).toBe( - "unsupported", - ); - }); -}); diff --git a/httui-desktop/src/components/blocks/db/__tests__/ExplainPlan.test.tsx b/httui-desktop/src/components/blocks/db/__tests__/ExplainPlan.test.tsx deleted file mode 100644 index 89521cc5..00000000 --- a/httui-desktop/src/components/blocks/db/__tests__/ExplainPlan.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { ExplainPlan } from "@/components/blocks/db/ExplainPlan"; -import { - type PlanNode, - formatRows, -} from "@/components/blocks/db/explain-plan-types"; -import { renderWithProviders, screen } from "@/test/render"; - -function node(over: Partial = {}): PlanNode { - return { - op: "Limit", - target: "(rows=50)", - cost: "0.42..18.7", - rows: 50, - pct: 100, - warn: false, - children: [], - ...over, - }; -} - -describe("formatRows", () => { - it("comma-groups large numbers in en-US locale", () => { - expect(formatRows(1234567, "en-US")).toBe("1,234,567"); - }); - - it("preserves small numbers", () => { - expect(formatRows(50, "en-US")).toBe("50"); - }); -}); - -describe("ExplainPlan", () => { - it("renders the unsupported state with optional driver label", () => { - renderWithProviders( - , - ); - const root = screen.getByTestId("explain-plan"); - expect(root.getAttribute("data-state")).toBe("unsupported"); - expect(root.textContent).toMatch(/SQLite/); - }); - - it("renders the loading state when plan is null", () => { - renderWithProviders(); - expect(screen.getByTestId("explain-plan").getAttribute("data-state")).toBe( - "loading", - ); - }); - - it("renders a single-node plan with op + cost + rows", () => { - renderWithProviders(); - expect(screen.getByTestId("explain-plan").getAttribute("data-state")).toBe( - "ready", - ); - expect(screen.getByTestId("explain-plan-op").textContent).toBe("Limit"); - expect(screen.getByTestId("explain-plan-cost").textContent).toBe( - "0.42..18.7", - ); - expect(screen.getByTestId("explain-plan-rows").textContent).toBe("50"); - }); - - it("renders the cost bar with the expected percentage", () => { - renderWithProviders(); - expect( - screen.getByTestId("explain-plan-cost-bar").getAttribute("data-pct"), - ).toBe("75"); - }); - - it("clamps pct above 100 and below 0 in the cost bar", () => { - renderWithProviders(); - expect( - screen.getByTestId("explain-plan-cost-bar").getAttribute("data-pct"), - ).toBe("100"); - }); - - it("flags warn nodes with the warning icon + data-warn", () => { - renderWithProviders(); - expect(screen.getByTestId("explain-plan-warn-icon")).toBeInTheDocument(); - const planNode = screen.getByTestId("explain-plan-node"); - expect(planNode.getAttribute("data-warn")).toBe("true"); - }); - - it("renders children at increased depth", () => { - const root: PlanNode = node({ - children: [node({ op: "Sort" }), node({ op: "Hash" })], - }); - renderWithProviders(); - const nodes = screen.getAllByTestId("explain-plan-node"); - expect(nodes).toHaveLength(3); - expect(nodes[1]!.getAttribute("data-depth")).toBe("1"); - expect(nodes[2]!.getAttribute("data-depth")).toBe("1"); - }); - - it("hides the toggle on leaf nodes", () => { - renderWithProviders(); - expect( - screen.queryByTestId("explain-plan-toggle-0-Limit-(rows=50)"), - ).not.toBeInTheDocument(); - }); - - it("renders the toggle on parent nodes and collapses on click", async () => { - const root: PlanNode = node({ - children: [node({ op: "Sort" })], - }); - renderWithProviders(); - // Initially open: 2 nodes rendered (root + child). - expect(screen.getAllByTestId("explain-plan-node")).toHaveLength(2); - const toggle = screen.getByTestId("explain-plan-toggle-0-Limit-(rows=50)"); - expect(toggle.textContent).toBe("▾"); - await userEvent.setup().click(toggle); - // Collapsed: only the root remains visible. - expect(screen.getAllByTestId("explain-plan-node")).toHaveLength(1); - }); - - it("hides the target text when target is empty", () => { - renderWithProviders(); - expect(screen.queryByTestId("explain-plan-target")).not.toBeInTheDocument(); - }); - - it("flags the last child via data-last", () => { - const root: PlanNode = node({ - children: [node({ op: "First" }), node({ op: "Last" })], - }); - renderWithProviders(); - const nodes = screen.getAllByTestId("explain-plan-node"); - // root is implicitly last, plus the second child. - expect(nodes[0]!.getAttribute("data-last")).toBe("true"); - expect(nodes[1]!.getAttribute("data-last")).toBeNull(); - expect(nodes[2]!.getAttribute("data-last")).toBe("true"); - }); - - it("renders 6+ levels deep without crashing (acceptance criterion)", () => { - let root: PlanNode = node({ op: "L6" }); - for (const op of ["L5", "L4", "L3", "L2", "L1"]) { - root = node({ op, children: [root] }); - } - renderWithProviders(); - const nodes = screen.getAllByTestId("explain-plan-node"); - expect(nodes).toHaveLength(6); - expect(nodes[5]!.getAttribute("data-depth")).toBe("5"); - }); -}); diff --git a/httui-desktop/src/components/blocks/db/explain-plan-types.ts b/httui-desktop/src/components/blocks/db/explain-plan-types.ts deleted file mode 100644 index 3a1b66d8..00000000 --- a/httui-desktop/src/components/blocks/db/explain-plan-types.ts +++ /dev/null @@ -1,33 +0,0 @@ -// frontend type mirror of `httui-core::explain::PlanNode`. -// -// The Rust side is canonical (parser lives there). This file is the -// TS shape the React renderer consumes — keeps backend canonical -// while letting the UI compile without round-tripping through any -// Rust-emitted types. -// -// Field meanings: see `httui-core/src/explain/node.rs` and the -// canvas Workbench §SqlBlock mock. - -export interface PlanNode { - /** Operation kind — "Limit", "Sort", "Hash Anti Join", "Seq Scan", … */ - op: string; - /** Free-text target; e.g. `r.created_at DESC`, `routes`, `(rows=50)`. */ - target: string; - /** Cost range as the driver reports it ("0.42..18.7"). */ - cost: string; - rows: number; - /** Share of total query cost, 0..100. */ - pct: number; - /** Heuristic warn flag — Seq Scan over 10k rows, hash anti-join with - * >50% cost share, Sort exceeding work_mem, etc. */ - warn: boolean; - children: ReadonlyArray; -} - -/** - * Format `rows` with the user's locale separators. The canvas mock - * shows comma-grouped large numbers ("1,234,567 rows"). - */ -export function formatRows(rows: number, locale?: string): string { - return rows.toLocaleString(locale); -} diff --git a/httui-desktop/src/components/blocks/db/index.ts b/httui-desktop/src/components/blocks/db/index.ts deleted file mode 100644 index 46955906..00000000 --- a/httui-desktop/src/components/blocks/db/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * DB block public exports. - * - * The legacy TipTap NodeView (`DbBlockView` + `node.ts`) was removed after - * stage 9 of the db-block-redesign — the current fenced-block path owns - * rendering via `cm-db-block.tsx` and `DbFencedPanel`. The registry-based - * flow lived on for HTTP / E2E blocks only; `db-*` bypasses it entirely. - */ - -export type { - DbBlockData, - DbResponse, - DbResult, - DbColumn, - DbRow, - DbMessage, - DbStats, - CellValue, -} from "./types"; -export { - normalizeDbResponse, - firstSelectResult, - isSelectResult, - isMutationResult, - isErrorResult, - isDbResponse, -} from "./types"; diff --git a/httui-desktop/src/components/blocks/run-diff/RunDiffPanel.tsx b/httui-desktop/src/components/blocks/run-diff/RunDiffPanel.tsx deleted file mode 100644 index f400da0e..00000000 --- a/httui-desktop/src/components/blocks/run-diff/RunDiffPanel.tsx +++ /dev/null @@ -1,340 +0,0 @@ -// Run diff side-by-side panel. -// -// Pure presentational consumer of `RunDiff` from -// `lib/blocks/run-diff.ts`. Four tabs: Body / Headers / Status / -// Timing. Body uses red/green inline highlights at the changed key; -// headers row-aligned; status + timing summary chips. The consumer -// supplies the diff (already computed) so this component has no -// effects. - -import { Box, Flex, Text, chakra } from "@chakra-ui/react"; -import { useState } from "react"; - -import type { - HeaderDiffEntry, - JsonDiffEntry, - RunDiff, -} from "@/lib/blocks/run-diff"; - -type Tab = "body" | "headers" | "status" | "timing"; - -export interface RunDiffPanelProps { - diff: RunDiff; - /** Initial tab. Default: "body" (or "status" when bodyTruncated). */ - initialTab?: Tab; -} - -export function RunDiffPanel({ diff, initialTab }: RunDiffPanelProps) { - const defaultTab: Tab = - initialTab ?? (diff.bodyTruncated ? "status" : "body"); - const [tab, setTab] = useState(defaultTab); - - return ( - - - - h.op !== "equal").length} - /> - - - - - {tab === "body" && } - {tab === "headers" && } - {tab === "status" && } - {tab === "timing" && } - - ); -} - -function TabBtn({ - current, - value, - onPick, - label, - count, -}: { - current: Tab; - value: Tab; - onPick: (t: Tab) => void; - label: string; - count?: number; -}) { - const active = current === value; - return ( - onPick(value)} - px={3} - py={2} - bg="transparent" - borderWidth={0} - borderBottomWidth="2px" - borderBottomColor={active ? "brand.fg" : "transparent"} - fontFamily="mono" - fontSize="11px" - color={active ? "fg" : "fg.muted"} - cursor="pointer" - _hover={{ color: "fg" }} - > - {label} - {typeof count === "number" && count > 0 && ( - - ({count}) - - )} - - ); -} - -function BodyTab({ diff }: { diff: RunDiff }) { - if (diff.bodyTruncated) { - return ( - - Body diff skipped — at least one side exceeds 200 KB. Open the full file - from{" "} - - .httui/runs/ - - . - - ); - } - if (diff.body.length === 0) { - return ( - - Bodies match. - - ); - } - return ( - - {diff.body.map((e, i) => ( - - ))} - - ); -} - -function BodyRow({ entry }: { entry: JsonDiffEntry }) { - return ( - - - {entry.op === "add" ? "+" : entry.op === "remove" ? "−" : "~"} - - - {entry.path} - - - {entry.before !== undefined && ( - - {fmt(entry.before)} - - )} - {entry.after !== undefined && ( - - {fmt(entry.after)} - - )} - - - ); -} - -function HeadersTab({ entries }: { entries: ReadonlyArray }) { - if (entries.length === 0) { - return ( - - No headers on either side. - - ); - } - return ( - - {entries.map((h) => ( - - - {h.key} - - - {h.before ?? ""} - - - {h.after ?? ""} - - - ))} - - ); -} - -function StatusTab({ diff }: { diff: RunDiff }) { - return ( - - A: - - {diff.status.before ?? "—"} - - B: - - {diff.status.after ?? "—"} - - - ); -} - -function TimingTab({ diff }: { diff: RunDiff }) { - const delta = diff.timing.deltaMs; - return ( - - A: - {diff.timing.before ?? "—"}ms - B: - {diff.timing.after ?? "—"}ms - {delta !== undefined && ( - 0 ? "error" : delta < 0 ? "brand.fg" : "fg.muted"} - data-testid="run-diff-timing-delta" - > - {delta > 0 ? `+${delta}` : delta}ms - - )} - - ); -} - -function fmt(v: unknown): string { - if (v === null) return "null"; - if (typeof v === "string") return JSON.stringify(v); - if (typeof v === "number" || typeof v === "boolean") return String(v); - try { - return JSON.stringify(v); - } catch { - return String(v); - } -} diff --git a/httui-desktop/src/components/blocks/run-diff/RunHistoryMenu.tsx b/httui-desktop/src/components/blocks/run-diff/RunHistoryMenu.tsx deleted file mode 100644 index b4b2ebff..00000000 --- a/httui-desktop/src/components/blocks/run-diff/RunHistoryMenu.tsx +++ /dev/null @@ -1,170 +0,0 @@ -// Block run-history menu. -// -// Presentational. Lists the last N runs of a single block (already -// fetched by the consumer via `listBlockHistory`) with per-row -// View / Diff-with-current / Diff-with… actions. The consumer wires -// the handlers to the existing run cache + ``. - -import { Box, Flex, Text } from "@chakra-ui/react"; - -import { Btn } from "@/components/atoms"; -import type { HistoryEntry } from "@/lib/tauri/commands"; - -export interface RunHistoryMenuProps { - entries: ReadonlyArray; - /** Optional id of the run currently rendered as "live" — Diff-with-current is hidden for it. */ - liveRunId?: number | null; - onView?: (entry: HistoryEntry) => void; - onDiffWithCurrent?: (entry: HistoryEntry) => void; - /** "Diff with…" picker — the consumer pops a second selector. */ - onDiffWithPick?: (entry: HistoryEntry) => void; -} - -export function RunHistoryMenu({ - entries, - liveRunId, - onView, - onDiffWithCurrent, - onDiffWithPick, -}: RunHistoryMenuProps) { - if (entries.length === 0) { - return ( - - No runs yet. Run the block to start the history. - - ); - } - - return ( - - {entries.map((entry) => ( - - ))} - - ); -} - -function Row({ - entry, - isLive, - onView, - onDiffWithCurrent, - onDiffWithPick, -}: { - entry: HistoryEntry; - isLive: boolean; - onView?: (e: HistoryEntry) => void; - onDiffWithCurrent?: (e: HistoryEntry) => void; - onDiffWithPick?: (e: HistoryEntry) => void; -}) { - const ok = entry.outcome === "success"; - return ( - - - {entry.method} - - - {entry.status ?? "—"} - - - {entry.url_canonical} - - - {formatRanAt(entry.ran_at)} - - - {onView && ( - onView(entry)} - > - View - - )} - {onDiffWithCurrent && !isLive && ( - onDiffWithCurrent(entry)} - > - Diff - - )} - {onDiffWithPick && ( - onDiffWithPick(entry)} - > - Diff… - - )} - - - ); -} - -/** Best-effort relative formatter. The Rust side stores ISO-8601; - * fall back to the raw string when parsing fails. */ -function formatRanAt(iso: string): string { - const t = Date.parse(iso); - if (Number.isNaN(t)) return iso; - const diffSec = Math.max(0, Math.floor((Date.now() - t) / 1000)); - if (diffSec < 60) return `${diffSec}s ago`; - if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; - if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; - return `${Math.floor(diffSec / 86400)}d ago`; -} diff --git a/httui-desktop/src/components/blocks/run-diff/__tests__/RunDiffPanel.test.tsx b/httui-desktop/src/components/blocks/run-diff/__tests__/RunDiffPanel.test.tsx deleted file mode 100644 index 73e4ce6f..00000000 --- a/httui-desktop/src/components/blocks/run-diff/__tests__/RunDiffPanel.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, expect, it } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { RunDiffPanel } from "@/components/blocks/run-diff/RunDiffPanel"; -import type { RunDiff } from "@/lib/blocks/run-diff"; -import { renderWithProviders, screen } from "@/test/render"; - -function diff(over: Partial = {}): RunDiff { - return { - status: { before: 200, after: 200, changed: false }, - headers: [], - body: [], - timing: { before: 100, after: 150, deltaMs: 50 }, - bodyTruncated: false, - ...over, - }; -} - -describe("RunDiffPanel", () => { - it("renders 4 tabs and starts on Body by default", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-tab-body")).toBeInTheDocument(); - expect(screen.getByTestId("run-diff-tab-headers")).toBeInTheDocument(); - expect(screen.getByTestId("run-diff-tab-status")).toBeInTheDocument(); - expect(screen.getByTestId("run-diff-tab-timing")).toBeInTheDocument(); - expect(screen.getByTestId("run-diff-panel").getAttribute("data-tab")).toBe( - "body", - ); - }); - - it("starts on Status when bodyTruncated is true", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-panel").getAttribute("data-tab")).toBe( - "status", - ); - }); - - it("respects initialTab prop", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-panel").getAttribute("data-tab")).toBe( - "timing", - ); - }); - - it("switches tabs on click", async () => { - renderWithProviders(); - await userEvent.setup().click(screen.getByTestId("run-diff-tab-headers")); - expect(screen.getByTestId("run-diff-panel").getAttribute("data-tab")).toBe( - "headers", - ); - }); - - it("renders 'Bodies match' when body diff is empty", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-body-equal")).toBeInTheDocument(); - }); - - it("renders truncation hint when bodyTruncated is true and body tab is selected", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("run-diff-body-truncated")).toBeInTheDocument(); - }); - - it("renders one body row per JSON diff entry with op-coded glyph", () => { - renderWithProviders( - , - ); - expect( - screen.getByTestId("run-diff-body-row-user.name").getAttribute("data-op"), - ).toBe("change"); - expect( - screen.getByTestId("run-diff-body-row-user.id").getAttribute("data-op"), - ).toBe("add"); - expect( - screen.getByTestId("run-diff-body-row-user.old").getAttribute("data-op"), - ).toBe("remove"); - }); - - it("body row shows quoted string for string before/after values", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("run-diff-body-row-x-before").textContent).toBe( - '"a"', - ); - expect(screen.getByTestId("run-diff-body-row-x-after").textContent).toBe( - '"b"', - ); - }); - - it("renders headers tab with one row per entry", async () => { - renderWithProviders( - , - ); - expect( - screen - .getByTestId("run-diff-headers-row-x-trace") - .getAttribute("data-op"), - ).toBe("change"); - expect( - screen - .getByTestId("run-diff-headers-row-content-type") - .getAttribute("data-op"), - ).toBe("equal"); - }); - - it("renders headers empty hint when no entries", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-headers-empty")).toBeInTheDocument(); - }); - - it("renders status tab with before/after numbers", () => { - renderWithProviders( - , - ); - const status = screen.getByTestId("run-diff-status"); - expect(status.getAttribute("data-changed")).toBe("true"); - expect(status.textContent).toMatch(/200/); - expect(status.textContent).toMatch(/500/); - }); - - it("renders timing delta with sign", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-timing-delta").textContent).toMatch( - /\+50ms/, - ); - }); - - it("hides timing delta when one side is undefined", () => { - renderWithProviders( - , - ); - expect( - screen.queryByTestId("run-diff-timing-delta"), - ).not.toBeInTheDocument(); - }); - - it("body tab count badge ignores zero", () => { - renderWithProviders(); - expect(screen.getByTestId("run-diff-tab-body").textContent).not.toMatch( - /\(\d/, - ); - }); - - it("body tab count badge shows count > 0", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("run-diff-tab-body").textContent).toMatch( - /\(2\)/, - ); - }); -}); diff --git a/httui-desktop/src/components/blocks/run-diff/__tests__/RunHistoryMenu.test.tsx b/httui-desktop/src/components/blocks/run-diff/__tests__/RunHistoryMenu.test.tsx deleted file mode 100644 index 2dbefe99..00000000 --- a/httui-desktop/src/components/blocks/run-diff/__tests__/RunHistoryMenu.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { RunHistoryMenu } from "@/components/blocks/run-diff/RunHistoryMenu"; -import type { HistoryEntry } from "@/lib/tauri/commands"; -import { renderWithProviders, screen } from "@/test/render"; - -function entry(over: Partial = {}): HistoryEntry { - return { - id: 1, - file_path: "a.md", - block_alias: "x", - method: "GET", - url_canonical: "https://api/users", - status: 200, - request_size: null, - response_size: 100, - elapsed_ms: 50, - outcome: "success", - ran_at: new Date(Date.now() - 30_000).toISOString(), - ...over, - }; -} - -describe("RunHistoryMenu", () => { - it("renders empty hint when entries is empty", () => { - renderWithProviders(); - expect(screen.getByTestId("run-history-menu-empty")).toBeInTheDocument(); - }); - - it("renders one row per entry with method + status + url", () => { - renderWithProviders( - , - ); - expect( - screen.getByTestId("run-history-menu").getAttribute("data-count"), - ).toBe("2"); - expect(screen.getByTestId("run-history-row-1")).toBeInTheDocument(); - expect( - screen.getByTestId("run-history-row-2").getAttribute("data-outcome"), - ).toBe("error"); - }); - - it("hides Diff-with-current button on the live row", () => { - renderWithProviders( - {}} - />, - ); - expect( - screen.queryByTestId("run-history-row-1-diff-current"), - ).not.toBeInTheDocument(); - expect( - screen.getByTestId("run-history-row-1").getAttribute("data-live"), - ).toBe("true"); - }); - - it("fires onView when View clicked", async () => { - const onView = vi.fn(); - renderWithProviders( - , - ); - await userEvent.setup().click(screen.getByTestId("run-history-row-1-view")); - expect(onView).toHaveBeenCalledTimes(1); - expect(onView.mock.calls[0][0].id).toBe(1); - }); - - it("fires onDiffWithCurrent when Diff clicked on a non-live row", async () => { - const onDiff = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("run-history-row-2-diff-current")); - expect(onDiff).toHaveBeenCalledTimes(1); - expect(onDiff.mock.calls[0][0].id).toBe(2); - }); - - it("fires onDiffWithPick when Diff… clicked", async () => { - const onPick = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("run-history-row-7-diff-pick")); - expect(onPick).toHaveBeenCalledTimes(1); - expect(onPick.mock.calls[0][0].id).toBe(7); - }); - - it("hides action buttons that don't have a handler", () => { - renderWithProviders(); - expect( - screen.queryByTestId("run-history-row-1-view"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("run-history-row-1-diff-current"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("run-history-row-1-diff-pick"), - ).not.toBeInTheDocument(); - }); - - it("renders status as em-dash when null", () => { - renderWithProviders( - , - ); - expect(screen.getByTestId("run-history-row-1").textContent).toMatch(/—/); - }); - - it("renders ISO date as a relative 's ago' label", () => { - const tenSecAgo = new Date(Date.now() - 10_000).toISOString(); - renderWithProviders( - , - ); - expect(screen.getByTestId("run-history-row-1").textContent).toMatch( - /\ds ago/, - ); - }); -}); diff --git a/httui-desktop/src/components/chat/ToolUseBlock.tsx b/httui-desktop/src/components/chat/ToolUseBlock.tsx deleted file mode 100644 index 55e8ef84..00000000 --- a/httui-desktop/src/components/chat/ToolUseBlock.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { memo, useState } from "react"; -import { Box, HStack, Text } from "@chakra-ui/react"; -import { - LuChevronDown, - LuChevronRight, - LuLoader, - LuCheck, - LuX, - LuFileText, - LuSearch, - LuTerminal, - LuPencil, - LuFolderSearch, - LuGlobe, - LuWrench, -} from "react-icons/lu"; -import type { ChatToolCall } from "@/lib/tauri/chat"; -import type { ToolActivity } from "@/stores/chat"; - -interface ToolUseBlockProps { - toolCall?: ChatToolCall; - activity?: ToolActivity; -} - -/** Strip MCP prefix: "mcp__httui_notes__list_connections" → "list_connections" */ -function shortName(name: string): string { - const parts = name.split("__"); - return parts.length > 2 ? parts.slice(2).join("__") : name; -} - -/** Pick an icon based on tool name */ -function toolIcon(name: string) { - const n = name.toLowerCase(); - if (n.includes("read") || n.includes("cat")) return LuFileText; - if ( - n.includes("write") || - n.includes("edit") || - n.includes("create") || - n.includes("update") || - n.includes("delete") - ) - return LuPencil; - if (n.includes("grep") || n.includes("search")) return LuSearch; - if (n.includes("glob") || n.includes("list")) return LuFolderSearch; - if (n.includes("bash") || n.includes("exec")) return LuTerminal; - if (n.includes("fetch") || n.includes("web")) return LuGlobe; - return LuWrench; -} - -function formatInput(input: unknown): string { - if (typeof input === "string") { - try { - return JSON.stringify(JSON.parse(input), null, 2); - } catch { - return input; - } - } - return JSON.stringify(input, null, 2); -} - -/** Show the most relevant field inline */ -function inlineSummary(input: unknown): string | null { - if (!input || typeof input !== "object") return null; - const obj = input as Record; - if ("command" in obj) return String(obj.command); - if ("file_path" in obj) return String(obj.file_path); - if ("path" in obj && "pattern" in obj) return `${obj.pattern} in ${obj.path}`; - if ("pattern" in obj) return String(obj.pattern); - if ("path" in obj) return String(obj.path); - if ("note_path" in obj) return String(obj.note_path); - return null; -} - -export const ToolUseBlock = memo(function ToolUseBlock({ - toolCall, - activity, -}: ToolUseBlockProps) { - const [expanded, setExpanded] = useState(false); - - const rawName = toolCall?.tool_name ?? activity?.name ?? "Unknown"; - const name = shortName(rawName); - const input = toolCall ? toolCall.input_json : activity ? activity.input : {}; - const result = toolCall?.result_json ?? activity?.result; - const isError = toolCall?.is_error ?? activity?.isError ?? false; - const isPending = activity?.pending ?? false; - - const statusColor = isPending - ? "blue.400" - : isError - ? "red.400" - : "green.400"; - - const StatusIcon = isPending ? LuLoader : isError ? LuX : LuCheck; - const ToolIcon = toolIcon(rawName); - const summary = inlineSummary(input); - - return ( - - {/* Header */} - setExpanded((prev) => !prev)} - gap={1} - _hover={{ bg: "bg.subtle" }} - > - - - - - - - - {name} - - {summary && ( - - {summary} - - )} - - {expanded ? ( - - ) : ( - - )} - - - - {/* Body */} - {expanded && ( - - {/* Input */} - - Input - - - {formatInput(input)} - - - {/* Result */} - {result && ( - <> - - Result - - - {result.length > 2000 - ? result.slice(0, 2000) + "\n... (truncated)" - : result} - - - )} - - {isPending && ( - - Executing... - - )} - - )} - - ); -}); diff --git a/httui-desktop/src/components/editor/__tests__/markdown-extensions.test.ts b/httui-desktop/src/components/editor/__tests__/markdown-extensions.test.ts index 294ed1a9..d0c7dd37 100644 --- a/httui-desktop/src/components/editor/__tests__/markdown-extensions.test.ts +++ b/httui-desktop/src/components/editor/__tests__/markdown-extensions.test.ts @@ -41,9 +41,6 @@ vi.mock("@/lib/codemirror/cm-slash-commands", () => ({ slashCompletionSource: vi.fn(), slashIconOption: { id: "slash-icon" }, })); -vi.mock("@/lib/codemirror/cm-block-widgets", () => ({ - createEditorBlockWidgets: vi.fn(() => ({ blockWidgets: true })), -})); vi.mock("@/components/editor/editor-theme", () => ({ editorTheme: { theme: true }, })); diff --git a/httui-desktop/src/components/editor/markdown-extensions.ts b/httui-desktop/src/components/editor/markdown-extensions.ts index bda1308d..74afbd04 100644 --- a/httui-desktop/src/components/editor/markdown-extensions.ts +++ b/httui-desktop/src/components/editor/markdown-extensions.ts @@ -36,7 +36,6 @@ import { slashCompletionSource, slashIconOption, } from "@/lib/codemirror/cm-slash-commands"; -import { createEditorBlockWidgets } from "@/lib/codemirror/cm-block-widgets"; import { editorTheme } from "@/components/editor/editor-theme"; import { createDbBlockExtension, @@ -144,7 +143,6 @@ export function buildExtensions(params: BuildExtensionsParams) { ...(docHeaderHandle ? [docHeaderHandle.extension] : []), createDbBlockExtension(), createHttpBlockExtension(), - createEditorBlockWidgets(), tables(), slashCommands(), wikilinks({ diff --git a/httui-desktop/src/components/layout/AddBlockMenu.tsx b/httui-desktop/src/components/layout/AddBlockMenu.tsx deleted file mode 100644 index a7a27731..00000000 --- a/httui-desktop/src/components/layout/AddBlockMenu.tsx +++ /dev/null @@ -1,213 +0,0 @@ -// Floating "+ Add block" menu. -// -// 7 options spanning the executable + non-executable block surface: -// Markdown / HTTP / SQL / MongoDB / WebSocket / GraphQL / Shell. -// Today only HTTP + SQL execute; the remaining four insert a -// placeholder fence with `executable=false` so the document still -// reads as the right block kind. -// -// Pure presentational: takes an `onInsert(template)` callback. The -// parent wires it to a CM6 transaction (insert at cursor, or at end- -// of-doc when the editor isn't focused). Tests + Storybook can pass -// any callback. - -import { Box, Menu, Portal, chakra } from "@chakra-ui/react"; -import { useCallback } from "react"; -import { - LuPlus, - LuFileText, - LuGlobe, - LuDatabase, - LuLeaf, - LuRadio, - LuShare2, - LuTerminal, -} from "react-icons/lu"; - -const Trigger = chakra("button"); - -export type BlockKind = - | "markdown" - | "http" - | "sql" - | "mongodb" - | "websocket" - | "graphql" - | "shell"; - -export interface BlockTemplate { - kind: BlockKind; - label: string; - /** Whether this kind has a real executor today. Non-executable - * kinds insert with `executable=false` info-string token. */ - executable: boolean; - /** Template text to insert at cursor. */ - insert: string; - /** Optional negative offset to land the cursor inside the template - * (e.g. inside a fence body). */ - cursorOffset?: number; -} - -export const BLOCK_TEMPLATES: Readonly> = { - markdown: { - kind: "markdown", - label: "Markdown", - executable: false, - insert: "## \n\n", - cursorOffset: -2, - }, - http: { - kind: "http", - label: "HTTP", - executable: true, - insert: "```http alias=req1\nGET \n```\n", - cursorOffset: -5, - }, - sql: { - kind: "sql", - label: "SQL", - executable: true, - insert: "```db alias=q1\nSELECT 1;\n```\n", - cursorOffset: -5, - }, - mongodb: { - kind: "mongodb", - label: "MongoDB", - executable: false, - insert: - "```mongodb alias=q1 executable=false\ndb.collection.find({})\n```\n", - cursorOffset: -6, - }, - websocket: { - kind: "websocket", - label: "WebSocket", - executable: false, - insert: "```ws alias=ws1 executable=false\nwss://example.com/socket\n```\n", - cursorOffset: -6, - }, - graphql: { - kind: "graphql", - label: "GraphQL", - executable: false, - insert: "```graphql alias=q1 executable=false\nquery {\n \n}\n```\n", - cursorOffset: -8, - }, - shell: { - kind: "shell", - label: "Shell", - executable: false, - insert: "```sh alias=s1 executable=false\necho hello\n```\n", - cursorOffset: -6, - }, -}; - -const ICONS: Record> = { - markdown: LuFileText, - http: LuGlobe, - sql: LuDatabase, - mongodb: LuLeaf, - websocket: LuRadio, - graphql: LuShare2, - shell: LuTerminal, -}; - -const KIND_ORDER: ReadonlyArray = [ - "markdown", - "http", - "sql", - "mongodb", - "websocket", - "graphql", - "shell", -]; - -export interface AddBlockMenuProps { - onInsert: (template: BlockTemplate) => void; - /** Optional aria-label on the trigger for screen readers. */ - ariaLabel?: string; - /** Trigger size (px). Default 32 for the standalone floating - * button; pass 20 to fit inside the editor toolbar (28px tall). */ - triggerSize?: number; -} - -export function AddBlockMenu({ - onInsert, - ariaLabel = "Add block", - triggerSize = 32, -}: AddBlockMenuProps) { - const handleSelect = useCallback( - (kind: BlockKind) => { - onInsert(BLOCK_TEMPLATES[kind]); - }, - [onInsert], - ); - - const iconSize = Math.round(triggerSize / 2); - - return ( - - - - - - - - - - {KIND_ORDER.map((kind) => { - const t = BLOCK_TEMPLATES[kind]; - const Icon = ICONS[kind]; - return ( - handleSelect(kind)} - cursor="pointer" - px={2} - py={1.5} - borderRadius="3px" - > - - - {t.label} - {!t.executable && ( - - non-exec - - )} - - - ); - })} - - - - - ); -} diff --git a/httui-desktop/src/components/layout/__tests__/AddBlockMenu.test.tsx b/httui-desktop/src/components/layout/__tests__/AddBlockMenu.test.tsx deleted file mode 100644 index a0f7930e..00000000 --- a/httui-desktop/src/components/layout/__tests__/AddBlockMenu.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderWithProviders, screen } from "@/test/render"; -import userEvent from "@testing-library/user-event"; - -import { - AddBlockMenu, - BLOCK_TEMPLATES, - type BlockKind, -} from "@/components/layout/AddBlockMenu"; - -describe("AddBlockMenu", () => { - it("renders a single trigger with aria-label='Add block' by default", () => { - renderWithProviders( {}} />); - expect( - screen.getByRole("button", { name: "Add block" }), - ).toBeInTheDocument(); - }); - - it("supports an ariaLabel override", () => { - renderWithProviders( - {}} ariaLabel="Insert here" />, - ); - expect( - screen.getByRole("button", { name: "Insert here" }), - ).toBeInTheDocument(); - }); - - it("opens a menu with all 7 block kinds", async () => { - const user = userEvent.setup(); - renderWithProviders( {}} />); - - await user.click(screen.getByRole("button", { name: "Add block" })); - - const expectedKinds: BlockKind[] = [ - "markdown", - "http", - "sql", - "mongodb", - "websocket", - "graphql", - "shell", - ]; - for (const kind of expectedKinds) { - expect(screen.getByText(BLOCK_TEMPLATES[kind].label)).toBeInTheDocument(); - } - }); - - it("dispatches onInsert with the chosen template", async () => { - const user = userEvent.setup(); - const onInsert = vi.fn(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: "Add block" })); - await user.click(screen.getByText("HTTP")); - - expect(onInsert).toHaveBeenCalledTimes(1); - expect(onInsert.mock.calls[0][0]).toEqual(BLOCK_TEMPLATES.http); - }); - - it("non-executable kinds carry the executable=false marker in their template", () => { - for (const k of [ - "mongodb", - "websocket", - "graphql", - "shell", - ] as BlockKind[]) { - expect(BLOCK_TEMPLATES[k].executable).toBe(false); - expect(BLOCK_TEMPLATES[k].insert).toContain("executable=false"); - } - }); - - it("executable kinds (HTTP + SQL) do NOT carry executable=false", () => { - for (const k of ["http", "sql"] as BlockKind[]) { - expect(BLOCK_TEMPLATES[k].executable).toBe(true); - expect(BLOCK_TEMPLATES[k].insert).not.toContain("executable=false"); - } - }); - - it("HTTP template lands at the URL position via cursorOffset", () => { - expect(BLOCK_TEMPLATES.http.cursorOffset).toBe(-5); - expect(BLOCK_TEMPLATES.http.insert).toContain("```http alias=req1"); - }); -}); diff --git a/httui-desktop/src/components/layout/connections/NewConnectionEnvBinder.tsx b/httui-desktop/src/components/layout/connections/NewConnectionEnvBinder.tsx deleted file mode 100644 index d907443e..00000000 --- a/httui-desktop/src/components/layout/connections/NewConnectionEnvBinder.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Canvas §5 — "Vincular ao ambiente" pills. -// -// Horizontal strip of env pills. Each entry is selectable; the active -// state gets accent-soft bg + accent border (canvas spec). A read-only -// env (e.g. prod under a guard) shows "(read-only)" inline and stays -// selectable but with a softer affordance — the page consumer enforces -// "no writes" elsewhere; the pill is purely visual. A trailing -// "+ novo" dashed pill dispatches `onCreateNew` when provided. -// -// Pure presentational — selection set lifted to the consumer. - -import { Box, HStack, Text, chakra } from "@chakra-ui/react"; - -const PillButton = chakra("button"); - -export interface EnvBinderEntry { - id: string; - name: string; - readOnly?: boolean; -} - -export interface NewConnectionEnvBinderProps { - envs: ReadonlyArray; - selectedIds: ReadonlyArray; - onToggle: (id: string) => void; - onCreateNew?: () => void; -} - -export function NewConnectionEnvBinder({ - envs, - selectedIds, - onToggle, - onCreateNew, -}: NewConnectionEnvBinderProps) { - const selected = new Set(selectedIds); - return ( - - - Vincular ao ambiente - - - {envs.map((env) => { - const active = selected.has(env.id); - return ( - onToggle(env.id)} - display="inline-flex" - alignItems="center" - gap={1.5} - h="22px" - px="10px" - borderRadius="999px" - borderWidth="1px" - borderStyle="solid" - borderColor={active ? "brand.fg" : "border"} - bg={active ? "brand.subtle" : "transparent"} - color={active ? "fg" : "fg.muted"} - fontSize="11px" - fontWeight={active ? 600 : 500} - cursor="pointer" - _hover={{ - borderColor: active ? "brand.fg" : "fg.subtle", - color: "fg", - }} - > - {env.name} - {env.readOnly && ( - - (read-only) - - )} - - ); - })} - {onCreateNew && ( - - + novo - - )} - - - ); -} diff --git a/httui-desktop/src/components/layout/connections/NewConnectionTestBanner.tsx b/httui-desktop/src/components/layout/connections/NewConnectionTestBanner.tsx deleted file mode 100644 index 46c47702..00000000 --- a/httui-desktop/src/components/layout/connections/NewConnectionTestBanner.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// Canvas §5 — Inline test result banner for the "Nova conexão" modal. -// -// 4 visual states: hidden (idle), running (subtle dot + "Testando…"), -// ok (color-mix --ok 8% bg, --ok 30% border, dot-ok + "Conexão OK" -// + mono detail line + "Re-testar" 11px accent on the right), err -// (same shape with --err palette). -// -// Pure presentational — discriminated-union state lifted to the -// consumer. - -import { Box, Flex, Text, chakra } from "@chakra-ui/react"; - -const RetryButton = chakra("button"); - -export type NewConnectionTestState = - | { kind: "idle" } - | { kind: "running" } - | { kind: "ok"; detail: string; latencyMs: number } - | { kind: "err"; message: string }; - -export interface NewConnectionTestBannerProps { - state: NewConnectionTestState; - onRetry?: () => void; -} - -export function NewConnectionTestBanner({ - state, - onRetry, -}: NewConnectionTestBannerProps) { - if (state.kind === "idle") return null; - - if (state.kind === "running") { - return ( - - - - - Testing… - - - - ); - } - - if (state.kind === "ok") { - return ( - - - - Connection OK - - {state.detail} · {state.latencyMs}ms - - - {onRetry && ( - - Re-test - - )} - - - ); - } - - return ( - - - - Failed - - {state.message} - - - {onRetry && ( - - Re-test - - )} - - - ); -} diff --git a/httui-desktop/src/components/layout/connections/__tests__/NewConnectionEnvBinder.test.tsx b/httui-desktop/src/components/layout/connections/__tests__/NewConnectionEnvBinder.test.tsx deleted file mode 100644 index 557cd5e0..00000000 --- a/httui-desktop/src/components/layout/connections/__tests__/NewConnectionEnvBinder.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { renderWithProviders, screen } from "@/test/render"; -import { NewConnectionEnvBinder } from "@/components/layout/connections/NewConnectionEnvBinder"; - -const ENVS = [ - { id: "local", name: "local" }, - { id: "staging", name: "staging" }, - { id: "qa-eu", name: "qa-eu" }, - { id: "prod", name: "prod", readOnly: true }, -]; - -describe("NewConnectionEnvBinder", () => { - it("renders one pill per env in order", () => { - renderWithProviders( - , - ); - for (const env of ENVS) { - expect( - screen.getByTestId(`new-connection-env-pill-${env.id}`), - ).toBeInTheDocument(); - } - }); - - it("marks selected pills via data-active + aria-pressed", () => { - renderWithProviders( - , - ); - const staging = screen.getByTestId("new-connection-env-pill-staging"); - expect(staging.getAttribute("data-active")).toBe("true"); - expect(staging.getAttribute("aria-pressed")).toBe("true"); - const local = screen.getByTestId("new-connection-env-pill-local"); - expect(local.getAttribute("data-active")).toBe("false"); - }); - - it("flags the read-only env via data-readonly + the inline (read-only) tag", () => { - renderWithProviders( - , - ); - const prod = screen.getByTestId("new-connection-env-pill-prod"); - expect(prod.getAttribute("data-readonly")).toBe("true"); - expect(prod.textContent).toContain("(read-only)"); - }); - - it("clicking a pill dispatches onToggle with that env id", async () => { - const onToggle = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("new-connection-env-pill-local")); - expect(onToggle).toHaveBeenCalledWith("local"); - }); - - it("renders the dashed '+ novo' pill only when onCreateNew is supplied", () => { - const { rerender } = renderWithProviders( - , - ); - expect( - screen.queryByTestId("new-connection-env-pill-new"), - ).not.toBeInTheDocument(); - - rerender( - , - ); - expect( - screen.getByTestId("new-connection-env-pill-new"), - ).toBeInTheDocument(); - }); - - it("clicking '+ novo' dispatches onCreateNew", async () => { - const onCreateNew = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("new-connection-env-pill-new")); - expect(onCreateNew).toHaveBeenCalledTimes(1); - }); - - it("renders the section heading from the canvas spec", () => { - renderWithProviders( - , - ); - expect(screen.getByText("Vincular ao ambiente")).toBeInTheDocument(); - }); -}); diff --git a/httui-desktop/src/components/layout/connections/__tests__/NewConnectionTestBanner.test.tsx b/httui-desktop/src/components/layout/connections/__tests__/NewConnectionTestBanner.test.tsx deleted file mode 100644 index 68ccf41c..00000000 --- a/httui-desktop/src/components/layout/connections/__tests__/NewConnectionTestBanner.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import { renderWithProviders, screen } from "@/test/render"; -import { NewConnectionTestBanner } from "@/components/layout/connections/NewConnectionTestBanner"; - -describe("NewConnectionTestBanner", () => { - it("renders nothing when idle", () => { - const { container } = renderWithProviders( - , - ); - expect(container.firstChild).toBeNull(); - }); - - it("renders the running banner with neutral dot", () => { - renderWithProviders( - , - ); - expect( - screen.getByTestId("new-connection-test-banner-running"), - ).toBeInTheDocument(); - expect(screen.getByTestId("dot-running")).toBeInTheDocument(); - expect(screen.getByText("Testing…")).toBeInTheDocument(); - }); - - it("renders the ok banner with detail line + latency", () => { - renderWithProviders( - , - ); - const banner = screen.getByTestId("new-connection-test-banner-ok"); - expect(banner).toBeInTheDocument(); - expect(banner.textContent).toContain("Connection OK"); - expect(banner.textContent).toContain("postgres 15.4 · 47 tables"); - expect(banner.textContent).toContain("18ms"); - expect(screen.getByTestId("dot-ok")).toBeInTheDocument(); - }); - - it("renders the err banner with the message", () => { - renderWithProviders( - , - ); - const banner = screen.getByTestId("new-connection-test-banner-err"); - expect(banner).toBeInTheDocument(); - expect(banner.textContent).toContain("Failed"); - expect(banner.textContent).toContain("ECONNREFUSED"); - expect(screen.getByTestId("dot-err")).toBeInTheDocument(); - }); - - it("renders the Re-testar button on ok + dispatches onRetry", async () => { - const onRetry = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("new-connection-test-retry")); - expect(onRetry).toHaveBeenCalledTimes(1); - }); - - it("renders the Re-testar button on err + dispatches onRetry", async () => { - const onRetry = vi.fn(); - renderWithProviders( - , - ); - await userEvent - .setup() - .click(screen.getByTestId("new-connection-test-retry")); - expect(onRetry).toHaveBeenCalledTimes(1); - }); - - it("hides Re-testar when onRetry is not provided", () => { - renderWithProviders( - , - ); - expect( - screen.queryByTestId("new-connection-test-retry"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/httui-desktop/src/components/layout/docheader/DocHeaderStatusBadge.tsx b/httui-desktop/src/components/layout/docheader/DocHeaderStatusBadge.tsx deleted file mode 100644 index 238b0d52..00000000 --- a/httui-desktop/src/components/layout/docheader/DocHeaderStatusBadge.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// frontmatter `status:` badge. -// -// Small pill rendered near the H1 (consumer's choice of mount point). -// Pure presentational; consumer reads `frontmatter.status` from the -// parser and passes it in. Hides itself when `status` is -// undefined / null so consumers can mount unconditionally above -// the title without conditional wrappers. -// -// Per spec the three valid values are `draft | active | archived`. -// Unknown values fall through to the muted palette so a -// forward-compat `status: review` sentinel still renders rather than -// disappearing — same forward-compat lens that `Frontmatter::extra` -// uses. - -import { Box } from "@chakra-ui/react"; - -export type DocHeaderStatusValue = "draft" | "active" | "archived"; - -export interface DocHeaderStatusBadgeProps { - /** `frontmatter.status` from the parser. Hidden when - * null / undefined / empty so consumer mounts unconditionally. */ - status?: string | null; - /** Optional click handler; turns the badge into a ` - - - - - - - - - - ); - - return { prompt, PromptDialog }; -} diff --git a/httui-desktop/src/hooks/useTemplates.ts b/httui-desktop/src/hooks/useTemplates.ts deleted file mode 100644 index 309b9e5b..00000000 --- a/httui-desktop/src/hooks/useTemplates.ts +++ /dev/null @@ -1,64 +0,0 @@ -// templates picker data source. -// -// Fetches built-in + vault-local templates once per vault change. -// Templates barely change in a single session (vault-local edits -// require dropping a file into `.httui/templates/`); polling -// would be overkill. The hook exposes `refresh()` so the picker -// can re-fetch after the user creates a new template through -// the app. -// -// Idle when `vaultPath` is null (matches the `useGitRemotes` -// posture). - -import { useCallback, useEffect, useRef, useState } from "react"; - -import { listTemplates, type Template } from "@/lib/tauri/templates"; - -export interface UseTemplatesResult { - templates: Template[]; - loaded: boolean; - error: string | null; - refresh: () => void; -} - -export function useTemplates(vaultPath: string | null): UseTemplatesResult { - const [templates, setTemplates] = useState([]); - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(null); - const cancelledRef = useRef(false); - - const fetchOnce = useCallback(async () => { - if (!vaultPath) { - setTemplates([]); - setLoaded(false); - setError(null); - return; - } - try { - const next = await listTemplates(vaultPath); - if (cancelledRef.current) return; - setTemplates(next); - setLoaded(true); - setError(null); - } catch (e) { - if (cancelledRef.current) return; - setTemplates([]); - setLoaded(false); - setError(e instanceof Error ? e.message : String(e)); - } - }, [vaultPath]); - - useEffect(() => { - cancelledRef.current = false; - void fetchOnce(); - return () => { - cancelledRef.current = true; - }; - }, [fetchOnce]); - - const refresh = useCallback(() => { - void fetchOnce(); - }, [fetchOnce]); - - return { templates, loaded, error, refresh }; -} diff --git a/httui-desktop/src/hooks/useTheme.ts b/httui-desktop/src/hooks/useTheme.ts deleted file mode 100644 index 1741fb10..00000000 --- a/httui-desktop/src/hooks/useTheme.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useColorMode } from "@/components/ui/color-mode"; - -export function useTheme() { - const { colorMode, toggleColorMode } = useColorMode(); - - return { - theme: colorMode as "light" | "dark", - toggleTheme: toggleColorMode, - }; -} diff --git a/httui-desktop/src/lib/blocks/__tests__/assertions-aggregate.test.ts b/httui-desktop/src/lib/blocks/__tests__/assertions-aggregate.test.ts deleted file mode 100644 index 639e684d..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/assertions-aggregate.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - aggregateAssertionResults, - firstAssertionFailureBlock, - type BlockAssertionRun, -} from "@/lib/blocks/assertions-aggregate"; - -function run( - alias: string, - total: number, - failures: number, - noResult = false, -): BlockAssertionRun { - return { - blockAlias: alias, - total, - result: noResult - ? null - : { - pass: failures === 0, - failures: Array.from({ length: failures }, (_, i) => ({ - line: i + 1, - raw: `assertion-${i}`, - actual: 1, - expected: 2, - reason: "values not equal", - })), - }, - }; -} - -describe("aggregateAssertionResults", () => { - it("returns a zero summary when runs is empty", () => { - expect(aggregateAssertionResults([])).toEqual({ - blocks: 0, - assertions: 0, - passed: 0, - failed: 0, - failedBlocks: [], - allPass: true, - }); - }); - - it("counts blocks even when none have assertions", () => { - const out = aggregateAssertionResults([ - run("a", 0, 0, true), - run("b", 0, 0, true), - ]); - expect(out.blocks).toBe(2); - expect(out.assertions).toBe(0); - expect(out.allPass).toBe(true); - }); - - it("rolls up totals across multiple blocks (all-pass case)", () => { - const out = aggregateAssertionResults([run("a", 3, 0), run("b", 4, 0)]); - expect(out).toEqual({ - blocks: 2, - assertions: 7, - passed: 7, - failed: 0, - failedBlocks: [], - allPass: true, - }); - }); - - it("collects failedBlocks in input order; passed = total - failures", () => { - const out = aggregateAssertionResults([ - run("a", 3, 0), - run("b", 4, 1), - run("c", 2, 2), - ]); - expect(out.passed).toBe(3 + 3 + 0); - expect(out.failed).toBe(0 + 1 + 2); - expect(out.failedBlocks).toEqual(["b", "c"]); - expect(out.allPass).toBe(false); - }); - - it("treats null result as 'unrun': counts in total but not in passed/failed", () => { - const out = aggregateAssertionResults([ - run("a", 3, 0, true), - run("b", 2, 1), - ]); - expect(out.assertions).toBe(5); - expect(out.passed).toBe(1); - expect(out.failed).toBe(1); - }); - - it("allPass is true when total is zero across the runs (no assertions to fail)", () => { - const out = aggregateAssertionResults([run("a", 0, 0, true)]); - expect(out.allPass).toBe(true); - }); -}); - -describe("firstAssertionFailureBlock", () => { - it("returns null when nothing has failed", () => { - expect( - firstAssertionFailureBlock([run("a", 1, 0), run("b", 1, 0)]), - ).toBeNull(); - }); - - it("returns the first failing alias in input order", () => { - expect( - firstAssertionFailureBlock([ - run("a", 1, 0), - run("b", 2, 1), - run("c", 3, 2), - ]), - ).toBe("b"); - }); - - it("ignores blocks with null result (unrun)", () => { - expect( - firstAssertionFailureBlock([ - run("a", 1, 0, true), - run("b", 1, 0, true), - run("c", 1, 1), - ]), - ).toBe("c"); - }); -}); diff --git a/httui-desktop/src/lib/blocks/__tests__/assertions.test.ts b/httui-desktop/src/lib/blocks/__tests__/assertions.test.ts deleted file mode 100644 index b38d0021..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/assertions.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - dbResponseToAssertionContext, - evaluateAllAssertions, - evaluateAssertion, - extractAssertionLines, - httpResponseToAssertionContext, - parseAllAssertions, - parseAssertionLine, - parseRhs, - resolveLhs, - type AssertionContext, -} from "@/lib/blocks/assertions"; - -describe("extractAssertionLines", () => { - it("returns empty when no marker is present", () => { - expect(extractAssertionLines("GET /\n")).toEqual([]); - }); - - it("collects every commented line until a blank line", () => { - const body = `GET / - -# expect: -# status === 200 -# time < 1000 -# $.body.id === 1 - -# trailing comment after blank — ignored`; - const out = extractAssertionLines(body); - expect(out.map((l) => l.rawLine)).toEqual([ - "status === 200", - "time < 1000", - "$.body.id === 1", - ]); - }); - - it("stops when a non-comment line shows up before blank", () => { - const body = `# expect: -# a === 1 -not-a-comment -# b === 2`; - const out = extractAssertionLines(body); - expect(out.map((l) => l.rawLine)).toEqual(["a === 1"]); - }); - - it("matches the marker case-insensitively", () => { - expect( - extractAssertionLines("# EXPECT:\n# status === 200").map( - (l) => l.rawLine, - ), - ).toEqual(["status === 200"]); - }); - - it("attaches the 1-indexed body line number", () => { - const body = `line1 -# expect: -# status === 200`; - const out = extractAssertionLines(body); - expect(out[0].bodyLine).toBe(3); - }); -}); - -describe("parseAssertionLine", () => { - it("parses ` === `", () => { - expect(parseAssertionLine("status === 200", 5)).toEqual({ - line: 5, - raw: "status === 200", - lhs: "status", - op: "===", - rhs: "200", - }); - }); - - it("parses `<` / `<=` / `>` / `>=`", () => { - expect(parseAssertionLine("time < 1000", 1)?.op).toBe("<"); - expect(parseAssertionLine("time <= 1000", 1)?.op).toBe("<="); - expect(parseAssertionLine("count > 0", 1)?.op).toBe(">"); - expect(parseAssertionLine("count >= 1", 1)?.op).toBe(">="); - }); - - it("parses word operators when surrounded by whitespace", () => { - expect(parseAssertionLine("$.body.name matches /alice/i", 1)?.op).toBe( - "matches", - ); - expect(parseAssertionLine("$.body.tags contains 'admin'", 1)?.op).toBe( - "contains", - ); - }); - - it("returns null on whitespace-only or empty lines", () => { - expect(parseAssertionLine("", 1)).toBeNull(); - expect(parseAssertionLine(" ", 1)).toBeNull(); - }); - - it("returns null when an operator is missing", () => { - expect(parseAssertionLine("just a comment", 1)).toBeNull(); - }); - - it("returns null when one side is empty", () => { - expect(parseAssertionLine("=== 200", 1)).toBeNull(); - expect(parseAssertionLine("status ===", 1)).toBeNull(); - }); -}); - -describe("parseAllAssertions", () => { - it("composes extract + parse and drops unparseable lines silently", () => { - const body = `GET / - -# expect: -# status === 200 -# this line cannot parse -# time < 1000`; - const out = parseAllAssertions(body); - expect(out.map((p) => p.lhs)).toEqual(["status", "time"]); - }); -}); - -describe("resolveLhs", () => { - const ctx: AssertionContext = { - status: 200, - time_ms: 42, - body: { id: 1, user: { name: "alice" }, tags: ["admin", "ops"] }, - headers: { "Content-Type": "application/json", "X-Trace": "abc" }, - row: [ - { id: 1, name: "row-one" }, - { id: 2, name: "row-two" }, - ], - }; - - it("resolves status / time", () => { - expect(resolveLhs("status", ctx)).toBe(200); - expect(resolveLhs("time", ctx)).toBe(42); - }); - - it("resolves $.body. with dot descent", () => { - expect(resolveLhs("$.body.id", ctx)).toBe(1); - expect(resolveLhs("$.body.user.name", ctx)).toBe("alice"); - }); - - it("resolves $.body[N] when the body is an array", () => { - const ctxA: AssertionContext = { body: [10, 20, 30] }; - expect(resolveLhs("$.body[1]", ctxA)).toBe(20); - }); - - it("resolves $.headers case-insensitively", () => { - expect(resolveLhs("$.headers.content-type", ctx)).toBe("application/json"); - expect(resolveLhs("$.headers.X-TRACE", ctx)).toBe("abc"); - }); - - it("resolves $.row[N].col", () => { - expect(resolveLhs("$.row[0].name", ctx)).toBe("row-one"); - expect(resolveLhs("$.row[1].id", ctx)).toBe(2); - }); - - it("returns undefined for missing paths", () => { - expect(resolveLhs("$.body.nope", ctx)).toBeUndefined(); - expect(resolveLhs("$.headers.missing", { headers: {} })).toBeUndefined(); - expect(resolveLhs("$.row[5].id", ctx)).toBeUndefined(); - }); - - it("returns undefined for unknown LHS shapes", () => { - expect(resolveLhs("anything", ctx)).toBeUndefined(); - }); -}); - -describe("parseRhs", () => { - it("parses numeric literals (positive, negative, decimal)", () => { - expect(parseRhs("200")).toEqual({ kind: "number", value: 200 }); - expect(parseRhs("-42")).toEqual({ kind: "number", value: -42 }); - expect(parseRhs("1.5")).toEqual({ kind: "number", value: 1.5 }); - }); - - it("parses double-quoted and single-quoted strings", () => { - expect(parseRhs('"hello"')).toEqual({ kind: "string", value: "hello" }); - expect(parseRhs("'world'")).toEqual({ kind: "string", value: "world" }); - }); - - it("parses regex literals with flags", () => { - const r = parseRhs("/^hello$/i"); - expect(r.kind).toBe("regex"); - if (r.kind === "regex") { - expect(r.value.source).toBe("^hello$"); - expect(r.value.flags).toBe("i"); - } - }); - - it("falls back to raw when nothing else matches", () => { - expect(parseRhs("ok")).toEqual({ kind: "raw", value: "ok" }); - }); -}); - -describe("evaluateAssertion / evaluateAllAssertions", () => { - const ctx: AssertionContext = { - status: 200, - time_ms: 87, - body: { id: 1, name: "alice", tags: ["admin", "ops"] }, - headers: { "Content-Type": "application/json" }, - row: [{ col: "x" }], - }; - - function ev(line: string) { - const parsed = parseAssertionLine(line, 1); - if (!parsed) throw new Error(`could not parse: ${line}`); - return evaluateAssertion(parsed, ctx); - } - - it("=== passes on equal numbers and fails otherwise", () => { - expect(ev("status === 200").pass).toBe(true); - expect(ev("status === 404").pass).toBe(false); - }); - - it("!== inverts ===", () => { - expect(ev("status !== 404").pass).toBe(true); - expect(ev("status !== 200").pass).toBe(false); - }); - - it("comparison operators work on numbers", () => { - expect(ev("time < 1000").pass).toBe(true); - expect(ev("time <= 87").pass).toBe(true); - expect(ev("time > 50").pass).toBe(true); - expect(ev("time >= 87").pass).toBe(true); - expect(ev("time < 50").pass).toBe(false); - }); - - it("matches uses regex literal", () => { - expect(ev("$.body.name matches /^al/").pass).toBe(true); - expect(ev("$.body.name matches /^bob$/").pass).toBe(false); - }); - - it("contains matches substring on strings", () => { - expect(ev("$.body.name contains 'lic'").pass).toBe(true); - expect(ev("$.body.name contains 'zzz'").pass).toBe(false); - }); - - it("contains matches array element when actual is an array", () => { - expect(ev("$.body.tags contains 'admin'").pass).toBe(true); - expect(ev("$.body.tags contains 'missing'").pass).toBe(false); - }); - - it("=== with regex literal also works as a 'matches' shortcut", () => { - expect(ev("$.body.name === /^al/").pass).toBe(true); - }); - - it("=== falls back to stringification for raw RHS tokens", () => { - expect(ev("$.body.id === 1").pass).toBe(true); - expect(ev("$.body.name === alice").pass).toBe(true); - }); - - it("attaches a failure record with line + raw + actual + expected", () => { - const result = ev("status === 404"); - expect(result.pass).toBe(false); - if (!result.pass) { - expect(result.failure.line).toBe(1); - expect(result.failure.raw).toBe("status === 404"); - expect(result.failure.actual).toBe(200); - expect(result.failure.expected).toBe(404); - expect(result.failure.reason).toMatch(/not equal/); - } - }); - - it("numeric compare fails clearly when actual isn't a number", () => { - const result = ev("$.body.name > 100"); - expect(result.pass).toBe(false); - if (!result.pass) expect(result.failure.reason).toMatch(/not a number/); - }); - - it("matches fails clearly when rhs isn't a regex literal", () => { - const result = ev("$.body.name matches alice"); - expect(result.pass).toBe(false); - if (!result.pass) expect(result.failure.reason).toMatch(/regex/); - }); - - it("evaluateAllAssertions aggregates pass=true when every check passes", () => { - const parsed = parseAllAssertions(`# expect: -# status === 200 -# time < 1000`); - const out = evaluateAllAssertions(parsed, ctx); - expect(out.pass).toBe(true); - expect(out.failures).toEqual([]); - }); - - it("evaluateAllAssertions aggregates failures and flips pass=false", () => { - const parsed = parseAllAssertions(`# expect: -# status === 200 -# status === 404 -# time < 10`); - const out = evaluateAllAssertions(parsed, ctx); - expect(out.pass).toBe(false); - expect(out.failures.length).toBe(2); - expect(out.failures[0].raw).toContain("status === 404"); - }); -}); - -describe("httpResponseToAssertionContext", () => { - it("maps status_code / headers / body straight through", () => { - const ctx = httpResponseToAssertionContext({ - status_code: 200, - headers: { "Content-Type": "application/json" }, - body: { id: 1 }, - elapsed_ms: 42, - }); - expect(ctx.status).toBe(200); - expect(ctx.headers).toEqual({ "Content-Type": "application/json" }); - expect(ctx.body).toEqual({ id: 1 }); - }); - - it("prefers timing.total_ms over elapsed_ms when present", () => { - const ctx = httpResponseToAssertionContext({ - status_code: 200, - headers: {}, - body: null, - elapsed_ms: 99, - timing: { total_ms: 42 }, - }); - expect(ctx.time_ms).toBe(42); - }); - - it("falls back to elapsed_ms when timing.total_ms is missing", () => { - const ctx = httpResponseToAssertionContext({ - status_code: 200, - headers: {}, - body: null, - elapsed_ms: 99, - }); - expect(ctx.time_ms).toBe(99); - }); -}); - -describe("dbResponseToAssertionContext", () => { - it("uses the first SELECT result's rows for $.row", () => { - const ctx = dbResponseToAssertionContext({ - results: [{ kind: "select", rows: [{ id: 1 }, { id: 2 }] }], - stats: { elapsed_ms: 7 }, - }); - expect(ctx.row).toEqual([{ id: 1 }, { id: 2 }]); - expect(ctx.body).toEqual([{ id: 1 }, { id: 2 }]); - expect(ctx.time_ms).toBe(7); - }); - - it("returns empty rows when no SELECT result is present", () => { - const ctx = dbResponseToAssertionContext({ - results: [{ kind: "mutation", rows_affected: 3 }], - }); - expect(ctx.row).toEqual([]); - expect(ctx.body).toEqual([]); - }); - - it("returns empty rows when SELECT exists but rows isn't an array", () => { - const ctx = dbResponseToAssertionContext({ - results: [{ kind: "select", rows: null }], - }); - expect(ctx.row).toEqual([]); - }); - - it("works with no stats", () => { - const ctx = dbResponseToAssertionContext({ - results: [], - }); - expect(ctx.time_ms).toBeUndefined(); - }); -}); diff --git a/httui-desktop/src/lib/blocks/__tests__/captures.test.ts b/httui-desktop/src/lib/blocks/__tests__/captures.test.ts deleted file mode 100644 index 884c423b..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/captures.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - captureValuesFromBody, - evaluateCaptures, - extractCaptureLines, - isSecretCaptureKey, - parseAllCaptures, - parseCaptureLine, - type CaptureContext, -} from "@/lib/blocks/captures"; - -describe("extractCaptureLines", () => { - it("returns empty when no marker is present", () => { - expect(extractCaptureLines("POST /\n")).toEqual([]); - }); - - it("collects every commented line until a blank line", () => { - const body = `POST / - -# capture: -# token = $.body.access_token -# user_id = $.body.user.id - -# trailing comment after blank — ignored`; - expect(extractCaptureLines(body).map((l) => l.rawLine)).toEqual([ - "token = $.body.access_token", - "user_id = $.body.user.id", - ]); - }); - - it("matches the marker case-insensitively", () => { - expect( - extractCaptureLines("# CAPTURE:\n# k = $.body.x").map((l) => l.rawLine), - ).toEqual(["k = $.body.x"]); - }); - - it("stops at the first non-comment line before blank", () => { - const body = `# capture: -# a = $.body.x -not-a-comment -# b = $.body.y`; - expect(extractCaptureLines(body).map((l) => l.rawLine)).toEqual([ - "a = $.body.x", - ]); - }); - - it("attaches the 1-indexed body line number", () => { - const body = `line1 -# capture: -# token = $.body.t`; - expect(extractCaptureLines(body)[0].bodyLine).toBe(3); - }); -}); - -describe("parseCaptureLine", () => { - it("parses ` = `", () => { - expect(parseCaptureLine("token = $.body.access_token", 7)).toEqual({ - line: 7, - raw: "token = $.body.access_token", - key: "token", - expr: "$.body.access_token", - }); - }); - - it("returns null when no `=` is present", () => { - expect(parseCaptureLine("token $.body.x", 1)).toBeNull(); - }); - - it("returns null when key or expr is empty", () => { - expect(parseCaptureLine("= $.body.x", 1)).toBeNull(); - expect(parseCaptureLine("token =", 1)).toBeNull(); - }); - - it("returns null when key contains whitespace or dot", () => { - expect(parseCaptureLine("my key = $.body.x", 1)).toBeNull(); - expect(parseCaptureLine("a.b = $.body.x", 1)).toBeNull(); - }); - - it("accepts hyphens, underscores, and leading dollar/underscore", () => { - expect(parseCaptureLine("user_id = $.body.user.id", 1)?.key).toBe( - "user_id", - ); - expect(parseCaptureLine("first-name = $.body.first", 1)?.key).toBe( - "first-name", - ); - expect(parseCaptureLine("$tmp = $.body.x", 1)?.key).toBe("$tmp"); - expect(parseCaptureLine("_internal = $.body.x", 1)?.key).toBe("_internal"); - }); -}); - -describe("parseAllCaptures", () => { - it("composes extract + parse and drops unparseable lines silently", () => { - const body = `# capture: -# token = $.body.access_token -# this-line-is-malformed -# user_id = $.body.user.id`; - const out = parseAllCaptures(body); - expect(out.map((c) => c.key)).toEqual(["token", "user_id"]); - }); -}); - -describe("evaluateCaptures + captureValuesFromBody", () => { - const ctx: CaptureContext = { - status: 200, - time_ms: 17, - body: { access_token: "abc.def.ghi", user: { id: 99 } }, - headers: { "X-Trace": "trace-1" }, - row: [{ id: 1 }], - }; - - it("evaluates each capture against the context", () => { - const out = evaluateCaptures( - [ - { - line: 1, - raw: "token = $.body.access_token", - key: "token", - expr: "$.body.access_token", - }, - { - line: 2, - raw: "user = $.body.user.id", - key: "user", - expr: "$.body.user.id", - }, - ], - ctx, - ); - expect(out).toEqual({ token: "abc.def.ghi", user: 99 }); - }); - - it("returns undefined when the path doesn't resolve", () => { - const out = evaluateCaptures( - [ - { - line: 1, - raw: "missing = $.body.nope", - key: "missing", - expr: "$.body.nope", - }, - ], - ctx, - ); - expect(out.missing).toBeUndefined(); - }); - - it("supports status / time / $.headers / $.row", () => { - const out = captureValuesFromBody( - `# capture: -# st = status -# t = time -# trace = $.headers.X-Trace -# row_id = $.row[0].id`, - ctx, - ); - expect(out).toEqual({ - st: 200, - t: 17, - trace: "trace-1", - row_id: 1, - }); - }); - - it("captureValuesFromBody returns {} when no marker is present", () => { - expect(captureValuesFromBody("POST /\n", ctx)).toEqual({}); - }); -}); - -describe("isSecretCaptureKey", () => { - it("flags password / token / secret / key / auth*", () => { - expect(isSecretCaptureKey("password")).toBe(true); - expect(isSecretCaptureKey("access_token")).toBe(true); - expect(isSecretCaptureKey("user_secret")).toBe(true); - expect(isSecretCaptureKey("api_key")).toBe(true); - expect(isSecretCaptureKey("authorization")).toBe(true); - }); - - it("doesn't flag innocuous keys", () => { - expect(isSecretCaptureKey("user_id")).toBe(false); - expect(isSecretCaptureKey("count")).toBe(false); - expect(isSecretCaptureKey("name")).toBe(false); - }); - - it("matches case-insensitively", () => { - expect(isSecretCaptureKey("Password")).toBe(true); - expect(isSecretCaptureKey("AUTH_BEARER")).toBe(true); - }); -}); diff --git a/httui-desktop/src/lib/blocks/__tests__/explain-support.test.ts b/httui-desktop/src/lib/blocks/__tests__/explain-support.test.ts deleted file mode 100644 index 51b10751..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/explain-support.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - EXPLAIN_BODY_CAP, - EXPLAIN_SUPPORTED_DRIVERS, - driverSupportsExplain, -} from "@/lib/blocks/explain-support"; - -describe("driverSupportsExplain", () => { - it("matches Postgres aliases", () => { - expect(driverSupportsExplain("postgres")).toBe(true); - expect(driverSupportsExplain("postgresql")).toBe(true); - expect(driverSupportsExplain("pg")).toBe(true); - }); - - it("matches MySQL family", () => { - expect(driverSupportsExplain("mysql")).toBe(true); - expect(driverSupportsExplain("mariadb")).toBe(true); - }); - - it("rejects SQLite per spec", () => { - expect(driverSupportsExplain("sqlite")).toBe(false); - }); - - it("rejects unknown drivers", () => { - expect(driverSupportsExplain("oracle")).toBe(false); - expect(driverSupportsExplain("bigquery")).toBe(false); - expect(driverSupportsExplain("snowflake")).toBe(false); - expect(driverSupportsExplain("mongo")).toBe(false); - }); - - it("normalizes case and whitespace", () => { - expect(driverSupportsExplain(" Postgres ")).toBe(true); - expect(driverSupportsExplain("MYSQL")).toBe(true); - expect(driverSupportsExplain(" MariaDB ")).toBe(true); - }); - - it("treats empty / null / undefined as unsupported", () => { - expect(driverSupportsExplain("")).toBe(false); - expect(driverSupportsExplain(" ")).toBe(false); - expect(driverSupportsExplain(null)).toBe(false); - expect(driverSupportsExplain(undefined)).toBe(false); - }); - - it("EXPLAIN_BODY_CAP mirrors backend value", () => { - expect(EXPLAIN_BODY_CAP).toBe(200_000); - }); - - it("EXPLAIN_SUPPORTED_DRIVERS is the documented set", () => { - expect(Array.from(EXPLAIN_SUPPORTED_DRIVERS).sort()).toEqual( - ["mariadb", "mysql", "pg", "postgres", "postgresql"].sort(), - ); - }); -}); diff --git a/httui-desktop/src/lib/blocks/__tests__/run-diff.test.ts b/httui-desktop/src/lib/blocks/__tests__/run-diff.test.ts deleted file mode 100644 index bad1001c..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/run-diff.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - diffHeaders, - diffJson, - diffRuns, - type RunSnapshot, -} from "@/lib/blocks/run-diff"; - -describe("diffJson", () => { - it("returns empty when scalars are equal", () => { - expect(diffJson(1, 1)).toEqual([]); - expect(diffJson("x", "x")).toEqual([]); - expect(diffJson(null, null)).toEqual([]); - }); - - it("returns a change entry when scalars differ", () => { - expect(diffJson(1, 2)).toEqual([ - { path: "$", op: "change", before: 1, after: 2 }, - ]); - }); - - it("flags type mismatches as a change entry at the path", () => { - expect(diffJson("1", 1)).toEqual([ - { path: "$", op: "change", before: "1", after: 1 }, - ]); - }); - - it("emits add when before is undefined and after is set", () => { - expect(diffJson(undefined, 5)).toEqual([ - { path: "$", op: "add", after: 5 }, - ]); - }); - - it("emits remove when before is set and after is undefined", () => { - expect(diffJson(5, undefined)).toEqual([ - { path: "$", op: "remove", before: 5 }, - ]); - }); - - it("descends into objects with dot-path labels", () => { - const before = { user: { id: 1, name: "alice" } }; - const after = { user: { id: 1, name: "bob" } }; - expect(diffJson(before, after)).toEqual([ - { - path: "user.name", - op: "change", - before: "alice", - after: "bob", - }, - ]); - }); - - it("emits add for new object keys, remove for missing ones", () => { - const before = { a: 1, b: 2 }; - const after = { a: 1, c: 3 }; - expect(diffJson(before, after)).toEqual([ - { path: "b", op: "remove", before: 2 }, - { path: "c", op: "add", after: 3 }, - ]); - }); - - it("walks arrays index-aligned with bracket paths", () => { - expect(diffJson([1, 2, 3], [1, 9, 3])).toEqual([ - { path: "[1]", op: "change", before: 2, after: 9 }, - ]); - }); - - it("emits add/remove entries when array lengths differ", () => { - expect(diffJson([1, 2], [1, 2, 3, 4])).toEqual([ - { path: "[2]", op: "add", after: 3 }, - { path: "[3]", op: "add", after: 4 }, - ]); - expect(diffJson([1, 2, 3], [1])).toEqual([ - { path: "[1]", op: "remove", before: 2 }, - { path: "[2]", op: "remove", before: 3 }, - ]); - }); - - it("treats key reordering inside an object as no diff", () => { - expect(diffJson({ a: 1, b: 2 }, { b: 2, a: 1 })).toEqual([]); - }); - - it("handles nested arrays of objects with full paths", () => { - const before = { items: [{ id: 1 }, { id: 2 }] }; - const after = { items: [{ id: 1 }, { id: 9 }] }; - expect(diffJson(before, after)).toEqual([ - { path: "items[1].id", op: "change", before: 2, after: 9 }, - ]); - }); - - it("flags object-vs-array type mismatch as a single change entry", () => { - expect(diffJson({ a: 1 }, [1])).toEqual([ - { path: "$", op: "change", before: { a: 1 }, after: [1] }, - ]); - }); -}); - -describe("diffHeaders", () => { - it("flags equal entries with op=equal (so the UI can render the row)", () => { - expect( - diffHeaders( - { "Content-Type": "application/json" }, - { "content-type": "application/json" }, - ), - ).toEqual([ - { - key: "content-type", - op: "equal", - before: "application/json", - after: "application/json", - }, - ]); - }); - - it("matches keys case-insensitively but echoes the after-side casing", () => { - expect(diffHeaders({ "X-Trace": "a" }, { "x-trace": "b" })).toEqual([ - { key: "x-trace", op: "change", before: "a", after: "b" }, - ]); - }); - - it("emits add/remove for one-sided keys, sorted by lower-cased key", () => { - expect( - diffHeaders({ A: "1" }, { B: "2" }).map((h) => `${h.op}:${h.key}`), - ).toEqual(["remove:A", "add:B"]); - }); - - it("returns rows sorted by lower-cased key", () => { - expect( - diffHeaders({ Z: "1", A: "2" }, { Z: "1", A: "2" }).map((h) => h.key), - ).toEqual(["A", "Z"]); - }); - - it("treats undefined inputs as empty maps", () => { - expect(diffHeaders()).toEqual([]); - expect(diffHeaders({ A: "1" })).toEqual([ - { key: "A", op: "remove", before: "1" }, - ]); - }); -}); - -describe("diffRuns", () => { - function snap(over: Partial = {}): RunSnapshot { - return { - status: 200, - headers: { "Content-Type": "application/json" }, - body: { id: 1 }, - time_ms: 100, - ...over, - }; - } - - it("flags status changes via status.changed", () => { - const out = diffRuns(snap(), snap({ status: 500 })); - expect(out.status).toEqual({ before: 200, after: 500, changed: true }); - }); - - it("computes timing.deltaMs as after - before", () => { - const out = diffRuns(snap({ time_ms: 100 }), snap({ time_ms: 250 })); - expect(out.timing.deltaMs).toBe(150); - }); - - it("leaves timing.deltaMs undefined when one side is missing", () => { - const out = diffRuns(snap({ time_ms: undefined }), snap()); - expect(out.timing.deltaMs).toBeUndefined(); - }); - - it("walks headers + body using the lower-level helpers", () => { - const out = diffRuns(snap({ body: { id: 1 } }), snap({ body: { id: 2 } })); - expect(out.body).toEqual([ - { path: "id", op: "change", before: 1, after: 2 }, - ]); - }); - - it("skips body diff when either side exceeds 200 KB", () => { - const big = snap({ size_bytes: 250 * 1024 }); - const out = diffRuns(big, snap()); - expect(out.bodyTruncated).toBe(true); - expect(out.body).toEqual([]); - }); - - it("does not skip when neither side exceeds the cap", () => { - const out = diffRuns( - snap({ size_bytes: 1024 }), - snap({ size_bytes: 1024, body: { id: 9 } }), - ); - expect(out.bodyTruncated).toBe(false); - expect(out.body.length).toBe(1); - }); - - it("returns empty diffs when the two runs are identical", () => { - const a = snap(); - const b = snap(); - const out = diffRuns(a, b); - expect(out.status.changed).toBe(false); - expect(out.body).toEqual([]); - // headers list every key (with op=equal) so the consumer can - // render the row — this is the "header diff is row-aligned" - // behaviour from the spec. - expect(out.headers.every((h) => h.op === "equal")).toBe(true); - }); -}); diff --git a/httui-desktop/src/lib/blocks/__tests__/run-history-trim.test.ts b/httui-desktop/src/lib/blocks/__tests__/run-history-trim.test.ts deleted file mode 100644 index f62d515e..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/run-history-trim.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { HistoryEntry } from "@/lib/tauri/commands"; - -import { KEEP_PER_BLOCK, pickKeepN, trimToKeepN } from "../run-history-trim"; - -function entry(over: Partial = {}): HistoryEntry { - return { - id: 1, - file_path: "a.md", - block_alias: "x", - method: "GET", - url_canonical: "https://api/users", - status: 200, - request_size: null, - response_size: 100, - elapsed_ms: 50, - outcome: "success", - ran_at: "2026-05-02T12:00:00Z", - ...over, - }; -} - -describe("pickKeepN", () => { - it("returns empty kept + all dropped when n <= 0", () => { - const result = pickKeepN([entry()], 0); - expect(result.kept).toHaveLength(0); - expect(result.dropped).toHaveLength(1); - }); - - it("keeps everything when there are fewer entries than n", () => { - const result = pickKeepN( - [entry({ id: 1 }), entry({ id: 2 })], - KEEP_PER_BLOCK, - ); - expect(result.kept).toHaveLength(2); - expect(result.dropped).toHaveLength(0); - }); - - it("keeps the newest n per (file_path, alias) group", () => { - const entries: HistoryEntry[] = []; - for (let i = 0; i < 12; i++) { - entries.push( - entry({ - id: i, - ran_at: `2026-05-02T12:${String(i).padStart(2, "0")}:00Z`, - }), - ); - } - const result = pickKeepN(entries, 10); - expect(result.kept).toHaveLength(10); - expect(result.dropped).toHaveLength(2); - // Survivors must include the two newest (id 11 and 10). - expect(result.kept.map((e) => e.id)).toContain(11); - expect(result.kept.map((e) => e.id)).toContain(10); - // Dropped must be the two oldest. - expect(result.dropped.map((e) => e.id).sort()).toEqual([0, 1]); - }); - - it("trims per-group independently", () => { - const entries: HistoryEntry[] = []; - // Group A: 3 entries - for (let i = 0; i < 3; i++) { - entries.push( - entry({ - id: i, - file_path: "a.md", - block_alias: "x", - ran_at: `2026-05-02T12:0${i}:00Z`, - }), - ); - } - // Group B: 5 entries - for (let i = 0; i < 5; i++) { - entries.push( - entry({ - id: 100 + i, - file_path: "b.md", - block_alias: "y", - ran_at: `2026-05-02T12:0${i}:00Z`, - }), - ); - } - const result = pickKeepN(entries, 2); - // Group A: keep 2, drop 1. Group B: keep 2, drop 3. - expect(result.kept).toHaveLength(4); - expect(result.dropped).toHaveLength(4); - // Newest in group A is id=2 (ran_at 12:02); in group B is id=104. - expect(result.kept.map((e) => e.id)).toContain(2); - expect(result.kept.map((e) => e.id)).toContain(104); - }); - - it("treats different aliases as different groups even on the same file", () => { - const entries: HistoryEntry[] = [ - entry({ id: 1, block_alias: "x" }), - entry({ id: 2, block_alias: "x" }), - entry({ id: 3, block_alias: "y" }), - entry({ id: 4, block_alias: "y" }), - ]; - const result = pickKeepN(entries, 1); - expect(result.kept).toHaveLength(2); - expect(result.dropped).toHaveLength(2); - }); - - it("breaks ran_at ties by id desc (newer auto-id wins)", () => { - const entries: HistoryEntry[] = [ - entry({ id: 100, ran_at: "2026-05-02T12:00:00Z" }), - entry({ id: 101, ran_at: "2026-05-02T12:00:00Z" }), - ]; - const result = pickKeepN(entries, 1); - expect(result.kept[0]!.id).toBe(101); - expect(result.dropped[0]!.id).toBe(100); - }); - - it("returns dropped entries in input order within each group", () => { - // The dropped array isn't strictly ordered; consumers just feed - // them to a cleanup routine. But the test guards against the - // function shuffling them more than necessary. - const entries: HistoryEntry[] = []; - for (let i = 0; i < 5; i++) { - entries.push( - entry({ - id: i, - ran_at: `2026-05-02T12:0${i}:00Z`, - }), - ); - } - const result = pickKeepN(entries, 2); - // Dropped contains the 3 oldest (ids 0, 1, 2). Order within - // dropped reflects the descending sort: id 2 first (newest of - // the dropped trio), then id 1, then id 0. - expect(result.dropped.map((e) => e.id)).toEqual([2, 1, 0]); - }); - - it("defaults n to KEEP_PER_BLOCK", () => { - const entries: HistoryEntry[] = []; - for (let i = 0; i < KEEP_PER_BLOCK + 3; i++) { - entries.push( - entry({ - id: i, - ran_at: `2026-05-02T12:${String(i).padStart(2, "0")}:00Z`, - }), - ); - } - const result = pickKeepN(entries); - expect(result.kept).toHaveLength(KEEP_PER_BLOCK); - }); - - it("does not mutate the input array", () => { - const entries: HistoryEntry[] = [entry({ id: 1 }), entry({ id: 2 })]; - const before = entries.map((e) => e.id); - pickKeepN(entries, 1); - expect(entries.map((e) => e.id)).toEqual(before); - }); -}); - -describe("trimToKeepN", () => { - it("returns just the kept entries", () => { - const entries: HistoryEntry[] = []; - for (let i = 0; i < 3; i++) { - entries.push( - entry({ - id: i, - ran_at: `2026-05-02T12:0${i}:00Z`, - }), - ); - } - const trimmed = trimToKeepN(entries, 2); - expect(trimmed).toHaveLength(2); - expect(trimmed.map((e) => e.id).sort()).toEqual([1, 2]); - }); -}); diff --git a/httui-desktop/src/lib/blocks/__tests__/serialize-explain-plan.test.ts b/httui-desktop/src/lib/blocks/__tests__/serialize-explain-plan.test.ts deleted file mode 100644 index d209b984..00000000 --- a/httui-desktop/src/lib/blocks/__tests__/serialize-explain-plan.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { serializeExplainPlan } from "@/lib/blocks/serialize-explain-plan"; - -describe("serializeExplainPlan", () => { - it("returns undefined for null", () => { - expect(serializeExplainPlan(null)).toBeUndefined(); - }); - - it("returns undefined for undefined", () => { - expect(serializeExplainPlan(undefined)).toBeUndefined(); - }); - - it("passes a string through verbatim (truncated-fallback shape)", () => { - // The 200 KB cap path stores the truncated text as Value::String - // on the Rust side. The wire delivers a plain string; we keep - // it as-is so the consumer sees the truncation marker. - const truncated = "[".padEnd(190_000, "x") + "[explain payload truncated]"; - expect(serializeExplainPlan(truncated)).toBe(truncated); - }); - - it("JSON.stringify's a Postgres-shape parsed plan (array)", () => { - const plan = [{ Plan: { "Node Type": "Seq Scan", "Total Cost": 12.5 } }]; - const s = serializeExplainPlan(plan); - expect(s).toBe(JSON.stringify(plan)); - // Round-trips cleanly so the consumer can JSON.parse on read. - expect(JSON.parse(s!)).toEqual(plan); - }); - - it("JSON.stringify's a MySQL-shape parsed plan (object)", () => { - const plan = { - query_block: { select_id: 1, table: { access_type: "ref" } }, - }; - const s = serializeExplainPlan(plan); - expect(JSON.parse(s!)).toEqual(plan); - }); - - it("handles primitive non-string values (defensive)", () => { - // The executor never emits these, but if a future driver - // does, the helper shouldn't drop the value silently. - expect(serializeExplainPlan(42)).toBe("42"); - expect(serializeExplainPlan(true)).toBe("true"); - }); - - it("returns undefined on circular references (no crash)", () => { - const plan: Record = { a: 1 }; - plan.self = plan; - expect(serializeExplainPlan(plan)).toBeUndefined(); - }); - - it("returns the string value '\"\"' for an empty-string plan", () => { - // An empty string from the truncated path is still meaningful - // (someone hit the cap with all-whitespace?); pass it through. - expect(serializeExplainPlan("")).toBe(""); - }); - - it("preserves nested arrays inside the parsed plan shape", () => { - const plan = { - query_block: { - nested_loop: [{ table: { access_type: "ALL" } }], - }, - }; - const s = serializeExplainPlan(plan); - expect(JSON.parse(s!)).toEqual(plan); - }); -}); diff --git a/httui-desktop/src/lib/blocks/assertions-aggregate.ts b/httui-desktop/src/lib/blocks/assertions-aggregate.ts deleted file mode 100644 index ab6ec5c7..00000000 --- a/httui-desktop/src/lib/blocks/assertions-aggregate.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Run-all assertion aggregator. -// -// Pure helper: takes a per-block list of `{ blockAlias, total, result }` -// and returns the summary `` renders. spec: -// "7 blocks, 23 assertions, 22 passed, 1 failed". - -import type { AssertionResult } from "./assertions"; - -export interface BlockAssertionRun { - /** Block identity (alias when present, else a synthetic fallback). */ - blockAlias: string; - /** Number of parsed assertions in this block. */ - total: number; - /** Hook output. Null when the block has no assertions. */ - result: AssertionResult | null; -} - -export interface RunAllAssertionSummary { - /** Total blocks observed (whether or not they had assertions). */ - blocks: number; - /** Total assertions across all blocks. */ - assertions: number; - /** Assertions that passed. */ - passed: number; - /** Assertions that failed. */ - failed: number; - /** Block aliases that contained ≥1 failure, in input order. */ - failedBlocks: string[]; - /** True when every assertion passed (or there were none). */ - allPass: boolean; -} - -export function aggregateAssertionResults( - runs: ReadonlyArray, -): RunAllAssertionSummary { - let assertions = 0; - let passed = 0; - let failed = 0; - const failedBlocks: string[] = []; - for (const run of runs) { - assertions += run.total; - if (!run.result) continue; - const blockFailures = run.result.failures.length; - failed += blockFailures; - passed += run.total - blockFailures; - if (blockFailures > 0) failedBlocks.push(run.blockAlias); - } - return { - blocks: runs.length, - assertions, - passed, - failed, - failedBlocks, - allPass: failed === 0, - }; -} - -/** Find the first block (in input order) whose assertions failed. - * Used by run-all to short-circuit unless the user shift-clicks. */ -export function firstAssertionFailureBlock( - runs: ReadonlyArray, -): string | null { - for (const run of runs) { - if (run.result && !run.result.pass) return run.blockAlias; - } - return null; -} diff --git a/httui-desktop/src/lib/blocks/assertions.ts b/httui-desktop/src/lib/blocks/assertions.ts deleted file mode 100644 index e4a178c8..00000000 --- a/httui-desktop/src/lib/blocks/assertions.ts +++ /dev/null @@ -1,373 +0,0 @@ -// Block assertions. -// -// Pure parser + evaluator for the canvas §10 inline-test syntax. The -// `# expect:` marker section lives inside the fenced HTTP/DB block -// after the body; lines like `# ` describe one -// expectation each. Lines that don't parse are silently dropped (per -// the spec — the user shouldn't have to escape every comment). -// -// Storage example: -// ```http alias=getUser -// GET https://api.example.com/users/1 -// -// # expect: -// # status === 200 -// # time < 1000 -// # $.body.id === 1 -// ``` - -const EXPECT_MARKER = /^\s*#\s*expect\s*:\s*$/i; -const COMMENT_PREFIX = /^\s*#\s?/; -const NUMBER_LITERAL = /^-?\d+(?:\.\d+)?$/; -const STRING_LITERAL = /^(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/; -const REGEX_LITERAL = /^\/((?:\\.|[^/\\])+)\/([gimsuy]*)$/; -const OP_TOKENS = [ - "===", - "!==", - "<=", - ">=", - "<", - ">", - "matches", - "contains", -] as const; - -export type AssertionOp = (typeof OP_TOKENS)[number]; - -export interface ParsedAssertion { - /** 1-indexed line offset *within the block body* — useful for - * surfacing failures back to the editor. */ - line: number; - /** Original line (after stripping the `#` comment prefix). */ - raw: string; - lhs: string; - op: AssertionOp; - rhs: string; -} - -export interface AssertionContext { - status?: number; - /** Total elapsed milliseconds. */ - time_ms?: number; - body?: unknown; - /** HTTP response headers; lookups are case-insensitive. */ - headers?: Record; - /** DB result rows; `$.row[N].col` indexes here. */ - row?: ReadonlyArray>; -} - -export interface AssertionFailure { - line: number; - raw: string; - actual: unknown; - expected: unknown; - reason?: string; -} - -export interface AssertionResult { - pass: boolean; - failures: AssertionFailure[]; -} - -/** Walk the block body and return the lines under `# expect:` (after - * stripping the comment prefix). Returns `[]` when no marker is - * present. Stops at the first blank or EOB. */ -export function extractAssertionLines(blockBody: string): { - rawLine: string; - bodyLine: number; -}[] { - const lines = blockBody.split("\n"); - const out: { rawLine: string; bodyLine: number }[] = []; - let inSection = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!inSection) { - if (EXPECT_MARKER.test(line)) inSection = true; - continue; - } - if (line.trim() === "") break; - if (!COMMENT_PREFIX.test(line)) break; - out.push({ - rawLine: line.replace(COMMENT_PREFIX, ""), - bodyLine: i + 1, - }); - } - return out; -} - -/** Tokenize one assertion line into `{ lhs, op, rhs }`. Returns null - * when the line doesn't fit ` `. */ -export function parseAssertionLine( - rawLine: string, - bodyLine: number, -): ParsedAssertion | null { - const trimmed = rawLine.trim(); - if (!trimmed) return null; - for (const op of OP_TOKENS) { - const idx = findOperator(trimmed, op); - if (idx < 0) continue; - const lhs = trimmed.slice(0, idx).trim(); - const rhs = trimmed.slice(idx + op.length).trim(); - if (!lhs || !rhs) return null; - return { line: bodyLine, raw: trimmed, lhs, op, rhs }; - } - return null; -} - -function findOperator(line: string, op: AssertionOp): number { - if (op === "matches" || op === "contains") { - const re = new RegExp(`\\s${op}\\s`); - const match = re.exec(line); - return match ? match.index + 1 : -1; - } - return line.indexOf(op); -} - -export function parseAllAssertions(blockBody: string): ParsedAssertion[] { - const lines = extractAssertionLines(blockBody); - const out: ParsedAssertion[] = []; - for (const { rawLine, bodyLine } of lines) { - const parsed = parseAssertionLine(rawLine, bodyLine); - if (parsed) out.push(parsed); - } - return out; -} - -/** Resolve an LHS expression into a value pulled from `ctx`. Returns - * `undefined` when the path doesn't exist. */ -export function resolveLhs(lhs: string, ctx: AssertionContext): unknown { - const trimmed = lhs.trim(); - if (trimmed === "status") return ctx.status; - if (trimmed === "time") return ctx.time_ms; - if (trimmed.startsWith("$.headers.")) { - const name = trimmed.slice("$.headers.".length).toLowerCase(); - if (!ctx.headers) return undefined; - for (const [k, v] of Object.entries(ctx.headers)) { - if (k.toLowerCase() === name) return v; - } - return undefined; - } - if (trimmed.startsWith("$.body")) { - return navigatePath(ctx.body, trimmed.slice("$.body".length)); - } - if (trimmed.startsWith("$.row")) { - return navigatePath(ctx.row, trimmed.slice("$.row".length)); - } - return undefined; -} - -/** Coerce `` token into a typed value: number / string / regex. - * Returns `{ kind: "raw", value }` for tokens that don't match a - * literal — those are compared as strings. */ -export function parseRhs( - rhs: string, -): - | { kind: "number"; value: number } - | { kind: "string"; value: string } - | { kind: "regex"; value: RegExp } - | { kind: "raw"; value: string } { - const trimmed = rhs.trim(); - if (NUMBER_LITERAL.test(trimmed)) { - return { kind: "number", value: Number(trimmed) }; - } - const strMatch = STRING_LITERAL.exec(trimmed); - if (strMatch) { - return { kind: "string", value: strMatch[1] ?? strMatch[2] ?? "" }; - } - const reMatch = REGEX_LITERAL.exec(trimmed); - if (reMatch) { - try { - return { kind: "regex", value: new RegExp(reMatch[1], reMatch[2]) }; - } catch { - // fall through to raw - } - } - return { kind: "raw", value: trimmed }; -} - -/** Evaluate a single parsed assertion against `ctx`. Returns - * `{ pass: true }` on match, `{ pass: false, failure }` on miss. */ -export function evaluateAssertion( - parsed: ParsedAssertion, - ctx: AssertionContext, -): { pass: true } | { pass: false; failure: AssertionFailure } { - const actual = resolveLhs(parsed.lhs, ctx); - const rhs = parseRhs(parsed.rhs); - const expected = - rhs.kind === "regex" - ? `/${rhs.value.source}/${rhs.value.flags}` - : rhs.value; - const reason = compareReason(parsed.op, actual, rhs); - if (reason === null) return { pass: true }; - return { - pass: false, - failure: { - line: parsed.line, - raw: parsed.raw, - actual, - expected, - reason, - }, - }; -} - -type RhsParsed = ReturnType; - -function compareReason( - op: AssertionOp, - actual: unknown, - rhs: RhsParsed, -): string | null { - switch (op) { - case "===": - return strictEqual(actual, rhs) ? null : "values not equal"; - case "!==": - return strictEqual(actual, rhs) ? "values are equal" : null; - case "<": - case "<=": - case ">": - case ">=": - return numericCompare(op, actual, rhs); - case "matches": - return matchesCompare(actual, rhs); - case "contains": - return containsCompare(actual, rhs); - } -} - -function strictEqual(actual: unknown, rhs: RhsParsed): boolean { - if (rhs.kind === "regex") { - return typeof actual === "string" && rhs.value.test(actual); - } - if (rhs.kind === "number") return actual === rhs.value; - if (rhs.kind === "string") return actual === rhs.value; - // raw fallback: stringify and compare - return String(actual) === rhs.value; -} - -function numericCompare( - op: AssertionOp, - actual: unknown, - rhs: RhsParsed, -): string | null { - if (rhs.kind !== "number") - return `rhs not a number: ${JSON.stringify(rhs.value)}`; - if (typeof actual !== "number") - return `actual not a number: ${JSON.stringify(actual)}`; - const ok = - op === "<" - ? actual < rhs.value - : op === "<=" - ? actual <= rhs.value - : op === ">" - ? actual > rhs.value - : actual >= rhs.value; - return ok ? null : `${actual} ${op} ${rhs.value} is false`; -} - -function matchesCompare(actual: unknown, rhs: RhsParsed): string | null { - if (rhs.kind !== "regex") return "rhs is not a regex literal /.../"; - if (typeof actual !== "string") return "actual is not a string"; - return rhs.value.test(actual) ? null : "regex did not match"; -} - -function containsCompare(actual: unknown, rhs: RhsParsed): string | null { - const needle = rhs.kind === "string" || rhs.kind === "raw" ? rhs.value : null; - if (typeof actual === "string") { - if (needle === null) return "rhs not stringy"; - return actual.includes(needle) ? null : "substring not found"; - } - if (Array.isArray(actual)) { - const target = rhs.kind === "number" ? rhs.value : needle; - if (target === null) return "rhs not comparable"; - return actual.some((el) => el === target) ? null : "element not found"; - } - return "actual is neither string nor array"; -} - -export function evaluateAllAssertions( - parsed: ReadonlyArray, - ctx: AssertionContext, -): AssertionResult { - const failures: AssertionFailure[] = []; - for (const p of parsed) { - const result = evaluateAssertion(p, ctx); - if (!result.pass) failures.push(result.failure); - } - return { pass: failures.length === 0, failures }; -} - -// --- Adapters from concrete response shapes (runtime wiring) -------------- - -/** Build an `AssertionContext` from the HTTP response shape emitted by - * `executeHttpStreamed`. `time_ms` prefers `timing.total_ms` over - * `elapsed_ms` because the breakdown total is the canonical figure; - * either is fine for the `time < N` predicate. */ -export function httpResponseToAssertionContext(resp: { - status_code: number; - headers: Record; - body: unknown; - elapsed_ms?: number; - timing?: { total_ms?: number }; -}): AssertionContext { - return { - status: resp.status_code, - time_ms: resp.timing?.total_ms ?? resp.elapsed_ms, - body: resp.body, - headers: resp.headers, - }; -} - -/** Build an `AssertionContext` from the DB response shape. Uses the - * first SELECT result's rows for `$.row[N].col`; mutation-only or - * error-only responses produce an empty `row` array (so `$.row[N]` - * evaluates to undefined and assertions fail with a clear reason). */ -export function dbResponseToAssertionContext(resp: { - results: ReadonlyArray<{ kind: string } & Record>; - stats?: { elapsed_ms?: number }; -}): AssertionContext { - const select = resp.results.find((r) => r.kind === "select"); - const rows = - select && Array.isArray(select.rows) - ? (select.rows as Record[]) - : []; - return { - time_ms: resp.stats?.elapsed_ms, - row: rows, - body: rows, - }; -} - -/** Navigate a JSONPath fragment like `.foo.bar` or `[0].col`. Returns - * undefined when the path doesn't resolve. Limited subset: dot - * descent and numeric index brackets only — no filters, no `..`. */ -function navigatePath(root: unknown, path: string): unknown { - if (!path) return root; - let current: unknown = root; - let i = 0; - while (i < path.length) { - const c = path[i]; - if (c === ".") { - i++; - const start = i; - while (i < path.length && path[i] !== "." && path[i] !== "[") i++; - const key = path.slice(start, i); - if (!key) return undefined; - if (current == null || typeof current !== "object") return undefined; - current = (current as Record)[key]; - continue; - } - if (c === "[") { - const close = path.indexOf("]", i + 1); - if (close < 0) return undefined; - const idx = Number(path.slice(i + 1, close)); - if (!Number.isInteger(idx)) return undefined; - if (!Array.isArray(current)) return undefined; - current = current[idx]; - i = close + 1; - continue; - } - return undefined; - } - return current; -} diff --git a/httui-desktop/src/lib/blocks/captures.ts b/httui-desktop/src/lib/blocks/captures.ts deleted file mode 100644 index f8feffbe..00000000 --- a/httui-desktop/src/lib/blocks/captures.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Block captures — (parser + evaluator). -// -// Mirrors the assertions fence-section shape `# capture:` -// marker section after the body, with ` = ` lines that -// extract values from the response. Reuses `resolveLhs` from -// assertions.ts so both features share the same JSONPath subset. -// -// Storage example: -// ```http alias=login -// POST /auth -// -// # capture: -// # token = $.body.access_token -// # user_id = $.body.user.id -// ``` - -import { resolveLhs, type AssertionContext } from "./assertions"; - -const CAPTURE_MARKER = /^\s*#\s*capture\s*:\s*$/i; -const COMMENT_PREFIX = /^\s*#\s?/; -/** Same constraints as variable names — no whitespace / no dot. */ -const KEY_REGEX = /^[A-Za-z_$][A-Za-z0-9_$-]*$/; - -export interface ParsedCapture { - /** 1-indexed line offset within the block body. */ - line: number; - raw: string; - key: string; - /** Right-hand expression (e.g. `$.body.access_token`, `status`). */ - expr: string; -} - -export type CaptureContext = AssertionContext; - -/** Walk the block body and return the lines under `# capture:`. */ -export function extractCaptureLines(blockBody: string): { - rawLine: string; - bodyLine: number; -}[] { - const lines = blockBody.split("\n"); - const out: { rawLine: string; bodyLine: number }[] = []; - let inSection = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!inSection) { - if (CAPTURE_MARKER.test(line)) inSection = true; - continue; - } - if (line.trim() === "") break; - if (!COMMENT_PREFIX.test(line)) break; - out.push({ - rawLine: line.replace(COMMENT_PREFIX, ""), - bodyLine: i + 1, - }); - } - return out; -} - -/** Parse a single ` = ` line. Returns null when the line - * doesn't fit the shape OR when the key fails validation. */ -export function parseCaptureLine( - rawLine: string, - bodyLine: number, -): ParsedCapture | null { - const trimmed = rawLine.trim(); - if (!trimmed) return null; - const eq = trimmed.indexOf("="); - if (eq < 0) return null; - const key = trimmed.slice(0, eq).trim(); - const expr = trimmed.slice(eq + 1).trim(); - if (!key || !expr) return null; - if (!KEY_REGEX.test(key)) return null; - return { line: bodyLine, raw: trimmed, key, expr }; -} - -export function parseAllCaptures(blockBody: string): ParsedCapture[] { - const lines = extractCaptureLines(blockBody); - const out: ParsedCapture[] = []; - for (const { rawLine, bodyLine } of lines) { - const parsed = parseCaptureLine(rawLine, bodyLine); - if (parsed) out.push(parsed); - } - return out; -} - -/** Evaluate every capture against `ctx`. Each captured key maps to - * the resolved value (which may be `undefined` when the path doesn't - * exist — the consumer decides whether to drop, mask, or keep). */ -export function evaluateCaptures( - captures: ReadonlyArray, - ctx: CaptureContext, -): Record { - const out: Record = {}; - for (const c of captures) { - out[c.key] = resolveLhs(c.expr, ctx); - } - return out; -} - -/** Convenience: parse + evaluate in one call. */ -export function captureValuesFromBody( - blockBody: string, - ctx: CaptureContext, -): Record { - return evaluateCaptures(parseAllCaptures(blockBody), ctx); -} - -/** Privacy guard (preview — used by the store on insert). */ -const SECRET_NAME_REGEX = /(password|token|secret|key|auth\w*)/i; - -/** True when `name` looks like a secret by convention. */ -export function isSecretCaptureKey(name: string): boolean { - return SECRET_NAME_REGEX.test(name); -} diff --git a/httui-desktop/src/lib/blocks/explain-support.ts b/httui-desktop/src/lib/blocks/explain-support.ts deleted file mode 100644 index 5cc23873..00000000 --- a/httui-desktop/src/lib/blocks/explain-support.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Explain-support lookup mirroring `httui-core::explain::prefix`. -// -// Pairs with the backend `prefix_explain_sql` function: the UI uses -// this synchronously to decide whether to show the EXPLAIN toggle -// and to render the "EXPLAIN unavailable for this driver" hint -// without an IPC roundtrip. -// -// **Drift contract:** when adding a driver to Rust's `normalize_driver` -// in `httui-core/src/explain/prefix.rs`, mirror it here. The list -// stays small enough that two sources are cheaper than a Tauri -// roundtrip on every UI render. - -/** Driver strings that the backend can prefix with EXPLAIN. Lowercased, - * trimmed at lookup time so the consumer can pass raw connection - * config values. */ -export const EXPLAIN_SUPPORTED_DRIVERS: ReadonlySet = new Set([ - "postgres", - "postgresql", - "pg", - "mysql", - "mariadb", -]); - -/** Body cap mirroring `httui-core::explain::prefix::EXPLAIN_BODY_CAP`. - * Used by the run-history viewer to flag "plan truncated" when the - * stored payload sits exactly at this size. */ -export const EXPLAIN_BODY_CAP = 200_000; - -/** True when the driver's EXPLAIN output is something the backend can - * capture as a JSON plan (Postgres / MySQL family). False for - * SQLite / BigQuery / Snowflake / Mongo / unknown — those map to - * `ExplainError::Unsupported` in the backend. */ -export function driverSupportsExplain( - driver: string | null | undefined, -): boolean { - if (!driver) return false; - return EXPLAIN_SUPPORTED_DRIVERS.has(driver.trim().toLowerCase()); -} diff --git a/httui-desktop/src/lib/blocks/run-diff.ts b/httui-desktop/src/lib/blocks/run-diff.ts deleted file mode 100644 index 874a76ea..00000000 --- a/httui-desktop/src/lib/blocks/run-diff.ts +++ /dev/null @@ -1,235 +0,0 @@ -// Run diff. -// -// Pure side-by-side comparison between two runs of the same HTTP/DB -// block. Three diff layers: status (scalar), headers (key-level -// add/remove/change/equal), body (recursive JSON diff with path -// labels). Reorder of object keys is NOT a diff — JSON objects are -// unordered. Array diffing is index-aligned in V1 (per spec — "100ms -// for 100 KB bodies"); a smarter LCS pass can come later if needed. - -const MAX_BODY_BYTES = 200 * 1024; // 200 KB per side per spec - -export type JsonDiffOp = "add" | "remove" | "change"; -export type HeaderDiffOp = "add" | "remove" | "change" | "equal"; - -export interface JsonDiffEntry { - /** Dotted/bracketed path, e.g. `user.id` or `items[0].name`. */ - path: string; - op: JsonDiffOp; - before?: unknown; - after?: unknown; -} - -export interface HeaderDiffEntry { - key: string; - before?: string; - after?: string; - op: HeaderDiffOp; -} - -export interface RunSnapshot { - status?: number; - headers?: Record; - body?: unknown; - time_ms?: number; - /** Approximate body size in bytes — when above MAX_BODY_BYTES, the - * body diff is skipped and a single sentinel entry is returned. */ - size_bytes?: number; -} - -export interface StatusDiff { - before?: number; - after?: number; - changed: boolean; -} - -export interface TimingDiff { - before?: number; - after?: number; - deltaMs?: number; -} - -export interface RunDiff { - status: StatusDiff; - headers: HeaderDiffEntry[]; - body: JsonDiffEntry[]; - timing: TimingDiff; - /** True when at least one side's body exceeded MAX_BODY_BYTES and - * the body diff was skipped. */ - bodyTruncated: boolean; -} - -/** Recursive JSON diff with path-tagged entries. Unchanged keys - * produce no entry; type mismatches produce a single `change` - * entry at that path. */ -export function diffJson( - before: unknown, - after: unknown, - basePath = "", -): JsonDiffEntry[] { - if (Object.is(before, after)) return []; - - if (Array.isArray(before) && Array.isArray(after)) { - return diffArrays(before, after, basePath); - } - - if (isPlainObject(before) && isPlainObject(after)) { - return diffObjects(before, after, basePath); - } - - // Scalars or mismatched types — emit a single change entry. - if (before === undefined) { - return [{ path: basePath || "$", op: "add", after }]; - } - if (after === undefined) { - return [{ path: basePath || "$", op: "remove", before }]; - } - return [{ path: basePath || "$", op: "change", before, after }]; -} - -function diffObjects( - before: Record, - after: Record, - basePath: string, -): JsonDiffEntry[] { - const out: JsonDiffEntry[] = []; - const keys = new Set([...Object.keys(before), ...Object.keys(after)]); - for (const key of [...keys].sort()) { - const path = basePath ? `${basePath}.${key}` : key; - const inBefore = key in before; - const inAfter = key in after; - if (inBefore && !inAfter) { - out.push({ path, op: "remove", before: before[key] }); - continue; - } - if (!inBefore && inAfter) { - out.push({ path, op: "add", after: after[key] }); - continue; - } - out.push(...diffJson(before[key], after[key], path)); - } - return out; -} - -function diffArrays( - before: unknown[], - after: unknown[], - basePath: string, -): JsonDiffEntry[] { - const out: JsonDiffEntry[] = []; - const max = Math.max(before.length, after.length); - for (let i = 0; i < max; i++) { - const path = `${basePath}[${i}]`; - if (i >= before.length) { - out.push({ path, op: "add", after: after[i] }); - continue; - } - if (i >= after.length) { - out.push({ path, op: "remove", before: before[i] }); - continue; - } - out.push(...diffJson(before[i], after[i], path)); - } - return out; -} - -/** Header diff — case-insensitive key match. Returns one entry per - * union key, with op `add`/`remove`/`change`/`equal`. The display - * key uses the casing from `after` when present, else `before`. */ -export function diffHeaders( - before: Readonly> = {}, - after: Readonly> = {}, -): HeaderDiffEntry[] { - const beforeMap = lowerMap(before); - const afterMap = lowerMap(after); - const lowerKeys = new Set([ - ...Object.keys(beforeMap), - ...Object.keys(afterMap), - ]); - const out: HeaderDiffEntry[] = []; - for (const lk of [...lowerKeys].sort()) { - const beforeEntry = beforeMap[lk]; - const afterEntry = afterMap[lk]; - if (beforeEntry && !afterEntry) { - out.push({ - key: beforeEntry.original, - op: "remove", - before: beforeEntry.value, - }); - continue; - } - if (!beforeEntry && afterEntry) { - out.push({ - key: afterEntry.original, - op: "add", - after: afterEntry.value, - }); - continue; - } - if (beforeEntry && afterEntry) { - const op = beforeEntry.value === afterEntry.value ? "equal" : "change"; - out.push({ - key: afterEntry.original, - op, - before: beforeEntry.value, - after: afterEntry.value, - }); - } - } - return out; -} - -/** Top-level run-vs-run diff. Skips body diffing when either side - * exceeds the 200 KB cap (per spec — "degrades gracefully past - * 200 KB"). */ -export function diffRuns(runA: RunSnapshot, runB: RunSnapshot): RunDiff { - const status: StatusDiff = { - before: runA.status, - after: runB.status, - changed: runA.status !== runB.status, - }; - - const timing: TimingDiff = { - before: runA.time_ms, - after: runB.time_ms, - deltaMs: - runA.time_ms !== undefined && runB.time_ms !== undefined - ? runB.time_ms - runA.time_ms - : undefined, - }; - - const headers = diffHeaders(runA.headers, runB.headers); - - const aOversize = (runA.size_bytes ?? 0) > MAX_BODY_BYTES; - const bOversize = (runB.size_bytes ?? 0) > MAX_BODY_BYTES; - const bodyTruncated = aOversize || bOversize; - const body = bodyTruncated ? [] : diffJson(runA.body, runB.body); - - return { status, headers, body, timing, bodyTruncated }; -} - -// --- internals -------------------------------------------------------- - -function isPlainObject(v: unknown): v is Record { - return ( - typeof v === "object" && - v !== null && - !Array.isArray(v) && - Object.getPrototypeOf(v) === Object.prototype - ); -} - -interface LowerEntry { - original: string; - value: string; -} - -function lowerMap( - obj: Readonly>, -): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(obj)) { - out[k.toLowerCase()] = { original: k, value: v }; - } - return out; -} diff --git a/httui-desktop/src/lib/blocks/run-history-trim.ts b/httui-desktop/src/lib/blocks/run-history-trim.ts deleted file mode 100644 index 322e2d7378c205e9da56985615f74059f463025e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3174 zcmb_eUr!rH5bv`;#U!c}o7iV~sVW0$la#bk(u6|tgb-fejeT3ryVu=4oD)Lohv+Bl zC+Tl??*K#cRtYe^+nt^H{h8V6l#Uz66r5gD$yLpcmex@ynOtKgrS`$PmMVN^gXt7M zbXs#hqf6#GE~}Ie^g&j8lI_E#uK1!>p&XN{wDO~jJ|SE@LVJmz>blZg(87jtCJ%@v zqylZ64p^wyOhdH{2&Tw4dCuN1n@WxtQ;ok&wkq(J4T5qEi4e5l)|w(Fm8*K6a^>VT z1)H8(Sl`Yzdu!A%!4%!-$6r9AE zXJrVDo(hfAIU!)JTp$~h6J9+Oz$Ry^REuP{qnLH96Y^Z8#eY^t!glA+$BqHT{;Pcm&Q{x=BDh{drpH z#WbkK>1l4)YdQDn{$McO*`e2}z7LnVQl6fZf}HB0q>^0Z=CsOaXFA|7@vt1}26}gN zbbN7qbaL_P{m0ku=#chy5wu&P4R$RR0vrR_cksc76ZTCN=z2gTCrgf<@S;ur{rjI- zqTvlA`rOQ;Q&uRo9+L&l+NcWu2pJIa4naQb*$Gk`9ZGG8RbEou5z{3Is}8d*RL;)# zA16>aTT`FFmTeyRCAzdbT~H9KbfkeE57j3dy*Z_Po2o>+h`7uBky@f2y1XSJsO53$ zH<7jhs{d7hiHP+wS+q32-&huv%`Y!fdzr(C#a%xFx)P(qS~^mP+J}Tbq+Ku=Ttj4N zK?v-WxlmzF!?&j&$%8tx0+2gY@fV%jl~Z-8bMm6awF)`FpuBX}*vhVS-V+D;2O)jo z>?K^VXeGp47l6~g0?mOkh80*Z8OtW4fhr`&i@%IMLtTn~hhL$wUioSFQac|e zo`GnjF_}aftH9WJmcbwstLGG50%W0w)ac4F0kls8G1+ESMomEylN2jbT5yhe0BS&T z*+Yu4ABZx6z(4dhA>`BRFD-J^hi6@c_lHrrIq(28UvEP!KgGA~!`?Oic zmqhJ?2x$Kal zhEBzM$G$|ztEdiYnIywqPn zirl)=ankytnpKusGd(#3W!;w^=MItEH3zpV=qK8v8BO;3F4QeuWXK5@O?*VyFsh|T zztNRkHG2USc7vpoyyEnRQKT(fTszV1tGlX1>oO%JNMYHVXF=|q=w-Ou;WoR~P*%7v z^<2}@UyX*$aim;;WKP`m^(@OqnRskIrav1Iw~9z``{--j*a2kNeBl#{`Dh b`jh>?2gL(7i(oov@6zvJ97FxF3PJim8YmzT diff --git a/httui-desktop/src/lib/blocks/serialize-explain-plan.ts b/httui-desktop/src/lib/blocks/serialize-explain-plan.ts deleted file mode 100644 index 161906ba..00000000 --- a/httui-desktop/src/lib/blocks/serialize-explain-plan.ts +++ /dev/null @@ -1,44 +0,0 @@ -// explain plan ↔ run-history serialization. -// -// `DbResponse.plan` (httui-core) hands the frontend a JSON value -// when `explain=true`: an object/array on Postgres (parsed JSON) -// or a string when the executor's 200 KB cap kicked in. The -// run-history table column is `plan TEXT`, so the consumer needs -// to flatten back to a string before calling -// `insert_block_history`. -// -// The Rust extractor already JSON-encodes everything before the -// cap is applied (`parsed.to_string()` happens inside -// `extract_plan_from_results`), but the wire shape can still be -// either: -// - `serde_json::Value::String(capped)` when truncated, or -// - `serde_json::Value::Object`/`Array` when within the cap. -// -// This helper handles both shapes plus the absent-plan case (no -// `explain=true` → `null`/`undefined` → no insert.plan field). - -/** Serialize `DbResponse.plan` into the wire shape consumed by - * `InsertHistoryEntry.plan`. Returns `undefined` when there's - * nothing to persist — pass through to `insert_block_history` - * without a plan column (the Rust insert lets the column stay - * NULL). */ -export function serializeExplainPlan(plan: unknown): string | undefined { - if (plan === null || plan === undefined) return undefined; - if (typeof plan === "string") { - // Already serialized — typically the truncated-as-string - // fallback path from `extract_plan_from_results`. Pass through - // verbatim. - return plan; - } - // Object / array / number / boolean — JSON.stringify so the column - // round-trips. The consumer parses on read (frontend display layer - // mount). - try { - return JSON.stringify(plan); - } catch { - // Defensive — circular references shouldn't reach this layer - // (`DbResponse.plan` came from sqlx which can't produce them), - // but if they do, drop the value rather than crash the insert. - return undefined; - } -} diff --git a/httui-desktop/src/lib/changelog/__tests__/classify-block-changes.test.ts b/httui-desktop/src/lib/changelog/__tests__/classify-block-changes.test.ts deleted file mode 100644 index 540d5201..00000000 --- a/httui-desktop/src/lib/changelog/__tests__/classify-block-changes.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { classifyBlockChanges } from "../classify-block-changes"; -import { parseUnifiedDiff } from "../parse-diff"; - -function diffOf(text: string) { - return parseUnifiedDiff(text)[0]!.hunks; -} - -describe("classifyBlockChanges", () => { - it("returns empty when both contents have the same blocks and no hunks", () => { - const before = "```http alias=a\nGET /\n```\n"; - const after = before; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: [], - }); - expect(out).toEqual([]); - }); - - it("flags added blocks", () => { - const before = "intro\n"; - const after = ["intro", "```http alias=new", "GET /new", "```"].join("\n"); - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: [], - }); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ - kind: "added", - blockKind: "http", - alias: "new", - }); - }); - - it("flags removed blocks", () => { - const before = ["```http alias=old", "GET /", "```"].join("\n"); - const after = "intro\n"; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: [], - }); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ kind: "removed", alias: "old" }); - }); - - it("classifies a body change inside an existing block", () => { - const before = ["```http alias=a", "GET /old", "```"].join("\n"); - const after = ["```http alias=a", "GET /new", "```"].join("\n"); - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -2,1 +2,1 @@ --GET /old -+GET /new -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ - kind: "modified-body", - alias: "a", - }); - }); - - it("classifies an info-string change as modified-info-string", () => { - const before = ["```http alias=a", "GET /", "```"].join("\n"); - const after = ["```http alias=a timeout=5000", "GET /", "```"].join("\n"); - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -1,1 +1,1 @@ --\`\`\`http alias=a -+\`\`\`http alias=a timeout=5000 -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - // alias matches on both sides, so no add/remove. The hunk - // touches startLine, classified as info-string. - expect(out).toContainEqual( - expect.objectContaining({ - kind: "modified-info-string", - alias: "a", - }), - ); - }); - - it("classifies an assertions section change as modified-assertions", () => { - const before = [ - "```http alias=a", - "GET /", - "# expect:", - "status === 200", - "```", - ].join("\n"); - const after = [ - "```http alias=a", - "GET /", - "# expect:", - "status === 201", - "```", - ].join("\n"); - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -4,1 +4,1 @@ --status === 200 -+status === 201 -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - expect(out).toContainEqual( - expect.objectContaining({ - kind: "modified-assertions", - alias: "a", - }), - ); - }); - - it("classifies a captures section change as modified-captures", () => { - const before = [ - "```http alias=a", - "GET /", - "# capture:", - "id = $.body.id", - "```", - ].join("\n"); - const after = [ - "```http alias=a", - "GET /", - "# capture:", - "id = $.body.user.id", - "```", - ].join("\n"); - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -4,1 +4,1 @@ --id = $.body.id -+id = $.body.user.id -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - expect(out).toContainEqual( - expect.objectContaining({ - kind: "modified-captures", - alias: "a", - }), - ); - }); - - it("dedupes when a hunk touches the same block multiple times in the same kind", () => { - const before = ["```http alias=a", "GET /old", "POST /old", "```"].join( - "\n", - ); - const after = ["```http alias=a", "GET /new", "POST /new", "```"].join( - "\n", - ); - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -2,2 +2,2 @@ --GET /old --POST /old -+GET /new -+POST /new -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - // Both lines in the same block, same body kind → 1 entry. - expect(out).toHaveLength(1); - expect(out[0]!.kind).toBe("modified-body"); - }); - - it("matches alias-less blocks by position fallback", () => { - const before = ["```http", "GET /old", "```"].join("\n"); - const after = ["```http", "GET /new", "```"].join("\n"); - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -2,1 +2,1 @@ --GET /old -+GET /new -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ - kind: "modified-body", - alias: null, - }); - }); - - it("renames an alias as remove + add (not a modification)", () => { - const before = ["```http alias=old", "GET /", "```"].join("\n"); - const after = ["```http alias=new", "GET /", "```"].join("\n"); - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: [], - }); - const kinds = out.map((c) => c.kind).sort(); - expect(kinds).toEqual(["added", "removed"]); - }); - - it("ignores hunk lines that fall outside any block", () => { - const before = "intro\n\n```http alias=a\nGET /\n```\n"; - const after = "INTRO\n\n```http alias=a\nGET /\n```\n"; - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -1,1 +1,1 @@ --intro -+INTRO -`; - const out = classifyBlockChanges({ - beforeContent: before, - afterContent: after, - hunks: diffOf(diff), - }); - expect(out).toEqual([]); - }); -}); diff --git a/httui-desktop/src/lib/changelog/__tests__/find-blocks.test.ts b/httui-desktop/src/lib/changelog/__tests__/find-blocks.test.ts deleted file mode 100644 index 770750f8..00000000 --- a/httui-desktop/src/lib/changelog/__tests__/find-blocks.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - findBlockForLine, - findBlocksForHunkLines, - findFencedBlocks, -} from "../find-blocks"; - -describe("findFencedBlocks", () => { - it("returns empty for content with no fenced blocks", () => { - expect(findFencedBlocks("just some text\n# heading\n")).toEqual([]); - }); - - it("finds an HTTP block with alias and timeout info-string tokens", () => { - const md = [ - "intro", - "```http alias=req1 timeout=30000", - "GET https://api/users", - "```", - "outro", - ].join("\n"); - const blocks = findFencedBlocks(md); - expect(blocks).toHaveLength(1); - expect(blocks[0]!.kind).toBe("http"); - expect(blocks[0]!.alias).toBe("req1"); - expect(blocks[0]!.startLine).toBe(2); - expect(blocks[0]!.endLine).toBe(4); - }); - - it("finds a DB block with the connection id baked into the kind tag", () => { - const md = ["```db-payments alias=q1", "SELECT 1", "```"].join("\n"); - const blocks = findFencedBlocks(md); - expect(blocks).toHaveLength(1); - expect(blocks[0]!.kind).toBe("db"); - expect(blocks[0]!.alias).toBe("q1"); - expect(blocks[0]!.infoString).toMatch(/db-payments/); - }); - - it("finds sh / ws / gql blocks", () => { - const md = [ - "```sh", - "echo hi", - "```", - "", - "```ws alias=stream", - "{}", - "```", - "", - "```gql alias=q", - "{ users { id } }", - "```", - ].join("\n"); - const kinds = findFencedBlocks(md).map((b) => b.kind); - expect(kinds).toEqual(["sh", "ws", "gql"]); - }); - - it("returns null alias when info-string has no alias= token", () => { - const md = ["```http", "GET /", "```"].join("\n"); - const blocks = findFencedBlocks(md); - expect(blocks[0]!.alias).toBeNull(); - }); - - it("finds multiple blocks in a single document", () => { - const md = [ - "intro", - "```http alias=a", - "GET /a", - "```", - "between", - "```http alias=b", - "GET /b", - "```", - ].join("\n"); - const blocks = findFencedBlocks(md); - expect(blocks.map((b) => b.alias)).toEqual(["a", "b"]); - }); - - it("ignores plain code fences (```ts, ```rust, plain ```)", () => { - const md = ["```ts", "const x = 1", "```", "", "```", "plain", "```"].join( - "\n", - ); - expect(findFencedBlocks(md)).toEqual([]); - }); - - it("treats an unclosed fence as not-yet-a-block (no entry produced)", () => { - const md = ["```http alias=req1", "GET /"].join("\n"); - expect(findFencedBlocks(md)).toEqual([]); - }); -}); - -describe("findBlockForLine", () => { - const md = [ - "line 1", - "```http alias=a", // line 2 — fence open - "GET /", // line 3 — body - "```", // line 4 — fence close - "outside", // line 5 - ].join("\n"); - const blocks = findFencedBlocks(md); - - it("returns the block containing the line", () => { - expect(findBlockForLine(blocks, 3)?.alias).toBe("a"); - }); - - it("includes both fence lines as part of the block", () => { - expect(findBlockForLine(blocks, 2)?.alias).toBe("a"); - expect(findBlockForLine(blocks, 4)?.alias).toBe("a"); - }); - - it("returns null for lines outside any block", () => { - expect(findBlockForLine(blocks, 1)).toBeNull(); - expect(findBlockForLine(blocks, 5)).toBeNull(); - }); -}); - -describe("findBlocksForHunkLines", () => { - const md = [ - "```http alias=a", // 1 - "GET /a", // 2 - "```", // 3 - "between", // 4 - "```http alias=b", // 5 - "GET /b", // 6 - "```", // 7 - ].join("\n"); - const blocks = findFencedBlocks(md); - - it("returns the unique blocks any of the lines fall into", () => { - const matched = findBlocksForHunkLines(blocks, [2, 6]); - expect(matched.map((b) => b.alias).sort()).toEqual(["a", "b"]); - }); - - it("dedupes when multiple lines hit the same block", () => { - const matched = findBlocksForHunkLines(blocks, [1, 2, 3]); - expect(matched).toHaveLength(1); - }); - - it("ignores lines outside any block", () => { - expect(findBlocksForHunkLines(blocks, [4])).toEqual([]); - }); - - it("returns empty for empty input", () => { - expect(findBlocksForHunkLines(blocks, [])).toEqual([]); - }); -}); diff --git a/httui-desktop/src/lib/changelog/__tests__/parse-diff.test.ts b/httui-desktop/src/lib/changelog/__tests__/parse-diff.test.ts deleted file mode 100644 index 45ed7a8d..00000000 --- a/httui-desktop/src/lib/changelog/__tests__/parse-diff.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseUnifiedDiff, selectRunbookMd } from "../parse-diff"; - -describe("parseUnifiedDiff", () => { - it("returns empty array for empty input", () => { - expect(parseUnifiedDiff("")).toEqual([]); - }); - - it("parses a minimal single-file modification", () => { - const diff = `diff --git a/x.md b/x.md ---- a/x.md -+++ b/x.md -@@ -1,3 +1,3 @@ - ctx --old -+new -`; - const files = parseUnifiedDiff(diff); - expect(files).toHaveLength(1); - expect(files[0]!.path).toBe("x.md"); - expect(files[0]!.oldPath).toBe("x.md"); - expect(files[0]!.isAdded).toBe(false); - expect(files[0]!.isDeleted).toBe(false); - expect(files[0]!.hunks).toHaveLength(1); - const hunk = files[0]!.hunks[0]!; - expect(hunk.oldStart).toBe(1); - expect(hunk.oldLines).toBe(3); - expect(hunk.newStart).toBe(1); - expect(hunk.newLines).toBe(3); - }); - - it("tracks added line numbers in the new file", () => { - const diff = `diff --git a/x b/x ---- a/x -+++ b/x -@@ -10,2 +10,4 @@ - a - b -+added1 -+added2 -`; - const hunk = parseUnifiedDiff(diff)[0]!.hunks[0]!; - expect(hunk.addedLines).toEqual([12, 13]); - expect(hunk.removedLines).toEqual([]); - }); - - it("tracks removed line numbers in the old file", () => { - const diff = `diff --git a/x b/x ---- a/x -+++ b/x -@@ -10,4 +10,2 @@ - a - b --rem1 --rem2 -`; - const hunk = parseUnifiedDiff(diff)[0]!.hunks[0]!; - expect(hunk.removedLines).toEqual([12, 13]); - expect(hunk.addedLines).toEqual([]); - }); - - it("flags pure-add files via oldPath = /dev/null", () => { - const diff = `diff --git a/new.md b/new.md ---- /dev/null -+++ b/new.md -@@ -0,0 +1,2 @@ -+hello -+world -`; - const f = parseUnifiedDiff(diff)[0]!; - expect(f.isAdded).toBe(true); - expect(f.isDeleted).toBe(false); - expect(f.path).toBe("new.md"); - expect(f.hunks[0]!.addedLines).toEqual([1, 2]); - }); - - it("flags pure-delete files via newPath = /dev/null", () => { - const diff = `diff --git a/old.md b/old.md ---- a/old.md -+++ /dev/null -@@ -1,2 +0,0 @@ --bye --cruel -`; - const f = parseUnifiedDiff(diff)[0]!; - expect(f.isDeleted).toBe(true); - expect(f.isAdded).toBe(false); - expect(f.oldPath).toBe("old.md"); - }); - - it("parses multiple files in one diff", () => { - const diff = `diff --git a/a.md b/a.md ---- a/a.md -+++ b/a.md -@@ -1,1 +1,1 @@ --a -+A -diff --git a/b.md b/b.md ---- a/b.md -+++ b/b.md -@@ -1,1 +1,1 @@ --b -+B -`; - const files = parseUnifiedDiff(diff); - expect(files).toHaveLength(2); - expect(files[0]!.path).toBe("a.md"); - expect(files[1]!.path).toBe("b.md"); - }); - - it("parses multiple hunks per file", () => { - const diff = `diff --git a/x b/x ---- a/x -+++ b/x -@@ -1,1 +1,1 @@ --a -+A -@@ -10,1 +10,1 @@ --z -+Z -`; - const f = parseUnifiedDiff(diff)[0]!; - expect(f.hunks).toHaveLength(2); - expect(f.hunks[0]!.oldStart).toBe(1); - expect(f.hunks[1]!.oldStart).toBe(10); - }); - - it("defaults oldLines/newLines to 1 when omitted", () => { - // `@@ -5 +5 @@` is the `,1`-omitted form that git actually emits. - const diff = `diff --git a/x b/x ---- a/x -+++ b/x -@@ -5 +5 @@ --a -+A -`; - const h = parseUnifiedDiff(diff)[0]!.hunks[0]!; - expect(h.oldLines).toBe(1); - expect(h.newLines).toBe(1); - }); - - it("ignores `\\ No newline at end of file` markers", () => { - const diff = `diff --git a/x b/x ---- a/x -+++ b/x -@@ -1,1 +1,1 @@ --a -\\ No newline at end of file -+A -\\ No newline at end of file -`; - const h = parseUnifiedDiff(diff)[0]!.hunks[0]!; - expect(h.addedLines).toEqual([1]); - expect(h.removedLines).toEqual([1]); - }); - - it("preserves the raw hunk body for downstream block-detection", () => { - const diff = `diff --git a/x b/x ---- a/x -+++ b/x -@@ -1,1 +1,1 @@ --a -+A -`; - const h = parseUnifiedDiff(diff)[0]!.hunks[0]!; - expect(h.raw).toContain("@@ -1,1 +1,1 @@"); - expect(h.raw).toContain("-a"); - expect(h.raw).toContain("+A"); - }); -}); - -describe("selectRunbookMd", () => { - it("keeps .md files inside runbooks/", () => { - const diff = `diff --git a/runbooks/db.md b/runbooks/db.md ---- a/runbooks/db.md -+++ b/runbooks/db.md -@@ -1,1 +1,1 @@ --x -+y -`; - const files = parseUnifiedDiff(diff); - expect(selectRunbookMd(files)).toHaveLength(1); - }); - - it("drops .md files outside runbooks/", () => { - const diff = `diff --git a/notes/random.md b/notes/random.md ---- a/notes/random.md -+++ b/notes/random.md -@@ -1,1 +1,1 @@ --x -+y -`; - expect(selectRunbookMd(parseUnifiedDiff(diff))).toHaveLength(0); - }); - - it("drops non-.md files inside runbooks/", () => { - const diff = `diff --git a/runbooks/script.sh b/runbooks/script.sh ---- a/runbooks/script.sh -+++ b/runbooks/script.sh -@@ -1,1 +1,1 @@ --x -+y -`; - expect(selectRunbookMd(parseUnifiedDiff(diff))).toHaveLength(0); - }); - - it("matches deleted files via oldPath", () => { - const diff = `diff --git a/runbooks/old.md b/runbooks/old.md ---- a/runbooks/old.md -+++ /dev/null -@@ -1,1 +0,0 @@ --x -`; - expect(selectRunbookMd(parseUnifiedDiff(diff))).toHaveLength(1); - }); -}); diff --git a/httui-desktop/src/lib/changelog/classify-block-changes.ts b/httui-desktop/src/lib/changelog/classify-block-changes.ts deleted file mode 100644 index d3c89ee4..00000000 --- a/httui-desktop/src/lib/changelog/classify-block-changes.ts +++ /dev/null @@ -1,186 +0,0 @@ -// per-block change classifier. -// -// Combines `parseUnifiedDiff` (slice 1) + `findFencedBlocks` -// (slice 2) to classify every block-level change in a touched -// `*.md` file as one of: -// -// - Added — a fenced block exists in the after-side -// content but not the before-side (matched by alias OR by -// position when alias is null) -// - Removed — exists in before but not after -// - ModifiedBody — body lines (between fences, excluding -// `# expect:` / `# capture:` marker sections) changed -// - ModifiedInfoString — only the opening fence line changed -// - ModifiedAssertions — `# expect:` section diff -// - ModifiedCaptures — `# capture:` section diff -// -// A single hunk can produce multiple `BlockChange` entries when it -// crosses block boundaries (rare but possible). The classifier -// deduplicates `(blockKey, kind)` so a hunk that touches the same -// block twice doesn't double-count. - -import { type DiffHunk } from "./parse-diff"; -import { - type BlockKind, - type FencedBlock, - findBlockForLine, - findFencedBlocks, -} from "./find-blocks"; - -export type ChangeKind = - | "added" - | "removed" - | "modified-body" - | "modified-info-string" - | "modified-assertions" - | "modified-captures"; - -export interface BlockChange { - kind: ChangeKind; - blockKind: BlockKind; - /** Block alias when present, else null (positional-id fallback - * is the consumer's job — slice 3 doesn't synthesize bNN ids). */ - alias: string | null; - /** Info-string of the after-side block (or before-side for - * Removed). Useful to render "GET /users" in the AI prompt. */ - infoString: string; -} - -export interface ClassifyInput { - beforeContent: string; - afterContent: string; - hunks: ReadonlyArray; -} - -export function classifyBlockChanges(input: ClassifyInput): BlockChange[] { - const beforeBlocks = findFencedBlocks(input.beforeContent); - const afterBlocks = findFencedBlocks(input.afterContent); - const beforeKeys = blockKeys(beforeBlocks); - const afterKeys = blockKeys(afterBlocks); - - const out: BlockChange[] = []; - const seen = new Set(); - const push = (key: string, change: BlockChange) => { - const dedupKey = `${key}::${change.kind}`; - if (seen.has(dedupKey)) return; - seen.add(dedupKey); - out.push(change); - }; - - // Added: in after but not before. - for (const b of afterBlocks) { - const key = blockKey(b); - if (!beforeKeys.has(key)) { - push(key, { - kind: "added", - blockKind: b.kind, - alias: b.alias, - infoString: b.infoString, - }); - } - } - - // Removed: in before but not after. - for (const b of beforeBlocks) { - const key = blockKey(b); - if (!afterKeys.has(key)) { - push(key, { - kind: "removed", - blockKind: b.kind, - alias: b.alias, - infoString: b.infoString, - }); - } - } - - // Modified: per-hunk classification against the after-side - // structure. Skip hunks whose touched lines fall on already- - // added blocks (those are covered by the "added" pass). - const beforeKeySet = beforeKeys; - const afterKeySet = afterKeys; - for (const hunk of input.hunks) { - for (const line of hunk.addedLines) { - const block = findBlockForLine(afterBlocks, line); - if (!block) continue; - const key = blockKey(block); - if (!beforeKeySet.has(key)) continue; // Added block — already pushed. - const subKind = classifyHunkLineWithinBlock( - block, - line, - input.afterContent, - ); - push(key, { - kind: subKind, - blockKind: block.kind, - alias: block.alias, - infoString: block.infoString, - }); - } - for (const line of hunk.removedLines) { - const block = findBlockForLine(beforeBlocks, line); - if (!block) continue; - const key = blockKey(block); - if (!afterKeySet.has(key)) continue; // Removed block — already pushed. - const subKind = classifyHunkLineWithinBlock( - block, - line, - input.beforeContent, - ); - push(key, { - kind: subKind, - blockKind: block.kind, - alias: block.alias, - infoString: block.infoString, - }); - } - } - - return out; -} - -/** Stable identifier for matching before↔after blocks. Uses alias - * when present (canonical), else falls back to "kind@startLine" - * positional matching — imperfect across moves but good enough for - * the slice-1 classifier. */ -function blockKey(b: FencedBlock): string { - if (b.alias) return `alias:${b.alias}`; - return `pos:${b.kind}@${b.startLine}`; -} - -function blockKeys(bs: ReadonlyArray): Set { - const set = new Set(); - for (const b of bs) set.add(blockKey(b)); - return set; -} - -function classifyHunkLineWithinBlock( - block: FencedBlock, - line: number, - content: string, -): ChangeKind { - if (line === block.startLine) return "modified-info-string"; - // Find the source of `line` within the content; check if the line - // text starts a marker section. Marker sections persist for the - // remainder of the block, so we walk back to find the most recent - // marker line. - const lines = content.split("\n"); - // 1-indexed → 0-indexed for array access. - const cur = lines[line - 1] ?? ""; - if (isAssertionMarker(cur)) return "modified-assertions"; - if (isCaptureMarker(cur)) return "modified-captures"; - // Walk back within the block looking for the most recent marker. - for (let i = line - 1; i >= block.startLine; i--) { - const text = lines[i - 1] ?? ""; - if (isAssertionMarker(text)) return "modified-assertions"; - if (isCaptureMarker(text)) return "modified-captures"; - } - return "modified-body"; -} - -function isAssertionMarker(line: string): boolean { - return /^#\s*expect\s*:/iu.test(line.trim()); -} - -function isCaptureMarker(line: string): boolean { - return /^#\s*capture\s*:/iu.test(line.trim()); -} diff --git a/httui-desktop/src/lib/changelog/find-blocks.ts b/httui-desktop/src/lib/changelog/find-blocks.ts deleted file mode 100644 index cd367369..00000000 --- a/httui-desktop/src/lib/changelog/find-blocks.ts +++ /dev/null @@ -1,124 +0,0 @@ -// fenced-block walker. -// -// Given the content of a `.md` file (the after-side, since hunks -// reference new line numbers there), find every executable fenced -// block and return its [start, end] line range. The third slice -// pairs each diff hunk to the block(s) it overlaps, then classifies -// the change shape (body / info-string / assertions / captures / -// added / removed). -// -// Pure logic; no markdown library — the executable-block fence -// shapes are tightly constrained (```http / ```db- / ```sh / -// ```ws / ```gql) so a focused scanner is simpler than pulling in -// a CommonMark parser. - -export type BlockKind = "http" | "db" | "sh" | "ws" | "gql"; - -export interface FencedBlock { - kind: BlockKind; - /** Full info-string (e.g. `"http alias=req1 timeout=30000"`). */ - infoString: string; - /** Alias from the info-string when present (`alias=foo` token). */ - alias: string | null; - /** 1-indexed line of the opening fence. */ - startLine: number; - /** 1-indexed line of the closing fence. */ - endLine: number; -} - -const FENCE_REGEX = /^```(http|db-[A-Za-z0-9_-]+|sh|ws|gql)(?:\s+(.+))?$/; - -export function findFencedBlocks(content: string): FencedBlock[] { - const lines = content.split("\n"); - const blocks: FencedBlock[] = []; - let open: { - kind: BlockKind; - infoString: string; - alias: string | null; - start: number; - } | null = null; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]!; - if (open === null) { - const m = line.match(FENCE_REGEX); - if (m) { - const tag = m[1]!; - const rest = (m[2] ?? "").trim(); - const kind: BlockKind = tag.startsWith("db-") - ? "db" - : (tag as BlockKind); - const infoString = tag.startsWith("db-") - ? `${tag} ${rest}`.trim() - : rest; - const alias = parseAliasFromInfoString(rest); - open = { - kind, - infoString, - alias, - start: i + 1, - }; - } - } else { - // Looking for the closing fence. - if (line.trim() === "```") { - blocks.push({ - kind: open.kind, - infoString: open.infoString, - alias: open.alias, - startLine: open.start, - endLine: i + 1, - }); - open = null; - } - } - } - return blocks; -} - -function parseAliasFromInfoString(rest: string): string | null { - // Tokens are space-separated `key=value`; alias appears as the - // canonical first token of HTTP/DB/etc. info strings (per - // CLAUDE.md "alias → timeout → display → mode" order). - for (const token of rest.split(/\s+/u)) { - if (token.startsWith("alias=")) { - const v = token.slice("alias=".length).trim(); - if (v.length > 0) return v; - } - } - return null; -} - -/** - * Pair a 1-indexed line number to the block that contains it. Lines - * outside any block (between blocks or in the document body) return - * `null`. The opening + closing fence lines themselves count as part - * of the blockuses this to detect whether an info-string change - * touched the fence line. - */ -export function findBlockForLine( - blocks: ReadonlyArray, - line: number, -): FencedBlock | null { - for (const b of blocks) { - if (line >= b.startLine && line <= b.endLine) return b; - } - return null; -} - -/** - * For a given hunk's added-line set, return the unique blocks the - * hunk touches. A hunk that adds lines spanning two blocks (e.g. - * deleted one + added next) appears in both blocks' classification - * results. Empty input → empty output. - */ -export function findBlocksForHunkLines( - blocks: ReadonlyArray, - lines: ReadonlyArray, -): FencedBlock[] { - const seen = new Set(); - for (const line of lines) { - const block = findBlockForLine(blocks, line); - if (block) seen.add(block); - } - return Array.from(seen); -} diff --git a/httui-desktop/src/lib/changelog/parse-diff.ts b/httui-desktop/src/lib/changelog/parse-diff.ts deleted file mode 100644 index cc76d598..00000000 --- a/httui-desktop/src/lib/changelog/parse-diff.ts +++ /dev/null @@ -1,152 +0,0 @@ -// pure unified-diff parser. -// -// of the AI commit-changelog feature classifies block-level -// changes from `git diff --cached`. The pipeline is: -// 1. parse the unified diff text into structured hunks ← THIS FILE -// 2. for each file, identify which fenced block each hunk falls -// inside (by walking the after-side markdown) — carries -// 3. classify each per-block change (added / removed / modified -// body / modified info-string / modified assertions / -// modified captures) — carries -// -// We parse just the bits we need: file paths from `--- a/x` / `+++ b/x`, -// hunks from `@@ -oldStart,oldLines +newStart,newLines @@`, and the -// per-line +/- context. We don't build the full git diff AST (no -// binary diffs, no rename detection beyond the path strings). - -export interface DiffHunk { - oldStart: number; - oldLines: number; - newStart: number; - newLines: number; - /** 1-indexed line numbers IN THE NEW FILE that were added. */ - addedLines: number[]; - /** 1-indexed line numbers IN THE OLD FILE that were removed. */ - removedLines: number[]; - /** Raw hunk text (header + body) for downstream block-detection - * context — follow-up walks each hunk against the - * after-side markdown to find the block. */ - raw: string; -} - -export interface DiffFile { - /** Path on the AFTER side (`+++ b/`). For deletions this is - * `/dev/null` and consumers should use `oldPath` instead. */ - path: string; - oldPath: string; - /** Pure-add file: the OLD path was `/dev/null`. */ - isAdded: boolean; - /** Pure-delete file: the NEW path was `/dev/null`. */ - isDeleted: boolean; - hunks: DiffHunk[]; -} - -export function parseUnifiedDiff(diff: string): DiffFile[] { - if (diff.length === 0) return []; - const lines = diff.split("\n"); - const files: DiffFile[] = []; - let current: DiffFile | null = null; - let currentHunk: DiffHunk | null = null; - let oldLineNo = 0; - let newLineNo = 0; - - const flushHunk = () => { - if (current && currentHunk) { - current.hunks.push(currentHunk); - } - currentHunk = null; - }; - const flushFile = () => { - flushHunk(); - if (current) files.push(current); - current = null; - }; - - for (const line of lines) { - if (line.startsWith("diff --git ")) { - flushFile(); - current = { - path: "", - oldPath: "", - isAdded: false, - isDeleted: false, - hunks: [], - }; - } else if (line.startsWith("--- ") && current) { - const parsed = stripAOrB(line.slice(4)); - current.oldPath = parsed; - current.isAdded = parsed === "/dev/null"; - } else if (line.startsWith("+++ ") && current) { - const parsed = stripAOrB(line.slice(4)); - current.path = parsed; - current.isDeleted = parsed === "/dev/null"; - } else if (line.startsWith("@@") && current) { - flushHunk(); - const header = parseHunkHeader(line); - if (!header) continue; - currentHunk = { - ...header, - addedLines: [], - removedLines: [], - raw: line + "\n", - }; - oldLineNo = header.oldStart; - newLineNo = header.newStart; - } else if (currentHunk) { - currentHunk.raw += line + "\n"; - if (line.startsWith("+") && !line.startsWith("+++")) { - currentHunk.addedLines.push(newLineNo); - newLineNo += 1; - } else if (line.startsWith("-") && !line.startsWith("---")) { - currentHunk.removedLines.push(oldLineNo); - oldLineNo += 1; - } else if (line.startsWith(" ")) { - oldLineNo += 1; - newLineNo += 1; - } else if (line.startsWith("\\")) { - // "\ No newline at end of file" — skip without advancing. - } - } - } - flushFile(); - return files; -} - -function stripAOrB(p: string): string { - // Handles both `a/path` / `b/path` (the conventional prefix) and a - // bare `/dev/null`. - const trimmed = p.trim(); - if (trimmed === "/dev/null") return trimmed; - if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) { - return trimmed.slice(2); - } - return trimmed; -} - -interface HunkHeader { - oldStart: number; - oldLines: number; - newStart: number; - newLines: number; -} - -function parseHunkHeader(line: string): HunkHeader | null { - // `@@ -[,] +[,] @@` - const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/u); - if (!match) return null; - return { - oldStart: parseInt(match[1]!, 10), - oldLines: match[2] ? parseInt(match[2]!, 10) : 1, - newStart: parseInt(match[3]!, 10), - newLines: match[4] ? parseInt(match[4]!, 10) : 1, - }; -} - -/** Convenience: pick out the `*.md` files inside `runbooks/` that the - * changelog should consider. spec says only those count. */ -export function selectRunbookMd(files: ReadonlyArray): DiffFile[] { - return files.filter((f) => { - const path = f.isDeleted ? f.oldPath : f.path; - return path.endsWith(".md") && path.includes("runbooks/"); - }); -} diff --git a/httui-desktop/src/lib/codemirror/__tests__/cm-numbered-headings.test.ts b/httui-desktop/src/lib/codemirror/__tests__/cm-numbered-headings.test.ts deleted file mode 100644 index fb7491cf..00000000 --- a/httui-desktop/src/lib/codemirror/__tests__/cm-numbered-headings.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { Text } from "@codemirror/state"; - -import { buildHeadingDecorations } from "@/lib/codemirror/cm-numbered-headings"; - -function asLines(text: string) { - return Text.of(text.split("\n")); -} - -function numbers(decorations: ReturnType) { - const out: number[] = []; - decorations.decorations.between(0, Number.MAX_SAFE_INTEGER, (_f, _t, dec) => { - const num = dec.spec?.attributes?.["data-heading-number"]; - if (num) out.push(Number(num)); - }); - return out; -} - -function levels(decorations: ReturnType) { - const out: number[] = []; - decorations.decorations.between(0, Number.MAX_SAFE_INTEGER, (_f, _t, dec) => { - const level = dec.spec?.attributes?.["data-heading-level"]; - if (level) out.push(Number(level)); - }); - return out; -} - -describe("buildHeadingDecorations", () => { - it("numbers a flat list of h1+h2 sequentially", () => { - const doc = asLines("# A\n\n## B\n\n# C\n"); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(3); - expect(numbers(result)).toEqual([1, 2, 3]); - }); - - it("counts only top-level # and ## (skips ### h3 and deeper)", () => { - const doc = asLines("# A\n## B\n### should-skip\n#### also-skip\n# C\n"); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(3); - }); - - it("skips headings inside fenced code blocks", () => { - const doc = asLines( - "# A\n\n```\n# inside-fence\n## inside-fence-2\n```\n\n# B\n", - ); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(2); - expect(numbers(result)).toEqual([1, 2]); - }); - - it("recognises ~~~ fences too", () => { - const doc = asLines("# A\n\n~~~\n# inside\n~~~\n\n# B\n"); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(2); - }); - - it("ignores non-heading lines that begin with #", () => { - // `#tag` (no space) is not a heading per CommonMark. - const doc = asLines("#tag\n# real heading\n"); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(1); - }); - - it("returns no decorations for an empty doc", () => { - const doc = asLines(""); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(0); - }); - - it("ignores headings with no following text (just `#`)", () => { - const doc = asLines("#\n## \n# real\n"); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(1); - }); - - it("attributes carry the positional number, starting at 1", () => { - const doc = asLines("# first\n# second\n# third\n"); - const result = buildHeadingDecorations(doc); - expect(numbers(result)).toEqual([1, 2, 3]); - }); - - it("nested fence with mismatched closer keeps tracking", () => { - // Open ```, then `~~~` (wrong marker — counts as content), then - // closing ```. Headings between the two ``` are still skipped. - const doc = asLines("# A\n```\n~~~\n# inside\n~~~\n```\n# B\n"); - const result = buildHeadingDecorations(doc); - expect(result.count).toBe(2); - }); - - it("emits data-heading-level matching the marker length", () => { - // `#` → level 1, `##` → level 2. Level powers the H1-only - // typography rule in editor-theme. - const doc = asLines("# H1\n## H2\n# H1 again\n## H2 again\n"); - const result = buildHeadingDecorations(doc); - expect(levels(result)).toEqual([1, 2, 1, 2]); - }); -}); diff --git a/httui-desktop/src/lib/codemirror/__tests__/cm-scroll.browser.test.tsx b/httui-desktop/src/lib/codemirror/__tests__/cm-scroll.browser.test.tsx deleted file mode 100644 index baf2579d..00000000 --- a/httui-desktop/src/lib/codemirror/__tests__/cm-scroll.browser.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Browser mode test — reproduces the scroll reset bug that happens - * when a block widget grows (e.g., after query execution adds results). - * - * Uses real Chromium layout so CM6's measure phase runs with real - * offsetHeight/scrollTop values — impossible to simulate in jsdom. - */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { - createEditorBlockWidgets, - getWidgetContainers, -} from "../cm-block-widgets"; - -// Long document with a block in the middle so we have room to scroll. -// Uses e2e (not db) because db-* blocks migrated to a different -// registry in cm-db-block.tsx — the scroll-reset bug this test guards -// against affects the shared PortalWidget measurement path that e2e -// still uses. -function buildDoc(blockAtLine: number, totalLines: number): string { - const lines: string[] = []; - for (let i = 0; i < totalLines; i++) { - if (i === blockAtLine) { - lines.push("```e2e {alias=q1}"); - lines.push("step1"); - lines.push("```"); - } else { - lines.push(`line ${i} — some content to make the doc scrollable`); - } - } - return lines.join("\n"); -} - -async function nextFrame() { - return new Promise((resolve) => requestAnimationFrame(() => resolve())); -} - -async function waitFrames(n: number) { - for (let i = 0; i < n; i++) await nextFrame(); -} - -describe("CM6 widget height change scroll bug", () => { - let container: HTMLDivElement; - let view: EditorView; - - beforeEach(() => { - container = document.createElement("div"); - container.style.width = "600px"; - container.style.height = "400px"; - container.style.overflow = "hidden"; - container.style.position = "fixed"; - container.style.top = "0"; - container.style.left = "0"; - document.body.appendChild(container); - }); - - afterEach(() => { - view?.destroy(); - container.remove(); - }); - - it("does NOT reset scroll to 0 when a widget above the viewport grows", async () => { - const state = EditorState.create({ - doc: buildDoc(10, 100), - extensions: [ - createEditorBlockWidgets(), - EditorView.theme({ - "&": { height: "400px" }, - ".cm-scroller": { overflow: "auto" }, - }), - ], - }); - view = new EditorView({ state, parent: container }); - - // Wait for layout - await waitFrames(3); - - // Get the widget element - const widgetEntry = getWidgetContainers().get("block_0"); - expect(widgetEntry).toBeDefined(); - const widgetEl = widgetEntry!.element; - - // Scroll so the block is ABOVE the viewport (simulate user scrolled past) - view.scrollDOM.scrollTop = 600; - await waitFrames(3); - const beforeScroll = view.scrollDOM.scrollTop; - expect(beforeScroll).toBeGreaterThan(100); - - // Simulate block execution: widget grows dramatically (e.g., results table) - widgetEl.style.minHeight = "500px"; - widgetEl.innerHTML = - "
Results table
"; - - // Wait for ResizeObserver to fire and CM6 measure to run - await waitFrames(5); - - // Trigger a scroll event (this is what the user does — scrolling a bit - // triggers CM6's onScrollChanged → measure → miscalculation → scrollTop = 0) - view.scrollDOM.dispatchEvent(new Event("scroll")); - await waitFrames(3); - - // ASSERTION: scroll should NOT have been reset to 0 - expect(view.scrollDOM.scrollTop).not.toBe(0); - expect(view.scrollDOM.scrollTop).toBeGreaterThan(100); - }); -}); diff --git a/httui-desktop/src/lib/codemirror/cm-block-widgets.tsx b/httui-desktop/src/lib/codemirror/cm-block-widgets.tsx index f4bc104e..6cb919f1 100644 --- a/httui-desktop/src/lib/codemirror/cm-block-widgets.tsx +++ b/httui-desktop/src/lib/codemirror/cm-block-widgets.tsx @@ -1,9 +1,4 @@ -import { - Annotation, - RangeSetBuilder, - StateField, - Text as CMText, -} from "@codemirror/state"; +import { RangeSetBuilder, StateField, Text as CMText } from "@codemirror/state"; import { Decoration, WidgetType, @@ -14,22 +9,12 @@ import { createRoot, type Root } from "react-dom/client"; import { Provider } from "@/components/ui/provider"; import { StandaloneBlock } from "@/components/blocks/standalone/StandaloneBlock"; -/** - * Annotation to mark transactions originated from block widgets. - * When present, the decoration StateField maps positions instead of rebuilding, - * preventing widget destruction/recreation (which causes flicker). - */ -export const widgetTransaction = Annotation.define(); - -// Scanner detects http only (db has its own scanner in cm-db-block.tsx). -// The editor widget builder used to mount the BlockAdapter pipeline for -// e2e blocks; with E2E removed, no language flows through that adapter -// — the set is empty, `createEditorBlockWidgets` becomes a no-op, and -// http blocks are owned entirely by `cm-http-block.tsx`. The DiffViewer -// (read-only) still uses `findFencedBlocks` to render http widgets in -// the diff side-by-side view via `createBlockWidgetPlugin`. +// Scans `http` fences only (db has its own scanner in cm-db-block.tsx). +// Consumed by the read-only DiffViewer (`createBlockWidgetPlugin`) and by +// document / reference / move-block helpers that call `findFencedBlocks`. +// The editor-side portal pipeline for the removed E2E block was dropped +// (it had been a no-op since E2E removal). const BLOCK_OPEN_RE = /^```(http)(.*)$/; -const EDITOR_WIDGET_LANGS: ReadonlySet = new Set(); const BLOCK_CLOSE_RE = /^```\s*$/; export interface FencedBlock { @@ -240,258 +225,3 @@ export function createBlockWidgetPlugin( provide: (f) => EditorView.decorations.from(f), }); } - -// ── Portal widgets (React renders directly into these divs) ────────────────── - -/** - * Widget registry — maps blockId → { element, block }. - * React renders into these divs via createPortal (in WidgetPortals component). - * CM6 owns the div and measures its height naturally — no height cache needed. - */ -const widgetContainers = new Map< - string, - { element: HTMLElement; block: FencedBlock } ->(); -let portalVersion = 0; -const portalListeners = new Set<() => void>(); - -function notifyPortals() { - portalVersion++; - for (const fn of portalListeners) fn(); -} - -export function subscribeToPortals(cb: () => void) { - portalListeners.add(cb); - return () => { - portalListeners.delete(cb); - }; -} -export function getPortalVersion() { - return portalVersion; -} -export function getWidgetContainers() { - return widgetContainers; -} - -/** - * Portal widget — a div in CM6's document flow. - * React renders the e2e block component (E2eBlockView) directly into this - * div via createPortal. CM6 measures height naturally. No overlay, no - * absolute positioning, no height cache. - * - * (Pre-redesign this also rendered HTTP blocks; those now live in - * `cm-http-block.tsx` + `HttpFencedPanel`.) - */ -// Height cache keyed by blockId — stores last measured DOM height so CM6's -// estimatedHeight returns a stable value even across widget rebuilds. -// Without this, CM6's scroll anchoring calculates wrong positions when -// widget content changes async (e.g., query results arrive after execution). -const widgetHeights = new Map(); - -class PortalWidget extends WidgetType { - constructor( - readonly blockId: string, - readonly block: FencedBlock, - ) { - super(); - } - - toDOM(): HTMLElement { - // Outer div — CM6 sees this. Has min-height to prevent shrinkage during - // React transient re-renders (which would break CM6 scroll anchoring). - const div = document.createElement("div"); - div.className = "cm-block-portal"; - const saved = widgetHeights.get(this.blockId); - if (saved) div.style.minHeight = `${saved}px`; - - // Inner div — React renders here. Its natural height is observed to - // drive the outer div's min-height. This separation lets us shrink - // legitimately (toggle edit/split) without the min-height feedback loop. - const inner = document.createElement("div"); - inner.className = "cm-block-portal-inner"; - div.appendChild(inner); - - widgetContainers.set(this.blockId, { element: inner, block: this.block }); - - let shrinkRaf1 = 0; - let shrinkRaf2 = 0; - const ro = new ResizeObserver(() => { - const h = inner.offsetHeight; - if (h <= 0) return; - const prev = widgetHeights.get(this.blockId) ?? 0; - if (h > prev) { - // Grow immediately - widgetHeights.set(this.blockId, h); - div.style.minHeight = `${h}px`; - cancelAnimationFrame(shrinkRaf1); - cancelAnimationFrame(shrinkRaf2); - } else if (h < prev) { - // Shrink after layout stabilizes (~2 rAFs absorb React transients) - cancelAnimationFrame(shrinkRaf1); - cancelAnimationFrame(shrinkRaf2); - shrinkRaf1 = requestAnimationFrame(() => { - shrinkRaf2 = requestAnimationFrame(() => { - const current = inner.offsetHeight; - if ( - current > 0 && - current < (widgetHeights.get(this.blockId) ?? 0) - ) { - widgetHeights.set(this.blockId, current); - div.style.minHeight = `${current}px`; - } - }); - }); - } - }); - ro.observe(inner); - (div as HTMLElement & { __ro?: ResizeObserver }).__ro = ro; - - notifyPortals(); - return div; - } - - updateDOM(dom: HTMLElement): boolean { - widgetContainers.set(this.blockId, { element: dom, block: this.block }); - notifyPortals(); - return true; - } - - destroy(dom: HTMLElement): void { - if (dom.offsetHeight > 0) { - widgetHeights.set(this.blockId, dom.offsetHeight); - } - const ro = (dom as HTMLElement & { __ro?: ResizeObserver }).__ro; - ro?.disconnect(); - widgetContainers.delete(this.blockId); - notifyPortals(); - } - - eq(other: PortalWidget): boolean { - return this.blockId === other.blockId; - } - - get estimatedHeight(): number { - // Read actual DOM height if widget is currently rendered - const entry = widgetContainers.get(this.blockId); - if (entry?.element.offsetHeight) { - return entry.element.offsetHeight; - } - return widgetHeights.get(this.blockId) ?? 100; - } - - ignoreEvent(): boolean { - return true; - } -} - -/** Generate a stable ID for a block — index-based so alias/content edits don't destroy widgets */ -function getBlockId(_block: FencedBlock, index: number): string { - return `block_${index}`; -} - -const hiddenLineDecoration = Decoration.line({ class: "cm-hidden-block-line" }); - -function buildEditorDecorations( - state: import("@codemirror/state").EditorState, -): DecorationSet { - const decorations: { from: number; to: number; deco: Decoration }[] = []; - // Filter to languages this adapter still handles. http blocks render via - // `cm-http-block.tsx` and would clash if both extensions decorated them. - const blocks = findFencedBlocks(state.doc).filter((b) => - EDITOR_WIDGET_LANGS.has(b.lang), - ); - - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i]; - // Portal widget — React renders block UI directly into this div - decorations.push({ - from: block.from, - to: block.from, - deco: Decoration.widget({ - widget: new PortalWidget(getBlockId(block, i), block), - block: true, - side: -1, - }), - }); - - // Hide each line of the block's raw markdown - const startLine = state.doc.lineAt(block.from).number; - const endLine = state.doc.lineAt(block.to).number; - for (let lineNum = startLine; lineNum <= endLine; lineNum++) { - const line = state.doc.line(lineNum); - decorations.push({ - from: line.from, - to: line.from, - deco: hiddenLineDecoration, - }); - } - } - - decorations.sort((a, b) => a.from - b.from || a.to - b.to); - - const builder = new RangeSetBuilder(); - for (const { from, to, deco } of decorations) { - builder.add(from, to, deco); - } - return builder.finish(); -} - -/** Count adapter-routed blocks in a document (only e2e today). */ -function countBlocks(doc: CMText): number { - let count = 0; - for (let i = 1; i <= doc.lines; i++) { - const m = doc.line(i).text.match(BLOCK_OPEN_RE); - if (m && EDITOR_WIDGET_LANGS.has(m[1])) count++; - } - return count; -} - -/** - * Create editor block extension — placeholders + hidden lines + atomic ranges. - * Actual widget rendering is done by WidgetPortals via React createPortal into PortalWidget divs. - */ -export function createEditorBlockWidgets() { - let lastBlockCount = 0; - // Cache block ranges for atomicRanges — avoids redundant findFencedBlocks calls - let cachedBlocks: FencedBlock[] = []; - - const field = StateField.define({ - create(state) { - cachedBlocks = findFencedBlocks(state.doc).filter((b) => - EDITOR_WIDGET_LANGS.has(b.lang), - ); - lastBlockCount = cachedBlocks.length; - return buildEditorDecorations(state); - }, - update(decos, tr) { - if (tr.annotation(widgetTransaction)) { - return decos.map(tr.changes); - } - if (tr.docChanged) { - const newCount = countBlocks(tr.state.doc); - if (newCount !== lastBlockCount) { - lastBlockCount = newCount; - cachedBlocks = findFencedBlocks(tr.state.doc).filter((b) => - EDITOR_WIDGET_LANGS.has(b.lang), - ); - return buildEditorDecorations(tr.state); - } - return decos.map(tr.changes); - } - return decos; - }, - provide: (f) => EditorView.decorations.from(f), - }); - - // Make hidden block ranges atomic so cursor skips over them - // Reuses cachedBlocks from the StateField instead of re-scanning - const atomicBlocks = EditorView.atomicRanges.of(() => { - const builder = new RangeSetBuilder(); - for (const block of cachedBlocks) { - builder.add(block.from, block.to, Decoration.mark({})); - } - return builder.finish(); - }); - - return [field, atomicBlocks]; -} diff --git a/httui-desktop/src/lib/codemirror/cm-numbered-headings.tsx b/httui-desktop/src/lib/codemirror/cm-numbered-headings.tsx deleted file mode 100644 index 0dc0662b..00000000 --- a/httui-desktop/src/lib/codemirror/cm-numbered-headings.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Numbered-section-heading decoration. -// -// Renders a leading "1.", "2.", ... before each top-level `#` / -// `##` heading line. Numbering is positional across the whole -// document and rebuilds on every state change, so insert / delete -// re-numbers automatically. Headings inside fenced code blocks -// (```...``` or `~~~...~~~`) are skipped — they're prose-rendered -// as code, not document outline. -// -// The decoration is a `Decoration.line` that adds a -// `cm-numbered-heading` class plus `data-heading-number` and -// `data-heading-level` (1 or 2) attributes — actual styling (accent -// circle, serif font, H1 size) lives in the editor theme so this -// module stays presentation-free. - -import { RangeSetBuilder, StateField, type Extension } from "@codemirror/state"; -import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; - -const HEADING_RE = /^(#{1,2})\s+\S/; -const FENCE_RE = /^(```|~~~)/; - -interface BuildResult { - decorations: DecorationSet; - /** Total numbered-heading lines found — exposed for tests. */ - count: number; -} - -/** Walk the document and build a `RangeSet` of `cm-numbered-heading` - * line decorations. Pure function: takes a doc, returns the - * decoration set. Easy to unit-test. */ -export function buildHeadingDecorations(doc: { - lines: number; - line: (n: number) => { from: number; text: string }; -}): BuildResult { - const builder = new RangeSetBuilder(); - let inFence = false; - let fenceMarker: string | null = null; - let counter = 0; - - for (let i = 1; i <= doc.lines; i += 1) { - const line = doc.line(i); - const text = line.text; - - // Toggle fence state. A fence line starts with ``` or ~~~. The - // closing fence must use the same marker; any non-matching - // marker is treated as content inside the fence. - const fenceMatch = FENCE_RE.exec(text); - if (fenceMatch) { - const marker = fenceMatch[1]; - if (!inFence) { - inFence = true; - fenceMarker = marker; - } else if (marker === fenceMarker) { - inFence = false; - fenceMarker = null; - } - continue; - } - - if (inFence) continue; - - const headingMatch = HEADING_RE.exec(text); - if (!headingMatch) continue; - - counter += 1; - const level = headingMatch[1].length; // 1 for `#`, 2 for `##` - builder.add( - line.from, - line.from, - Decoration.line({ - class: "cm-numbered-heading", - attributes: { - "data-heading-number": String(counter), - "data-heading-level": String(level), - }, - }), - ); - } - - return { decorations: builder.finish(), count: counter }; -} - -const numberedHeadingsField = StateField.define({ - create(state) { - return buildHeadingDecorations(state.doc).decorations; - }, - update(value, tr) { - if (!tr.docChanged) return value; - return buildHeadingDecorations(tr.state.doc).decorations; - }, - provide: (f) => EditorView.decorations.from(f), -}); - -export function numberedHeadings(): Extension { - return numberedHeadingsField; -} diff --git a/httui-desktop/src/lib/share/__tests__/share-url.test.ts b/httui-desktop/src/lib/share/__tests__/share-url.test.ts deleted file mode 100644 index 7cbf3810..00000000 --- a/httui-desktop/src/lib/share/__tests__/share-url.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseRemoteUrl, type ParsedRemote } from "../remote-host"; -import { - composeBlobUrl, - composeCompareUrl, - composeTreeUrl, -} from "../share-url"; - -function parsed(url: string): ParsedRemote { - const p = parseRemoteUrl(url); - if (!p) throw new Error(`expected parse for ${url}`); - return p; -} - -describe("composeBlobUrl", () => { - it("composes a GitHub blob URL", () => { - const r = composeBlobUrl( - parsed("https://github.com/owner/repo.git"), - "abc1234", - "src/lib/foo.ts", - ); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.url).toBe( - "https://github.com/owner/repo/blob/abc1234/src/lib/foo.ts", - ); - } - }); - - it("composes a GitLab blob URL with /-/blob/ shape", () => { - const r = composeBlobUrl( - parsed("https://gitlab.com/group/repo.git"), - "abc", - "src/x", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://gitlab.com/group/repo/-/blob/abc/src/x"); - }); - - it("composes a self-hosted GitLab blob URL", () => { - const r = composeBlobUrl( - parsed("git@gitlab.example.com:group/repo.git"), - "sha", - "p", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://gitlab.example.com/group/repo/-/blob/sha/p"); - }); - - it("appends #L when line is provided", () => { - const r = composeBlobUrl( - parsed("https://github.com/owner/repo"), - "abc", - "x.ts", - 42, - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url.endsWith("#L42")).toBe(true); - }); - - it("ignores non-positive lines", () => { - const r = composeBlobUrl( - parsed("https://github.com/owner/repo"), - "abc", - "x.ts", - 0, - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).not.toContain("#L"); - }); - - it("strips leading slashes from the file path", () => { - const r = composeBlobUrl( - parsed("https://github.com/owner/repo"), - "abc", - "/src/x", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://github.com/owner/repo/blob/abc/src/x"); - }); - - it("returns unsupported-host hint for Bitbucket / Gitea / Other", () => { - for (const url of [ - "git@bitbucket.org:t/r.git", - "https://gitea.com/o/r", - "https://code.example.com/o/r", - ]) { - const r = composeBlobUrl(parsed(url), "abc", "x"); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.reason).toBe("unsupported-host"); - expect(r.hint).toMatch(/Manual: open/); - expect(r.fallback.startsWith("https://")).toBe(true); - } - } - }); - - it("converts ssh URLs to https for the rendered share link", () => { - const r = composeBlobUrl( - parsed("git@github.com:owner/repo.git"), - "abc", - "x", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url.startsWith("https://github.com/")).toBe(true); - }); -}); - -describe("composeTreeUrl", () => { - it("composes a GitHub tree URL", () => { - const r = composeTreeUrl( - parsed("https://github.com/owner/repo"), - "abc1234", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://github.com/owner/repo/tree/abc1234"); - }); - - it("composes a GitLab tree URL with /-/tree/ shape", () => { - const r = composeTreeUrl(parsed("https://gitlab.com/g/r"), "abc"); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://gitlab.com/g/r/-/tree/abc"); - }); - - it("returns the manual hint for unsupported forges", () => { - const r = composeTreeUrl(parsed("https://code.example.com/o/r"), "abc"); - expect(r.ok).toBe(false); - }); -}); - -describe("composeCompareUrl", () => { - it("composes a GitHub compare URL", () => { - const r = composeCompareUrl( - parsed("https://github.com/owner/repo"), - "main", - "feat/x", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://github.com/owner/repo/compare/main...feat/x"); - }); - - it("composes a GitLab compare URL with /-/compare/ shape", () => { - const r = composeCompareUrl( - parsed("https://gitlab.com/g/r"), - "main", - "feat/x", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://gitlab.com/g/r/-/compare/main...feat/x"); - }); - - it("composes a self-hosted GitLab compare URL", () => { - const r = composeCompareUrl( - parsed("git@gitlab.example.com:g/r.git"), - "main", - "x", - ); - if (!r.ok) throw new Error("expected ok"); - expect(r.url).toBe("https://gitlab.example.com/g/r/-/compare/main...x"); - }); - - it("returns the manual hint for Bitbucket / Gitea / Other", () => { - const r = composeCompareUrl(parsed("git@bitbucket.org:t/r.git"), "a", "b"); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.fallback).toBe("https://bitbucket.org/t/r"); - } - }); -}); diff --git a/httui-desktop/src/lib/share/share-url.ts b/httui-desktop/src/lib/share/share-url.ts deleted file mode 100644 index de58f6dd..00000000 --- a/httui-desktop/src/lib/share/share-url.ts +++ /dev/null @@ -1,142 +0,0 @@ -// share URL composers. -// -// `composeBlobUrl` — `/blob//` (GitHub -// shape) or `/-/blob//` (GitLab shape). Optional -// `#L` anchor. -// `composeTreeUrl` — fallback: `/tree/` when no -// active file is open. -// `composeCompareUrl` — `/compare/..`. -// -// All composers return either a string URL or a structured -// `UnsupportedResult` that the consumer can show as a "Manual: open -// " hint with a copy-URL fallback (per epic spec for -// Bitbucket / Gitea / Other). - -import type { ParsedRemote, RemoteHost } from "./remote-host"; - -export interface UnsupportedResult { - ok: false; - reason: "unsupported-host"; - /** Pre-formatted hint copy the consumer can render verbatim. */ - hint: string; - /** Always points at the bare origin so the consumer can offer a - * copy-URL or "open in browser" fallback. */ - fallback: string; -} - -export interface SupportedResult { - ok: true; - url: string; -} - -export type ComposeResult = SupportedResult | UnsupportedResult; - -export function composeBlobUrl( - remote: ParsedRemote, - sha: string, - path: string, - line?: number, -): ComposeResult { - const base = baseUrl(remote); - const cleanedPath = path.replace(/^\/+/u, ""); - const anchor = line && line > 0 ? `#L${line}` : ""; - switch (remote.host.kind) { - case "github": - case "bitbucket": - case "gitea": - case "other": - // GitHub-shaped path; Bitbucket/Gitea/Other still get the - // "manual" hint below. - if ( - remote.host.kind === "bitbucket" || - remote.host.kind === "gitea" || - remote.host.kind === "other" - ) { - return manualHint(remote); - } - return { ok: true, url: `${base}/blob/${sha}/${cleanedPath}${anchor}` }; - case "gitlab": - case "gitlab_self_hosted": - return { ok: true, url: `${base}/-/blob/${sha}/${cleanedPath}${anchor}` }; - } -} - -export function composeTreeUrl( - remote: ParsedRemote, - sha: string, -): ComposeResult { - const base = baseUrl(remote); - switch (remote.host.kind) { - case "github": - return { ok: true, url: `${base}/tree/${sha}` }; - case "gitlab": - case "gitlab_self_hosted": - return { ok: true, url: `${base}/-/tree/${sha}` }; - case "bitbucket": - case "gitea": - case "other": - return manualHint(remote); - } -} - -export function composeCompareUrl( - remote: ParsedRemote, - base: string, - current: string, -): ComposeResult { - const baseHttp = baseUrl(remote); - switch (remote.host.kind) { - case "github": - return { - ok: true, - url: `${baseHttp}/compare/${base}...${current}`, - }; - case "gitlab": - case "gitlab_self_hosted": - return { - ok: true, - url: `${baseHttp}/-/compare/${base}...${current}`, - }; - case "bitbucket": - case "gitea": - case "other": - return manualHint(remote); - } -} - -/** - * Re-derives an HTTPS base URL from a `ParsedRemote`. Always returns - * the canonical `https:////` form regardless of - * whether the user typed an SSH URL — so the rendered share URL is - * always something a browser can open. - */ -function baseUrl(remote: ParsedRemote): string { - const host = remote.hostStr; - return `https://${host}/${remote.owner}/${remote.repo}`; -} - -function manualHint(remote: ParsedRemote): UnsupportedResult { - const label = humanLabel(remote.host); - return { - ok: false, - reason: "unsupported-host", - hint: `Manual: open ${remote.hostStr} in browser (${label} share URLs are not yet auto-composed).`, - fallback: baseUrl(remote), - }; -} - -function humanLabel(host: RemoteHost): string { - switch (host.kind) { - case "github": - return "GitHub"; - case "gitlab": - case "gitlab_self_hosted": - return "GitLab"; - case "bitbucket": - return "Bitbucket"; - case "gitea": - return "Gitea"; - case "other": - return "this forge"; - } -} diff --git a/httui-desktop/src/lib/tauri/__tests__/captures-cache.test.ts b/httui-desktop/src/lib/tauri/__tests__/captures-cache.test.ts deleted file mode 100644 index b1db9a75..00000000 --- a/httui-desktop/src/lib/tauri/__tests__/captures-cache.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { - deleteCapturesCache, - readCapturesCache, - writeCapturesCache, -} from "@/lib/tauri/captures-cache"; -import { clearTauriMocks, mockTauriCommand } from "@/test/mocks/tauri"; - -describe("captures-cache Tauri wrappers", () => { - afterEach(() => { - clearTauriMocks(); - }); - - it("writeCapturesCache invokes write_captures_cache_cmd with named args", async () => { - const seen: Array> = []; - mockTauriCommand("write_captures_cache_cmd", (args) => { - seen.push(args as Record); - return "/tmp/v/.httui/captures/x.md.json"; - }); - const out = await writeCapturesCache("/tmp/v", "x.md", '{"a":1}'); - expect(out).toBe("/tmp/v/.httui/captures/x.md.json"); - expect(seen[0]).toEqual({ - vaultPath: "/tmp/v", - filePath: "x.md", - json: '{"a":1}', - }); - }); - - it("readCapturesCache returns the JSON string when present", async () => { - mockTauriCommand("read_captures_cache_cmd", () => '{"a":1}'); - const r = await readCapturesCache("/v", "x.md"); - expect(r).toBe('{"a":1}'); - }); - - it("readCapturesCache returns null when missing", async () => { - mockTauriCommand("read_captures_cache_cmd", () => null); - await expect(readCapturesCache("/v", "absent.md")).resolves.toBeNull(); - }); - - it("deleteCapturesCache returns boolean removed flag", async () => { - mockTauriCommand("delete_captures_cache_cmd", () => true); - await expect(deleteCapturesCache("/v", "x.md")).resolves.toBe(true); - mockTauriCommand("delete_captures_cache_cmd", () => false); - await expect(deleteCapturesCache("/v", "absent.md")).resolves.toBe(false); - }); - - it("propagates IPC errors", async () => { - mockTauriCommand("write_captures_cache_cmd", () => { - throw new Error("file_path may not contain `..` segments"); - }); - await expect( - writeCapturesCache("/v", "../escape.md", "{}"), - ).rejects.toThrow(/`\.\.`/); - }); -}); diff --git a/httui-desktop/src/lib/tauri/__tests__/run-bodies.test.ts b/httui-desktop/src/lib/tauri/__tests__/run-bodies.test.ts deleted file mode 100644 index 80824ee4..00000000 --- a/httui-desktop/src/lib/tauri/__tests__/run-bodies.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { - listRunBodies, - readRunBody, - trimRunBodies, - writeRunBody, - type RunBodyEntry, -} from "@/lib/tauri/run-bodies"; -import { clearTauriMocks, mockTauriCommand } from "@/test/mocks/tauri"; - -describe("run-bodies Tauri wrappers", () => { - afterEach(() => { - clearTauriMocks(); - }); - - it("writeRunBody invokes write_run_body_cmd with named args + array body", async () => { - const seen: Array> = []; - mockTauriCommand("write_run_body_cmd", (args) => { - seen.push(args as Record); - return "/tmp/x.httui/runs/x.md/a/r1.json"; - }); - - const out = await writeRunBody( - "/tmp/x", - "x.md", - "a", - "r1", - "json", - new Uint8Array([0x7b, 0x7d]), - ); - - expect(out).toBe("/tmp/x.httui/runs/x.md/a/r1.json"); - expect(seen).toEqual([ - { - vaultPath: "/tmp/x", - filePath: "x.md", - alias: "a", - runId: "r1", - kind: "json", - body: [0x7b, 0x7d], - }, - ]); - }); - - it("readRunBody returns Uint8Array when the file exists", async () => { - mockTauriCommand("read_run_body_cmd", () => [0x68, 0x69]); - const r = await readRunBody("/v", "x.md", "a", "r1"); - expect(r).toBeInstanceOf(Uint8Array); - expect(Array.from(r as Uint8Array)).toEqual([0x68, 0x69]); - }); - - it("readRunBody returns null when the file is missing", async () => { - mockTauriCommand("read_run_body_cmd", () => null); - await expect(readRunBody("/v", "x.md", "a", "missing")).resolves.toBeNull(); - }); - - it("listRunBodies passes through entries verbatim", async () => { - const fakeEntries: RunBodyEntry[] = [ - { run_id: "01b", kind: "json", byte_size: 12, truncated: false }, - { run_id: "01a", kind: "bin", byte_size: 1_048_576, truncated: true }, - ]; - mockTauriCommand("list_run_bodies_cmd", () => fakeEntries); - const r = await listRunBodies("/v", "x.md", "a"); - expect(r).toEqual(fakeEntries); - }); - - it("trimRunBodies returns the deleted count", async () => { - const seen: Array> = []; - mockTauriCommand("trim_run_bodies_cmd", (args) => { - seen.push(args as Record); - return 3; - }); - const r = await trimRunBodies("/v", "x.md", "a", 10); - expect(r).toBe(3); - expect(seen[0]).toEqual({ - vaultPath: "/v", - filePath: "x.md", - alias: "a", - keepN: 10, - }); - }); - - it("propagates IPC errors", async () => { - mockTauriCommand("write_run_body_cmd", () => { - throw new Error("alias contains invalid char"); - }); - await expect( - writeRunBody("/v", "x.md", "bad alias", "r1", "json", new Uint8Array()), - ).rejects.toThrow(/invalid char/); - }); -}); diff --git a/httui-desktop/src/lib/tauri/captures-cache.ts b/httui-desktop/src/lib/tauri/captures-cache.ts deleted file mode 100644 index aa468307..00000000 --- a/httui-desktop/src/lib/tauri/captures-cache.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Tauri wrappers for the captures cache. -// -// When auto-capture is ON for a file, the consumer (Capture store -// + footer UI) filters secret-flagged entries out, JSON-stringifies -// the captures map, and writes it via `writeCapturesCache`. On app -// start each open file calls `readCapturesCache` to seed the -// in-memory store. Toggling auto-capture OFF deletes the file via -// `deleteCapturesCache`. -// -// JSON shape is owned by the consumer — backend treats it as opaque. - -import { invoke } from "@tauri-apps/api/core"; - -export function readCapturesCache( - vaultPath: string, - filePath: string, -): Promise { - return invoke("read_captures_cache_cmd", { vaultPath, filePath }); -} - -export function writeCapturesCache( - vaultPath: string, - filePath: string, - json: string, -): Promise { - return invoke("write_captures_cache_cmd", { vaultPath, filePath, json }); -} - -export function deleteCapturesCache( - vaultPath: string, - filePath: string, -): Promise { - return invoke("delete_captures_cache_cmd", { vaultPath, filePath }); -} diff --git a/httui-desktop/src/lib/tauri/run-bodies.ts b/httui-desktop/src/lib/tauri/run-bodies.ts deleted file mode 100644 index 6523e858..00000000 --- a/httui-desktop/src/lib/tauri/run-bodies.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Tauri wrappers for the run-body filesystem cache. -// -// Powers the streamed-execution path's post-run persistence and the -// run-diff viewer's history reads. Bytes flow through `Vec` / -// `Uint8Array` so binary HTTP responses survive the IPC boundary -// untouched. - -import { invoke } from "@tauri-apps/api/core"; - -export type RunBodyKind = "json" | "bin"; - -export interface RunBodyEntry { - run_id: string; - /** "json" | "bin" — chosen by the writer at insert time. */ - kind: string; - byte_size: number; - /** True when the writer hit the 1 MiB cap and appended the - * truncation marker. */ - truncated: boolean; -} - -export function writeRunBody( - vaultPath: string, - filePath: string, - alias: string, - runId: string, - kind: RunBodyKind, - body: Uint8Array | number[], -): Promise { - return invoke("write_run_body_cmd", { - vaultPath, - filePath, - alias, - runId, - kind, - body: Array.from(body), - }); -} - -export async function readRunBody( - vaultPath: string, - filePath: string, - alias: string, - runId: string, -): Promise { - const result = await invoke("read_run_body_cmd", { - vaultPath, - filePath, - alias, - runId, - }); - return result ? new Uint8Array(result) : null; -} - -export function listRunBodies( - vaultPath: string, - filePath: string, - alias: string, -): Promise { - return invoke("list_run_bodies_cmd", { vaultPath, filePath, alias }); -} - -export function trimRunBodies( - vaultPath: string, - filePath: string, - alias: string, - keepN: number, -): Promise { - return invoke("trim_run_bodies_cmd", { - vaultPath, - filePath, - alias, - keepN, - }); -} - -/** Move every cached run body for `(filePath, oldAlias)` to - * `(filePath, newAlias)`. Resolves to `false` when the source dir - * is missing (no runs to move); rejects when the destination dir - * already has cached runs. Powers the alias-rename - * flow that fires when the user edits a block's `alias=` info- - * string token. */ -export function renameAliasRuns( - vaultPath: string, - filePath: string, - oldAlias: string, - newAlias: string, -): Promise { - return invoke("rename_alias_runs_cmd", { - vaultPath, - filePath, - oldAlias, - newAlias, - }); -} diff --git a/httui-desktop/src/lib/tauri/templates.ts b/httui-desktop/src/lib/tauri/templates.ts deleted file mode 100644 index 349624dc..00000000 --- a/httui-desktop/src/lib/tauri/templates.ts +++ /dev/null @@ -1,33 +0,0 @@ -// coverage:exclude file — pure invoke() wrappers + IPC types. -// -// Tauri wrappers for `httui_core::templates`. The -// empty-state Templates card calls `listTemplates(vault)` -// to render the picker; the chosen template's `body` is copied -// verbatim into a fresh runbook. - -import { invoke } from "@tauri-apps/api/core"; - -/** Where a template comes from. Mirrors Rust `TemplateSource` - * (snake_case serde). */ -export type TemplateSource = "builtin" | "vault"; - -export interface Template { - /** Stable id — file stem for vault, slug for built-ins. */ - id: string; - /** Display name (frontmatter `title:` ?? id). */ - name: string; - /** Short description (frontmatter `description:` ?? ""). */ - description: string; - source: TemplateSource; - /** Full markdown body, frontmatter included. Copied verbatim - * into the runbook the picker creates. */ - body: string; -} - -/** List built-in + vault-local templates for the picker. Built-ins - * return an empty array until the embedded-templates content slice - * ships; vault-local templates come from - * `/.httui/templates/*.md`. */ -export function listTemplates(vaultPath: string): Promise { - return invoke("list_templates_cmd", { vaultPath }); -} diff --git a/httui-desktop/src/stores/__tests__/captureStore.test.ts b/httui-desktop/src/stores/__tests__/captureStore.test.ts deleted file mode 100644 index ab9e74cd..00000000 --- a/httui-desktop/src/stores/__tests__/captureStore.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; - -import { useCaptureStore } from "@/stores/captureStore"; - -describe("useCaptureStore", () => { - beforeEach(() => { - useCaptureStore.setState({ values: {} }); - }); - - it("starts empty", () => { - expect(useCaptureStore.getState().values).toEqual({}); - }); - - it("setBlockCaptures wraps each value in a CaptureEntry with isSecret", () => { - useCaptureStore - .getState() - .setBlockCaptures("a.md", "login", { token: "t", user_id: 99 }); - const block = useCaptureStore.getState().values["a.md"]?.["login"]; - expect(block?.token).toEqual({ value: "t", isSecret: true }); - expect(block?.user_id).toEqual({ value: 99, isSecret: false }); - }); - - it("coerces non-primitive values to null", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { - obj: { nested: 1 }, - arr: [1, 2], - und: undefined, - nil: null, - bool: true, - }); - const block = useCaptureStore.getState().values["a.md"]?.["x"]; - expect(block?.obj.value).toBeNull(); - expect(block?.arr.value).toBeNull(); - expect(block?.und.value).toBeNull(); - expect(block?.nil.value).toBeNull(); - expect(block?.bool.value).toBe(true); - }); - - it("setBlockCaptures replaces the alias map (no merge)", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - useCaptureStore.getState().setBlockCaptures("a.md", "x", { b: 2 }); - expect(useCaptureStore.getState().values["a.md"]?.["x"]).toEqual({ - b: { value: 2, isSecret: false }, - }); - }); - - it("setBlockCaptures preserves siblings under the same file", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - useCaptureStore.getState().setBlockCaptures("a.md", "y", { b: 2 }); - expect( - Object.keys(useCaptureStore.getState().values["a.md"] ?? {}), - ).toEqual(["x", "y"]); - }); - - it("clearBlockCaptures drops the alias and the whole file when last", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - useCaptureStore.getState().clearBlockCaptures("a.md", "x"); - expect(useCaptureStore.getState().values).toEqual({}); - }); - - it("clearBlockCaptures keeps siblings when removing one alias", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - useCaptureStore.getState().setBlockCaptures("a.md", "y", { b: 2 }); - useCaptureStore.getState().clearBlockCaptures("a.md", "x"); - expect( - Object.keys(useCaptureStore.getState().values["a.md"] ?? {}), - ).toEqual(["y"]); - }); - - it("clearBlockCaptures is a no-op when alias not present", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - const before = useCaptureStore.getState().values; - useCaptureStore.getState().clearBlockCaptures("a.md", "missing"); - expect(useCaptureStore.getState().values).toBe(before); - }); - - it("clearFile drops every alias for the file", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - useCaptureStore.getState().setBlockCaptures("a.md", "y", { b: 2 }); - useCaptureStore.getState().setBlockCaptures("b.md", "z", { c: 3 }); - useCaptureStore.getState().clearFile("a.md"); - expect(useCaptureStore.getState().values).toEqual({ - "b.md": { z: { c: { value: 3, isSecret: false } } }, - }); - }); - - it("clearFile is a no-op when file not present", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - const before = useCaptureStore.getState().values; - useCaptureStore.getState().clearFile("missing.md"); - expect(useCaptureStore.getState().values).toBe(before); - }); - - it("clearAll resets the entire store", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - useCaptureStore.getState().setBlockCaptures("b.md", "y", { b: 2 }); - useCaptureStore.getState().clearAll(); - expect(useCaptureStore.getState().values).toEqual({}); - }); - - it("getCapture returns the entry or undefined", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: "v" }); - expect(useCaptureStore.getState().getCapture("a.md", "x", "a")).toEqual({ - value: "v", - isSecret: false, - }); - expect( - useCaptureStore.getState().getCapture("a.md", "x", "missing"), - ).toBeUndefined(); - expect( - useCaptureStore.getState().getCapture("nope.md", "x", "a"), - ).toBeUndefined(); - }); - - it("getBlockCaptures returns the alias map or {}", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1, b: 2 }); - const block = useCaptureStore.getState().getBlockCaptures("a.md", "x"); - expect(Object.keys(block)).toEqual(["a", "b"]); - expect(useCaptureStore.getState().getBlockCaptures("nope.md", "x")).toEqual( - {}, - ); - }); - - // persistence (loadFromCacheJson / dumpForCacheJson) - - it("loadFromCacheJson hydrates the file from a valid JSON map", () => { - const json = JSON.stringify({ - login: { user_id: 42, role: "admin" }, - profile: { handle: "alice" }, - }); - useCaptureStore.getState().loadFromCacheJson("a.md", json); - const file = useCaptureStore.getState().values["a.md"]; - expect(file?.login?.user_id).toEqual({ value: 42, isSecret: false }); - expect(file?.login?.role).toEqual({ value: "admin", isSecret: false }); - expect(file?.profile?.handle).toEqual({ value: "alice", isSecret: false }); - }); - - it("loadFromCacheJson re-derives isSecret from key name", () => { - // The persisted shape doesn't round-trip the secret flag — it's - // recomputed on read so the in-memory mask survives even if the - // user opens an older cache file. - const json = JSON.stringify({ login: { token: "tk", user_id: 1 } }); - useCaptureStore.getState().loadFromCacheJson("a.md", json); - const block = useCaptureStore.getState().values["a.md"]?.login; - expect(block?.token?.isSecret).toBe(true); - expect(block?.user_id?.isSecret).toBe(false); - }); - - it("loadFromCacheJson is a no-op on invalid JSON", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - const before = useCaptureStore.getState().values; - useCaptureStore.getState().loadFromCacheJson("a.md", "not-json"); - expect(useCaptureStore.getState().values).toBe(before); - }); - - it("loadFromCacheJson is a no-op when top-level isn't an object", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "x", { a: 1 }); - const before = useCaptureStore.getState().values; - useCaptureStore - .getState() - .loadFromCacheJson("a.md", JSON.stringify([1, 2])); - expect(useCaptureStore.getState().values).toBe(before); - useCaptureStore.getState().loadFromCacheJson("a.md", JSON.stringify(null)); - expect(useCaptureStore.getState().values).toBe(before); - useCaptureStore.getState().loadFromCacheJson("a.md", JSON.stringify("x")); - expect(useCaptureStore.getState().values).toBe(before); - }); - - it("loadFromCacheJson skips alias entries that aren't objects", () => { - // Defensive: a corrupted file shouldn't blow up the hydrate; only - // the well-shaped aliases land. - const json = JSON.stringify({ - good: { k: "v" }, - bad_array: [1, 2], - bad_str: "x", - bad_null: null, - }); - useCaptureStore.getState().loadFromCacheJson("a.md", json); - const file = useCaptureStore.getState().values["a.md"]; - expect(Object.keys(file ?? {})).toEqual(["good"]); - expect(file?.good?.k).toEqual({ value: "v", isSecret: false }); - }); - - it("loadFromCacheJson coerces non-primitive values via the same rules", () => { - const json = JSON.stringify({ - block: { - obj_val: { nested: 1 }, - arr_val: [1, 2], - nil_val: null, - str_val: "ok", - }, - }); - useCaptureStore.getState().loadFromCacheJson("a.md", json); - const block = useCaptureStore.getState().values["a.md"]?.block; - expect(block?.obj_val?.value).toBeNull(); - expect(block?.arr_val?.value).toBeNull(); - expect(block?.nil_val?.value).toBeNull(); - expect(block?.str_val?.value).toBe("ok"); - }); - - it("loadFromCacheJson replaces the file's whole capture map", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "old", { k: "v" }); - useCaptureStore - .getState() - .loadFromCacheJson("a.md", JSON.stringify({ fresh: { k2: "v2" } })); - const file = useCaptureStore.getState().values["a.md"]; - expect(Object.keys(file ?? {})).toEqual(["fresh"]); - }); - - it("dumpForCacheJson returns null when the file is absent", () => { - expect(useCaptureStore.getState().dumpForCacheJson("absent.md")).toBeNull(); - }); - - it("dumpForCacheJson returns null when every alias is empty after filter", () => { - // Single secret-named entry — drops to nothing, so the consumer - // should skip the write. - useCaptureStore.getState().setBlockCaptures("a.md", "x", { token: "t" }); - expect(useCaptureStore.getState().dumpForCacheJson("a.md")).toBeNull(); - }); - - it("dumpForCacheJson filters secrets out of the persisted JSON", () => { - useCaptureStore - .getState() - .setBlockCaptures("a.md", "login", { token: "t", user_id: 7 }); - const json = useCaptureStore.getState().dumpForCacheJson("a.md"); - expect(json).not.toBeNull(); - const parsed = JSON.parse(json!); - expect(parsed).toEqual({ login: { user_id: 7 } }); - }); - - it("dumpForCacheJson preserves non-secret aliases across blocks", () => { - useCaptureStore.getState().setBlockCaptures("a.md", "p", { id: 1 }); - useCaptureStore.getState().setBlockCaptures("a.md", "q", { name: "alice" }); - const json = useCaptureStore.getState().dumpForCacheJson("a.md"); - expect(JSON.parse(json!)).toEqual({ - p: { id: 1 }, - q: { name: "alice" }, - }); - }); - - it("dump → load round-trips non-secret values", () => { - useCaptureStore - .getState() - .setBlockCaptures("a.md", "x", { id: 99, name: "bob" }); - const json = useCaptureStore.getState().dumpForCacheJson("a.md")!; - useCaptureStore.setState({ values: {} }); - useCaptureStore.getState().loadFromCacheJson("a.md", json); - const block = useCaptureStore.getState().values["a.md"]?.x; - expect(block?.id).toEqual({ value: 99, isSecret: false }); - expect(block?.name).toEqual({ value: "bob", isSecret: false }); - }); -}); diff --git a/httui-desktop/src/stores/captureStore.ts b/httui-desktop/src/stores/captureStore.ts deleted file mode 100644 index bb634d0a..00000000 --- a/httui-desktop/src/stores/captureStore.ts +++ /dev/null @@ -1,232 +0,0 @@ -// Block captures session store. -// -// In-memory only by default. ships a separate persistence -// path (`.captures.json`) for auto-capture mode; this store keeps the -// session-scoped values so subsequent blocks can resolve -// `{{.captures.}}` against the previous run's output. -// -// Shape: `Record>>`. Plain -// records (not Map) so React/Zustand reactivity works without manual -// replace. - -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; - -import { isSecretCaptureKey } from "@/lib/blocks/captures"; - -export type CapturedValue = string | number | boolean | null; - -export interface CaptureEntry { - value: CapturedValue; - /** True when the key matched the secret-name regex - * (`password|token|secret|key|auth*`). */ - isSecret: boolean; -} - -export type ByAlias = Readonly< - Record>> ->; -export type ByFile = Readonly>; - -interface CaptureState { - values: ByFile; - /** Replace every capture for a given (file, alias) — typically - * called after a successful run with the freshly evaluated map. */ - setBlockCaptures: ( - filePath: string, - alias: string, - captures: Readonly>, - ) => void; - /** Drop every capture for a single block. */ - clearBlockCaptures: (filePath: string, alias: string) => void; - /** Drop every capture for an entire file (e.g. on file close). */ - clearFile: (filePath: string) => void; - /** Reset the whole store (e.g. on app restart — also achieved by - * not persisting). */ - clearAll: () => void; - /** Read accessor — undefined when not present. */ - getCapture: ( - filePath: string, - alias: string, - key: string, - ) => CaptureEntry | undefined; - /** Read accessor — every capture for one block (or empty object). */ - getBlockCaptures: ( - filePath: string, - alias: string, - ) => Readonly>; - /** Hydrate a file's captures from the JSON shape persisted in - * `.httui/captures/.json`. Tolerant: invalid JSON or - * an unexpected shape is a no-op (the cache is best-effort). The - * `isSecret` flag is re-derived from the key name on insert, so - * the persisted shape doesn't need to round-trip it. */ - loadFromCacheJson: (filePath: string, json: string) => void; - /** Serialize a file's captures into the persistence shape, dropping - * every entry whose key matched the secret-name regex. Returns - * `null` when there's nothing to persist (consumer should skip the - * write). */ - dumpForCacheJson: (filePath: string) => string | null; -} - -export const useCaptureStore = create()( - devtools( - (set, get) => ({ - values: {}, - - setBlockCaptures: (filePath, alias, raw) => - set( - (state) => { - const block: Record = {}; - for (const [k, v] of Object.entries(raw)) { - block[k] = { - value: coerceCapturedValue(v), - isSecret: isSecretCaptureKey(k), - }; - } - return { - values: { - ...state.values, - [filePath]: { - ...(state.values[filePath] ?? {}), - [alias]: block, - }, - }, - }; - }, - false, - "captures/set", - ), - - clearBlockCaptures: (filePath, alias) => - set( - (state) => { - const fileMap = state.values[filePath]; - if (!fileMap || !(alias in fileMap)) return state; - const nextFile = filterMap(fileMap, alias); - const nextValues = { ...state.values }; - if (Object.keys(nextFile).length === 0) { - delete nextValues[filePath]; - } else { - nextValues[filePath] = nextFile; - } - return { values: nextValues }; - }, - false, - "captures/clearBlock", - ), - - clearFile: (filePath) => - set( - (state) => { - if (!(filePath in state.values)) return state; - const next = { ...state.values }; - delete next[filePath]; - return { values: next }; - }, - false, - "captures/clearFile", - ), - - clearAll: () => set({ values: {} }, false, "captures/clearAll"), - - getCapture: (filePath, alias, key) => - get().values[filePath]?.[alias]?.[key], - - getBlockCaptures: (filePath, alias) => - get().values[filePath]?.[alias] ?? {}, - - loadFromCacheJson: (filePath, json) => { - const parsed = parseCacheJson(json); - if (!parsed) return; - set( - (state) => { - const fileMap: Record> = {}; - for (const [alias, byKey] of Object.entries(parsed)) { - const block: Record = {}; - for (const [k, v] of Object.entries(byKey)) { - block[k] = { - value: coerceCapturedValue(v), - isSecret: isSecretCaptureKey(k), - }; - } - fileMap[alias] = block; - } - return { - values: { ...state.values, [filePath]: fileMap }, - }; - }, - false, - "captures/loadFromCache", - ); - }, - - dumpForCacheJson: (filePath) => { - const fileMap = get().values[filePath]; - if (!fileMap) return null; - const out: Record> = {}; - for (const [alias, byKey] of Object.entries(fileMap)) { - const block: Record = {}; - for (const [k, entry] of Object.entries(byKey)) { - if (entry.isSecret) continue; - block[k] = entry.value; - } - if (Object.keys(block).length > 0) { - out[alias] = block; - } - } - if (Object.keys(out).length === 0) return null; - return JSON.stringify(out); - }, - }), - { name: "capture-store" }, - ), -); - -/** Parse + shape-validate the persisted JSON. Returns the parsed - * `{ alias: { key: value } }` map, or `null` when the input is - * unparseable / not the expected shape. The `value` is left as - * `unknown` so `coerceCapturedValue` can apply the same primitives- - * only rule used at runtime. */ -function parseCacheJson( - json: string, -): Record> | null { - let raw: unknown; - try { - raw = JSON.parse(json); - } catch { - return null; - } - if (!isPlainObject(raw)) return null; - const out: Record> = {}; - for (const [alias, byKey] of Object.entries(raw)) { - if (!isPlainObject(byKey)) continue; - out[alias] = byKey; - } - return out; -} - -function isPlainObject(v: unknown): v is Record { - return typeof v === "object" && v !== null && !Array.isArray(v); -} - -function filterMap( - obj: Readonly>, - drop: string, -): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(obj)) { - if (k !== drop) out[k] = v; - } - return out; -} - -/** Coerce captured value into the persisted shape. Strings, numbers, - * booleans, null pass through; objects/arrays/undefined collapse to - * `null` (the consumer typically renders `(empty)` and shows the path - * as a hint). */ -function coerceCapturedValue(v: unknown): CapturedValue { - if (v === null) return null; - if (typeof v === "string") return v; - if (typeof v === "number" || typeof v === "boolean") return v; - return null; -} From ae8a5bc0582ec46bffd29c9b391f03810b29d7ee Mon Sep 17 00:00:00 2001 From: gandarfh Date: Wed, 20 May 2026 18:10:04 -0300 Subject: [PATCH 03/10] refactor: extract shared format/state/hooks modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small extractions: - `lib/format/time.ts` (`formatElapsed` + `formatDurationCompact`), unifying 3 call sites. The 3 `relative-time` variants are left separate — their contracts diverge (HTTP vs DB vs history); noted in `time.ts`. - `blocks/execution-state.ts` — a canonical execution-state vocabulary (`idle | running | success | error | cancelled`). HTTP and DB fenced blocks re-export from it. The `ExecutableBlock.ts` flavor (with `cached`, no `cancelled`) stays separate — it belongs to the standalone/diff-viewer block, a different domain. - `jsString` and `pyString` consolidated into `backslashQuote` inside the codegen helpers. - `useConfigChangeRefresh` extracted from the 3 PageContainers, where the config-changed effect was duplicated verbatim. --- .../src/components/blocks/db/ResultTable.tsx | 6 +-- .../src/components/blocks/db/fenced/shared.ts | 17 +++--- .../src/components/blocks/execution-state.ts | 19 +++++++ .../components/blocks/http/fenced/shared.ts | 9 ++-- .../connections/ConnectionsPageContainer.tsx | 30 ++--------- .../EnvironmentsPageContainer.tsx | 23 +------- .../components/layout/history/HistoryList.tsx | 10 +--- .../history/__tests__/HistoryList.test.tsx | 17 ------ .../variables/VariablesPageContainer.tsx | 23 +------- .../__tests__/useConfigChangeRefresh.test.ts | 54 +++++++++++++++++++ .../src/hooks/useConfigChangeRefresh.ts | 41 ++++++++++++++ httui-desktop/src/lib/blocks/http-codegen.ts | 30 +++++------ .../src/lib/format/__tests__/time.test.ts | 35 ++++++++++++ httui-desktop/src/lib/format/time.ts | 29 ++++++++++ 14 files changed, 214 insertions(+), 129 deletions(-) create mode 100644 httui-desktop/src/components/blocks/execution-state.ts create mode 100644 httui-desktop/src/hooks/__tests__/useConfigChangeRefresh.test.ts create mode 100644 httui-desktop/src/hooks/useConfigChangeRefresh.ts create mode 100644 httui-desktop/src/lib/format/__tests__/time.test.ts create mode 100644 httui-desktop/src/lib/format/time.ts diff --git a/httui-desktop/src/components/blocks/db/ResultTable.tsx b/httui-desktop/src/components/blocks/db/ResultTable.tsx index 7a490e95..050a5562 100644 --- a/httui-desktop/src/components/blocks/db/ResultTable.tsx +++ b/httui-desktop/src/components/blocks/db/ResultTable.tsx @@ -2,6 +2,7 @@ import { Box, Flex, HStack, IconButton, Table } from "@chakra-ui/react"; import { Fragment, useCallback, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { LuCopy, LuX } from "react-icons/lu"; +import { formatElapsed } from "@/lib/format/time"; import type { CellValue } from "./types"; interface ResultTableProps { @@ -55,11 +56,6 @@ function isNumericType(type: string): boolean { ); } -function formatElapsed(ms: number): string { - if (ms < 1000) return `${ms}ms`; - return `${(ms / 1000).toFixed(2)}s`; -} - function JsonBlock({ value }: { value: unknown }) { return ( { - let cancelled = false; - let unlisten: (() => void) | null = null; - void (async () => { - const fn = await listen<{ category: string }>("config-changed", (e) => { - if (e.payload.category === "connections") { - void reload(); - } - }); - if (cancelled) { - fn(); - } else { - unlisten = fn; - } - })(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [reload]); + // Backend emits `config-changed` (category "connections") when the + // file or its `.local` sibling changes on disk → reload (the store + // dedups against in-flight UI mutations). + useConfigChangeRefresh("connections", reload); // Pre-fetch schema + usages on selection change so the detail // panel renders without an extra click. Backend grep is fast and diff --git a/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx b/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx index 6aab4838..079e2842 100644 --- a/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx +++ b/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx @@ -14,7 +14,7 @@ import { useRef, useState, } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; import { useEnvironmentStore } from "@/stores/environment"; import { listEnvVariables, type Environment } from "@/lib/tauri/commands"; @@ -104,26 +104,7 @@ export function EnvironmentsPageContainer({ void refreshEnvs(); }, [refreshEnvs]); - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | null = null; - void (async () => { - const fn = await listen<{ category: string }>("config-changed", (e) => { - if (e.payload.category === "environment") { - void refreshEnvs(); - } - }); - if (cancelled) { - fn(); - } else { - unlisten = fn; - } - })(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [refreshEnvs]); + useConfigChangeRefresh("environment", refreshEnvs); // Load varCount + secretCount per env in parallel, then assemble // summaries. diff --git a/httui-desktop/src/components/layout/history/HistoryList.tsx b/httui-desktop/src/components/layout/history/HistoryList.tsx index bbd3ab7e..b8c7e080 100644 --- a/httui-desktop/src/components/layout/history/HistoryList.tsx +++ b/httui-desktop/src/components/layout/history/HistoryList.tsx @@ -8,6 +8,7 @@ import { Box, Flex, Text } from "@chakra-ui/react"; +import { formatDurationCompact } from "@/lib/format/time"; import type { HistoryEntry } from "@/lib/tauri/commands"; export type HistoryOutcomeTone = "ok" | "warn" | "err" | "muted"; @@ -117,7 +118,7 @@ export function HistoryList({ entries, onSelect }: HistoryListProps) { minWidth="36px" textAlign="right" > - {formatElapsed(entry.elapsed_ms)} + {formatDurationCompact(entry.elapsed_ms)} )} @@ -175,13 +176,6 @@ export function formatRelative(isoOrMs: string | number): string { return `${day}d`; } -/** "12ms" / "1.2s" / "3m" depending on magnitude. */ -export function formatElapsed(ms: number): string { - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - return `${Math.round(ms / 60_000)}m`; -} - function dotColor(tone: HistoryOutcomeTone): string { switch (tone) { case "ok": diff --git a/httui-desktop/src/components/layout/history/__tests__/HistoryList.test.tsx b/httui-desktop/src/components/layout/history/__tests__/HistoryList.test.tsx index 043f1ec9..06d75fda 100644 --- a/httui-desktop/src/components/layout/history/__tests__/HistoryList.test.tsx +++ b/httui-desktop/src/components/layout/history/__tests__/HistoryList.test.tsx @@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event"; import { HistoryList, - formatElapsed, formatRelative, hasPlan, label, @@ -192,19 +191,3 @@ describe("formatRelative", () => { expect(formatRelative("not a date")).toBe("—"); }); }); - -describe("formatElapsed", () => { - it("renders ms under 1000", () => { - expect(formatElapsed(0)).toBe("0ms"); - expect(formatElapsed(120)).toBe("120ms"); - expect(formatElapsed(999)).toBe("999ms"); - }); - it("renders s under 60_000 with one decimal", () => { - expect(formatElapsed(1500)).toBe("1.5s"); - expect(formatElapsed(59_999)).toBe("60.0s"); - }); - it("renders m above 60_000", () => { - expect(formatElapsed(120_000)).toBe("2m"); - expect(formatElapsed(3 * 60_000)).toBe("3m"); - }); -}); diff --git a/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx b/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx index 02e7ba54..7f480712 100644 --- a/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx +++ b/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx @@ -11,7 +11,7 @@ // page stays prop-driven, data + IPC live here. import { useCallback, useEffect, useMemo, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; import { useEnvironmentStore } from "@/stores/environment"; import { useSessionOverrideStore } from "@/stores/sessionOverride"; @@ -95,26 +95,7 @@ export function VariablesPageContainer({ }, [refreshEnvs]); // External `envs/*.toml` edits via the file watcher. - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | null = null; - void (async () => { - const fn = await listen<{ category: string }>("config-changed", (e) => { - if (e.payload.category === "environment") { - void refreshEnvs(); - } - }); - if (cancelled) { - fn(); - } else { - unlisten = fn; - } - })(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [refreshEnvs]); + useConfigChangeRefresh("environment", refreshEnvs); // Cross-env merge whenever the env list changes or a setVariable // bumps `variablesVersion`. diff --git a/httui-desktop/src/hooks/__tests__/useConfigChangeRefresh.test.ts b/httui-desktop/src/hooks/__tests__/useConfigChangeRefresh.test.ts new file mode 100644 index 00000000..a5e9b5fe --- /dev/null +++ b/httui-desktop/src/hooks/__tests__/useConfigChangeRefresh.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { + emitTauriEvent, + clearTauriListeners, + listen, +} from "@/test/mocks/tauri-event"; + +import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; + +afterEach(() => { + clearTauriListeners(); + vi.clearAllMocks(); +}); + +describe("useConfigChangeRefresh", () => { + it("invokes onChange when the matching category fires", async () => { + const onChange = vi.fn(); + renderHook(() => useConfigChangeRefresh("connections", onChange)); + await waitFor(() => + expect(listen).toHaveBeenCalledWith( + "config-changed", + expect.any(Function), + ), + ); + + act(() => emitTauriEvent("config-changed", { category: "connections" })); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("ignores a non-matching category", async () => { + const onChange = vi.fn(); + renderHook(() => useConfigChangeRefresh("environment", onChange)); + await waitFor(() => expect(listen).toHaveBeenCalled()); + + act(() => emitTauriEvent("config-changed", { category: "connections" })); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("unsubscribes on unmount", async () => { + const onChange = vi.fn(); + const { unmount } = renderHook(() => + useConfigChangeRefresh("connections", onChange), + ); + await waitFor(() => expect(listen).toHaveBeenCalled()); + + unmount(); + // Let the async-listen cleanup settle, then the (now-detached) + // subscription must not deliver. + await waitFor(() => undefined); + act(() => emitTauriEvent("config-changed", { category: "connections" })); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/httui-desktop/src/hooks/useConfigChangeRefresh.ts b/httui-desktop/src/hooks/useConfigChangeRefresh.ts new file mode 100644 index 00000000..10883f38 --- /dev/null +++ b/httui-desktop/src/hooks/useConfigChangeRefresh.ts @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; + +/** + * Subscribe to the backend `config-changed` event and invoke + * `onChange` whenever the emitted `category` matches. The backend + * emits this when a watched config file (or its `.local` sibling) + * changes on disk. + * + * Handles the async-listen race: if the component unmounts before + * `listen()` resolves, the resolved unlisten is invoked immediately + * so no stale subscription survives. + * + * Extracted from the verbatim effect that lived in + * Connections/Variables/Environments PageContainers (audit 01 §8). + */ +export function useConfigChangeRefresh( + category: string, + onChange: () => void, +): void { + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + void (async () => { + const fn = await listen<{ category: string }>("config-changed", (e) => { + if (e.payload.category === category) { + onChange(); + } + }); + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [category, onChange]); +} diff --git a/httui-desktop/src/lib/blocks/http-codegen.ts b/httui-desktop/src/lib/blocks/http-codegen.ts index 2ed764cf..d9454cb1 100644 --- a/httui-desktop/src/lib/blocks/http-codegen.ts +++ b/httui-desktop/src/lib/blocks/http-codegen.ts @@ -70,8 +70,9 @@ export function toCurl(parsed: HttpMessageParsed): string { // ─────────────────────── fetch (JavaScript) ─────────────────────── -/** Quote a string as a single-quoted JS literal. */ -function jsString(s: string): string { +/** Quote a string as a single-quoted, backslash-escaped literal. + * Valid for both JS and Python single-quoted string literals. */ +function backslashQuote(s: string): string { return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`; } @@ -79,17 +80,17 @@ export function toFetch(parsed: HttpMessageParsed): string { const url = buildUrlWithQuery(parsed); const headers = enabledKV(parsed.headers); const lines: string[] = []; - lines.push(`await fetch(${jsString(url)}, {`); - lines.push(` method: ${jsString(parsed.method)},`); + lines.push(`await fetch(${backslashQuote(url)}, {`); + lines.push(` method: ${backslashQuote(parsed.method)},`); if (headers.length > 0) { lines.push(` headers: {`); for (const h of headers) { - lines.push(` ${jsString(h.key)}: ${jsString(h.value)},`); + lines.push(` ${backslashQuote(h.key)}: ${backslashQuote(h.value)},`); } lines.push(` },`); } if (methodHasBody(parsed.method, parsed.body)) { - lines.push(` body: ${jsString(parsed.body)},`); + lines.push(` body: ${backslashQuote(parsed.body)},`); } lines.push(`});`); return lines.join("\n"); @@ -97,23 +98,20 @@ export function toFetch(parsed: HttpMessageParsed): string { // ─────────────────────── Python requests ─────────────────────── -/** Quote as a Python single-quoted string. */ -function pyString(s: string): string { - return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`; -} - export function toPython(parsed: HttpMessageParsed): string { const lines: string[] = []; lines.push("import requests"); lines.push(""); const fnName = parsed.method.toLowerCase(); lines.push(`response = requests.${fnName}(`); - lines.push(` ${pyString(parsed.url)},`); + lines.push(` ${backslashQuote(parsed.url)},`); const params = enabledKV(parsed.params); if (params.length > 0) { lines.push(` params={`); for (const p of params) { - lines.push(` ${pyString(p.key)}: ${pyString(p.value)},`); + lines.push( + ` ${backslashQuote(p.key)}: ${backslashQuote(p.value)},`, + ); } lines.push(` },`); } @@ -121,12 +119,14 @@ export function toPython(parsed: HttpMessageParsed): string { if (headers.length > 0) { lines.push(` headers={`); for (const h of headers) { - lines.push(` ${pyString(h.key)}: ${pyString(h.value)},`); + lines.push( + ` ${backslashQuote(h.key)}: ${backslashQuote(h.value)},`, + ); } lines.push(` },`); } if (methodHasBody(parsed.method, parsed.body)) { - lines.push(` data=${pyString(parsed.body)},`); + lines.push(` data=${backslashQuote(parsed.body)},`); } lines.push(`)`); return lines.join("\n"); diff --git a/httui-desktop/src/lib/format/__tests__/time.test.ts b/httui-desktop/src/lib/format/__tests__/time.test.ts new file mode 100644 index 00000000..dc2dbfce --- /dev/null +++ b/httui-desktop/src/lib/format/__tests__/time.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; + +import { formatElapsed, formatDurationCompact } from "@/lib/format/time"; + +describe("formatElapsed", () => { + it("renders ms under 1000", () => { + expect(formatElapsed(0)).toBe("0ms"); + expect(formatElapsed(120)).toBe("120ms"); + expect(formatElapsed(999)).toBe("999ms"); + }); + it("renders two-decimal seconds at/above 1000ms", () => { + expect(formatElapsed(1000)).toBe("1.00s"); + expect(formatElapsed(1500)).toBe("1.50s"); + expect(formatElapsed(5200)).toBe("5.20s"); + expect(formatElapsed(90_000)).toBe("90.00s"); + }); +}); + +// These assertions mirror the previous HistoryList.formatElapsed tests +// verbatim — proves the relocated/renamed formatter preserves behavior. +describe("formatDurationCompact", () => { + it("renders ms under 1000", () => { + expect(formatDurationCompact(0)).toBe("0ms"); + expect(formatDurationCompact(120)).toBe("120ms"); + expect(formatDurationCompact(999)).toBe("999ms"); + }); + it("renders s under 60_000 with one decimal", () => { + expect(formatDurationCompact(1500)).toBe("1.5s"); + expect(formatDurationCompact(59_999)).toBe("60.0s"); + }); + it("renders m above 60_000", () => { + expect(formatDurationCompact(120_000)).toBe("2m"); + expect(formatDurationCompact(3 * 60_000)).toBe("3m"); + }); +}); diff --git a/httui-desktop/src/lib/format/time.ts b/httui-desktop/src/lib/format/time.ts new file mode 100644 index 00000000..af822038 --- /dev/null +++ b/httui-desktop/src/lib/format/time.ts @@ -0,0 +1,29 @@ +/** + * Shared time/duration formatters. + * + * NOTE: relative-time ("X ago") is intentionally NOT unified here. The + * three call sites (`git/git-derive.ts`, `http/fenced/shared.ts`, + * `db/fenced/shared.ts`) have genuinely divergent contracts — floor vs + * round, with/without a "just now" bucket, 7d→ISO cap vs none, and + * different input types (Date | epoch-ms | epoch-s | ISO). Collapsing + * them would silently change rendered timestamps in git/http/db, so it + * is a deliberate behavior-normalization decision, not a mechanical + * dedup. See docs-llm/code-audit/01-duplication.md §3. + */ + +/** Elapsed duration as `123ms` or `1.23s` (two-decimal seconds). */ +export function formatElapsed(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +/** + * Compact elapsed duration: `12ms` / `1.2s` / `3m` depending on + * magnitude (one-decimal seconds, minutes above 60s). Used by the + * history list where space is tight. + */ +export function formatDurationCompact(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms / 60_000)}m`; +} From 9d10fc7b74208f95a0db6ac6f5dc42f38239bb09 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Wed, 20 May 2026 18:10:18 -0300 Subject: [PATCH 04/10] refactor(stores): connections store + cross-env fan-out + config sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `stores/connections.ts` — a zustand store mirroring `environment.ts`. The `config-changed` watcher lives outside the store (a UI/effect concern, not state). - `ConnectionsPageContainer` + `ConnectionsList` migrated to the new store, eliminating 2 copies of `listConnections` + manual reload(). `pings` stays local (per-row, ephemeral). - `hooks/useCrossEnvVariables.ts` — a `Promise.all` fan-out for `listEnvVariables` per environment, cancellation-guarded. It reads the store but is intentionally not a zustand action: a single-shape store contract would diverge between the Variables and Environment pages. - The `ConnectionsPageContainer` prefetch effect is now keyed on the selected connection name (a memoized string) rather than the `connections` array, so unrelated refreshes (ping/CRUD/config) no longer re-dispatch `find_connection_uses_cmd`. - `useConfigSyncedResource(category, refresh)` composes `useConfigChangeRefresh` with the mount-refresh, collapsing the duplicated effect pair into one line per container. `EnvironmentsPageContainer` no longer needs a `useEffect`. A generic `useMasterDetail` was considered and rejected: only 2 sites diverge, thinly — the unifications above already collapsed the real duplication. --- .../layout/connections/ConnectionsList.tsx | 32 ++-- .../connections/ConnectionsPageContainer.tsx | 90 +++++----- .../ConnectionsPageContainer.test.tsx | 40 ++++- .../EnvironmentsPageContainer.tsx | 74 +++----- .../variables/VariablesPageContainer.tsx | 67 +++----- .../__tests__/useConfigSyncedResource.test.ts | 75 ++++++++ .../__tests__/useCrossEnvVariables.test.ts | 160 ++++++++++++++++++ .../src/hooks/useConfigSyncedResource.ts | 34 ++++ .../src/hooks/useCrossEnvVariables.ts | 69 ++++++++ .../src/stores/__tests__/connections.test.ts | 112 ++++++++++++ httui-desktop/src/stores/connections.ts | 75 ++++++++ 11 files changed, 668 insertions(+), 160 deletions(-) create mode 100644 httui-desktop/src/hooks/__tests__/useConfigSyncedResource.test.ts create mode 100644 httui-desktop/src/hooks/__tests__/useCrossEnvVariables.test.ts create mode 100644 httui-desktop/src/hooks/useConfigSyncedResource.ts create mode 100644 httui-desktop/src/hooks/useCrossEnvVariables.ts create mode 100644 httui-desktop/src/stores/__tests__/connections.test.ts create mode 100644 httui-desktop/src/stores/connections.ts diff --git a/httui-desktop/src/components/layout/connections/ConnectionsList.tsx b/httui-desktop/src/components/layout/connections/ConnectionsList.tsx index e32a3321..6bd6dca9 100644 --- a/httui-desktop/src/components/layout/connections/ConnectionsList.tsx +++ b/httui-desktop/src/components/layout/connections/ConnectionsList.tsx @@ -12,12 +12,8 @@ import { import { LuPlus, LuDatabase } from "react-icons/lu"; import { useCallback, useEffect, useState } from "react"; import type { Connection } from "@/lib/tauri/connections"; -import { - listConnections, - createConnection, - deleteConnection, - testConnection, -} from "@/lib/tauri/connections"; +import { testConnection } from "@/lib/tauri/connections"; +import { useConnectionsStore } from "@/stores/connections"; import { useConnectionSessionOverrideStore } from "@/stores/connectionSessionOverride"; import { TemporaryChip } from "@/components/layout/variables/TemporaryChip"; import { ConnectionForm } from "./ConnectionForm"; @@ -53,24 +49,18 @@ async function pingConnection(id: string): Promise { } export function ConnectionsList() { - const [connections, setConnections] = useState([]); + const connections = useConnectionsStore((s) => s.connections); + const refresh = useConnectionsStore((s) => s.refresh); + const createConn = useConnectionsStore((s) => s.createConnection); + const deleteConn = useConnectionsStore((s) => s.deleteConnection); const [editingConn, setEditingConn] = useState(null); const [showForm, setShowForm] = useState(false); const [testing, setTesting] = useState(null); const [pings, setPings] = useState>({}); const overrides = useConnectionSessionOverrideStore((s) => s.overrides); - const refresh = useCallback(async () => { - try { - const conns = await listConnections(); - setConnections(conns); - } catch { - // silently fail - } - }, []); - useEffect(() => { - refresh(); + void refresh(); }, [refresh]); // Auto-ping every connection on mount + whenever the connection @@ -92,13 +82,12 @@ export function ConnectionsList() { const handleDelete = useCallback( async (id: string) => { try { - await deleteConnection(id); - await refresh(); + await deleteConn(id); } catch { // ignore } }, - [refresh], + [deleteConn], ); const handleTest = useCallback(async (id: string) => { @@ -113,7 +102,7 @@ export function ConnectionsList() { try { // Password lives in the keychain and can't be read back — the // copy starts without one (rotate it from the popover). - await createConnection({ + await createConn({ name: `${conn.name} copy`, driver: conn.driver, host: conn.host ?? undefined, @@ -127,7 +116,6 @@ export function ConnectionsList() { max_pool_size: conn.max_pool_size, is_readonly: conn.is_readonly, }); - await refresh(); } catch { // Name collision / backend reject — ignore (matches the // list's existing fire-and-forget error posture). diff --git a/httui-desktop/src/components/layout/connections/ConnectionsPageContainer.tsx b/httui-desktop/src/components/layout/connections/ConnectionsPageContainer.tsx index 900c9820..cc75a9b8 100644 --- a/httui-desktop/src/components/layout/connections/ConnectionsPageContainer.tsx +++ b/httui-desktop/src/components/layout/connections/ConnectionsPageContainer.tsx @@ -6,18 +6,14 @@ // don't invent a workspace tab to host it. import { useCallback, useEffect, useMemo, useState } from "react"; -import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; +import { useConfigSyncedResource } from "@/hooks/useConfigSyncedResource"; import { - createConnection, - deleteConnection, findConnectionUses, - listConnections, testConnection, - updateConnection, - type Connection, type UpdateConnectionInput, } from "@/lib/tauri/connections"; +import { useConnectionsStore } from "@/stores/connections"; import { useSchemaCacheStore } from "@/stores/schemaCache"; import { useWorkspaceStore } from "@/stores/workspace"; import type { RunbookUsage } from "./connection-usages"; @@ -32,7 +28,11 @@ export function ConnectionsPageContainer({ onNavigateFile, }: ConnectionsPageContainerProps) { const vaultPath = useWorkspaceStore((s) => s.vaultPath); - const [connections, setConnections] = useState([]); + const connections = useConnectionsStore((s) => s.connections); + const refreshConnections = useConnectionsStore((s) => s.refresh); + const createConn = useConnectionsStore((s) => s.createConnection); + const updateConn = useConnectionsStore((s) => s.updateConnection); + const deleteConn = useConnectionsStore((s) => s.deleteConnection); const [selectedId, setSelectedId] = useState(null); const [usagesByConnection, setUsagesByConnection] = useState< Record @@ -47,58 +47,62 @@ export function ConnectionsPageContainer({ const refreshSchema = useSchemaCacheStore((s) => s.refresh); const schemaByConn = useSchemaCacheStore((s) => s.byConnection); - const reload = useCallback(async () => { - const list = await listConnections(); - setConnections(list); - }, []); - - useEffect(() => { - void reload(); - }, [reload]); - - // react to external `connections.toml` edits via the file watcher. - // Backend emits `config-changed` (category "connections") when the - // file or its `.local` sibling changes on disk → reload (the store - // dedups against in-flight UI mutations). - useConfigChangeRefresh("connections", reload); + // Refresh on mount + on external `connections.toml` edits (the + // backend emits `config-changed` category "connections" when the + // file or its `.local` sibling changes on disk). + useConfigSyncedResource("connections", refreshConnections); + + // The only field the prefetch effect reads from `connections` is + // the selected connection's name. Derive it here so the effect can + // depend on a stable string instead of the whole array. + const selectedConnName = useMemo( + () => + selectedId + ? (connections.find((c) => c.id === selectedId)?.name ?? null) + : null, + [selectedId, connections], + ); // Pre-fetch schema + usages on selection change so the detail - // panel renders without an extra click. Backend grep is fast and - // the user can't outrun it via repeated selects. + // panel renders without an extra click. Keyed on the selected + // connection's *name* (B4): the old `connections`-array dep re-ran + // the FS grep + ensureSchema on every unrelated store refresh + // (test-ping, CRUD, config-changed) even when the selection was + // unchanged. A rename still re-fires (name changes); a real + // selection change still re-fires (selectedId changes). useEffect(() => { - if (!selectedId || !vaultPath) return; - const conn = connections.find((c) => c.id === selectedId); - if (!conn) return; + if (!selectedId || !vaultPath || !selectedConnName) return; void ensureSchema(selectedId); - setUsagesLoading((m) => ({ ...m, [conn.name]: true })); - findConnectionUses(vaultPath, conn.name) + setUsagesLoading((m) => ({ ...m, [selectedConnName]: true })); + findConnectionUses(vaultPath, selectedConnName) .then((r) => { const usages: RunbookUsage[] = r.map((u) => ({ filePath: u.file, line: u.line, preview: null, })); - setUsagesByConnection((m) => ({ ...m, [conn.name]: usages })); + setUsagesByConnection((m) => ({ + ...m, + [selectedConnName]: usages, + })); }) .finally(() => { - setUsagesLoading((m) => ({ ...m, [conn.name]: false })); + setUsagesLoading((m) => ({ ...m, [selectedConnName]: false })); }); - }, [selectedId, connections, vaultPath, ensureSchema]); + }, [selectedId, selectedConnName, vaultPath, ensureSchema]); const handleSaveCredentials = useCallback( async (id: string, input: UpdateConnectionInput) => { - await updateConnection(id, input); - await reload(); + await updateConn(id, input); }, - [reload], + [updateConn], ); const handleRotatePassword = useCallback( async (id: string, newPassword: string) => { - await updateConnection(id, { password: newPassword }); - await reload(); + await updateConn(id, { password: newPassword }); }, - [reload], + [updateConn], ); const handleTestConnection = useCallback( @@ -114,7 +118,7 @@ export function ConnectionsPageContainer({ async (id: string) => { const src = connections.find((c) => c.id === id); if (!src) return; - await createConnection({ + await createConn({ name: `${src.name}-copy`, driver: src.driver, host: src.host ?? undefined, @@ -124,18 +128,16 @@ export function ConnectionsPageContainer({ ssl_mode: src.ssl_mode ?? undefined, is_readonly: src.is_readonly, }); - await reload(); }, - [connections, reload], + [connections, createConn], ); const handleDelete = useCallback( async (id: string) => { - await deleteConnection(id); + await deleteConn(id); if (selectedId === id) setSelectedId(null); - await reload(); }, - [reload, selectedId], + [deleteConn, selectedId], ); // Slice the schemaCache map to the shape ConnectionsPage expects. @@ -198,7 +200,7 @@ export function ConnectionsPageContainer({ setEditingId(null); }} onCreated={() => { - void reload(); + void refreshConnections(); }} /> diff --git a/httui-desktop/src/components/layout/connections/__tests__/ConnectionsPageContainer.test.tsx b/httui-desktop/src/components/layout/connections/__tests__/ConnectionsPageContainer.test.tsx index e730540e..a2598ba8 100644 --- a/httui-desktop/src/components/layout/connections/__tests__/ConnectionsPageContainer.test.tsx +++ b/httui-desktop/src/components/layout/connections/__tests__/ConnectionsPageContainer.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act } from "@testing-library/react"; import { renderWithProviders, screen, waitFor } from "@/test/render"; import userEvent from "@testing-library/user-event"; import { mockTauriCommand, clearTauriMocks } from "@/test/mocks/tauri"; @@ -9,8 +10,10 @@ vi.mock("@/lib/theme/apply", () => ({ applyTheme: vi.fn() })); import { ConnectionsPageContainer } from "@/components/layout/connections/ConnectionsPageContainer"; import { useWorkspaceStore } from "@/stores/workspace"; import { useSchemaCacheStore } from "@/stores/schemaCache"; +import { useConnectionsStore } from "@/stores/connections"; +import type { Connection } from "@/lib/tauri/connections"; -const sampleList = [ +const sampleList: Connection[] = [ { id: "payments-db", name: "payments-db", @@ -52,6 +55,7 @@ afterEach(() => { clearTauriListeners(); useSchemaCacheStore.setState({ byConnection: {} }); useWorkspaceStore.setState({ vaultPath: null }); + useConnectionsStore.setState({ connections: [], loaded: false }); }); describe("ConnectionsPageContainer", () => { @@ -386,4 +390,38 @@ describe("ConnectionsPageContainer", () => { expect(screen.queryByTestId("connections-detail-loaded")).toBeNull(); }); }); + + it("does not re-grep usages when the connections array churns but the selection is unchanged (B4)", async () => { + let usesCalls = 0; + mockTauriCommand("find_connection_uses_cmd", () => { + usesCalls += 1; + return []; + }); + renderWithProviders(); + const user = userEvent.setup(); + await user.click(await screen.findByTestId("connection-row-payments-db")); + // Selection fired the prefetch exactly once. + await waitFor(() => expect(usesCalls).toBe(1)); + + // Simulate an unrelated store refresh (test-ping / CRUD / + // config-changed) emitting a fresh array with identical data — + // the selected connection's name is unchanged. + act(() => { + useConnectionsStore.setState({ + connections: [{ ...sampleList[0] }], + loaded: true, + }); + }); + // Flush any effects the re-render might have scheduled. + await act(async () => { + await Promise.resolve(); + }); + + // The grep must NOT have re-run — pre-B4 it keyed on the whole + // `connections` array and re-fired here. + expect(usesCalls).toBe(1); + expect( + screen.queryByTestId("connections-detail-loaded"), + ).toBeInTheDocument(); + }); }); diff --git a/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx b/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx index 079e2842..6dca5e18 100644 --- a/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx +++ b/httui-desktop/src/components/layout/environments/EnvironmentsPageContainer.tsx @@ -6,18 +6,12 @@ // Mirrors VariablesPageContainer / ConnectionsPageContainer: // presentational page stays prop-driven, data + IPC live here. -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useConfigSyncedResource } from "@/hooks/useConfigSyncedResource"; +import { useCrossEnvVariables } from "@/hooks/useCrossEnvVariables"; import { useEnvironmentStore } from "@/stores/environment"; -import { listEnvVariables, type Environment } from "@/lib/tauri/commands"; +import { type Environment } from "@/lib/tauri/commands"; import { CloneEnvironmentForm, @@ -79,7 +73,6 @@ export function EnvironmentsPageContainer({ ); const renameEnvironment = useEnvironmentStore((s) => s.renameEnvironment); const deleteEnvironment = useEnvironmentStore((s) => s.deleteEnvironment); - const variablesVersion = useEnvironmentStore((s) => s.variablesVersion); // FLIP swap of the ACTIVE pill across cards: capture the old // pill's bounding rect before switchEnvironment fires, then in @@ -90,7 +83,6 @@ export function EnvironmentsPageContainer({ activeEnvironment?.name ?? null, ); - const [summaries, setSummaries] = useState([]); const [cloning, setCloning] = useState<{ filename: string; name: string; @@ -98,45 +90,31 @@ export function EnvironmentsPageContainer({ const [renaming, setRenaming] = useState(null); const [deleting, setDeleting] = useState(null); const [creatingEnv, setCreatingEnv] = useState(false); - const [secretCounts, setSecretCounts] = useState>({}); - useEffect(() => { - void refreshEnvs(); - }, [refreshEnvs]); + // Refresh on mount + on external `envs/*.toml` edits (backend + // `config-changed` category "environment"). + useConfigSyncedResource("environment", refreshEnvs); - useConfigChangeRefresh("environment", refreshEnvs); + // Shared cross-env fan-out (audit 05 §A.3 / backlog S2). The async + // load lives in the hook; the page-specific derivation below stays + // a synchronous memo — same output, same cadence as the old effect. + const bundles = useCrossEnvVariables(); - // Load varCount + secretCount per env in parallel, then assemble - // summaries. - useEffect(() => { - let cancelled = false; - if (environments.length === 0) { - setSummaries([]); - setSecretCounts({}); - return; - } - void Promise.all( - environments.map(async (env) => { - const vars = await listEnvVariables(env.id).catch(() => []); - const secrets = vars.filter((v) => v.is_secret).length; - return { - summary: envToSummary(env, vars.length), - secrets, - }; - }), - ).then((rows) => { - if (cancelled) return; - setSummaries(rows.map((r) => r.summary)); - setSecretCounts( - Object.fromEntries( - rows.map(({ summary, secrets }) => [summary.filename, secrets]), - ), - ); - }); - return () => { - cancelled = true; - }; - }, [environments, variablesVersion]); + const summaries = useMemo( + () => bundles.map(({ env, vars }) => envToSummary(env, vars.length)), + [bundles], + ); + + const secretCounts = useMemo>( + () => + Object.fromEntries( + bundles.map(({ env, vars }) => [ + `${env.name}.toml`, + vars.filter((v) => v.is_secret).length, + ]), + ), + [bundles], + ); const envByFilename = useMemo(() => { const m = new Map(); diff --git a/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx b/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx index 7f480712..beab616d 100644 --- a/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx +++ b/httui-desktop/src/components/layout/variables/VariablesPageContainer.tsx @@ -11,17 +11,16 @@ // page stays prop-driven, data + IPC live here. import { useCallback, useEffect, useMemo, useState } from "react"; -import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; +import { useConfigSyncedResource } from "@/hooks/useConfigSyncedResource"; +import { + useCrossEnvVariables, + type EnvVarsBundle, +} from "@/hooks/useCrossEnvVariables"; import { useEnvironmentStore } from "@/stores/environment"; import { useSessionOverrideStore } from "@/stores/sessionOverride"; import { useWorkspaceStore } from "@/stores/workspace"; -import { - listEnvVariables, - resolveEnvVariables, - type Environment, - type EnvVariable, -} from "@/lib/tauri/commands"; +import { resolveEnvVariables, type Environment } from "@/lib/tauri/commands"; import { grepVarUses, type VarUseEntry } from "@/lib/tauri/var-uses"; import { NewVariableForm } from "./NewVariableForm"; @@ -34,11 +33,6 @@ interface VariablesPageContainerProps { onNavigateFile?: (filePath: string) => void; } -interface EnvVarsBundle { - env: Environment; - vars: EnvVariable[]; -} - /** Merge per-env variable lists into one row per key. Values map is * keyed by env *name* so the page's `envColumnNames` (also names) * resolves directly. Secret rows mask via `isSecret = true`. */ @@ -77,52 +71,35 @@ export function VariablesPageContainer({ const activeEnvironment = useEnvironmentStore((s) => s.activeEnvironment); const refreshEnvs = useEnvironmentStore((s) => s.refresh); const setVariable = useEnvironmentStore((s) => s.setVariable); - const variablesVersion = useEnvironmentStore((s) => s.variablesVersion); const overrides = useSessionOverrideStore((s) => s.overrides); const setOverride = useSessionOverrideStore((s) => s.setOverride); const clearOverride = useSessionOverrideStore((s) => s.clearOverride); - const [rows, setRows] = useState([]); const [usesEntriesByKey, setUsesEntriesByKey] = useState< Record >({}); const [selectedKey, setSelectedKey] = useState(null); const [creating, setCreating] = useState(false); - // Initial env load — store does not auto-refresh on mount. - useEffect(() => { - void refreshEnvs(); - }, [refreshEnvs]); - - // External `envs/*.toml` edits via the file watcher. - useConfigChangeRefresh("environment", refreshEnvs); + // Refresh on mount + on external `envs/*.toml` edits (backend + // `config-changed` category "environment"). + useConfigSyncedResource("environment", refreshEnvs); - // Cross-env merge whenever the env list changes or a setVariable - // bumps `variablesVersion`. - useEffect(() => { - let cancelled = false; - if (environments.length === 0) { - setRows([]); - return; - } - void Promise.all( - environments.map(async (env) => ({ - env, - vars: await listEnvVariables(env.id).catch(() => [] as EnvVariable[]), - })), - ).then((bundles) => { - if (cancelled) return; - const merged = mergeCrossEnvVariables(bundles); - const annotated = merged.map((r) => ({ + // Shared cross-env fan-out (audit 05 §A.3 / backlog S2). The async + // load lives in the hook; the merge + usesCount annotation stay a + // synchronous memo here. Output is identical to the old effect — and + // the annotation no longer re-fires the per-env IPC when only + // `usesEntriesByKey` (the grep result) changes, which the old effect + // did redundantly. + const bundles = useCrossEnvVariables(); + const rows = useMemo( + () => + mergeCrossEnvVariables(bundles).map((r) => ({ ...r, usesCount: usesEntriesByKey[r.key]?.length ?? 0, - })); - setRows(annotated); - }); - return () => { - cancelled = true; - }; - }, [environments, variablesVersion, usesEntriesByKey]); + })), + [bundles, usesEntriesByKey], + ); // One-shot vault grep per key. Cheap (regex over *.md) and the // result is invariant to env changes — refetch only when the key diff --git a/httui-desktop/src/hooks/__tests__/useConfigSyncedResource.test.ts b/httui-desktop/src/hooks/__tests__/useConfigSyncedResource.test.ts new file mode 100644 index 00000000..2f788d30 --- /dev/null +++ b/httui-desktop/src/hooks/__tests__/useConfigSyncedResource.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; + +import { + emitTauriEvent, + clearTauriListeners, + listen, +} from "@/test/mocks/tauri-event"; + +import { useConfigSyncedResource } from "@/hooks/useConfigSyncedResource"; + +afterEach(() => { + clearTauriListeners(); + vi.clearAllMocks(); +}); + +describe("useConfigSyncedResource", () => { + it("refreshes once on mount", async () => { + const refresh = vi.fn(); + renderHook(() => useConfigSyncedResource("connections", refresh)); + expect(refresh).toHaveBeenCalledTimes(1); + // The config-changed subscription is wired (composed primitive). + await waitFor(() => + expect(listen).toHaveBeenCalledWith( + "config-changed", + expect.any(Function), + ), + ); + }); + + it("refreshes again when the matching category fires", async () => { + const refresh = vi.fn(); + renderHook(() => useConfigSyncedResource("environment", refresh)); + await waitFor(() => expect(listen).toHaveBeenCalled()); + expect(refresh).toHaveBeenCalledTimes(1); // mount + + act(() => emitTauriEvent("config-changed", { category: "environment" })); + expect(refresh).toHaveBeenCalledTimes(2); // + watcher + }); + + it("ignores a non-matching category", async () => { + const refresh = vi.fn(); + renderHook(() => useConfigSyncedResource("environment", refresh)); + await waitFor(() => expect(listen).toHaveBeenCalled()); + refresh.mockClear(); + + act(() => emitTauriEvent("config-changed", { category: "connections" })); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("stops refreshing after unmount", async () => { + const refresh = vi.fn(); + const { unmount } = renderHook(() => + useConfigSyncedResource("connections", refresh), + ); + await waitFor(() => expect(listen).toHaveBeenCalled()); + refresh.mockClear(); + + unmount(); + await waitFor(() => undefined); + act(() => emitTauriEvent("config-changed", { category: "connections" })); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("does not re-fire the mount refresh while the ref is stable", () => { + const refresh = vi.fn(); + const { rerender } = renderHook(() => + useConfigSyncedResource("connections", refresh), + ); + expect(refresh).toHaveBeenCalledTimes(1); + rerender(); + rerender(); + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/httui-desktop/src/hooks/__tests__/useCrossEnvVariables.test.ts b/httui-desktop/src/hooks/__tests__/useCrossEnvVariables.test.ts new file mode 100644 index 00000000..ae76948b --- /dev/null +++ b/httui-desktop/src/hooks/__tests__/useCrossEnvVariables.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; + +import { useCrossEnvVariables } from "@/hooks/useCrossEnvVariables"; +import type { Environment, EnvVariable } from "@/lib/tauri/commands"; +import { useEnvironmentStore } from "@/stores/environment"; +import { clearTauriMocks, mockTauriCommand } from "@/test/mocks/tauri"; + +const env = (over: Partial = {}): Environment => ({ + id: "env-local", + name: "local", + is_active: false, + created_at: "", + description: null, + ...over, +}); + +const v = (over: Partial = {}): EnvVariable => ({ + id: "v1", + environment_id: "env-local", + key: "API_BASE", + value: "x", + is_secret: false, + created_at: "", + ...over, +}); + +beforeEach(() => { + useEnvironmentStore.setState({ + environments: [], + activeEnvironment: null, + variablesVersion: 0, + }); + clearTauriMocks(); +}); + +afterEach(() => { + clearTauriMocks(); + useEnvironmentStore.setState({ + environments: [], + activeEnvironment: null, + variablesVersion: 0, + }); + vi.clearAllMocks(); +}); + +describe("useCrossEnvVariables", () => { + it("returns an empty list when there are no environments", () => { + const { result } = renderHook(() => useCrossEnvVariables()); + expect(result.current).toEqual([]); + }); + + it("fans out list_env_variables across every environment", async () => { + useEnvironmentStore.setState({ + environments: [ + env({ id: "env-local", name: "local" }), + env({ id: "env-prod", name: "prod" }), + ], + }); + mockTauriCommand("list_env_variables", (args) => { + const a = args as { environmentId?: string }; + if (a.environmentId === "env-local") { + return [v({ id: "v1", key: "API_BASE", value: "http://local" })]; + } + if (a.environmentId === "env-prod") { + return [ + v({ + id: "v2", + environment_id: "env-prod", + key: "API_BASE", + value: "https://prod", + }), + ]; + } + return []; + }); + + const { result } = renderHook(() => useCrossEnvVariables()); + + await waitFor(() => expect(result.current).toHaveLength(2)); + const byName = Object.fromEntries( + result.current.map((b) => [b.env.name, b]), + ); + expect(byName.local.vars[0].value).toBe("http://local"); + expect(byName.prod.vars[0].value).toBe("https://prod"); + }); + + it("swallows a per-env failure and yields [] for that env", async () => { + useEnvironmentStore.setState({ + environments: [ + env({ id: "env-local", name: "local" }), + env({ id: "env-prod", name: "prod" }), + ], + }); + mockTauriCommand("list_env_variables", (args) => { + const a = args as { environmentId?: string }; + if (a.environmentId === "env-prod") { + throw new Error("prod read failed"); + } + return [v({ id: "v1", key: "API_BASE" })]; + }); + + const { result } = renderHook(() => useCrossEnvVariables()); + + await waitFor(() => expect(result.current).toHaveLength(2)); + const byName = Object.fromEntries( + result.current.map((b) => [b.env.name, b]), + ); + expect(byName.local.vars).toHaveLength(1); + expect(byName.prod.vars).toEqual([]); + }); + + it("re-runs the fan-out when variablesVersion bumps", async () => { + useEnvironmentStore.setState({ + environments: [env({ id: "env-local", name: "local" })], + }); + let calls = 0; + mockTauriCommand("list_env_variables", () => { + calls += 1; + return [v({ id: `v${calls}`, value: `value-${calls}` })]; + }); + + const { result } = renderHook(() => useCrossEnvVariables()); + await waitFor(() => + expect(result.current[0]?.vars[0]?.value).toBe("value-1"), + ); + + act(() => { + useEnvironmentStore.setState({ variablesVersion: 1 }); + }); + + await waitFor(() => + expect(result.current[0]?.vars[0]?.value).toBe("value-2"), + ); + expect(calls).toBe(2); + }); + + it("ignores a fan-out that resolves after unmount (cancelled guard)", async () => { + useEnvironmentStore.setState({ + environments: [env({ id: "env-local", name: "local" })], + }); + let release!: (vars: EnvVariable[]) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + mockTauriCommand("list_env_variables", () => pending); + + const { result, unmount } = renderHook(() => useCrossEnvVariables()); + expect(result.current).toEqual([]); + + unmount(); + // Resolve after the cleanup ran — the cancelled guard must drop it. + await act(async () => { + release([v({ id: "late" })]); + await pending; + }); + + expect(result.current).toEqual([]); + }); +}); diff --git a/httui-desktop/src/hooks/useConfigSyncedResource.ts b/httui-desktop/src/hooks/useConfigSyncedResource.ts new file mode 100644 index 00000000..a4b49904 --- /dev/null +++ b/httui-desktop/src/hooks/useConfigSyncedResource.ts @@ -0,0 +1,34 @@ +import { useEffect } from "react"; + +import { useConfigChangeRefresh } from "@/hooks/useConfigChangeRefresh"; + +/** + * Keep a config-file-backed store resource synced for the lifetime of + * the consumer: refresh once on mount, then again whenever the backend + * emits `config-changed` for `category` (an external `*.toml` edit + * picked up by the file watcher). + * + * This is the exact mount-refresh + config-watch *pair* that the + * Connections / Environments / Variables PageContainers each + * re-implemented verbatim (audit 05 §A.2 §1: "initial-load effect" + + * "config-changed listener effect"). The two always travel together + * over the same `refresh` fn, so they are one cohesive unit. + * + * Composes the tested `useConfigChangeRefresh` primitive rather than + * replacing it — that hook stays the building block (its async-listen + * race handling and its own tests are unchanged). + * + * `refresh` must be a stable reference (a store action selected via + * `useXStore((s) => s.refresh)` is — Zustand actions are stable), or + * the mount effect re-fires on every render. + */ +export function useConfigSyncedResource( + category: string, + refresh: () => void | Promise, +): void { + useEffect(() => { + void refresh(); + }, [refresh]); + + useConfigChangeRefresh(category, refresh); +} diff --git a/httui-desktop/src/hooks/useCrossEnvVariables.ts b/httui-desktop/src/hooks/useCrossEnvVariables.ts new file mode 100644 index 00000000..60fccf2b --- /dev/null +++ b/httui-desktop/src/hooks/useCrossEnvVariables.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; + +import { useEnvironmentStore } from "@/stores/environment"; +import { + listEnvVariables, + type Environment, + type EnvVariable, +} from "@/lib/tauri/commands"; + +export interface EnvVarsBundle { + env: Environment; + vars: EnvVariable[]; +} + +/** + * Fan-out load of every environment's variables in parallel. + * + * This is the genuinely-shared async unit between + * `EnvironmentsPageContainer` (derives `summaries` + `secretCounts`) + * and `VariablesPageContainer` (derives cross-env `rows` via + * `mergeCrossEnvVariables`). Both used to re-implement this exact + * `Promise.all(environments.map(listEnvVariables))` effect, with the + * cancelled-guard boilerplate, then apply a page-specific derivation. + * + * The derivation stays page-specific *on purpose*: it is recomputed + * synchronously via `useMemo` over the returned bundles. A Zustand + * selector can't do this — the fan-out is async (audit 05 §A.3). So + * the async load is centralized here; the per-page shaping stays a + * pure memo at the call-site. + * + * Per-env failure is swallowed (`catch → []`) so one bad env doesn't + * blank the whole page — matches the prior behavior verbatim. + * + * NOT consumed by `EnvironmentManager`: that drawer loads a *single* + * selected env via the store's `loadVariables` action (a different + * contract — single-env, store-action with its own side effects), not + * this cross-env IPC fan-out. Folding it in would change behavior, so + * it is deliberately left as-is. + * + * Mount refresh + the `config-changed` listener stay at the call-site + * (`useConfigSyncedResource`) — out of scope for this hook. + */ +export function useCrossEnvVariables(): EnvVarsBundle[] { + const environments = useEnvironmentStore((s) => s.environments); + const variablesVersion = useEnvironmentStore((s) => s.variablesVersion); + const [bundles, setBundles] = useState([]); + + useEffect(() => { + let cancelled = false; + if (environments.length === 0) { + setBundles([]); + return; + } + void Promise.all( + environments.map(async (env) => ({ + env, + vars: await listEnvVariables(env.id).catch(() => [] as EnvVariable[]), + })), + ).then((next) => { + if (cancelled) return; + setBundles(next); + }); + return () => { + cancelled = true; + }; + }, [environments, variablesVersion]); + + return bundles; +} diff --git a/httui-desktop/src/stores/__tests__/connections.test.ts b/httui-desktop/src/stores/__tests__/connections.test.ts new file mode 100644 index 00000000..d9a66406 --- /dev/null +++ b/httui-desktop/src/stores/__tests__/connections.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { useConnectionsStore } from "@/stores/connections"; +import { mockTauriCommand, clearTauriMocks } from "@/test/mocks/tauri"; +import type { Connection } from "@/lib/tauri/connections"; + +const mkConn = (id: string, name: string): Connection => ({ + id, + name, + driver: "postgres", + host: "localhost", + port: 5432, + database_name: "db", + username: "u", + has_password: false, + ssl_mode: null, + timeout_ms: 0, + query_timeout_ms: 0, + ttl_seconds: 0, + max_pool_size: 0, + is_readonly: false, + last_tested_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}); + +function resetStore() { + useConnectionsStore.setState({ connections: [], loaded: false }); +} + +describe("connectionsStore", () => { + beforeEach(() => { + resetStore(); + clearTauriMocks(); + }); + afterEach(() => clearTauriMocks()); + + describe("refresh", () => { + it("populates connections and flips loaded", async () => { + mockTauriCommand("list_connections", () => [mkConn("1", "pg")]); + await useConnectionsStore.getState().refresh(); + const s = useConnectionsStore.getState(); + expect(s.connections.map((c) => c.id)).toEqual(["1"]); + expect(s.loaded).toBe(true); + }); + + it("swallows an IPC error without throwing or mutating state", async () => { + mockTauriCommand("list_connections", () => { + throw new Error("ipc down"); + }); + await expect( + useConnectionsStore.getState().refresh(), + ).resolves.toBeUndefined(); + const s = useConnectionsStore.getState(); + expect(s.connections).toEqual([]); + expect(s.loaded).toBe(false); + }); + }); + + describe("CRUD auto-refreshes", () => { + it("createConnection dispatches then refreshes, returns the created row", async () => { + const created = mkConn("9", "new"); + let createArgs: unknown = null; + mockTauriCommand("create_connection", (a: unknown) => { + createArgs = a; + return created; + }); + mockTauriCommand("list_connections", () => [created]); + + const ret = await useConnectionsStore + .getState() + .createConnection({ name: "new", driver: "postgres" }); + + expect(ret).toEqual(created); + expect(createArgs).toEqual({ + input: { name: "new", driver: "postgres" }, + }); + expect(useConnectionsStore.getState().connections).toEqual([created]); + }); + + it("updateConnection dispatches then refreshes", async () => { + const updated = mkConn("3", "renamed"); + mockTauriCommand("update_connection", () => updated); + mockTauriCommand("list_connections", () => [updated]); + + const ret = await useConnectionsStore + .getState() + .updateConnection("3", { name: "renamed" }); + + expect(ret).toEqual(updated); + expect(useConnectionsStore.getState().connections).toEqual([updated]); + }); + + it("deleteConnection dispatches then refreshes to the new list", async () => { + useConnectionsStore.setState({ + connections: [mkConn("1", "a"), mkConn("2", "b")], + loaded: true, + }); + let deleteArgs: unknown = null; + mockTauriCommand("delete_connection", (a: unknown) => { + deleteArgs = a; + }); + mockTauriCommand("list_connections", () => [mkConn("2", "b")]); + + await useConnectionsStore.getState().deleteConnection("1"); + + expect(deleteArgs).toEqual({ id: "1" }); + expect( + useConnectionsStore.getState().connections.map((c) => c.id), + ).toEqual(["2"]); + }); + }); +}); diff --git a/httui-desktop/src/stores/connections.ts b/httui-desktop/src/stores/connections.ts new file mode 100644 index 00000000..aa4e1f6e --- /dev/null +++ b/httui-desktop/src/stores/connections.ts @@ -0,0 +1,75 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import { + listConnections, + createConnection as createConnectionCmd, + updateConnection as updateConnectionCmd, + deleteConnection as deleteConnectionCmd, + type Connection, + type CreateConnectionInput, + type UpdateConnectionInput, +} from "@/lib/tauri/connections"; + +/** + * Single source of truth for the connection list. Mirrors + * `environment.ts`: `refresh()` pulls the IPC list; CRUD actions + * dispatch the command then `refresh()` so every surface (the + * ConnectionsPage detail panel and the sidebar ConnectionsList) + * stays in sync without per-component `listConnections()` copies or + * manual `reload()` fan-out. + * + * The `config-changed` file-watcher subscription is intentionally NOT + * owned here (mirrors environment.ts — it stays a consumer concern, + * wired via `useConfigSyncedResource`), keeping the store pure + * data + actions. + */ +interface ConnectionsState { + connections: Connection[]; + /** False until the first successful `refresh()` — lets consumers + * distinguish "not loaded yet" from "loaded, empty". */ + loaded: boolean; + + refresh: () => Promise; + createConnection: (input: CreateConnectionInput) => Promise; + updateConnection: ( + id: string, + input: UpdateConnectionInput, + ) => Promise; + deleteConnection: (id: string) => Promise; +} + +export const useConnectionsStore = create()( + devtools( + (set, get) => ({ + connections: [], + loaded: false, + + refresh: async () => { + try { + const list = await listConnections(); + set({ connections: list, loaded: true }); + } catch { + /* silently fail — mirrors environment.ts */ + } + }, + + createConnection: async (input) => { + const created = await createConnectionCmd(input); + await get().refresh(); + return created; + }, + + updateConnection: async (id, input) => { + const updated = await updateConnectionCmd(id, input); + await get().refresh(); + return updated; + }, + + deleteConnection: async (id) => { + await deleteConnectionCmd(id); + await get().refresh(); + }, + }), + { name: "connections-store" }, + ), +); From 788c9326d81a8e5582b5819bb8abc01d4919d178 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Wed, 20 May 2026 18:10:29 -0300 Subject: [PATCH 05/10] refactor(forms): useInlineForm hook + ConnectionForm reducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `hooks/useInlineForm.ts` — minimal scope: one string field + touched + an attemptSubmit gate. Deliberately not a generic record with per-field errors/reset — that would exceed what the call sites need. - `NewVariablePopover` migrated to `useInlineForm` + `validateVariableName`, which also closes an ad-hoc-rule bug (dot/whitespace names are now properly rejected). - The 4 env/variable inline forms (NewVariable / NewEnvironment / Clone / Rename) migrated to `useInlineForm`. Behavior-preserving: error messages identical, 0 edits to the existing tests. - `ConnectionForm` rebuilt on `useReducer` + a pure module `connection-form-state.ts` (state / init / reducer / validateConnection / buildConnectionInput). Kills 18 useState calls and the props-to-state driver-to-port effect. Form complexity drops 41 to 18; the prop-effect and async-complexity lint warnings are eliminated. React Hook Form / zod were considered and rejected: the existing tests + the reducer + the pure validator cover the same surface with zero new dependencies. --- .../layout/connections/ConnectionForm.tsx | 267 +++++++++--------- .../__tests__/ConnectionForm.test.tsx | 27 ++ .../__tests__/connection-form-state.test.ts | 261 +++++++++++++++++ .../connections/connection-form-state.ts | 225 +++++++++++++++ .../environments/CloneEnvironmentForm.tsx | 27 +- .../environments/NewEnvironmentForm.tsx | 29 +- .../environments/RenameEnvironmentForm.tsx | 32 +-- .../layout/variables/NewVariableForm.tsx | 26 +- .../layout/variables/NewVariablePopover.tsx | 41 ++- .../__tests__/NewVariablePopover.test.tsx | 27 ++ .../src/hooks/__tests__/useInlineForm.test.ts | 79 ++++++ httui-desktop/src/hooks/useInlineForm.ts | 67 +++++ 12 files changed, 893 insertions(+), 215 deletions(-) create mode 100644 httui-desktop/src/components/layout/connections/__tests__/connection-form-state.test.ts create mode 100644 httui-desktop/src/components/layout/connections/connection-form-state.ts create mode 100644 httui-desktop/src/hooks/__tests__/useInlineForm.test.ts create mode 100644 httui-desktop/src/hooks/useInlineForm.ts diff --git a/httui-desktop/src/components/layout/connections/ConnectionForm.tsx b/httui-desktop/src/components/layout/connections/ConnectionForm.tsx index fb3305b0..c8937733 100644 --- a/httui-desktop/src/components/layout/connections/ConnectionForm.tsx +++ b/httui-desktop/src/components/layout/connections/ConnectionForm.tsx @@ -11,102 +11,57 @@ import { Portal, } from "@chakra-ui/react"; import { LuX, LuPlugZap, LuDatabase } from "react-icons/lu"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useReducer, useRef } from "react"; -import type { - Connection, - CreateConnectionInput, -} from "@/lib/tauri/connections"; +import type { Connection } from "@/lib/tauri/connections"; import { createConnection, updateConnection, testConnection, } from "@/lib/tauri/connections"; -import { - DriverSelector, - DRIVER_CONFIG, - type Driver, -} from "./form/DriverSelector"; +import { DriverSelector, DRIVER_CONFIG } from "./form/DriverSelector"; import { SqliteFields } from "./form/SqliteFields"; import { NetworkFields } from "./form/NetworkFields"; import { AdvancedFields } from "./form/AdvancedFields"; import { buildConnectionPreview } from "./form/connection-string"; +import { + buildConnectionInput, + connectionFormReducer, + initConnectionFormState, + validateConnection, +} from "./connection-form-state"; interface ConnectionFormProps { connection: Connection | null; onClose: () => void; } -/** Modal form for creating / editing a database connection. Holds - * all field state (kept inline because the form's commit semantics — - * one Save button writing all fields atomically — don't benefit from - * splitting state into hooks). The visual sections delegate to - * `form/*` sub-components for size hygiene. */ +/** Modal form for creating / editing a database connection. All field + * state lives in one `useReducer` (`connection-form-state.ts`) — the + * old 18 `useState` + the props→state driver→port mirror effect were + * a desync hazard with zero field validation (audit 02 §4 / 05 Part + * B). The visual sections delegate to `form/*` sub-components. */ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { const isEdit = connection !== null; const overlayRef = useRef(null); - const [name, setName] = useState(connection?.name ?? ""); - const [driver, setDriver] = useState( - (connection?.driver as Driver) ?? "postgres", - ); - const [host, setHost] = useState(connection?.host ?? "localhost"); - const [port, setPort] = useState(connection?.port?.toString() ?? "5432"); - const [dbName, setDbName] = useState(connection?.database_name ?? ""); - const [username, setUsername] = useState(connection?.username ?? ""); - const [password, setPassword] = useState(""); - const [sslMode, setSslMode] = useState(connection?.ssl_mode ?? "disable"); - - // Advanced - const [showAdvanced, setShowAdvanced] = useState(false); - const [timeoutMs, setTimeoutMs] = useState( - (connection?.timeout_ms ?? 10000).toString(), - ); - const [queryTimeoutMs, setQueryTimeoutMs] = useState( - (connection?.query_timeout_ms ?? 30000).toString(), - ); - const [ttlSeconds, setTtlSeconds] = useState( - (connection?.ttl_seconds ?? 300).toString(), - ); - const [maxPoolSize, setMaxPoolSize] = useState( - (connection?.max_pool_size ?? 5).toString(), - ); - - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<"success" | "error" | null>( - null, + const [s, dispatch] = useReducer( + connectionFormReducer, + connection, + initConnectionFormState, ); - const [testError, setTestError] = useState(null); - const [error, setError] = useState(null); - - // Update default port when driver changes (only on new — editing - // keeps the existing port even if the driver is somehow swapped). - useEffect(() => { - if (!isEdit) { - setPort(DRIVER_CONFIG[driver].defaultPort); - } - }, [driver, isEdit]); const handleSave = useCallback(async () => { - setError(null); - setSaving(true); + const check = validateConnection(s); + if (!check.ok) { + dispatch({ type: "saveError", message: check.reason }); + return; + } + dispatch({ type: "saveStart" }); try { - const input: CreateConnectionInput = { - name, - driver, - ...(driver !== "sqlite" && { host, port: parseInt(port) || undefined }), - database_name: dbName || undefined, - ...(driver !== "sqlite" && { username: username || undefined }), - ...(driver !== "sqlite" && { password: password || undefined }), - ...(driver !== "sqlite" && { ssl_mode: sslMode }), - timeout_ms: parseInt(timeoutMs) || undefined, - query_timeout_ms: parseInt(queryTimeoutMs) || undefined, - ttl_seconds: parseInt(ttlSeconds) || undefined, - max_pool_size: parseInt(maxPoolSize) || undefined, - }; + const input = buildConnectionInput(s); if (isEdit && connection) { await updateConnection(connection.id, input); @@ -114,44 +69,28 @@ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { await createConnection(input); } + dispatch({ type: "saveDone" }); onClose(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); + dispatch({ + type: "saveError", + message: err instanceof Error ? err.message : String(err), + }); } - }, [ - name, - driver, - host, - port, - dbName, - username, - password, - sslMode, - timeoutMs, - queryTimeoutMs, - ttlSeconds, - maxPoolSize, - isEdit, - connection, - onClose, - ]); + }, [s, isEdit, connection, onClose]); const handleTest = useCallback(async () => { if (!isEdit || !connection) return; - setTesting(true); - setTestResult(null); - setTestError(null); + dispatch({ type: "testStart" }); try { await testConnection(connection.id); - setTestResult("success"); + dispatch({ type: "testSuccess" }); } catch (err) { - setTestResult("error"); - setTestError(err instanceof Error ? err.message : String(err)); - } finally { - setTesting(false); + dispatch({ + type: "testFailure", + message: err instanceof Error ? err.message : String(err), + }); } }, [isEdit, connection]); @@ -173,8 +112,8 @@ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { return () => window.removeEventListener("keydown", handler); }, [onClose]); - const isSqlite = driver === "sqlite"; - const driverColor = DRIVER_CONFIG[driver].color; + const isSqlite = s.driver === "sqlite"; + const driverColor = DRIVER_CONFIG[s.driver].color; return ( @@ -231,34 +170,62 @@ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { setName(e.target.value)} + value={s.name} + onChange={(e) => + dispatch({ + type: "setField", + field: "name", + value: e.target.value, + }) + } placeholder="Connection name" fontWeight="medium" /> - + + dispatch({ type: "setDriver", driver, isEdit }) + } + /> {/* Driver-specific fields */} {isSqlite ? ( - + + dispatch({ type: "setField", field: "dbName", value }) + } + /> ) : ( + dispatch({ type: "setField", field: "host", value }) + } + port={s.port} + onPortChange={(value) => + dispatch({ type: "setField", field: "port", value }) + } + dbName={s.dbName} + onDbNameChange={(value) => + dispatch({ type: "setField", field: "dbName", value }) + } + username={s.username} + onUsernameChange={(value) => + dispatch({ type: "setField", field: "username", value }) + } + password={s.password} + onPasswordChange={(value) => + dispatch({ type: "setField", field: "password", value }) + } + sslMode={s.sslMode} + onSslModeChange={(value) => + dispatch({ type: "setField", field: "sslMode", value }) + } /> )} @@ -276,41 +243,61 @@ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { rounded="md" truncate > - {buildConnectionPreview(driver, host, port, dbName, username)} + {buildConnectionPreview( + s.driver, + s.host, + s.port, + s.dbName, + s.username, + )} setShowAdvanced(!showAdvanced)} - timeoutMs={timeoutMs} - onTimeoutMsChange={setTimeoutMs} - queryTimeoutMs={queryTimeoutMs} - onQueryTimeoutMsChange={setQueryTimeoutMs} - ttlSeconds={ttlSeconds} - onTtlSecondsChange={setTtlSeconds} - maxPoolSize={maxPoolSize} - onMaxPoolSizeChange={setMaxPoolSize} + open={s.showAdvanced} + onToggle={() => dispatch({ type: "toggleAdvanced" })} + timeoutMs={s.timeoutMs} + onTimeoutMsChange={(value) => + dispatch({ type: "setField", field: "timeoutMs", value }) + } + queryTimeoutMs={s.queryTimeoutMs} + onQueryTimeoutMsChange={(value) => + dispatch({ + type: "setField", + field: "queryTimeoutMs", + value, + }) + } + ttlSeconds={s.ttlSeconds} + onTtlSecondsChange={(value) => + dispatch({ type: "setField", field: "ttlSeconds", value }) + } + maxPoolSize={s.maxPoolSize} + onMaxPoolSizeChange={(value) => + dispatch({ type: "setField", field: "maxPoolSize", value }) + } /> - {testResult && ( + {s.testResult && ( - {testResult === "success" + {s.testResult === "success" ? "Connection successful" - : `Connection failed${testError ? `: ${testError}` : ""}`} + : `Connection failed${ + s.testError ? `: ${s.testError}` : "" + }`} )} - {error && ( + {s.error && ( - {error} + {s.error} )} @@ -348,11 +335,11 @@ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { bg="bg.subtle" _hover={{ bg: "bg.emphasized" }} onClick={handleTest} - opacity={testing ? 0.5 : 1} - pointerEvents={testing ? "none" : "auto"} + opacity={s.testing ? 0.5 : 1} + pointerEvents={s.testing ? "none" : "auto"} mr="auto" > - {testing ? : } + {s.testing ? : } Test )} @@ -379,10 +366,10 @@ export function ConnectionForm({ connection, onClose }: ConnectionFormProps) { color="white" _hover={{ bg: `${driverColor}.600` }} onClick={handleSave} - opacity={saving || !name.trim() ? 0.5 : 1} - pointerEvents={saving || !name.trim() ? "none" : "auto"} + opacity={s.saving || !s.name.trim() ? 0.5 : 1} + pointerEvents={s.saving || !s.name.trim() ? "none" : "auto"} > - {saving ? : isEdit ? "Save" : "Create"} + {s.saving ? : isEdit ? "Save" : "Create"}
diff --git a/httui-desktop/src/components/layout/connections/__tests__/ConnectionForm.test.tsx b/httui-desktop/src/components/layout/connections/__tests__/ConnectionForm.test.tsx index dd3d781b..d61fa13c 100644 --- a/httui-desktop/src/components/layout/connections/__tests__/ConnectionForm.test.tsx +++ b/httui-desktop/src/components/layout/connections/__tests__/ConnectionForm.test.tsx @@ -86,6 +86,33 @@ describe("ConnectionForm", () => { expect(screen.getByText(/FILE PATH/i)).toBeInTheDocument(); }); + it("blocks Save and surfaces the validator error for an invalid form (F4)", async () => { + const user = userEvent.setup(); + let created = false; + mockTauriCommand("create_connection", () => { + created = true; + }); + renderWithProviders( + , + ); + + // sqlite with a name but no file path → previously created a + // broken connection silently; now the validator blocks it. + await user.type( + screen.getByPlaceholderText("Connection name"), + "local-db", + ); + await user.click(screen.getByText("SQLite")); + await user.click(screen.getByText(/create/i)); + + await waitFor(() => + expect( + screen.getByText(/SQLite file path is required/i), + ).toBeInTheDocument(), + ); + expect(created).toBe(false); + }); + it("captures error when create_connection rejects", async () => { const user = userEvent.setup(); mockTauriCommand("create_connection", () => { diff --git a/httui-desktop/src/components/layout/connections/__tests__/connection-form-state.test.ts b/httui-desktop/src/components/layout/connections/__tests__/connection-form-state.test.ts new file mode 100644 index 00000000..c8c7f2c2 --- /dev/null +++ b/httui-desktop/src/components/layout/connections/__tests__/connection-form-state.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; + +import type { Connection } from "@/lib/tauri/connections"; +import { + initConnectionFormState, + emptyConnectionFormState, + connectionFormReducer, + validateConnection, + buildConnectionInput, + type ConnectionFormState, +} from "@/components/layout/connections/connection-form-state"; + +const mkConnection = (over: Partial = {}): Connection => ({ + id: "c1", + name: "primary", + driver: "postgres", + host: "db.test", + port: 6543, + database_name: "mydb", + username: "alice", + has_password: false, + ssl_mode: "require", + timeout_ms: 9000, + query_timeout_ms: 25000, + ttl_seconds: 120, + max_pool_size: 8, + is_readonly: false, + last_tested_at: null, + created_at: "", + updated_at: "", + ...over, +}); + +const base = (): ConnectionFormState => initConnectionFormState(null); + +describe("initConnectionFormState", () => { + it("seeds a new connection from driver defaults (port from DRIVER_CONFIG)", () => { + const s = initConnectionFormState(null); + expect(s.name).toBe(""); + expect(s.driver).toBe("postgres"); + expect(s.host).toBe("localhost"); + expect(s.port).toBe("5432"); // postgres defaultPort — replaces the old mount effect + expect(s.password).toBe(""); + expect(s.saving).toBe(false); + expect(s.error).toBeNull(); + expect(s.timeoutMs).toBe("10000"); + }); + + it("prefills + keeps the stored port in edit mode", () => { + const s = initConnectionFormState(mkConnection()); + expect(s.name).toBe("primary"); + expect(s.host).toBe("db.test"); + expect(s.port).toBe("6543"); // stored port kept (old effect was gated on !isEdit) + expect(s.username).toBe("alice"); + expect(s.sslMode).toBe("require"); + expect(s.timeoutMs).toBe("9000"); + expect(s.password).toBe(""); // never echoed back + }); +}); + +describe("connectionFormReducer", () => { + it("setField updates only the keyed field", () => { + const s = connectionFormReducer(base(), { + type: "setField", + field: "name", + value: "staging", + }); + expect(s.name).toBe("staging"); + expect(s.host).toBe("localhost"); + }); + + it("setDriver re-derives the default port for a NEW connection", () => { + const mysql = connectionFormReducer(base(), { + type: "setDriver", + driver: "mysql", + isEdit: false, + }); + expect(mysql.driver).toBe("mysql"); + expect(mysql.port).toBe("3306"); + const sqlite = connectionFormReducer(base(), { + type: "setDriver", + driver: "sqlite", + isEdit: false, + }); + expect(sqlite.port).toBe(""); + }); + + it("setDriver keeps the stored port when editing", () => { + const edit = initConnectionFormState(mkConnection()); // port "6543" + const next = connectionFormReducer(edit, { + type: "setDriver", + driver: "mysql", + isEdit: true, + }); + expect(next.driver).toBe("mysql"); + expect(next.port).toBe("6543"); + }); + + it("toggleAdvanced flips the flag", () => { + const open = connectionFormReducer(base(), { type: "toggleAdvanced" }); + expect(open.showAdvanced).toBe(true); + expect( + connectionFormReducer(open, { type: "toggleAdvanced" }).showAdvanced, + ).toBe(false); + }); + + it("save lifecycle: start clears error, error sets it, done clears saving", () => { + const start = connectionFormReducer( + { ...base(), error: "old" }, + { type: "saveStart" }, + ); + expect(start).toMatchObject({ saving: true, error: null }); + const err = connectionFormReducer(start, { + type: "saveError", + message: "boom", + }); + expect(err).toMatchObject({ saving: false, error: "boom" }); + expect(connectionFormReducer(start, { type: "saveDone" }).saving).toBe( + false, + ); + }); + + it("test lifecycle: start resets, success + failure set the result", () => { + const start = connectionFormReducer(base(), { type: "testStart" }); + expect(start).toMatchObject({ + testing: true, + testResult: null, + testError: null, + }); + expect(connectionFormReducer(start, { type: "testSuccess" })).toMatchObject( + { testing: false, testResult: "success" }, + ); + expect( + connectionFormReducer(start, { + type: "testFailure", + message: "DNS", + }), + ).toMatchObject({ + testing: false, + testResult: "error", + testError: "DNS", + }); + }); +}); + +describe("validateConnection", () => { + it("requires a name", () => { + const r = validateConnection({ ...base(), name: " " }); + expect(r).toEqual({ ok: false, reason: "Connection name is required" }); + }); + + it("sqlite needs a file path, then passes", () => { + const empty = validateConnection({ + ...base(), + name: "db", + driver: "sqlite", + dbName: "", + }); + expect(empty).toEqual({ + ok: false, + reason: "SQLite file path is required", + }); + expect( + validateConnection({ + ...base(), + name: "db", + driver: "sqlite", + dbName: "/tmp/x.db", + }), + ).toEqual({ ok: true }); + }); + + it("network drivers require a host", () => { + expect(validateConnection({ ...base(), name: "db", host: "" })).toEqual({ + ok: false, + reason: "Host is required", + }); + }); + + it.each(["", "abc", "0", "-1", "70000", "12.5"])( + "rejects a bad port %j", + (port) => { + const r = validateConnection({ ...base(), name: "db", port }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/port/i); + }, + ); + + it("passes a well-formed network connection", () => { + expect( + validateConnection({ + ...base(), + name: "db", + host: "localhost", + port: "5432", + }), + ).toEqual({ ok: true }); + }); +}); + +describe("emptyConnectionFormState", () => { + it("is exactly what initConnectionFormState(null) returns", () => { + expect(initConnectionFormState(null)).toEqual(emptyConnectionFormState()); + }); +}); + +describe("buildConnectionInput", () => { + it("includes host/port/credentials/ssl for a network driver", () => { + const input = buildConnectionInput({ + ...base(), + name: "pg", + host: "db.x", + port: "5432", + username: "u", + password: "p", + sslMode: "require", + dbName: "app", + }); + expect(input).toMatchObject({ + name: "pg", + driver: "postgres", + host: "db.x", + port: 5432, + username: "u", + password: "p", + ssl_mode: "require", + database_name: "app", + }); + }); + + it("omits network fields for sqlite (only db path + advanced)", () => { + const input = buildConnectionInput({ + ...base(), + name: "local", + driver: "sqlite", + dbName: "/tmp/a.db", + }); + expect(input).toMatchObject({ + name: "local", + driver: "sqlite", + database_name: "/tmp/a.db", + }); + expect(input).not.toHaveProperty("host"); + expect(input).not.toHaveProperty("port"); + expect(input).not.toHaveProperty("username"); + expect(input).not.toHaveProperty("ssl_mode"); + }); + + it("coerces blank/garbage numerics to undefined (lenient, as before)", () => { + const input = buildConnectionInput({ + ...base(), + name: "pg", + port: "abc", + timeoutMs: "", + maxPoolSize: "x", + }); + expect(input.port).toBeUndefined(); + expect(input.timeout_ms).toBeUndefined(); + expect(input.max_pool_size).toBeUndefined(); + }); +}); diff --git a/httui-desktop/src/components/layout/connections/connection-form-state.ts b/httui-desktop/src/components/layout/connections/connection-form-state.ts new file mode 100644 index 00000000..651fe994 --- /dev/null +++ b/httui-desktop/src/components/layout/connections/connection-form-state.ts @@ -0,0 +1,225 @@ +// ConnectionForm state — extracted to kill the 18-useState sprawl and +// the props→state driver→port mirror effect (audit 02 §4 / 05 Part B, +// backlog F4). Pure: reducer + initializer + a first-error validator, +// unit-tested in isolation alongside the validateEnvName / variable- +// name.ts convention. + +import type { + Connection, + CreateConnectionInput, +} from "@/lib/tauri/connections"; + +import { DRIVER_CONFIG, type Driver } from "./form/DriverSelector"; + +/** Every text field driven by an `` / select in the form. The + * generic `setField` action keys on these so the component never + * hand-rolls a setter per field (the desync source). */ +export type TextField = + | "name" + | "host" + | "port" + | "dbName" + | "username" + | "password" + | "sslMode" + | "timeoutMs" + | "queryTimeoutMs" + | "ttlSeconds" + | "maxPoolSize"; + +export interface ConnectionFormState { + name: string; + driver: Driver; + host: string; + port: string; + dbName: string; + username: string; + password: string; + sslMode: string; + showAdvanced: boolean; + timeoutMs: string; + queryTimeoutMs: string; + ttlSeconds: string; + maxPoolSize: string; + saving: boolean; + testing: boolean; + testResult: "success" | "error" | null; + testError: string | null; + error: string | null; +} + +const TRANSIENT = { + showAdvanced: false, + saving: false, + testing: false, + testResult: null, + testError: null, + error: null, +} as const; + +/** The new-connection defaults. Port is the postgres default — + * exactly what the old mount `useEffect([driver,isEdit])` set, so + * dropping that effect (the props→state smell) is behavior- + * preserving. Zero branches: split out of `initConnectionFormState` + * so neither function trips the cyclomatic-complexity rule. */ +export function emptyConnectionFormState(): ConnectionFormState { + return { + name: "", + driver: "postgres", + host: "localhost", + port: DRIVER_CONFIG.postgres.defaultPort, + dbName: "", + username: "", + password: "", + sslMode: "disable", + timeoutMs: "10000", + queryTimeoutMs: "30000", + ttlSeconds: "300", + maxPoolSize: "5", + ...TRANSIENT, + }; +} + +/** + * Replaces the 18 `useState(connection?…)` initializers verbatim. New + * → driver defaults; edit → prefilled from the stored row, keeping the + * stored port (the old effect was gated on `!isEdit`) and never + * echoing the password back. + */ +export function initConnectionFormState( + connection: Connection | null, +): ConnectionFormState { + if (!connection) return emptyConnectionFormState(); + return { + name: connection.name ?? "", + driver: (connection.driver as Driver) ?? "postgres", + host: connection.host ?? "localhost", + port: connection.port?.toString() ?? "5432", + dbName: connection.database_name ?? "", + username: connection.username ?? "", + password: "", + sslMode: connection.ssl_mode ?? "disable", + timeoutMs: (connection.timeout_ms ?? 10000).toString(), + queryTimeoutMs: (connection.query_timeout_ms ?? 30000).toString(), + ttlSeconds: (connection.ttl_seconds ?? 300).toString(), + maxPoolSize: (connection.max_pool_size ?? 5).toString(), + ...TRANSIENT, + }; +} + +export type ConnectionFormAction = + | { type: "setField"; field: TextField; value: string } + | { type: "setDriver"; driver: Driver; isEdit: boolean } + | { type: "toggleAdvanced" } + | { type: "saveStart" } + | { type: "saveError"; message: string } + | { type: "saveDone" } + | { type: "testStart" } + | { type: "testSuccess" } + | { type: "testFailure"; message: string }; + +export function connectionFormReducer( + state: ConnectionFormState, + action: ConnectionFormAction, +): ConnectionFormState { + switch (action.type) { + case "setField": + return { ...state, [action.field]: action.value }; + case "setDriver": + // Driver swap re-derives the default port — but only for a new + // connection (editing keeps the stored port). This is the old + // `useEffect([driver,isEdit])` made synchronous + desync-free. + return { + ...state, + driver: action.driver, + port: action.isEdit + ? state.port + : DRIVER_CONFIG[action.driver].defaultPort, + }; + case "toggleAdvanced": + return { ...state, showAdvanced: !state.showAdvanced }; + case "saveStart": + return { ...state, error: null, saving: true }; + case "saveError": + return { ...state, error: action.message, saving: false }; + case "saveDone": + return { ...state, saving: false }; + case "testStart": + return { ...state, testing: true, testResult: null, testError: null }; + case "testSuccess": + return { ...state, testing: false, testResult: "success" }; + case "testFailure": + return { + ...state, + testing: false, + testResult: "error", + testError: action.message, + }; + } +} + +export type ConnectionValidation = { ok: true } | { ok: false; reason: string }; + +/** + * The field validation the form never had (audit 05 Part B: "No field + * validation at all"). Returns the *first* problem so it can surface + * in the existing single error Badge — no per-field error map, because + * the form has no per-field error UI (adding one would be + * over-engineering). Mirrors the `{ok}|{ok:false,reason}` shape of + * `validateEnvName` / `validateVariableName`. + */ +export function validateConnection( + state: ConnectionFormState, +): ConnectionValidation { + if (!state.name.trim()) { + return { ok: false, reason: "Connection name is required" }; + } + if (state.driver === "sqlite") { + if (!state.dbName.trim()) { + return { ok: false, reason: "SQLite file path is required" }; + } + return { ok: true }; + } + if (!state.host.trim()) { + return { ok: false, reason: "Host is required" }; + } + const port = Number(state.port); + if ( + !state.port.trim() || + !Number.isInteger(port) || + port <= 0 || + port > 65535 + ) { + return { ok: false, reason: "Port must be a number between 1 and 65535" }; + } + return { ok: true }; +} + +/** + * Map the form state to the IPC payload. Extracted from the + * component's `handleSave` (where the driver-conditional spreads + * inflated its cyclomatic complexity) so it is pure + unit-testable. + * `parseInt(...) || undefined` keeps the prior lenient numeric + * coercion verbatim — `validateConnection` is the real gate. + */ +export function buildConnectionInput( + state: ConnectionFormState, +): CreateConnectionInput { + const network = state.driver !== "sqlite"; + return { + name: state.name, + driver: state.driver, + ...(network && { + host: state.host, + port: parseInt(state.port) || undefined, + username: state.username || undefined, + password: state.password || undefined, + ssl_mode: state.sslMode, + }), + database_name: state.dbName || undefined, + timeout_ms: parseInt(state.timeoutMs) || undefined, + query_timeout_ms: parseInt(state.queryTimeoutMs) || undefined, + ttl_seconds: parseInt(state.ttlSeconds) || undefined, + max_pool_size: parseInt(state.maxPoolSize) || undefined, + }; +} diff --git a/httui-desktop/src/components/layout/environments/CloneEnvironmentForm.tsx b/httui-desktop/src/components/layout/environments/CloneEnvironmentForm.tsx index 326180ae..014fabab 100644 --- a/httui-desktop/src/components/layout/environments/CloneEnvironmentForm.tsx +++ b/httui-desktop/src/components/layout/environments/CloneEnvironmentForm.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { Btn, Input } from "@/components/atoms"; import { Checkbox } from "@/components/ui/checkbox"; +import { useInlineForm } from "@/hooks/useInlineForm"; import { validateEnvName } from "./env-name"; @@ -44,25 +45,23 @@ export function CloneEnvironmentForm({ onSubmit, onCancel, }: CloneEnvironmentFormProps) { - const [name, setName] = useState(""); + const nameField = useInlineForm("", (n) => + validateEnvName(n, existingFilenames), + ); const [copyVariables, setCopyVariables] = useState(true); const [copyConnectionsUsed, setCopyConnectionsUsed] = useState(false); const [markTemporary, setMarkTemporary] = useState(false); const [markPersonal, setMarkPersonal] = useState(false); - const [touched, setTouched] = useState(false); - const validation = validateEnvName(name, existingFilenames); - const showError = touched && !validation.ok; - const targetFilename = `${name.trim() || ""}${ + const targetFilename = `${nameField.value.trim() || ""}${ markPersonal ? ".local.toml" : ".toml" }`; function handleSubmit() { - setTouched(true); - if (!validation.ok) return; + if (!nameField.attemptSubmit()) return; onSubmit?.({ sourceFilename, - name: name.trim(), + name: nameField.value.trim(), copyVariables, copyConnectionsUsed, markTemporary, @@ -99,8 +98,8 @@ export function CloneEnvironmentForm({ setName(e.target.value)} + value={nameField.value} + onChange={(e) => nameField.setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -111,16 +110,16 @@ export function CloneEnvironmentForm({ } }} autoFocus - aria-invalid={showError} + aria-invalid={nameField.showError} /> - {showError && !validation.ok && ( + {nameField.showError && ( - {validation.reason} + {nameField.error} )} @@ -175,7 +174,7 @@ export function CloneEnvironmentForm({ variant="primary" data-testid="clone-environment-save" onClick={handleSubmit} - disabled={touched && !validation.ok} + disabled={nameField.showError} > Clone diff --git a/httui-desktop/src/components/layout/environments/NewEnvironmentForm.tsx b/httui-desktop/src/components/layout/environments/NewEnvironmentForm.tsx index cf209317..9679c023 100644 --- a/httui-desktop/src/components/layout/environments/NewEnvironmentForm.tsx +++ b/httui-desktop/src/components/layout/environments/NewEnvironmentForm.tsx @@ -5,9 +5,9 @@ // form per the spec; new envs are public by default. import { Box, Flex, Text } from "@chakra-ui/react"; -import { useState } from "react"; import { Btn, Input } from "@/components/atoms"; +import { useInlineForm } from "@/hooks/useInlineForm"; import { validateEnvName } from "./env-name"; @@ -27,16 +27,13 @@ export function NewEnvironmentForm({ onSubmit, onCancel, }: NewEnvironmentFormProps) { - const [name, setName] = useState(""); - const [touched, setTouched] = useState(false); - - const validation = validateEnvName(name, existingFilenames); - const showError = touched && !validation.ok; + const nameField = useInlineForm("", (n) => + validateEnvName(n, existingFilenames), + ); function handleSubmit() { - setTouched(true); - if (!validation.ok) return; - onSubmit?.({ name: name.trim() }); + if (!nameField.attemptSubmit()) return; + onSubmit?.({ name: nameField.value.trim() }); } return ( @@ -54,8 +51,8 @@ export function NewEnvironmentForm({ setName(e.target.value)} + value={nameField.value} + onChange={(e) => nameField.setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -66,16 +63,16 @@ export function NewEnvironmentForm({ } }} autoFocus - aria-invalid={showError} + aria-invalid={nameField.showError} /> - {showError && !validation.ok && ( + {nameField.showError && ( - {validation.reason} + {nameField.error} )} @@ -88,7 +85,7 @@ export function NewEnvironmentForm({ > creates{" "} - envs/{name.trim() || ""}.toml + envs/{nameField.value.trim() || ""}.toml @@ -103,7 +100,7 @@ export function NewEnvironmentForm({ variant="primary" data-testid="new-environment-save" onClick={handleSubmit} - disabled={touched && !validation.ok} + disabled={nameField.showError} > Save diff --git a/httui-desktop/src/components/layout/environments/RenameEnvironmentForm.tsx b/httui-desktop/src/components/layout/environments/RenameEnvironmentForm.tsx index e51ced7a..270a2c4c 100644 --- a/httui-desktop/src/components/layout/environments/RenameEnvironmentForm.tsx +++ b/httui-desktop/src/components/layout/environments/RenameEnvironmentForm.tsx @@ -7,9 +7,9 @@ // without colliding with itself. import { Box, Flex, Text } from "@chakra-ui/react"; -import { useState } from "react"; import { Btn, Input } from "@/components/atoms"; +import { useInlineForm } from "@/hooks/useInlineForm"; import { validateEnvName } from "./env-name"; import type { EnvironmentSummary } from "./envs-meta"; @@ -35,24 +35,22 @@ export function RenameEnvironmentForm({ onSubmit, onCancel, }: RenameEnvironmentFormProps) { - const [name, setName] = useState(env.name); - const [touched, setTouched] = useState(false); - // Filter out the source filename so renaming to the same name (or // changing case) doesn't trip the duplicate check. const others = existingFilenames.filter((f) => f !== env.filename); - const validation = validateEnvName(name, others); - const showError = touched && !validation.ok; - const noChange = name.trim() === env.name; + const nameField = useInlineForm(env.name, (n) => validateEnvName(n, others)); + const noChange = nameField.value.trim() === env.name; function handleSubmit() { - setTouched(true); - if (!validation.ok) return; + if (!nameField.attemptSubmit()) return; if (noChange) { onCancel?.(); return; } - onSubmit?.({ sourceFilename: env.filename, newName: name.trim() }); + onSubmit?.({ + sourceFilename: env.filename, + newName: nameField.value.trim(), + }); } return ( @@ -83,8 +81,8 @@ export function RenameEnvironmentForm({ setName(e.target.value)} + value={nameField.value} + onChange={(e) => nameField.setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -95,16 +93,16 @@ export function RenameEnvironmentForm({ } }} autoFocus - aria-invalid={showError} + aria-invalid={nameField.showError} /> - {showError && !validation.ok && ( + {nameField.showError && ( - {validation.reason} + {nameField.error} )} @@ -117,7 +115,7 @@ export function RenameEnvironmentForm({ > renames to{" "} - envs/{name.trim() || ""} + envs/{nameField.value.trim() || ""} {env.isPersonal ? ".local.toml" : ".toml"} @@ -133,7 +131,7 @@ export function RenameEnvironmentForm({ variant="primary" data-testid="rename-environment-save" onClick={handleSubmit} - disabled={touched && !validation.ok} + disabled={nameField.showError} > Rename diff --git a/httui-desktop/src/components/layout/variables/NewVariableForm.tsx b/httui-desktop/src/components/layout/variables/NewVariableForm.tsx index b47f878b..8dd1e06f 100644 --- a/httui-desktop/src/components/layout/variables/NewVariableForm.tsx +++ b/httui-desktop/src/components/layout/variables/NewVariableForm.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { LuLock, LuLockOpen, LuPlus, LuX } from "react-icons/lu"; import { Input } from "@/components/atoms"; +import { useInlineForm } from "@/hooks/useInlineForm"; import { validateVariableName } from "./variable-name"; @@ -37,19 +38,16 @@ export function NewVariableForm({ onSubmit, onCancel, }: NewVariableFormProps) { - const [name, setName] = useState(""); + const nameField = useInlineForm("", (n) => + validateVariableName(n, existingNames), + ); const [value, setValue] = useState(""); const [isSecret, setIsSecret] = useState(false); - const [touched, setTouched] = useState(false); - - const validation = validateVariableName(name, existingNames); - const showError = touched && !validation.ok; function handleSubmit() { - setTouched(true); - if (!validation.ok) return; + if (!nameField.attemptSubmit()) return; onSubmit?.({ - name: name.trim(), + name: nameField.value.trim(), value, isSecret, env: activeEnv, @@ -70,8 +68,8 @@ export function NewVariableForm({ setName(e.target.value)} + value={nameField.value} + onChange={(e) => nameField.setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); @@ -79,7 +77,7 @@ export function NewVariableForm({ } }} autoFocus - aria-invalid={showError} + aria-invalid={nameField.showError} css={{ border: "none", borderRadius: 0, @@ -145,7 +143,7 @@ export function NewVariableForm({ variant="ghost" colorPalette="green" onClick={handleSubmit} - disabled={touched && !validation.ok} + disabled={nameField.showError} > @@ -165,14 +163,14 @@ export function NewVariableForm({ - {showError && !validation.ok ? ( + {nameField.showError ? ( - {validation.reason} + {nameField.error} ) : ( void }) { const activeEnv = useEnvironmentStore((s) => s.activeEnvironment); const setVariable = useEnvironmentStore((s) => s.setVariable); - const [name, setName] = useState(""); + // F2: route the name through the shared inline-form idiom + the + // real `validateVariableName` (was an ad-hoc `name.trim() === ""` + // that skipped the whitespace/dot rules every other form enforces — + // audit 05 Part B). No duplicate list is passed: this popover is an + // upsert into the active env, so a colliding name is intentionally + // allowed (behavior preserved). `ipcError` is the separate + // save-failure channel. + const nameField = useInlineForm("", validateVariableName); const [value, setValue] = useState(""); const [type, setType] = useState("Text"); - const [error, setError] = useState(null); + const [ipcError, setIpcError] = useState(null); const cardRef = useRef(null); useEscapeClose(onClose); @@ -70,19 +80,21 @@ function NewVariableForm({ onClose }: { onClose: () => void }) { }, [onClose]); async function handleSave() { - if (name.trim() === "") { - setError("Name is required"); - return; - } + if (!nameField.attemptSubmit()) return; if (!activeEnv) { - setError("No active environment"); + setIpcError("No active environment"); return; } try { - await setVariable(activeEnv.id, name.trim(), value, type === "Secret"); + await setVariable( + activeEnv.id, + nameField.value.trim(), + value, + type === "Secret", + ); onClose(); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save"); + setIpcError(e instanceof Error ? e.message : "Failed to save"); } } @@ -122,9 +134,10 @@ function NewVariableForm({ onClose }: { onClose: () => void }) { setName(e.target.value)} + aria-invalid={nameField.showError} + onChange={(e) => nameField.setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -162,14 +175,14 @@ function NewVariableForm({ onClose }: { onClose: () => void }) { setValue((v) => v + t)} /> - {error && ( + {(nameField.showError || ipcError) && ( - {error} + {nameField.showError ? nameField.error : ipcError} )} @@ -185,7 +198,7 @@ function NewVariableForm({ onClose }: { onClose: () => void }) { variant="primary" data-testid="new-variable-save" onClick={() => void handleSave()} - disabled={!activeEnv || name.trim() === ""} + disabled={!activeEnv || nameField.value.trim() === ""} > Save diff --git a/httui-desktop/src/components/layout/variables/__tests__/NewVariablePopover.test.tsx b/httui-desktop/src/components/layout/variables/__tests__/NewVariablePopover.test.tsx index 8ab8b786..7f89bce7 100644 --- a/httui-desktop/src/components/layout/variables/__tests__/NewVariablePopover.test.tsx +++ b/httui-desktop/src/components/layout/variables/__tests__/NewVariablePopover.test.tsx @@ -152,6 +152,33 @@ describe("NewVariablePopover", () => { unregisterActiveEditor(view as never); }); + it("now rejects a name with a dot via the shared validator (F2 — was allowed by the old ad-hoc rule)", async () => { + const user = userEvent.setup(); + renderWithProviders(); + openPopover(); + await user.type(screen.getByTestId("new-variable-name"), "a.b"); + await user.click(screen.getByTestId("new-variable-save")); + expect(setVariable).not.toHaveBeenCalled(); + expect( + (await screen.findByTestId("new-variable-error")).textContent, + ).toMatch(/dot/i); + expect(useNewVariablePopoverStore.getState().open).toBe(true); + }); + + it("rejects a name with internal whitespace instead of saving it (F2)", async () => { + const user = userEvent.setup(); + renderWithProviders(); + openPopover(); + // Non-blank → the disabled gate lets the click through; the + // validator (not the old ad-hoc rule) is what blocks it. + await user.type(screen.getByTestId("new-variable-name"), "a b"); + await user.click(screen.getByTestId("new-variable-save")); + expect(setVariable).not.toHaveBeenCalled(); + expect( + (await screen.findByTestId("new-variable-error")).textContent, + ).toMatch(/whitespace/i); + }); + it("surfaces an error when there is no active environment", async () => { const user = userEvent.setup(); useEnvironmentStore.setState({ diff --git a/httui-desktop/src/hooks/__tests__/useInlineForm.test.ts b/httui-desktop/src/hooks/__tests__/useInlineForm.test.ts new file mode 100644 index 00000000..7eb38dc9 --- /dev/null +++ b/httui-desktop/src/hooks/__tests__/useInlineForm.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +import { useInlineForm, type InlineValidation } from "@/hooks/useInlineForm"; + +const required = (v: string): InlineValidation => + v.trim() ? { ok: true } : { ok: false, reason: "Name is required" }; + +describe("useInlineForm", () => { + it("starts with the initial value, untouched, no surfaced error", () => { + const { result } = renderHook(() => useInlineForm("", required)); + expect(result.current.value).toBe(""); + // invalid (empty) but not yet touched → nothing surfaced + expect(result.current.showError).toBe(false); + expect(result.current.error).toBe("Name is required"); + }); + + it("seeds from a non-empty initial (Rename case)", () => { + const { result } = renderHook(() => useInlineForm("prod", required)); + expect(result.current.value).toBe("prod"); + expect(result.current.showError).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it("setValue updates the value and re-validates without surfacing until submit", () => { + const { result } = renderHook(() => useInlineForm("ok", required)); + act(() => result.current.setValue(" ")); + expect(result.current.value).toBe(" "); + expect(result.current.error).toBe("Name is required"); + // still untouched → not shown + expect(result.current.showError).toBe(false); + }); + + it("attemptSubmit on an invalid value returns false and surfaces the error", () => { + const { result } = renderHook(() => useInlineForm("", required)); + let ok!: boolean; + act(() => { + ok = result.current.attemptSubmit(); + }); + expect(ok).toBe(false); + expect(result.current.showError).toBe(true); + expect(result.current.error).toBe("Name is required"); + }); + + it("attemptSubmit on a valid value returns true and never surfaces an error", () => { + const { result } = renderHook(() => useInlineForm("staging", required)); + let ok!: boolean; + act(() => { + ok = result.current.attemptSubmit(); + }); + expect(ok).toBe(true); + expect(result.current.showError).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it("clears the surfaced error once the value becomes valid after a failed submit", () => { + const { result } = renderHook(() => useInlineForm("", required)); + act(() => { + result.current.attemptSubmit(); + }); + expect(result.current.showError).toBe(true); + act(() => result.current.setValue("fixed")); + // touched stays true, but now valid → no error shown + expect(result.current.showError).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it("calls the (already-curried) validator with the live value each render", () => { + const validate = vi.fn( + (v: string): InlineValidation => + v === "dupe" ? { ok: false, reason: "exists" } : { ok: true }, + ); + const { result } = renderHook(() => useInlineForm("a", validate)); + expect(validate).toHaveBeenCalledWith("a"); + act(() => result.current.setValue("dupe")); + expect(validate).toHaveBeenLastCalledWith("dupe"); + expect(result.current.error).toBe("exists"); + }); +}); diff --git a/httui-desktop/src/hooks/useInlineForm.ts b/httui-desktop/src/hooks/useInlineForm.ts new file mode 100644 index 00000000..276adfaf --- /dev/null +++ b/httui-desktop/src/hooks/useInlineForm.ts @@ -0,0 +1,67 @@ +import { useState } from "react"; + +/** + * Result shape every name-validator in the codebase already returns + * (`validateVariableName`, `validateEnvName`, …). Structurally + * identical to those modules' own union types, declared here so this + * hook stays layer-clean (no `components/**` import). + */ +export type InlineValidation = { ok: true } | { ok: false; reason: string }; + +export interface UseInlineFormResult { + /** The single validated text field. Bind to the input. */ + value: string; + setValue: (next: string) => void; + /** `touched && invalid` — drives both the error and the + * disabled state of the submit button (the forms used + * `touched && !validation.ok`, which is exactly this). */ + showError: boolean; + /** The validator's rejection reason while invalid, else undefined. */ + error: string | undefined; + /** The submit gate: marks the field touched, then returns whether + * it is valid. Usage mirrors the hand-rolled idiom verbatim — + * `if (!form.attemptSubmit()) return; …build payload…`. */ + attemptSubmit: () => boolean; +} + +/** + * The group-2 inline-form idiom, extracted (audit 05 Part B §B.2.1). + * + * The env/variable inline forms (`NewVariableForm`, + * `NewEnvironmentForm`, `CloneEnvironmentForm`, + * `RenameEnvironmentForm`) each re-implement the same shape: one + * validated text field + a `touched` flag + a pure validator + + * `showError = touched && !ok` + a `setTouched(true); if (!ok) return` + * submit gate. Unvalidated extras (value, is_secret, the clone + * checkboxes, the type pills) are genuinely form-specific and stay as + * their own `useState` in the component — they are NOT part of the + * shared idiom, so this hook deliberately owns only the *one validated + * string*, not a generic `` record. That keeps the API to exactly + * what every consumer uses (no speculative per-field error map / no + * `reset` — none of the 5 forms reset; they unmount). + * + * `validate` is passed already-curried with whatever the form needs + * (e.g. `(n) => validateEnvName(n, existingFilenames)`), so the hook + * never knows about duplicate lists or filtering. + */ +export function useInlineForm( + initial: string, + validate: (value: string) => InlineValidation, +): UseInlineFormResult { + const [value, setValue] = useState(initial); + const [touched, setTouched] = useState(false); + + const validation = validate(value); + const showError = touched && !validation.ok; + + return { + value, + setValue, + showError, + error: validation.ok ? undefined : validation.reason, + attemptSubmit: () => { + setTouched(true); + return validation.ok; + }, + }; +} From 7cbf12b1bc0cae1e7aecf027fb3b85995366dbc9 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Tue, 19 May 2026 10:57:42 -0300 Subject: [PATCH 06/10] perf(git): stop the 2s poll churning subscribers on no-op ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git store's 2s poll did `set({status: await gitStatus()})` / `set({remotes: ...})` with a brand-new deserialized object every tick even when git was byte-identical. The store reference changed every 2s, so every subscriber (the git pane-tab and the git side panel) re-rendered on every no-op poll. The fix is at the root cause: a structural-equality guard in `refreshStatus` / `refreshRemotes` keeps the prior reference when the polled payload is unchanged. Subscribers selecting `s.status` / `s.remotes` then get a stable ref and don't re-render on a no-op poll. A real git change still swaps the ref (subscribers update); a stale `statusError` is still cleared without churning the status ref. `React.memo` on the leaf panels was considered and deliberately not added: their dominant re-render driver is non-ref-stable store data (or, for the *Page components, their own data — they don't subscribe to the git store at all). Wrapping them would be exactly the noise-memoization this change avoids. +4 store tests: ref identity across an unchanged poll, including a subscriber-not-notified assertion; ref swap on a real change for both status and remotes; stale-error clear without ref churn. --- .../src/stores/__tests__/git.test.ts | 85 +++++++++++++++++++ httui-desktop/src/stores/git.ts | 29 +++++++ 2 files changed, 114 insertions(+) diff --git a/httui-desktop/src/stores/__tests__/git.test.ts b/httui-desktop/src/stores/__tests__/git.test.ts index 748bc910..a4762333 100644 --- a/httui-desktop/src/stores/__tests__/git.test.ts +++ b/httui-desktop/src/stores/__tests__/git.test.ts @@ -267,6 +267,91 @@ describe("useGitStore", () => { }); }); + describe("poll ref-stability (H2)", () => { + // The real IPC deserializes a fresh object every tick; mimic that + // so the test exercises the structural-equality guard (not mock + // reference identity). + const freshStatus = () => JSON.parse(JSON.stringify(STATUS)) as GitStatus; + + it("keeps the same status + remotes refs across an unchanged poll", async () => { + mockTauriCommand("git_status_cmd", () => freshStatus()); + mockTauriCommand("git_remote_list_cmd", () => [{ ...REMOTE }]); + + st().acquire("/v"); + await flush(); + const statusRef = st().status; + const remotesRef = st().remotes; + expect(statusRef).toEqual(STATUS); + + const notified = vi.fn(); + const unsub = useGitStore.subscribe(notified); + vi.advanceTimersByTime(GIT_STATUS_POLL_MS); + await flush(); + unsub(); + + // Same data → same references → no subscriber would re-render. + expect(st().status).toBe(statusRef); + expect(st().remotes).toBe(remotesRef); + expect(notified).not.toHaveBeenCalled(); + }); + + it("swaps the status ref when git actually changes", async () => { + let clean = true; + mockTauriCommand("git_status_cmd", () => ({ + ...freshStatus(), + clean, + })); + mockTauriCommand("git_remote_list_cmd", () => []); + + st().acquire("/v"); + await flush(); + const statusRef = st().status; + + clean = false; // a file changed between polls + vi.advanceTimersByTime(GIT_STATUS_POLL_MS); + await flush(); + + expect(st().status).not.toBe(statusRef); + expect(st().status?.clean).toBe(false); + }); + + it("swaps the remotes ref when a remote is added", async () => { + let list: { name: string; url: string }[] = []; + mockTauriCommand("git_status_cmd", () => freshStatus()); + mockTauriCommand("git_remote_list_cmd", () => + list.map((r) => ({ ...r })), + ); + + st().acquire("/v"); + await flush(); + const remotesRef = st().remotes; + expect(remotesRef).toEqual([]); + + list = [REMOTE]; + vi.advanceTimersByTime(GIT_STATUS_POLL_MS); + await flush(); + + expect(st().remotes).not.toBe(remotesRef); + expect(st().remotes).toEqual([REMOTE]); + }); + + it("clears a stale statusError on an unchanged poll without churning the ref", async () => { + mockTauriCommand("git_status_cmd", () => freshStatus()); + mockTauriCommand("git_remote_list_cmd", () => []); + + st().acquire("/v"); + await flush(); + const statusRef = st().status; + useGitStore.setState({ statusError: "stale boom" }); + + vi.advanceTimersByTime(GIT_STATUS_POLL_MS); + await flush(); + + expect(st().statusError).toBeNull(); + expect(st().status).toBe(statusRef); + }); + }); + it("resetGitStore tears down a live poll timer", async () => { mockTauriCommand("git_status_cmd", () => STATUS); mockTauriCommand("git_remote_list_cmd", () => []); diff --git a/httui-desktop/src/stores/git.ts b/httui-desktop/src/stores/git.ts index a6e2c0d6..9d3762ae 100644 --- a/httui-desktop/src/stores/git.ts +++ b/httui-desktop/src/stores/git.ts @@ -80,6 +80,20 @@ function stopTimer(timer: ReturnType | null) { if (timer) clearInterval(timer); } +/** + * Structural equality for the polled IPC payloads. The 2s poll calls + * `gitStatus`/`gitRemoteList` which deserialize a *fresh* object every + * tick — `set({status:next})` then changes the store ref even when git + * is byte-identical, re-rendering every subscriber (GitPanelContainer + * + GitSidePanel) every 2s. Skipping the `set()` when the payload is + * unchanged keeps the old reference so subscribers don't re-render on + * a no-op poll. `JSON.stringify` is safe here: these are small plain + * structs from serde with a stable field order across polls. + */ +function sameJson(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + export const useGitStore = create()( devtools( (set, get) => ({ @@ -133,6 +147,12 @@ export const useGitStore = create()( try { const next = await gitStatus(vp); if (get().vaultPath !== vp) return; + const prev = get(); + if (prev.status !== null && sameJson(prev.status, next)) { + // Unchanged poll — keep the ref; only clear a stale error. + if (prev.statusError !== null) set({ statusError: null }); + return; + } set({ status: next, statusError: null }); } catch (e) { if (get().vaultPath !== vp) return; @@ -149,6 +169,15 @@ export const useGitStore = create()( try { const list = await gitRemoteList(vp); if (get().vaultPath !== vp) return; + const prev = get(); + if ( + prev.remotesLoaded && + prev.remotesError === null && + sameJson(prev.remotes, list) + ) { + // Unchanged poll — keep the ref so subscribers don't churn. + return; + } set({ remotes: list, remotesLoaded: true, remotesError: null }); } catch (e) { if (get().vaultPath !== vp) return; From 17d94d95f5207029952ecb6d090cf26394fdf849 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Wed, 20 May 2026 18:11:02 -0300 Subject: [PATCH 07/10] refactor(http): decompose HttpFencedPanel into siblings + a state hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpFencedPanel was a 2634-line component holding toolbar, form/raw mode, result tabs, status bar, settings drawer, codegen, history, cache hydration, and the run state machine. Brought under the 600-line size gate by extracting cohesive units. View siblings (each under 600 L, extracted verbatim): - `HttpJsonVisualizer.tsx` — JSON tree + flattener for the visualize tab. - `HttpBodyView.tsx` — read-only CM6 body viewer + image/PDF/HTML preview overlay + pretty/raw/visualize toggle. - `HttpInlineEditors.tsx` — the 3 inline CM editors used by the form-table cells. - `HttpFormTables.tsx` — body-tab dispatcher + FormUrlEncodedTable + MultipartTable + BinaryFilePicker. Shared state machine: - `useExecutableBlock` — the `idle -> running -> success | error | cancelled` machine + `AbortController` + collect-blocks/env + try/catch/finally. HTTP consumes it through an adapter (`validate`, `prepare`, `execute`, `elapsedOf`, `persist?`, `onOutcome?`, `onRunStart?`, `onProgress?`). DbFencedPanel deliberately does not consume it: its `runExplain` + `loadMore` co-mutate the same state as `run`, so migrating would force exposing raw setters and lose the encapsulation the hook exists to provide. Pure helpers: - `http-request-builder.ts` — `parseBody` / `deriveHost` / `httpElapsedOf` / `isValidHeaderName` / `HTTP_TOKEN_RE` / `buildExecutorParams`, with full module test coverage. Cohesive concerns split into 4 sibling hooks: - `useHttpRefsContext.ts` — refs context + stable getters. - `useHttpCacheHydrate.ts` — cache lookup on mount/body change; mutations skipped. - `useHttpCodegenSnippets.ts` — cURL/fetch/Python/HTTPie/.http pre-computation + `handleSendAs` + `copyAsCurl`. - `useHttpDrawerData.ts` — history + examples loaders + recordHistory + drawer actions. Result: HttpFencedPanel 2634 to 567 L. It now fits under the size gate with no escape-hatch pragma. --- .../blocks/http/fenced/HttpBodyView.tsx | 588 ++++ .../blocks/http/fenced/HttpFencedPanel.tsx | 2487 ++--------------- .../blocks/http/fenced/HttpFormTables.tsx | 507 ++++ .../blocks/http/fenced/HttpInlineEditors.tsx | 279 ++ .../blocks/http/fenced/HttpJsonVisualizer.tsx | 384 +++ .../http/fenced/__tests__/HttpBodyCM.test.tsx | 2 +- .../fenced/__tests__/HttpBodyView.test.ts | 108 + .../fenced/__tests__/HttpFormTables.test.ts | 53 + .../__tests__/HttpJsonVisualizer.test.ts | 109 + .../__tests__/http-request-builder.test.ts | 193 ++ .../http/fenced/http-request-builder.ts | 136 + .../blocks/http/fenced/useHttpCacheHydrate.ts | 78 + .../http/fenced/useHttpCodegenSnippets.ts | 149 + .../blocks/http/fenced/useHttpDrawerData.ts | 232 ++ .../blocks/http/fenced/useHttpRefsContext.ts | 68 + .../__tests__/useExecutableBlock.test.ts | 273 ++ httui-desktop/src/hooks/useExecutableBlock.ts | 282 ++ 17 files changed, 3648 insertions(+), 2280 deletions(-) create mode 100644 httui-desktop/src/components/blocks/http/fenced/HttpBodyView.tsx create mode 100644 httui-desktop/src/components/blocks/http/fenced/HttpFormTables.tsx create mode 100644 httui-desktop/src/components/blocks/http/fenced/HttpInlineEditors.tsx create mode 100644 httui-desktop/src/components/blocks/http/fenced/HttpJsonVisualizer.tsx create mode 100644 httui-desktop/src/components/blocks/http/fenced/__tests__/HttpBodyView.test.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/__tests__/HttpFormTables.test.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/__tests__/HttpJsonVisualizer.test.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/__tests__/http-request-builder.test.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/http-request-builder.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/useHttpCacheHydrate.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/useHttpCodegenSnippets.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/useHttpDrawerData.ts create mode 100644 httui-desktop/src/components/blocks/http/fenced/useHttpRefsContext.ts create mode 100644 httui-desktop/src/hooks/__tests__/useExecutableBlock.test.ts create mode 100644 httui-desktop/src/hooks/useExecutableBlock.ts diff --git a/httui-desktop/src/components/blocks/http/fenced/HttpBodyView.tsx b/httui-desktop/src/components/blocks/http/fenced/HttpBodyView.tsx new file mode 100644 index 00000000..9aeda2bd --- /dev/null +++ b/httui-desktop/src/components/blocks/http/fenced/HttpBodyView.tsx @@ -0,0 +1,588 @@ +// HTTP block response-body rendering: pretty/raw/visualize switch, +// the CM6 read-only viewer, the image/PDF/HTML preview card, and the +// fullscreen preview overlay. +// +// Extracted verbatim from HttpFencedPanel.tsx (A1 / audit 03 §1 seam +// #2). The orchestrator consumes only `HttpBodyView`; `detectPreview`, +// `selectBodyLanguage` and `detectLang` are also exported so the pure +// content-type/heuristic logic can be unit-tested (the panel had ~no +// coverage). Everything else is module-internal. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + Flex, + HStack, + IconButton, + Portal, + Text, +} from "@chakra-ui/react"; +import { + LuClipboard, + LuExpand, + LuFileText, + LuGlobe, + LuX, +} from "react-icons/lu"; +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"; +import { json } from "@codemirror/lang-json"; +import { xml } from "@codemirror/lang-xml"; +import { html } from "@codemirror/lang-html"; + +import type { HttpResponseFull } from "@/lib/tauri/streamedExecution"; + +import { formatBytes } from "./shared"; +import { + HttpJsonVisualizer, + parseJsonForVisualize, +} from "./HttpJsonVisualizer"; + +// "pretty" routes by content-type: image/pdf/html → visual preview; +// everything else → CM6 read-only viewer with syntax highlight. The +// dedicated "preview" mode was folded into pretty (it duplicated work). +type BodyViewMode = "pretty" | "raw" | "visualize"; + +// ─────────── CM6 read-only body viewer (Onda 4) ─────────── +// Replaces the old `` + lowlight +// renderer. CM6 paints incrementally even on multi-MB bodies, so the webview +// stops blocking on large responses. Pattern mirrors `StandaloneBlock.tsx` +// (`src/components/blocks/standalone/StandaloneBlock.tsx:102-163`). + +const cmReadOnlyBodyTheme = EditorView.theme({ + "&": { fontSize: "12px", maxHeight: "320px" }, + ".cm-content": { + fontFamily: "var(--chakra-fonts-mono)", + padding: "8px", + }, + ".cm-gutters": { display: "none" }, + ".cm-scroller": { overflow: "auto", overscrollBehavior: "contain" }, + ".cm-activeLine": { backgroundColor: "transparent" }, +}); +const cmBodyReadOnly = EditorState.readOnly.of(true); + +/** Pick a CM6 language extension based on the response Content-Type, with + * a JSON/XML heuristic fallback when the header is missing or generic. */ +export function selectBodyLanguage( + contentType: string | null, + text: string, +): Extension | null { + if (contentType) { + const ct = contentType.split(";")[0].trim().toLowerCase(); + if (ct.includes("json")) return json(); + if (ct.includes("xml") || ct.includes("svg")) return xml(); + if (ct.includes("html")) return html(); + } + const heuristic = detectLang(text, "pretty"); + if (heuristic === "json") return json(); + if (heuristic === "xml") return xml(); + return null; +} + +function HttpBodyCM6Viewer({ + text, + contentType, +}: { + text: string; + contentType: string | null; +}) { + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + const lang = selectBodyLanguage(contentType, text); + const extensions: Extension[] = [ + cmBodyReadOnly, + cmReadOnlyBodyTheme, + syntaxHighlighting(oneDarkHighlightStyle), + ...(lang ? [lang] : []), + ]; + const view = new EditorView({ + state: EditorState.create({ doc: text, extensions }), + parent: containerRef.current, + }); + return () => { + view.destroy(); + }; + }, [text, contentType]); + + return ( + + ); +} + +export function HttpBodyView({ + rawBody, + prettyBody, + response, +}: { + rawBody: string; + prettyBody: string; + response: HttpResponseFull; +}) { + const [view, setView] = useState("pretty"); + + const previewMeta = useMemo(() => detectPreview(response), [response]); + const visualizeData = useMemo( + () => parseJsonForVisualize(prettyBody), + [prettyBody], + ); + + const text = view === "pretty" ? prettyBody : rawBody; + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(text); + } catch { + /* noop */ + } + }; + + return ( + <> + + + + {visualizeData !== null && ( + + )} + + {(view === "pretty" || view === "raw") && ( + + + + )} + + {view === "pretty" && previewMeta.kind !== "none" && ( + + )} + {view === "visualize" && visualizeData !== null && ( + + )} + {((view === "pretty" && previewMeta.kind === "none") || view === "raw") && + (text ? ( + + ) : ( + + (empty body) + + ))} + + ); +} + +// ─────────────────────── Preview (image/PDF/HTML) ─────────────────────── + +type PreviewMeta = + | { kind: "none" } + | { kind: "image"; dataUrl: string; alt: string } + | { kind: "pdf"; dataUrl: string } + | { kind: "html"; html: string }; + +export function detectPreview(response: HttpResponseFull): PreviewMeta { + const ctRaw = + response.headers["content-type"] ?? response.headers["Content-Type"] ?? ""; + const ct = ctRaw.split(";")[0].trim().toLowerCase(); + const body = response.body; + + // Binary base64 — image or PDF + if ( + typeof body === "object" && + body !== null && + "encoding" in body && + (body as Record).encoding === "base64" + ) { + const data = String((body as Record).data ?? ""); + if (ct.startsWith("image/")) { + return { kind: "image", dataUrl: `data:${ct};base64,${data}`, alt: ct }; + } + if (ct === "application/pdf") { + return { kind: "pdf", dataUrl: `data:application/pdf;base64,${data}` }; + } + return { kind: "none" }; + } + + // HTML — rendered in a sandboxed iframe (no scripts). + if (ct === "text/html" && typeof body === "string") { + return { kind: "html", html: body }; + } + + return { kind: "none" }; +} + +/** + * Inline preview affordance per content kind: + * - **image**: rendered inline (no scroll-leak issue) with an "expand" + * IconButton overlay in the top-right that opens the fullscreen modal. + * - **pdf** / **html**: a richer placeholder card (icon + type + size + + * CTA) that opens the modal on click — the modal is required because + * iframe wheel events bypass DOM scroll containment in the Tauri + * webview and would otherwise leak into the markdown editor. + */ +function HttpBodyPreview({ + meta, + sizeBytes, +}: { + meta: PreviewMeta; + sizeBytes: number; +}) { + // Lifecycle: HTML preview uses a blob URL we must revoke on unmount. + const [blobUrl, setBlobUrl] = useState(null); + useEffect(() => { + if (meta.kind !== "html") { + setBlobUrl(null); + return; + } + const url = URL.createObjectURL( + new Blob([meta.html], { type: "text/html" }), + ); + setBlobUrl(url); + return () => URL.revokeObjectURL(url); + }, [meta]); + + const [open, setOpen] = useState(false); + + if (meta.kind === "none") { + return ( + + Preview not available for this response. + + ); + } + + const label = + meta.kind === "image" + ? "Image preview" + : meta.kind === "pdf" + ? "PDF preview" + : "HTML preview"; + + // Image renders inline — no internal scroll, so no leak. The expand + // button still gives access to the fullscreen viewer for big images. + if (meta.kind === "image") { + return ( + <> + + {meta.alt} + setOpen(true)} + position="absolute" + top={2} + right={2} + opacity={0.85} + _hover={{ opacity: 1 }} + > + + + + {open && ( + setOpen(false)} + /> + )} + + ); + } + + // PDF / HTML — richer placeholder card with icon + type + size + CTA. + const Icon = meta.kind === "pdf" ? LuFileText : LuGlobe; + const typeLine = meta.kind === "pdf" ? "PDF document" : "HTML page"; + + return ( + <> + + + + + + + + + {typeLine} + + + {formatBytes(sizeBytes)} · click to open in a focused viewer + + + + + {open && ( + setOpen(false)} + /> + )} + + ); +} + +/** + * Fullscreen preview modal — Portal + Box (deliberately not Chakra Dialog, + * which would steal focus from the markdown editor on close). Locks body + * scroll while open so wheel events that escape from data: URL iframes + * have nowhere to land. Esc + backdrop click + close button all dismiss. + */ +function PreviewOverlay({ + meta, + blobUrl, + label, + onClose, +}: { + meta: PreviewMeta; + blobUrl: string | null; + label: string; + onClose: () => void; +}) { + // Lock body scroll while open. The Tauri webview's compositor scroll + // routing means wheel leaks out of the iframe — locking the body + // prevents the host doc from scrolling beneath the modal. + useEffect(() => { + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prevOverflow; + }; + }, []); + + // Esc closes the modal. Window-level so it works regardless of focus + // (the iframe steals focus on its own). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( + + + e.stopPropagation()} + > + + + {label} + + + + + + + {meta.kind === "image" && ( + + {meta.alt} + + )} + {meta.kind === "pdf" && ( +