mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-14 15:42:02 +02:00
Collapse very long lines in response viewers (#532)
This commit is contained in:
@@ -121,6 +121,16 @@
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
/* An icon button any tag can carry, like the copy on a large value */
|
||||
.tag-action {
|
||||
@apply inline-flex items-center align-middle ml-1 cursor-pointer!;
|
||||
@apply text-text-subtlest hover:text-text;
|
||||
|
||||
svg {
|
||||
@apply w-[0.9em] h-[0.9em];
|
||||
}
|
||||
}
|
||||
|
||||
.fn {
|
||||
@apply inline-block;
|
||||
.fn-inner {
|
||||
|
||||
@@ -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";
|
||||
@@ -430,7 +430,7 @@ function EditorInner({
|
||||
: []),
|
||||
];
|
||||
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
|
||||
|
||||
const doc = `${defaultValue ?? ""}`;
|
||||
const config: EditorStateConfig = { extensions, doc };
|
||||
@@ -656,9 +656,11 @@ 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);
|
||||
// Editable documents get the exact hash, so a collision can never restore undo history
|
||||
// belonging to other content
|
||||
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
|
||||
stateObj.doc = undefined;
|
||||
|
||||
try {
|
||||
@@ -668,7 +670,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
|
||||
if (stateKey == null) return;
|
||||
|
||||
try {
|
||||
@@ -678,7 +680,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, { exact: !readOnly })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => [
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { text } from "./text/extension";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import {
|
||||
COLLAPSE_TOKEN_CHARS,
|
||||
largeValueField,
|
||||
largeValues,
|
||||
MAX_VISIBLE_LINE_CHARS,
|
||||
} from "./largeValues";
|
||||
|
||||
vi.mock("../../../lib/copy", () => ({ copyToClipboard: () => {} }));
|
||||
|
||||
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("hides the whole value, leaving its quotes visible", () => {
|
||||
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
|
||||
const state = jsonState(doc);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
// Exactly the text between the quotes, so the line reads as `"image":"<tag>"`
|
||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
||||
expect(doc[ranges[0]!.from - 1]).toBe('"');
|
||||
expect(doc[ranges[0]!.to]).toBe('"');
|
||||
|
||||
// Everything after the value is still rendered, unlike a plain column cut
|
||||
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
|
||||
});
|
||||
|
||||
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
|
||||
// machine. With a bigger body this falls back to the column cut, which is by design but
|
||||
// makes the assertion depend on how fast the runner is.
|
||||
test("keeps every key visible in a minified body with several large values", () => {
|
||||
const chunk = "B".repeat(20_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);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
||||
// Only the long line is touched, and all that is left of it is ` "image": ""`
|
||||
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 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("undelimited tokens", () => {
|
||||
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
|
||||
// line with nothing on it but a tag, so the column cut handles it instead.
|
||||
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
|
||||
|
||||
test("falls back to the column cut for a long line of plain text", () => {
|
||||
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
|
||||
const ranges = collapsedRanges(textState(doc));
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(ranges[0]!.to).toBe(doc.length);
|
||||
});
|
||||
|
||||
test("leaves the start of the line readable", () => {
|
||||
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
|
||||
const state = textState(doc);
|
||||
const ranges = collapsedRanges(state);
|
||||
|
||||
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
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 { copyToClipboard } from "../../../lib/copy";
|
||||
|
||||
/**
|
||||
* 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 quoted value longer than this on an over-long line is collapsed whole, ahead of the column
|
||||
* cut, so the structure around it stays visible. Needs a grammar to find.
|
||||
*/
|
||||
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
||||
|
||||
/**
|
||||
* 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 quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
|
||||
* as `"image": "999.8 KB hidden…"` with its key intact. A minified body with several large values
|
||||
* keeps every one of its keys. Needs a grammar to find the value.
|
||||
* 2. Collapse whatever is still past the column limit. This needs no grammar, so it covers
|
||||
* plain text, undelimited tokens, and any line that is simply long.
|
||||
*
|
||||
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
|
||||
* carries a button that copies exactly what it hides.
|
||||
*
|
||||
* Read-only editors only. Hiding part of a document someone is editing would mean editing
|
||||
* text they can't see.
|
||||
*/
|
||||
|
||||
/** A hidden range. The tag stands in for exactly this text, and the dialog shows it. */
|
||||
interface Collapse {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
class LargeValueWidget extends WidgetType {
|
||||
constructor(
|
||||
private readonly from: number,
|
||||
private readonly to: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: LargeValueWidget) {
|
||||
return other.from === this.from && other.to === this.to;
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const length = this.to - this.from;
|
||||
|
||||
const el = document.createElement("span");
|
||||
// The same neutral tag styling a path parameter uses. The theme scope matters: these
|
||||
// tokens resolve against the tag palette, not the editor's ambient one, where a
|
||||
// tag-sized border is meant to be near invisible.
|
||||
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `${formatSize(length)} hidden…`;
|
||||
el.appendChild(label);
|
||||
|
||||
// A span rather than a button: a button's box model makes the line taller. It keeps the
|
||||
// role and name so assistive tech still sees it as an action.
|
||||
const copy = document.createElement("span");
|
||||
copy.role = "button";
|
||||
copy.className = "tag-action";
|
||||
copy.title = `Copy hidden text (${length.toLocaleString()} characters)`;
|
||||
copy.ariaLabel = copy.title;
|
||||
// Lucide's `copy` icon, inlined because the widget builds its DOM synchronously and
|
||||
// rendering React here would leave it empty while CodeMirror measures line heights
|
||||
copy.innerHTML =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
||||
'aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>' +
|
||||
'<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
|
||||
// Keep the editor from moving the cursor when the button is pressed
|
||||
copy.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
copy.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyToClipboard(view.state.sliceDoc(this.from, this.to));
|
||||
});
|
||||
el.appendChild(copy);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const QUOTES = ['"', "'", "`"];
|
||||
|
||||
/**
|
||||
* Quoted values on this line big enough to collapse whole, in document order.
|
||||
*
|
||||
* Everything between the quotes goes, so a long string reads as `"image": "<tag>"` and the key
|
||||
* still tells you what it is. Undelimited tokens are left to the column cut instead: replacing
|
||||
* one whole would leave the line with nothing on it but a tag.
|
||||
*/
|
||||
function findLargeTokens(state: EditorState, 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 from = Math.max(node.from, line.from);
|
||||
const to = Math.min(node.to, line.to);
|
||||
const open = state.sliceDoc(from, from + 1);
|
||||
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
|
||||
|
||||
if (quoted && to - 1 > from + 1) {
|
||||
collapses.push({ from: from + 1, to: to - 1 });
|
||||
}
|
||||
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.from;
|
||||
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.to;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
||||
const tokens = findLargeTokens(state, 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.to <= cut);
|
||||
kept.push({ from: cut, to: 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<Decoration>[] = [];
|
||||
for (const line of longLines) {
|
||||
for (const { from, to } of collapsesForLine(state, tree, line)) {
|
||||
ranges.push(Decoration.replace({ widget: new LargeValueWidget(from, to) }).range(from, to));
|
||||
}
|
||||
}
|
||||
return Decoration.set(ranges);
|
||||
}
|
||||
|
||||
interface LargeValueState {
|
||||
longLines: Line[];
|
||||
decorations: DecorationSet;
|
||||
}
|
||||
|
||||
export const largeValueField = StateField.define<LargeValueState>({
|
||||
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];
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { docFingerprint } from "./docFingerprint";
|
||||
|
||||
const sampled = (text: string) => docFingerprint(text, { exact: false });
|
||||
const exact = (text: string) => docFingerprint(text, { exact: true });
|
||||
|
||||
/** Same length as `text`, with one character changed at `at` */
|
||||
const changeAt = (text: string, at: number) =>
|
||||
`${text.slice(0, at)}${text[at] === "b" ? "c" : "b"}${text.slice(at + 1)}`;
|
||||
|
||||
describe("docFingerprint", () => {
|
||||
test("is stable for the same text", () => {
|
||||
expect(sampled("hello")).toBe(sampled("hello"));
|
||||
expect(exact("hello")).toBe(exact("hello"));
|
||||
});
|
||||
|
||||
test("differs on different short text", () => {
|
||||
expect(sampled("hello")).not.toBe(sampled("world"));
|
||||
});
|
||||
|
||||
test("differs on length alone", () => {
|
||||
expect(sampled("a".repeat(1_000_000))).not.toBe(sampled("a".repeat(1_000_001)));
|
||||
});
|
||||
|
||||
test("hashes small documents in full, so any change is caught", () => {
|
||||
const doc = "a".repeat(100);
|
||||
for (let i = 0; i < doc.length; i++) {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, i)));
|
||||
}
|
||||
});
|
||||
|
||||
describe("sampled", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
|
||||
test("notices a change at the start", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, 0)));
|
||||
});
|
||||
|
||||
test("notices a change at the end", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length - 1)));
|
||||
});
|
||||
|
||||
test("notices a change in the middle", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length / 2)));
|
||||
});
|
||||
|
||||
test("misses a same-length change between the samples", () => {
|
||||
// The documented cost of sampling. Read-only editors accept it because the worst a
|
||||
// collision can do there is restore a fold or the cursor to the wrong place.
|
||||
expect(sampled(doc)).toBe(sampled(changeAt(doc, 250_000)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("exact", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
|
||||
test("catches a same-length change anywhere, which is why editable docs use it", () => {
|
||||
for (const at of [0, 250_000, doc.length / 2, 750_000, doc.length - 1]) {
|
||||
expect(exact(doc)).not.toBe(exact(changeAt(doc, at)));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not collide with the sampled fingerprint of the same text", () => {
|
||||
expect(exact(doc)).not.toBe(sampled(doc));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
/** How much of each end and the middle to hash */
|
||||
const SAMPLE_CHARS = 512;
|
||||
|
||||
/**
|
||||
* Identifies a document, so a cached editor state is never restored onto different content.
|
||||
*
|
||||
* Hashing the whole document costs about 4 ms per megabyte and is paid on every editor update
|
||||
* as well as on restore, which is a lot of work for a document nobody can edit.
|
||||
*
|
||||
* `exact` decides how much certainty that buys. Editable documents get a full hash, because a
|
||||
* collision there would restore undo history belonging to other content and let an undo write
|
||||
* nonsense into the body. Read-only documents get a sampled one: they hold the megabyte-sized
|
||||
* responses this exists for, their history can never be applied, and the worst a collision can
|
||||
* do is put a fold or the cursor in the wrong place.
|
||||
*
|
||||
* Sampling still pins the exact length plus three windows, so a colliding pair has to agree on
|
||||
* all four and differ only in between. Documents small enough to hash outright still are.
|
||||
*/
|
||||
export function docFingerprint(text: string, { exact }: { exact: boolean }): string {
|
||||
if (exact || 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(":");
|
||||
}
|
||||
Reference in New Issue
Block a user