mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-13 15:12:05 +02:00
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:
@@ -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 (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] gap-3 h-full">
|
||||
<HStack space={2} className="flex-wrap">
|
||||
<span className="text-text-subtle text-sm tabular-nums">
|
||||
{value.length.toLocaleString()} characters
|
||||
</span>
|
||||
{pageCount > 1 && (
|
||||
<HStack space={1} alignItems="center">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
icon="chevron_left"
|
||||
title="Previous page"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<span className="text-text-subtle text-sm tabular-nums">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
icon="chevron_right"
|
||||
title="Next page"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<CopyButton size="xs" variant="border" color="secondary" text={value} />
|
||||
</div>
|
||||
</HStack>
|
||||
<div className="overflow-auto bg-surface-highlight rounded-md p-3">
|
||||
<div className="font-mono text-sm whitespace-pre-wrap break-all select-auto">{slice}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LargeValueDialog.show = (value: string) => {
|
||||
showDialog({
|
||||
id: "large-value",
|
||||
title: "Large Value",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-10rem)] max-h-200!",
|
||||
render: () => <LargeValueDialog value={value} />,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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<Decoration>[] = [];
|
||||
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<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,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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(":");
|
||||
}
|
||||
Reference in New Issue
Block a user