diff --git a/crates/voleeo-storage/src/body_window.rs b/crates/voleeo-storage/src/body_window.rs index 3599691..d91b972 100644 --- a/crates/voleeo-storage/src/body_window.rs +++ b/crates/voleeo-storage/src/body_window.rs @@ -19,6 +19,10 @@ const CACHE_CAP: usize = 4; pub struct BodyWindow { pub lines: Vec, pub total_lines: u32, + /// Parallel to `lines`: where the block opened on that line closes, or `0` + /// when it opens none. The windowed viewer only holds a slice of the body, + /// so it can't find a block's end itself. + pub fold_ends: Vec, } #[derive(Type, Serialize, Deserialize, Debug, Clone)] @@ -84,17 +88,42 @@ pub struct SearchOpts { const MATCH_CAP: usize = 5000; -/// Pretty-print JSON so a minified one-line payload becomes scrollable; any -/// other text is stored verbatim. Returns the text and its line ranges. +/// A body indexed for windowing: the stored text plus everything derived from +/// one pass over it, so scroll and search never re-scan. struct Parsed { text: String, /// `(start, end)` byte range per line, excluding the trailing `\n`. lines: Vec<(usize, usize)>, + /// See `BodyWindow::fold_ends`. `0` is a safe sentinel — a block always + /// closes after it opens, so line 0 is never an end. + folds: Vec, } fn parse(text: String) -> Parsed { let lines = line_ranges(&text); - Parsed { text, lines } + let folds = fold_ends(&text, &lines); + Parsed { text, lines, folds } +} + +/// Match each block-opening line to the line that closes it, by bracket stack. +/// Bodies reach here pretty-printed (`format_for_storage`), where a trailing +/// `{`/`[` only ever opens a block: a string value ends in `"` and an inline +/// `{}` ends in `}` or `,`. Unbalanced text (non-JSON) simply yields no folds. +fn fold_ends(text: &str, lines: &[(usize, usize)]) -> Vec { + let mut ends = vec![0u32; lines.len()]; + let mut open: Vec = Vec::new(); + for (i, &(s, e)) in lines.iter().enumerate() { + let line = text[s..e].trim(); + if line.starts_with('}') || line.starts_with(']') { + if let Some(start) = open.pop() { + ends[start] = u32::try_from(i).unwrap_or(0); + } + } + if line.ends_with('{') || line.ends_with('[') { + open.push(i); + } + } + ends } /// Matches `str::lines()`: a trailing `\n` does not yield an empty final line. @@ -186,6 +215,7 @@ pub fn window( Ok(BodyWindow { lines, total_lines: u32::try_from(total).unwrap_or(u32::MAX), + fold_ends: parsed.folds[start..end].to_vec(), }) } @@ -258,3 +288,25 @@ fn is_word_bounded(bytes: &[u8], start: usize, len: usize) -> bool { let after = bytes.get(start + len).copied(); before.map(is_word) != Some(true) && after.map(is_word) != Some(true) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fold_ends_pairs_each_block_with_its_closing_line() { + let text = format_for_storage(r#"{"a":[{"b":1}],"empty":{},"s":"has { brace","z":2}"#); + let ends = fold_ends(&text, &line_ranges(&text)); + let lines: Vec<&str> = text.lines().collect(); + + // 0 `{` · 1 `"a": [` · 2 `{` · 3 `"b": 1` · 4 `},` · 5 `],` + // 6 `"empty": {},` · 7 `"s": "has { brace",` · 8 `"z": 2` · 9 `}` + assert_eq!(lines.len(), 10, "layout changed: {text}"); + assert_eq!(ends[0], 9, "root object"); + assert_eq!(ends[1], 5, "array"); + assert_eq!(ends[2], 4, "object inside the array"); + assert_eq!(ends[3], 0, "plain value opens nothing"); + assert_eq!(ends[6], 0, "inline {{}} is not an opener"); + assert_eq!(ends[7], 0, "a brace inside a string is not an opener"); + } +} diff --git a/packages/types/bindings.ts b/packages/types/bindings.ts index 7f6624e..341e527 100644 --- a/packages/types/bindings.ts +++ b/packages/types/bindings.ts @@ -754,6 +754,12 @@ export type BodySearchResult = { export type BodyWindow = { lines: string[], totalLines: number, + /** + * Parallel to `lines`: where the block opened on that line closes, or `0` + * when it opens none. The windowed viewer only holds a slice of the body, + * so it can't find a block's end itself. + */ + foldEnds: number[], }; export type BundleWorkspacePreview = BundleWorkspacePreview_Serialize | BundleWorkspacePreview_Deserialize; diff --git a/src-web/src/lib/lineFolds.test.ts b/src-web/src/lib/lineFolds.test.ts new file mode 100644 index 0000000..cd3b9be --- /dev/null +++ b/src-web/src/lib/lineFolds.test.ts @@ -0,0 +1,83 @@ +// @ts-expect-error — bun:test lacks TS types in this workspace +import { describe, expect, test } from "bun:test" +import { + type HiddenRange, + hiddenCount, + mergeRanges, + toRealLine, + toVisualLine, +} from "./lineFolds" + +describe("mergeRanges", () => { + test("sorts and leaves separated runs alone", () => { + expect( + mergeRanges([ + [8, 9], + [3, 5], + ]), + ).toEqual([ + [3, 5], + [8, 9], + ]) + }) + + test("nested folds collapse into the outer one", () => { + expect( + mergeRanges([ + [1, 100], + [10, 20], + ]), + ).toEqual([[1, 100]]) + }) + + test("touching runs merge", () => { + expect( + mergeRanges([ + [3, 5], + [6, 8], + ]), + ).toEqual([[3, 8]]) + }) +}) + +describe("toRealLine / toVisualLine", () => { + // One block open on line 2 hides lines 3-5; another on line 7 hides 8-9. + const ranges: HiddenRange[] = [ + [3, 5], + [8, 9], + ] + + test("rows before the first fold are unshifted", () => { + expect(toRealLine(ranges, 2)).toBe(2) + expect(toVisualLine(ranges, 2)).toBe(2) + }) + + test("rows after a fold skip its hidden lines", () => { + expect(toRealLine(ranges, 3)).toBe(6) + expect(toRealLine(ranges, 4)).toBe(7) + expect(toRealLine(ranges, 5)).toBe(10) + expect(toVisualLine(ranges, 6)).toBe(3) + expect(toVisualLine(ranges, 10)).toBe(5) + }) + + test("round-trips for every visible row", () => { + for (let v = 0; v < 8; v++) { + expect(toVisualLine(ranges, toRealLine(ranges, v))).toBe(v) + } + }) + + test("a hidden line maps to the collapsed row standing in for it", () => { + expect(toVisualLine(ranges, 4)).toBe(2) + expect(toVisualLine(ranges, 9)).toBe(4) + }) + + test("no folds is the identity", () => { + expect(toRealLine([], 42)).toBe(42) + expect(toVisualLine([], 42)).toBe(42) + expect(hiddenCount([])).toBe(0) + }) + + test("hiddenCount sums the runs", () => { + expect(hiddenCount(ranges)).toBe(5) + }) +}) diff --git a/src-web/src/lib/lineFolds.ts b/src-web/src/lib/lineFolds.ts new file mode 100644 index 0000000..c7510b9 --- /dev/null +++ b/src-web/src/lib/lineFolds.ts @@ -0,0 +1,46 @@ +/** Line folding as runs of hidden lines: a viewer holding only a slice of its + * content must translate rows on screen back to real line indices before it + * fetches or searches anything. */ + +/** Inclusive `[first, last]` run of hidden line indices. */ +export type HiddenRange = [number, number] + +/** Union of overlapping/adjacent runs — this is what makes nesting free: + * collapsing an outer block swallows any fold already closed inside it. */ +export function mergeRanges(ranges: HiddenRange[]): HiddenRange[] { + const out: HiddenRange[] = [] + for (const [s, e] of [...ranges].sort((a, b) => a[0] - b[0])) { + const last = out[out.length - 1] + if (last && s <= last[1] + 1) last[1] = Math.max(last[1], e) + else out.push([s, e]) + } + return out +} + +export function hiddenCount(ranges: HiddenRange[]): number { + return ranges.reduce((n, [s, e]) => n + e - s + 1, 0) +} + +// ponytail: both maps walk the whole range list; fine for the handful of blocks +// a person folds by hand, swap for a binary search if "collapse all" ever ships. + +/** Row on screen → line in the body. `ranges` must be merged. */ +export function toRealLine(ranges: HiddenRange[], visual: number): number { + let real = visual + for (const [s, e] of ranges) { + if (s > real) break + real += e - s + 1 + } + return real +} + +/** Line in the body → row on screen. A hidden line maps to the collapsed row + * standing in for it. `ranges` must be merged. */ +export function toVisualLine(ranges: HiddenRange[], real: number): number { + let visual = real + for (const [s, e] of ranges) { + if (s > real) break + visual -= Math.min(e, real) - s + 1 + } + return visual +} diff --git a/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBody.tsx b/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBody.tsx index 7929f94..29406f2 100644 --- a/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBody.tsx +++ b/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBody.tsx @@ -1,12 +1,13 @@ import { useVirtualizer } from "@tanstack/react-virtual" -import { Fragment, useEffect, useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" import { Glyph } from "@/components/Glyph" import { cn } from "@/lib/utils" import { useInterfaceStore } from "@/store/interface" import type { HttpResponse } from "../../../../../packages/types/bindings" import { FindBar } from "./FindBar" -import { jsonLineTokens } from "./jsonLineTokens" +import { useLineFolds } from "./useLineFolds" import { useWindowedBody } from "./useWindowedBody" +import { VirtualBodyLines } from "./VirtualBodyLines" // CodeMirror's effective line-height ≈ 1.5× its font size; match it so the // virtual rows and the editor look consistent at any font setting. @@ -19,23 +20,14 @@ function isJsonResponse(response: HttpResponse): boolean { return !!ct && /json/i.test(ct) } -/** Inline content of a line: JSON gets token coloring, anything else is plain. */ -function renderLine(text: string, json: boolean) { - if (!json) return text - return jsonLineTokens(text).map((t, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional within a stable line - - {t.text} - - )) -} - /** Virtualized viewer for large (windowed) response bodies: renders only the * visible lines, fetches them on demand, and searches backend-side. */ export function VirtualBody({ response }: { response: HttpResponse }) { const { + activeKey, total, getLine, + getFoldEnd, ensureRange, search, runSearch, @@ -43,6 +35,8 @@ export function VirtualBody({ response }: { response: HttpResponse }) { filter, applyFilter, } = useWindowedBody(response) + const { collapsed, visibleCount, toReal, toVisual, toggle, reveal } = + useLineFolds(total, getFoldEnd, activeKey) const parentRef = useRef(null) const [findOpen, setFindOpen] = useState(false) const [query, setQuery] = useState("") @@ -55,16 +49,17 @@ export function VirtualBody({ response }: { response: HttpResponse }) { const lineH = Math.round(fontSize * LINE_RATIO) const virt = useVirtualizer({ - count: total, + count: visibleCount, getScrollElement: () => parentRef.current, estimateSize: () => lineH, overscan: 30, }) const items = virt.getVirtualItems() - // Keep the visible range's blocks loaded. - const first = items[0]?.index ?? 0 - const last = items[items.length - 1]?.index ?? 0 + // Keep the visible range's blocks loaded — in real line coords, since a + // collapsed block makes the visual rows skip ahead. + const first = toReal(items[0]?.index ?? 0) + const last = toReal(items[items.length - 1]?.index ?? 0) useEffect(() => { if (total > 0) ensureRange(first, last) }, [first, last, total, ensureRange]) @@ -92,8 +87,10 @@ export function VirtualBody({ response }: { response: HttpResponse }) { const activeMatch = search.active >= 0 ? search.matches[search.active] : null useEffect(() => { - if (activeMatch) virt.scrollToIndex(activeMatch.line, { align: "center" }) - }, [activeMatch, virt]) + if (!activeMatch) return + reveal(activeMatch.line) + virt.scrollToIndex(toVisual(activeMatch.line), { align: "center" }) + }, [activeMatch, virt, reveal, toVisual]) // Re-measure rows when the font setting (and thus row height) changes. // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure on lineH change @@ -201,46 +198,19 @@ export function VirtualBody({ response }: { response: HttpResponse }) { lineHeight: `${lineH}px`, }} > -
-
- {/* Line-number gutter — unselectable so copies exclude it. */} -
- {items.map((vi) => ( -
- {vi.index + 1} -
- ))} -
- {/* The lines render as ONE contiguous block (separated by newlines) - so native selection spans them with no inter-line gaps. */} -
- {items.map((vi, idx) => ( - - - {renderLine(getLine(vi.index) ?? "", json)} - - {idx < items.length - 1 ? "\n" : ""} - - ))} -
-
-
+ ) diff --git a/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBodyLines.tsx b/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBodyLines.tsx new file mode 100644 index 0000000..ca146f7 --- /dev/null +++ b/src-web/src/views/ApiWorkspace/ResponsePane/VirtualBodyLines.tsx @@ -0,0 +1,126 @@ +import type { VirtualItem } from "@tanstack/react-virtual" +import { Fragment } from "react" +import { Glyph } from "@/components/Glyph" +import { cn } from "@/lib/utils" +import { jsonLineTokens } from "./jsonLineTokens" + +/** Inline content of a line: JSON gets token coloring, anything else is plain. */ +function renderLine(text: string, json: boolean) { + if (!json) return text + return jsonLineTokens(text).map((t, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional within a stable line + + {t.text} + + )) +} + +/** The text that closes a folded block, so the row reads `{ … },` the way + * CodeMirror renders it. ponytail: mirrors the bracket when the closing line + * sits outside the loaded window — exact for a root block, drops the trailing + * comma on a nested one whose end is far off screen. */ +function closerFor(open: string, end: string | undefined): string { + if (end !== undefined) return end.trim() + return open.trimEnd().endsWith("[") ? "]" : "}" +} + +/** The scrolled content: line numbers, fold chevrons, and the visible lines. */ +export function VirtualBodyLines({ + items, + totalSize, + lineH, + gutterWidth, + json, + getLine, + getFoldEnd, + toReal, + collapsed, + onToggle, + activeLine, +}: { + items: VirtualItem[] + totalSize: number + lineH: number + gutterWidth: string + json: boolean + getLine: (line: number) => string | undefined + getFoldEnd: (line: number) => number | undefined + toReal: (visual: number) => number + collapsed: ReadonlySet + onToggle: (line: number) => void + activeLine: number | null +}) { + const rows = items.map((vi) => ({ vi, line: toReal(vi.index) })) + + return ( +
+
+ {/* Line-number gutter — unselectable so copies exclude it. */} +
+ {rows.map(({ vi, line }) => ( +
+ {line + 1} +
+ ))} +
+ {/* Fold column, mirroring CodeMirror's chevron gutter. */} +
+ {rows.map(({ vi, line }) => + getFoldEnd(line) ? ( + + ) : ( +
+ ), + )} +
+ {/* The lines render as ONE contiguous block (separated by newlines) + so native selection spans them with no inter-line gaps. */} +
+ {rows.map(({ vi, line }, idx) => { + const text = getLine(line) ?? "" + const end = collapsed.has(line) ? getFoldEnd(line) : undefined + return ( + + + {renderLine(text, json)} + + {end !== undefined && ( + + + {renderLine(closerFor(text, getLine(end)), json)} + + )} + {idx < rows.length - 1 ? "\n" : ""} + + ) + })} +
+
+
+ ) +} diff --git a/src-web/src/views/ApiWorkspace/ResponsePane/useLineFolds.ts b/src-web/src/views/ApiWorkspace/ResponsePane/useLineFolds.ts new file mode 100644 index 0000000..59559a1 --- /dev/null +++ b/src-web/src/views/ApiWorkspace/ResponsePane/useLineFolds.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { + type HiddenRange, + hiddenCount, + mergeRanges, + toRealLine, + toVisualLine, +} from "@/lib/lineFolds" + +/** Folding for the windowed viewer: tracks which block-opening lines are + * collapsed and maps between visual rows (what the virtualizer counts) and + * real line indices (what the backend windows and searches). */ +export function useLineFolds( + total: number, + getFoldEnd: (line: number) => number | undefined, + resetKey: string, +) { + const [collapsed, setCollapsed] = useState>(new Set()) + + // Keep the empty set's identity when it's already empty — a fresh Set would + // rebuild `ranges` and both maps, re-firing every effect that depends on them. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fires on resetKey change + useEffect( + () => setCollapsed((c) => (c.size ? new Set() : c)), + [resetKey], + ) + + const ranges = useMemo(() => { + const rs: HiddenRange[] = [] + for (const start of collapsed) { + const end = getFoldEnd(start) + if (end && end > start) rs.push([start + 1, end]) + } + return mergeRanges(rs) + }, [collapsed, getFoldEnd]) + + const toReal = useCallback( + (visual: number) => toRealLine(ranges, visual), + [ranges], + ) + const toVisual = useCallback( + (real: number) => toVisualLine(ranges, real), + [ranges], + ) + + const toggle = useCallback((line: number) => { + setCollapsed((c) => { + const next = new Set(c) + if (!next.delete(line)) next.add(line) + return next + }) + }, []) + + /** Open every fold hiding `line`, so a search hit can be scrolled to. */ + const reveal = useCallback( + (line: number) => { + setCollapsed((c) => { + const next = new Set(c) + for (const start of c) { + const end = getFoldEnd(start) + if (end && line > start && line <= end) next.delete(start) + } + return next.size === c.size ? c : next + }) + }, + [getFoldEnd], + ) + + return { + collapsed, + visibleCount: total - hiddenCount(ranges), + toReal, + toVisual, + toggle, + reveal, + } +} diff --git a/src-web/src/views/ApiWorkspace/ResponsePane/useWindowedBody.ts b/src-web/src/views/ApiWorkspace/ResponsePane/useWindowedBody.ts index c9db983..c78d973 100644 --- a/src-web/src/views/ApiWorkspace/ResponsePane/useWindowedBody.ts +++ b/src-web/src/views/ApiWorkspace/ResponsePane/useWindowedBody.ts @@ -45,6 +45,8 @@ export function useWindowedBody(response: HttpResponse) { const total = filtered ? filter.total : baseTotal const lines = useRef>(new Map()) + /** Line index → line its block closes on. Only block-opening lines are kept. */ + const foldEnds = useRef>(new Map()) const loaded = useRef>(new Set()) const pending = useRef>(new Set()) const [, force] = useState(0) @@ -54,6 +56,7 @@ export function useWindowedBody(response: HttpResponse) { // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fires on activeKey change useEffect(() => { lines.current.clear() + foldEnds.current.clear() loaded.current.clear() pending.current.clear() }, [activeKey]) @@ -80,6 +83,8 @@ export function useWindowedBody(response: HttpResponse) { if (res.status !== "ok") return res.data.lines.forEach((ln, i) => { lines.current.set(from + i, ln) + const end = res.data.foldEnds[i] + if (end) foldEnds.current.set(from + i, end) }) loaded.current.add(b) rerender() @@ -91,6 +96,7 @@ export function useWindowedBody(response: HttpResponse) { ) const getLine = useCallback((i: number) => lines.current.get(i), []) + const getFoldEnd = useCallback((i: number) => foldEnds.current.get(i), []) const [search, setSearch] = useState(EMPTY_SEARCH) const runSearch = useCallback( @@ -150,8 +156,11 @@ export function useWindowedBody(response: HttpResponse) { ) return { + /** Identifies the body being viewed — changes when the response or filter does. */ + activeKey, total, getLine, + getFoldEnd, ensureRange, search, runSearch,