From 13cf0e851fd214bbf021cd0fe4782e667004fae2 Mon Sep 17 00:00:00 2001 From: Lucas Aguiar Date: Fri, 7 Aug 2026 11:06:58 -0300 Subject: [PATCH 1/2] feat: return each block's closing line with a body window The windowed viewer only ever holds a slice of the body, so it cannot tell where a block ends. fold_ends walks the pretty-printed text once with a bracket stack, and each window carries the ends of its own lines. --- crates/voleeo-storage/src/body_window.rs | 58 ++++++++++++++++++++++-- packages/types/bindings.ts | 6 +++ 2 files changed, 61 insertions(+), 3 deletions(-) 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; From ddd80eb90397ec489cd3174cfca52e0740b2eff5 Mon Sep 17 00:00:00 2001 From: Lucas Aguiar Date: Fri, 7 Aug 2026 11:07:08 -0300 Subject: [PATCH 2/2] feat: fold blocks in the large-response body viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responses over 256 KB render in the virtualized viewer, which had no fold controls — the bigger the payload, the less there was to navigate with. Collapsed blocks become runs of hidden lines, so rows on screen no longer map one to one onto the body and have to be translated back before anything is fetched or searched. That math lives in lib/lineFolds.ts with its own tests; useLineFolds owns the state, and the scrolled content moves out of VirtualBody to keep both files under the size limit. Closes #51 --- src-web/src/lib/lineFolds.test.ts | 83 ++++++++++++ src-web/src/lib/lineFolds.ts | 46 +++++++ .../ApiWorkspace/ResponsePane/VirtualBody.tsx | 88 ++++-------- .../ResponsePane/VirtualBodyLines.tsx | 126 ++++++++++++++++++ .../ApiWorkspace/ResponsePane/useLineFolds.ts | 77 +++++++++++ .../ResponsePane/useWindowedBody.ts | 9 ++ 6 files changed, 370 insertions(+), 59 deletions(-) create mode 100644 src-web/src/lib/lineFolds.test.ts create mode 100644 src-web/src/lib/lineFolds.ts create mode 100644 src-web/src/views/ApiWorkspace/ResponsePane/VirtualBodyLines.tsx create mode 100644 src-web/src/views/ApiWorkspace/ResponsePane/useLineFolds.ts 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,