Skip to content

Commit 2803dda

Browse files
authored
fix(editor): make fold all reliable for large files (#2912)
* fix(editor): make fold all reliable for large files * fix
1 parent 33a4084 commit 2803dda

2 files changed

Lines changed: 299 additions & 39 deletions

File tree

‎src/cm/foldingCommands.js‎

Lines changed: 149 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@ import {
66
foldNodeProp,
77
foldService,
88
foldState,
9+
forceParsing,
10+
language,
11+
StreamLanguage,
912
syntaxTree,
13+
syntaxTreeAvailable,
1014
unfoldEffect,
1115
} from "@codemirror/language";
1216
import { StateEffect } from "@codemirror/state";
1317

14-
const FULL_PARSE_BUDGET_MS = 100;
15-
const INCOMPLETE_PARSE_MARGIN = 50;
18+
const INITIAL_PARSE_BUDGET_MS = 12;
19+
const IDLE_PARSE_BUDGET_MS = 10;
20+
const MIN_IDLE_PARSE_BUDGET_MS = 2;
21+
const IDLE_CALLBACK_TIMEOUT_MS = 50;
22+
const pendingFoldAllJobs = new WeakMap();
1623

1724
function findServiceFold(state, line) {
1825
for (const service of state.facet(foldService)) {
@@ -22,68 +29,104 @@ function findServiceFold(state, line) {
2229
return null;
2330
}
2431

25-
function addSyntaxFolds(state, tree, line, addRange) {
26-
if (!tree || tree.length < line.to) return;
32+
function scheduleIdleWork(callback) {
33+
if (
34+
typeof window !== "undefined" &&
35+
typeof window.requestIdleCallback === "function"
36+
) {
37+
return {
38+
kind: "idle",
39+
id: window.requestIdleCallback(callback, {
40+
timeout: IDLE_CALLBACK_TIMEOUT_MS,
41+
}),
42+
};
43+
}
2744

28-
for (let iter = tree.resolveStack(line.to, 1); iter; iter = iter.next) {
29-
const node = iter.node;
30-
if (node.to <= line.to || node.from > line.to) continue;
31-
const lastChild = node.lastChild;
32-
if (
33-
tree.length !== state.doc.length &&
34-
node.to >= tree.length - INCOMPLETE_PARSE_MARGIN &&
35-
lastChild?.to === node.to &&
36-
lastChild.type.isError
37-
) {
38-
continue;
39-
}
45+
return {
46+
kind: "timeout",
47+
id: setTimeout(() => callback(null), 0),
48+
};
49+
}
4050

41-
const fold = node.type.prop(foldNodeProp);
42-
if (!fold) continue;
51+
function cancelIdleWork(handle) {
52+
if (!handle) return;
53+
if (
54+
handle.kind === "idle" &&
55+
typeof window !== "undefined" &&
56+
typeof window.cancelIdleCallback === "function"
57+
) {
58+
window.cancelIdleCallback(handle.id);
59+
} else {
60+
clearTimeout(handle.id);
61+
}
62+
}
4363

44-
const range = fold(node, state);
45-
if (
46-
range &&
47-
range.from >= line.from &&
48-
range.from <= line.to &&
49-
range.to > line.to
50-
) {
51-
addRange(range);
52-
}
64+
function cancelPendingFoldAll(view) {
65+
const job = pendingFoldAllJobs.get(view);
66+
if (!job) return;
67+
job.cancelled = true;
68+
cancelIdleWork(job.handle);
69+
pendingFoldAllJobs.delete(view);
70+
}
71+
72+
function getIdleParseBudget(deadline) {
73+
if (!deadline || typeof deadline.timeRemaining !== "function") {
74+
return IDLE_PARSE_BUDGET_MS;
5375
}
76+
return Math.max(
77+
MIN_IDLE_PARSE_BUDGET_MS,
78+
Math.min(IDLE_PARSE_BUDGET_MS, Math.floor(deadline.timeRemaining())),
79+
);
5480
}
5581

56-
/**
57-
* Fold every foldable block, including nested blocks. CodeMirror's built-in
58-
* foldAll intentionally skips nested ranges after finding a top-level fold.
59-
*/
60-
export function foldAllCodeBlocks(view) {
61-
const { state } = view;
62-
const tree =
63-
ensureSyntaxTree(state, state.doc.length, FULL_PARSE_BUDGET_MS) ??
64-
syntaxTree(state);
82+
function addSyntaxFolds(state, tree, addRange) {
83+
if (!tree || tree.length < state.doc.length) return;
84+
85+
tree.iterate({
86+
enter(ref) {
87+
const node = ref.node;
88+
const fold = node.type.prop(foldNodeProp);
89+
if (!fold) return;
90+
91+
const range = fold(node, state);
92+
if (!range) return;
6593

94+
const line = state.doc.lineAt(range.from);
95+
if (range.from <= line.to && range.to > line.to) addRange(range);
96+
},
97+
});
98+
}
99+
100+
function collectFoldEffects(state, tree) {
66101
const existing = new Set();
67102
foldedRanges(state).between(0, state.doc.length, (from, to) => {
68103
existing.add(`${from}:${to}`);
69104
});
70105

71-
const effects = [];
106+
const ranges = [];
72107
const discovered = new Set();
73108
const addRange = (range) => {
74109
const id = `${range.from}:${range.to}`;
75110
if (existing.has(id) || discovered.has(id)) return;
76111
discovered.add(id);
77-
effects.push(foldEffect.of(range));
112+
ranges.push(range);
78113
};
79114

80115
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
81116
const line = state.doc.line(lineNumber);
82117
const serviceRange = findServiceFold(state, line);
83118
if (serviceRange) addRange(serviceRange);
84-
else addSyntaxFolds(state, tree, line, addRange);
85119
}
120+
addSyntaxFolds(state, tree, addRange);
121+
122+
return ranges
123+
.sort((a, b) => a.from - b.from || a.to - b.to)
124+
.map((range) => foldEffect.of(range));
125+
}
86126

127+
function applyAllFolds(view, tree) {
128+
const { state } = view;
129+
const effects = collectFoldEffects(state, tree);
87130
if (!effects.length) return false;
88131
if (!state.field(foldState, false)) {
89132
// Install the state field first so it can consume the fold effects in
@@ -94,8 +137,75 @@ export function foldAllCodeBlocks(view) {
94137
return true;
95138
}
96139

140+
function scheduleProgressiveFoldAll(view, document) {
141+
const job = {
142+
cancelled: false,
143+
document,
144+
handle: null,
145+
};
146+
pendingFoldAllJobs.set(view, job);
147+
148+
const continueParsing = (deadline) => {
149+
job.handle = null;
150+
if (
151+
job.cancelled ||
152+
pendingFoldAllJobs.get(view) !== job ||
153+
view.state.doc !== document
154+
) {
155+
pendingFoldAllJobs.delete(view);
156+
return;
157+
}
158+
159+
try {
160+
const complete =
161+
syntaxTreeAvailable(view.state, document.length) ||
162+
forceParsing(view, document.length, getIdleParseBudget(deadline));
163+
164+
if (complete) {
165+
pendingFoldAllJobs.delete(view);
166+
applyAllFolds(view, syntaxTree(view.state));
167+
return;
168+
}
169+
} catch (error) {
170+
console.error("Failed to finish parsing for Fold all.", error);
171+
pendingFoldAllJobs.delete(view);
172+
return;
173+
}
174+
175+
job.handle = scheduleIdleWork(continueParsing);
176+
};
177+
178+
job.handle = scheduleIdleWork(continueParsing);
179+
}
180+
181+
/**
182+
* Fold every foldable block, including nested blocks. Small and already-parsed
183+
* documents fold synchronously. Large documents finish parsing in short idle
184+
* slices before all ranges are collected in one pass and one transaction.
185+
*/
186+
export function foldAllCodeBlocks(view) {
187+
cancelPendingFoldAll(view);
188+
const { state } = view;
189+
const activeLanguage = state.facet(language);
190+
191+
if (!activeLanguage || activeLanguage instanceof StreamLanguage) {
192+
return applyAllFolds(view, syntaxTree(state));
193+
}
194+
195+
const tree = ensureSyntaxTree(
196+
state,
197+
state.doc.length,
198+
INITIAL_PARSE_BUDGET_MS,
199+
);
200+
if (tree) return applyAllFolds(view, tree);
201+
202+
scheduleProgressiveFoldAll(view, state.doc);
203+
return true;
204+
}
205+
97206
/** Unfold every stored fold, including nested folds created above. */
98207
export function unfoldAllCodeBlocks(view) {
208+
cancelPendingFoldAll(view);
99209
const effects = [];
100210
foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => {
101211
effects.push(unfoldEffect.of({ from, to }));

‎tests/unit/foldingCommands.test.js‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// @vitest-environment happy-dom
2+
3+
import { javascript } from "@codemirror/lang-javascript";
4+
import { codeFolding, foldedRanges } from "@codemirror/language";
5+
import { EditorState } from "@codemirror/state";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
8+
const parsing = vi.hoisted(() => ({
9+
ensureSyntaxTree: vi.fn(),
10+
forceParsing: vi.fn(),
11+
syntaxTreeAvailable: vi.fn(),
12+
}));
13+
14+
vi.mock("@codemirror/language", async (importOriginal) => ({
15+
...(await importOriginal()),
16+
ensureSyntaxTree: parsing.ensureSyntaxTree,
17+
forceParsing: parsing.forceParsing,
18+
syntaxTreeAvailable: parsing.syntaxTreeAvailable,
19+
}));
20+
21+
const { foldAllCodeBlocks, unfoldAllCodeBlocks } = await import(
22+
"cm/foldingCommands"
23+
);
24+
25+
function createView() {
26+
let state = EditorState.create({
27+
doc: [
28+
"function outer() {",
29+
"\tif (ready) {",
30+
'\t\tconsole.log("ready");',
31+
"\t}",
32+
"}",
33+
].join("\n"),
34+
extensions: [javascript(), codeFolding()],
35+
});
36+
37+
return {
38+
get state() {
39+
return state;
40+
},
41+
dispatch(spec) {
42+
state = state.update(spec).state;
43+
},
44+
};
45+
}
46+
47+
function countFoldedRanges(state) {
48+
let count = 0;
49+
foldedRanges(state).between(0, state.doc.length, () => {
50+
count += 1;
51+
});
52+
return count;
53+
}
54+
55+
describe("progressive Fold all", () => {
56+
let idleCallbacks;
57+
let nextIdleId;
58+
59+
beforeEach(() => {
60+
idleCallbacks = new Map();
61+
nextIdleId = 1;
62+
window.requestIdleCallback = vi.fn((callback) => {
63+
const id = nextIdleId;
64+
nextIdleId += 1;
65+
idleCallbacks.set(id, callback);
66+
return id;
67+
});
68+
window.cancelIdleCallback = vi.fn((id) => {
69+
idleCallbacks.delete(id);
70+
});
71+
72+
parsing.ensureSyntaxTree.mockReset().mockReturnValue(null);
73+
parsing.forceParsing.mockReset();
74+
parsing.syntaxTreeAvailable.mockReset().mockReturnValue(false);
75+
});
76+
77+
afterEach(() => {
78+
vi.restoreAllMocks();
79+
});
80+
81+
function takeNextIdleCallback() {
82+
const next = idleCallbacks.entries().next().value;
83+
expect(next).toBeDefined();
84+
const [id, callback] = next;
85+
idleCallbacks.delete(id);
86+
return callback;
87+
}
88+
89+
function runNextIdleCallback() {
90+
takeNextIdleCallback()({
91+
didTimeout: false,
92+
timeRemaining: () => 8,
93+
});
94+
}
95+
96+
it("folds after parsing completes across multiple idle slices", () => {
97+
const view = createView();
98+
parsing.forceParsing
99+
.mockReturnValueOnce(false)
100+
.mockReturnValueOnce(false)
101+
.mockReturnValueOnce(true);
102+
103+
expect(foldAllCodeBlocks(view)).toBe(true);
104+
expect(countFoldedRanges(view.state)).toBe(0);
105+
106+
runNextIdleCallback();
107+
runNextIdleCallback();
108+
expect(countFoldedRanges(view.state)).toBe(0);
109+
110+
runNextIdleCallback();
111+
expect(parsing.forceParsing).toHaveBeenCalledTimes(3);
112+
expect(countFoldedRanges(view.state)).toBeGreaterThan(0);
113+
expect(idleCallbacks.size).toBe(0);
114+
});
115+
116+
it("cancels pending parsing when the document changes", () => {
117+
const view = createView();
118+
parsing.forceParsing.mockReturnValue(false);
119+
120+
foldAllCodeBlocks(view);
121+
runNextIdleCallback();
122+
expect(parsing.forceParsing).toHaveBeenCalledTimes(1);
123+
124+
view.dispatch({ changes: { from: 0, insert: "// changed\n" } });
125+
runNextIdleCallback();
126+
127+
expect(parsing.forceParsing).toHaveBeenCalledTimes(1);
128+
expect(countFoldedRanges(view.state)).toBe(0);
129+
expect(idleCallbacks.size).toBe(0);
130+
});
131+
132+
it("cancels pending parsing when Unfold all runs", () => {
133+
const view = createView();
134+
parsing.forceParsing.mockReturnValue(false);
135+
136+
foldAllCodeBlocks(view);
137+
const pendingCallback = takeNextIdleCallback();
138+
expect(unfoldAllCodeBlocks(view)).toBe(false);
139+
140+
pendingCallback({
141+
didTimeout: false,
142+
timeRemaining: () => 8,
143+
});
144+
145+
expect(window.cancelIdleCallback).toHaveBeenCalledTimes(1);
146+
expect(parsing.forceParsing).not.toHaveBeenCalled();
147+
expect(countFoldedRanges(view.state)).toBe(0);
148+
expect(idleCallbacks.size).toBe(0);
149+
});
150+
});

0 commit comments

Comments
 (0)