Collapse very long lines in response viewers

A response body on one enormous line, typically base64 image data in JSON,
stalls the UI. Cost tracks the longest line rather than the document size,
because soft wrap has to measure a line end to end to find its break points.

Read-only editors now collapse over-long lines to a placeholder that opens the
full value in a paged dialog. Tokens over 5k chars collapse individually where
there is a grammar, so a minified body keeps its keys visible; anything still
past column 10k is collapsed too, which needs no grammar. The document is
untouched, so copy, filter and save still see the full text.

Also replaces the whole-document md5 behind the editor state cache with a
sampled fingerprint. It ran on every editor update and again on restore.

Measured in WKWebView, per render pass:

  1MB single line   221ms -> 51ms
  3MB single line   968ms -> 138ms
  3MB switch away and back   265ms -> 37ms
This commit is contained in:
Gregory Schier
2026-08-12 14:50:04 -07:00
parent f6d926f4b9
commit 4a3af02eb6
8 changed files with 586 additions and 4 deletions
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vite-plus/test";
import { docFingerprint } from "./docFingerprint";
describe("docFingerprint", () => {
test("is stable for the same text", () => {
expect(docFingerprint("hello")).toBe(docFingerprint("hello"));
});
test("differs on different short text", () => {
expect(docFingerprint("hello")).not.toBe(docFingerprint("world"));
});
test("differs on length alone", () => {
expect(docFingerprint("a".repeat(1_000_000))).not.toBe(docFingerprint("a".repeat(1_000_001)));
});
test("notices a change at the start of a large document", () => {
const doc = "a".repeat(1_000_000);
expect(docFingerprint(doc)).not.toBe(docFingerprint(`b${doc.slice(1)}`));
});
test("notices a change at the end of a large document", () => {
const doc = "a".repeat(1_000_000);
expect(docFingerprint(doc)).not.toBe(docFingerprint(`${doc.slice(0, -1)}b`));
});
test("notices a change in the middle of a large document", () => {
const doc = "a".repeat(1_000_000);
const middle = doc.length / 2;
const changed = `${doc.slice(0, middle)}b${doc.slice(middle + 1)}`;
expect(doc.length).toBe(changed.length);
expect(docFingerprint(doc)).not.toBe(docFingerprint(changed));
});
test("hashes small documents in full, so any change is caught", () => {
const doc = "a".repeat(100);
for (let i = 0; i < doc.length; i++) {
const changed = `${doc.slice(0, i)}b${doc.slice(i + 1)}`;
expect(docFingerprint(doc)).not.toBe(docFingerprint(changed));
}
});
});
+31
View File
@@ -0,0 +1,31 @@
import { md5 } from "js-md5";
/** How much of each end and the middle to hash */
const SAMPLE_CHARS = 512;
/**
* A cheap stand-in for hashing a whole document.
*
* The editor caches undo history, folds and selection in sessionStorage, keyed by a hash of
* the document so a stale entry is never restored onto different content. Hashing the whole
* document costs about 4 ms per megabyte, and it is paid on every editor update as well as on
* restore, which is a lot of work to protect a fold position.
*
* Sampling the ends and the middle alongside the exact length is enough: two different
* documents would have to agree on length and all three samples to collide, and the cost of a
* collision is a fold or cursor landing where it doesn't belong. Documents small enough to
* hash outright still are.
*/
export function docFingerprint(text: string): string {
if (text.length <= SAMPLE_CHARS * 3) {
return `${text.length}:${md5(text)}`;
}
const middle = Math.floor((text.length - SAMPLE_CHARS) / 2);
return [
text.length,
md5(text.slice(0, SAMPLE_CHARS)),
md5(text.slice(middle, middle + SAMPLE_CHARS)),
md5(text.slice(-SAMPLE_CHARS)),
].join(":");
}