diff --git a/apps/yaak-client/components/LargeValueDialog.tsx b/apps/yaak-client/components/LargeValueDialog.tsx
new file mode 100644
index 00000000..1b3ecb2e
--- /dev/null
+++ b/apps/yaak-client/components/LargeValueDialog.tsx
@@ -0,0 +1,71 @@
+import { HStack } from "@yaakapp-internal/ui";
+import { useState } from "react";
+import { showDialog } from "../lib/dialog";
+import { CopyButton } from "./CopyButton";
+import { IconButton } from "./core/IconButton";
+
+/**
+ * How much of the value to render at once. Rendering the whole thing would hit exactly the
+ * layout cost this dialog exists to avoid, so it pages instead.
+ */
+const PAGE_CHARS = 20_000;
+
+interface Props {
+ value: string;
+}
+
+export function LargeValueDialog({ value }: Props) {
+ const [page, setPage] = useState(0);
+ const pageCount = Math.max(1, Math.ceil(value.length / PAGE_CHARS));
+ const start = page * PAGE_CHARS;
+ const slice = value.slice(start, start + PAGE_CHARS);
+
+ return (
+
+
+
+ {value.length.toLocaleString()} characters
+
+ {pageCount > 1 && (
+
+ setPage((p) => Math.max(0, p - 1))}
+ />
+
+ {page + 1} / {pageCount}
+
+ = pageCount - 1}
+ onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
+ />
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+LargeValueDialog.show = (value: string) => {
+ showDialog({
+ id: "large-value",
+ title: "Large Value",
+ size: "lg",
+ className: "h-[calc(100vh-10rem)] max-h-200!",
+ render: () => ,
+ });
+};
diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/apps/yaak-client/components/core/Editor/Editor.css
index 4a37a4cd..ecf6d37a 100644
--- a/apps/yaak-client/components/core/Editor/Editor.css
+++ b/apps/yaak-client/components/core/Editor/Editor.css
@@ -253,6 +253,12 @@
@apply cursor-default!;
}
+.cm-editor .cm-largeValue {
+ @apply px-2 mx-0.5 rounded border border-border-subtle bg-surface-highlight;
+ @apply text-text-subtle text-sm cursor-pointer align-middle;
+ @apply hover:text-text hover:border-border;
+}
+
.cm-editor .cm-activeLineGutter {
@apply bg-transparent text-text-subtle;
}
diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx
index 2dc1c203..acaf6a43 100644
--- a/apps/yaak-client/components/core/Editor/Editor.tsx
+++ b/apps/yaak-client/components/core/Editor/Editor.tsx
@@ -15,7 +15,6 @@ import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
-import { md5 } from "js-md5";
import type { ReactNode, RefObject } from "react";
import {
Children,
@@ -33,6 +32,7 @@ import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
+import { docFingerprint } from "../../../lib/docFingerprint";
import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai";
@@ -656,9 +656,9 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
- // Save state in sessionStorage by removing doc and saving the hash of it instead.
+ // Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
// This will be checked on restore and put back in if it matches.
- stateObj.docHash = md5(stateObj.doc);
+ stateObj.docHash = docFingerprint(stateObj.doc);
stateObj.doc = undefined;
try {
@@ -678,7 +678,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
- if (docHash !== md5(doc)) {
+ if (docHash !== docFingerprint(doc)) {
return null;
}
diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts
index ec9d46ce..95904089 100644
--- a/apps/yaak-client/components/core/Editor/extensions.ts
+++ b/apps/yaak-client/components/core/Editor/extensions.ts
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint";
+import { largeValues } from "./largeValues";
import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
@@ -258,6 +259,8 @@ export const baseExtensions = [
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: "-1" }),
+ // Read-only only, so we never hide part of a document someone is editing
+ largeValues,
];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts
new file mode 100644
index 00000000..1866616e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts
@@ -0,0 +1,174 @@
+import { EditorState } from "@codemirror/state";
+import { jsonc } from "@shopify/lang-jsonc";
+import { describe, expect, test, vi } from "vite-plus/test";
+import {
+ COLLAPSE_KEEP_CHARS,
+ COLLAPSE_TOKEN_CHARS,
+ largeValueField,
+ largeValues,
+ MAX_VISIBLE_LINE_CHARS,
+} from "./largeValues";
+
+vi.mock("../../LargeValueDialog", () => ({ LargeValueDialog: { show: () => {} } }));
+
+const BIG = "A".repeat(1_000_000);
+
+/** With a grammar, so tokens can be collapsed individually */
+const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
+
+/** Without a grammar, so only the column rule applies */
+const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
+
+function collapsedRanges(state: EditorState) {
+ const ranges: { from: number; to: number }[] = [];
+ const iter = state.field(largeValueField).decorations.iter();
+ while (iter.value != null) {
+ ranges.push({ from: iter.from, to: iter.to });
+ iter.next();
+ }
+ return ranges;
+}
+
+/** How much of each line is still rendered */
+function visibleLineLengths(state: EditorState) {
+ const hidden = collapsedRanges(state);
+ const lengths: number[] = [];
+ for (let n = 1; n <= state.doc.lines; n++) {
+ const line = state.doc.line(n);
+ const covered = hidden
+ .filter((h) => h.from >= line.from && h.to <= line.to)
+ .reduce((sum, h) => sum + (h.to - h.from), 0);
+ lengths.push(line.length - covered);
+ }
+ return lengths;
+}
+
+describe("collapsing", () => {
+ test("leaves an ordinary body alone", () => {
+ expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
+ });
+
+ test("leaves a large body of short lines alone", () => {
+ const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+ expect(collapsedRanges(jsonState(doc))).toEqual([]);
+ });
+
+ test("leaves a line just under the column limit alone", () => {
+ expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
+ });
+
+ test("never renders more than the column limit per line", () => {
+ for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
+ for (const length of visibleLineLengths(state)) {
+ expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ }
+ }
+ });
+
+ test("keeps the document text intact", () => {
+ const doc = `{"image":"${BIG}"}`;
+ expect(jsonState(doc).sliceDoc()).toBe(doc);
+ expect(jsonState(doc).doc.length).toBe(doc.length);
+ });
+});
+
+describe("token collapsing, with a grammar", () => {
+ test("collapses the string itself, keeping the structure around it", () => {
+ const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
+ const ranges = collapsedRanges(jsonState(doc));
+
+ expect(ranges).toHaveLength(1);
+ // The collapse covers the middle of the base64 string and nothing else
+ const stringStart = doc.indexOf(`"${BIG}"`);
+ expect(ranges[0]!.from).toBe(stringStart + COLLAPSE_KEEP_CHARS);
+ expect(ranges[0]!.to).toBe(stringStart + BIG.length + 2 - COLLAPSE_KEEP_CHARS);
+
+ // Everything after the value is still rendered, unlike a plain column cut
+ expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
+ });
+
+ test("keeps every key visible in a minified body with several large values", () => {
+ const chunk = "B".repeat(200_000);
+ const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
+ const state = jsonState(doc);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges).toHaveLength(5);
+ for (const key of ["a", "b", "c", "d", "e"]) {
+ // No collapse swallows the key
+ const at = doc.indexOf(`"${key}":`);
+ expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
+ }
+ expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ });
+
+ test("collapses a value on a pretty-printed line", () => {
+ const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
+ const state = jsonState(doc);
+
+ expect(collapsedRanges(state)).toHaveLength(1);
+ // Only the long line is touched
+ expect(visibleLineLengths(state)).toEqual([1, 18, expect.any(Number), 1]);
+ expect(state.doc.line(2).text).toBe(' "name": "a.png",');
+ });
+
+ test("ignores tokens under the collapse threshold", () => {
+ // Under the threshold once the surrounding quotes are counted
+ const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
+ const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+
+ // Nothing is big enough to collapse on its own, so the column rule takes over
+ const ranges = collapsedRanges(jsonState(doc));
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.to).toBe(doc.length);
+ });
+});
+
+describe("column collapsing, without a grammar", () => {
+ test("collapses everything past the limit", () => {
+ const ranges = collapsedRanges(plainState(BIG));
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
+ expect(ranges[0]!.to).toBe(BIG.length);
+ });
+
+ test("handles a long line of many short tokens", () => {
+ // A single-line CSV row: no token is long enough to collapse on its own
+ const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
+ const ranges = collapsedRanges(plainState(row));
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
+ });
+
+ test("collapses each long line independently", () => {
+ const doc = `${BIG}\nshort\n${BIG}`;
+ const ranges = collapsedRanges(plainState(doc));
+ expect(ranges).toHaveLength(2);
+ for (const length of visibleLineLengths(plainState(doc))) {
+ expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ }
+ });
+
+ test("never hides a line break", () => {
+ const doc = `${BIG}\nshort`;
+ const state = plainState(doc);
+ for (const { from, to } of collapsedRanges(state)) {
+ expect(state.sliceDoc(from, to)).not.toContain("\n");
+ }
+ expect(state.doc.lines).toBe(2);
+ });
+});
+
+describe("recomputing", () => {
+ test("updates when the document changes", () => {
+ const state = plainState('{"image":"short"}');
+ expect(collapsedRanges(state)).toEqual([]);
+
+ const next = state.update({
+ changes: { from: 0, to: state.doc.length, insert: BIG },
+ }).state;
+ expect(collapsedRanges(next)).toHaveLength(1);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/largeValues.ts b/apps/yaak-client/components/core/Editor/largeValues.ts
new file mode 100644
index 00000000..b9f9a795
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/largeValues.ts
@@ -0,0 +1,255 @@
+import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
+import type { EditorState, Extension, Range } from "@codemirror/state";
+import { StateField } from "@codemirror/state";
+import type { Tree as SyntaxTree } from "@lezer/common";
+import type { DecorationSet } from "@codemirror/view";
+import { Decoration, EditorView, WidgetType } from "@codemirror/view";
+import { LargeValueDialog } from "../../LargeValueDialog";
+
+/**
+ * How much of a line may be rendered before the rest is collapsed.
+ *
+ * VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
+ * reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
+ * collapse to a placeholder that can be opened instead.
+ */
+export const MAX_VISIBLE_LINE_CHARS = 10_000;
+
+/**
+ * A token longer than this on an over-long line is collapsed on its own, ahead of the column
+ * cut, so the structure around it stays visible. Needs a grammar to find.
+ */
+export const COLLAPSE_TOKEN_CHARS = 5_000;
+
+/** How much of a collapsed range stays visible at each end */
+export const COLLAPSE_KEEP_CHARS = 100;
+
+/**
+ * Keeps over-long lines from reaching layout, which is what makes the editor stall on a
+ * base64 blob or a minified payload.
+ *
+ * Cost tracks the length of the longest line, not the size of the document. A 1 MB response of
+ * ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls the UI,
+ * because soft wrap has to measure the whole line end to end to find its break points.
+ * Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
+ * 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
+ *
+ * Two rules, applied only to lines over {@link MAX_VISIBLE_LINE_CHARS}, so ordinary documents
+ * are untouched:
+ *
+ * 1. Collapse individual tokens over {@link COLLAPSE_TOKEN_CHARS}. For a language with a
+ * grammar this is the whole base64 string, so everything around it stays readable. A
+ * minified body with several large values keeps all of its keys visible.
+ * 2. Collapse whatever is still past the column limit. This needs no grammar, so it covers
+ * plain text and any line that isn't one big token.
+ *
+ * Nothing leaves the document. Copy, filter and save all still see the full text; the hidden
+ * part is reachable through {@link LargeValueDialog}.
+ *
+ * Read-only editors only. Hiding part of a document someone is editing would mean editing
+ * text they can't see.
+ */
+
+interface Collapse {
+ /** Bounds of the hidden part */
+ hiddenFrom: number;
+ hiddenTo: number;
+ /** Bounds of the whole value, including any visible ends, for the dialog */
+ valueFrom: number;
+ valueTo: number;
+}
+
+class LargeValueWidget extends WidgetType {
+ constructor(private readonly collapse: Collapse) {
+ super();
+ }
+
+ eq(other: LargeValueWidget) {
+ return (
+ other.collapse.hiddenFrom === this.collapse.hiddenFrom &&
+ other.collapse.hiddenTo === this.collapse.hiddenTo
+ );
+ }
+
+ toDOM(view: EditorView) {
+ const { hiddenFrom, hiddenTo, valueFrom, valueTo } = this.collapse;
+ const el = document.createElement("span");
+ el.className = "cm-largeValue";
+ el.textContent = `⋯ ${(hiddenTo - hiddenFrom).toLocaleString()} characters hidden ⋯`;
+ el.title = "View full value";
+ el.addEventListener("mousedown", (e) => {
+ // Keep the editor from putting a cursor behind the dialog
+ e.preventDefault();
+ e.stopPropagation();
+ LargeValueDialog.show(view.state.sliceDoc(valueFrom, valueTo));
+ });
+ return el;
+ }
+
+ ignoreEvent() {
+ return false;
+ }
+}
+
+interface Line {
+ from: number;
+ to: number;
+}
+
+/** Lines long enough to be a problem. Most documents have none, and we stop there. */
+function findLongLines(text: string): Line[] {
+ if (text.length <= MAX_VISIBLE_LINE_CHARS) {
+ return []; // No line can be longer than the whole text
+ }
+
+ const lines: Line[] = [];
+ let from = 0;
+ for (;;) {
+ const newline = text.indexOf("\n", from);
+ const to = newline < 0 ? text.length : newline;
+ if (to - from > MAX_VISIBLE_LINE_CHARS) {
+ lines.push({ from, to });
+ }
+ if (newline < 0) {
+ return lines;
+ }
+ from = newline + 1;
+ }
+}
+
+/**
+ * How long to spend parsing before falling back to the column rule.
+ *
+ * The initial parse is budgeted by time, so it stops partway through a document with several
+ * large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
+ * 12 ms, against the 171 ms of layout it saves.
+ */
+const PARSE_TIMEOUT_MS = 100;
+
+/** The parsed tree covering the long lines, as far as parsing got in the time allowed. */
+function treeForLongLines(state: EditorState, longLines: Line[]): SyntaxTree {
+ const lastLine = longLines[longLines.length - 1];
+ if (lastLine == null) {
+ return syntaxTree(state);
+ }
+ return ensureSyntaxTree(state, lastLine.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
+}
+
+/** Tokens on this line big enough to collapse on their own, in document order. */
+function findLargeTokens(tree: SyntaxTree, line: Line): Collapse[] {
+ const collapses: Collapse[] = [];
+
+ tree.iterate({
+ from: line.from,
+ to: line.to,
+ enter: (node) => {
+ // A node this small can't contain anything worth collapsing
+ if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
+ // Only leaves, so we collapse the string itself rather than the object holding it
+ if (node.node.firstChild != null) return true;
+
+ const valueFrom = Math.max(node.from, line.from);
+ const valueTo = Math.min(node.to, line.to);
+ const hiddenFrom = valueFrom + COLLAPSE_KEEP_CHARS;
+ const hiddenTo = valueTo - COLLAPSE_KEEP_CHARS;
+ if (hiddenTo > hiddenFrom) {
+ collapses.push({ hiddenFrom, hiddenTo, valueFrom, valueTo });
+ }
+ return false;
+ },
+ });
+
+ return collapses;
+}
+
+/**
+ * Where the line runs past the column limit, counting only what is still visible after the
+ * token collapses, or -1 if it fits.
+ */
+function findColumnCut(line: Line, tokens: Collapse[]): number {
+ let visible = 0;
+ let pos = line.from;
+
+ for (const token of [...tokens, null]) {
+ const segmentEnd = token == null ? line.to : token.hiddenFrom;
+ if (segmentEnd > pos) {
+ if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
+ return pos + (MAX_VISIBLE_LINE_CHARS - visible);
+ }
+ visible += segmentEnd - pos;
+ }
+ if (token != null) {
+ pos = token.hiddenTo;
+ }
+ }
+
+ return -1;
+}
+
+function collapsesForLine(tree: SyntaxTree, line: Line): Collapse[] {
+ const tokens = findLargeTokens(tree, line);
+ const cut = findColumnCut(line, tokens);
+ if (cut < 0) {
+ return tokens;
+ }
+
+ // The cut always lands in a visible stretch, so it never splits a token collapse
+ const kept = tokens.filter((t) => t.hiddenTo <= cut);
+ kept.push({ hiddenFrom: cut, hiddenTo: line.to, valueFrom: cut, valueTo: line.to });
+ return kept;
+}
+
+function buildDecorations(state: EditorState, longLines: Line[]): DecorationSet {
+ if (longLines.length === 0) {
+ return Decoration.none;
+ }
+
+ const tree = treeForLongLines(state, longLines);
+ const ranges: Range[] = [];
+ for (const line of longLines) {
+ for (const collapse of collapsesForLine(tree, line)) {
+ ranges.push(
+ Decoration.replace({ widget: new LargeValueWidget(collapse) }).range(
+ collapse.hiddenFrom,
+ collapse.hiddenTo,
+ ),
+ );
+ }
+ }
+ return Decoration.set(ranges);
+}
+
+interface LargeValueState {
+ longLines: Line[];
+ decorations: DecorationSet;
+}
+
+export const largeValueField = StateField.define({
+ create(state) {
+ const longLines = findLongLines(state.doc.toString());
+ return { longLines, decorations: buildDecorations(state, longLines) };
+ },
+
+ update(value, tr) {
+ if (tr.docChanged) {
+ const longLines = findLongLines(tr.state.doc.toString());
+ return { longLines, decorations: buildDecorations(tr.state, longLines) };
+ }
+ // Parsing is incremental, so a long line may only become a known token later. Documents
+ // with no long line can never gain a collapse, so they skip this entirely.
+ if (value.longLines.length > 0 && syntaxTree(tr.startState) !== syntaxTree(tr.state)) {
+ return { ...value, decorations: buildDecorations(tr.state, value.longLines) };
+ }
+ return value;
+ },
+
+ provide: (f) => [
+ EditorView.decorations.from(f, (v) => v.decorations),
+ // Step the cursor over a placeholder instead of stranding it inside
+ EditorView.atomicRanges.of(
+ (view) => view.state.field(f, false)?.decorations ?? Decoration.none,
+ ),
+ ],
+});
+
+export const largeValues: Extension = [largeValueField];
diff --git a/apps/yaak-client/lib/docFingerprint.test.ts b/apps/yaak-client/lib/docFingerprint.test.ts
new file mode 100644
index 00000000..0fab4878
--- /dev/null
+++ b/apps/yaak-client/lib/docFingerprint.test.ts
@@ -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));
+ }
+ });
+});
diff --git a/apps/yaak-client/lib/docFingerprint.ts b/apps/yaak-client/lib/docFingerprint.ts
new file mode 100644
index 00000000..cb37ec20
--- /dev/null
+++ b/apps/yaak-client/lib/docFingerprint.ts
@@ -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(":");
+}