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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions crates/voleeo-storage/src/body_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const CACHE_CAP: usize = 4;
pub struct BodyWindow {
pub lines: Vec<String>,
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<u32>,
}

#[derive(Type, Serialize, Deserialize, Debug, Clone)]
Expand Down Expand Up @@ -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<u32>,
}

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<u32> {
let mut ends = vec![0u32; lines.len()];
let mut open: Vec<usize> = 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.
Expand Down Expand Up @@ -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(),
})
}

Expand Down Expand Up @@ -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");
}
}
6 changes: 6 additions & 0 deletions packages/types/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
83 changes: 83 additions & 0 deletions src-web/src/lib/lineFolds.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
46 changes: 46 additions & 0 deletions src-web/src/lib/lineFolds.ts
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 29 additions & 59 deletions src-web/src/views/ApiWorkspace/ResponsePane/VirtualBody.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,30 +20,23 @@ 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
<span key={i} style={t.color ? { color: t.color } : undefined}>
{t.text}
</span>
))
}

/** 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,
stepMatch,
filter,
applyFilter,
} = useWindowedBody(response)
const { collapsed, visibleCount, toReal, toVisual, toggle, reveal } =
useLineFolds(total, getFoldEnd, activeKey)
const parentRef = useRef<HTMLDivElement>(null)
const [findOpen, setFindOpen] = useState(false)
const [query, setQuery] = useState("")
Expand All @@ -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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -201,46 +198,19 @@ export function VirtualBody({ response }: { response: HttpResponse }) {
lineHeight: `${lineH}px`,
}}
>
<div style={{ height: virt.getTotalSize(), position: "relative" }}>
<div
className="flex"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${items[0]?.start ?? 0}px)`,
}}
>
{/* Line-number gutter — unselectable so copies exclude it. */}
<div
className="select-none text-right text-muted pr-3 shrink-0"
style={{ width: gutter }}
>
{items.map((vi) => (
<div key={vi.key} style={{ height: lineH }}>
{vi.index + 1}
</div>
))}
</div>
{/* The lines render as ONE contiguous block (separated by newlines)
so native selection spans them with no inter-line gaps. */}
<div className="selectable-text whitespace-pre flex-1 min-w-0">
{items.map((vi, idx) => (
<Fragment key={vi.key}>
<span
className={cn(
activeMatch?.line === vi.index && "bg-accent/15",
)}
>
{renderLine(getLine(vi.index) ?? "", json)}
</span>
{idx < items.length - 1 ? "\n" : ""}
</Fragment>
))}
</div>
</div>
</div>
<VirtualBodyLines
items={items}
totalSize={virt.getTotalSize()}
lineH={lineH}
gutterWidth={gutter}
json={json}
getLine={getLine}
getFoldEnd={getFoldEnd}
toReal={toReal}
collapsed={collapsed}
onToggle={toggle}
activeLine={activeMatch?.line ?? null}
/>
</div>
</div>
)
Expand Down
Loading