Skip to content

Commit 9a98fa1

Browse files
committed
fix: fallback to debounced diff for slow diffs
1 parent 4ba53a4 commit 9a98fa1

File tree

6 files changed

+214
-54
lines changed

6 files changed

+214
-54
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
},
4848
"dependencies": {
4949
"@codemirror/commands": "^6.8.1",
50-
"@codemirror/merge": "^6.10.2",
50+
"@codemirror/merge": "^6.11.2",
5151
"@codemirror/search": "^6.5.11",
5252
"@codemirror/state": "^6.5.2",
5353
"@codemirror/view": "^6.38.1",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/editor/signs/diff.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export function rawHunkFromChunk(
189189
return rawHunk;
190190
}
191191

192+
const diffConfig = {
193+
scanLimit: 1000,
194+
timeout: 200,
195+
};
196+
192197
function diffViaCMMerge(
193198
textA: string,
194199
textB: string,
@@ -199,8 +204,8 @@ function diffViaCMMerge(
199204
const bDoc = Text.of(textB.split("\n"));
200205
const newChunks =
201206
chunks && changes
202-
? Chunk.updateB(chunks, aDoc, bDoc, changes)
203-
: Chunk.build(aDoc, bDoc);
207+
? Chunk.updateB(chunks, aDoc, bDoc, changes, diffConfig)
208+
: Chunk.build(aDoc, bDoc, diffConfig);
204209
const rawHunks: RawHunk[] = [];
205210
for (let i = 0; i < newChunks.length; i++) {
206211
const chunk = newChunks[i];
@@ -220,7 +225,8 @@ export function computeHunks(
220225
chunks: readonly Chunk[] | undefined,
221226
changes: ChangeDesc | undefined
222227
): { hunks: Hunk[]; chunks: readonly Chunk[] } {
223-
return diffViaCMMerge(textA, textB, chunks, changes);
228+
const res = diffViaCMMerge(textA, textB, chunks, changes);
229+
return res;
224230
// const lineDiff = diff.diffLines(textA, textB, {
225231
// newlineIsToken: true,
226232
// });

src/editor/signs/gutter.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { RangeSet, StateField, Transaction } from "@codemirror/state";
22
import { EditorView, gutter, GutterMarker } from "@codemirror/view";
33
import { Hunks, type Hunk, type SignType } from "./hunks";
44
import {
5-
GitCompareResultEffectType,
5+
DebouncedComputeHunksEffectType,
66
hunksState,
77
HunksStateHelper,
88
} from "./signs";
@@ -35,10 +35,14 @@ export const signsMarker = StateField.define({
3535
if (!data) {
3636
return RangeSet.empty;
3737
}
38-
const isNewCompare = tr.effects.some((effect) =>
39-
effect.is(GitCompareResultEffectType)
38+
const newDebouncedHunks = tr.effects.some((effect) =>
39+
effect.is(DebouncedComputeHunksEffectType)
4040
);
41-
if (tr.docChanged || isNewCompare) {
41+
42+
if (
43+
newDebouncedHunks ||
44+
((tr.docChanged || rangeSet.size == 0) && data.isDirty == false)
45+
) {
4246
const linesWithSign = new Set<number>();
4347
const markers = getMarkers(tr, data.hunks, false, linesWithSign);
4448
const stagedMarkers = getMarkers(
@@ -49,6 +53,8 @@ export const signsMarker = StateField.define({
4953
);
5054
rangeSet = RangeSet.of([...markers, ...stagedMarkers], true);
5155
return rangeSet;
56+
} else if (tr.docChanged) {
57+
rangeSet = rangeSet.map(tr.changes);
5258
}
5359
return rangeSet;
5460
},

src/editor/signs/signs.ts

Lines changed: 180 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ChangeDesc,
23
EditorState,
34
StateEffect,
45
StateField,
@@ -9,7 +10,12 @@ import { Hunks, type Hunk } from "../signs/hunks";
910
import { computeHunks } from "./diff";
1011
import type { Chunk } from "@codemirror/merge";
1112
import { pluginRef } from "src/pluginGlobalRef";
12-
import { editorInfoField } from "obsidian";
13+
import {
14+
debounce,
15+
editorEditorField,
16+
editorInfoField,
17+
type Debouncer,
18+
} from "obsidian";
1319

1420
/**
1521
* Given a document and a position, return the corresponding line number in the
@@ -27,7 +33,7 @@ export function lineFromPos(doc: Text, pos: number): number {
2733
export abstract class HunksStateHelper {
2834
static hasHunksData(state: EditorState): boolean {
2935
const data = state.field(hunksState, false);
30-
return !!data;
36+
return !!data && !data.isDirty;
3137
}
3238

3339
static getHunks(state: EditorState, staged: boolean): Hunk[] {
@@ -142,70 +148,205 @@ export const hunksState: StateField<HunksData | undefined> = StateField.define<
142148
>({
143149
create: (_state) => undefined,
144150
update: (previous, transaction) => {
145-
const prev: HunksData = previous
151+
const hunksData: HunksData = previous
146152
? { ...previous }
147153
: {
154+
maxDiffTimeMs: 0,
148155
hunks: [],
149156
stagedHunks: [],
150157
chunks: undefined,
158+
isDirty: false,
151159
};
152160
let newCompare = false;
153161

154162
for (const effect of transaction.effects) {
155163
if (effect.is(GitCompareResultEffectType)) {
156-
newCompare = true;
157-
prev.compareText = effect.value.compareText;
158-
prev.compareTextHead = effect.value.compareTextHead;
164+
hunksData.compareText = effect.value.compareText;
165+
hunksData.compareTextHead = effect.value.compareTextHead;
166+
167+
// Only issue new hunk computation if compareText has changed
168+
newCompare = previous?.compareText !== effect.value.compareText;
169+
if (newCompare) {
170+
hunksData.chunks = undefined;
171+
}
159172
}
160-
}
161-
if (prev.compareText !== undefined) {
162-
const editorText = transaction.state.doc.toString();
163-
if (newCompare) {
164-
prev.chunks = undefined;
173+
if (effect.is(DebouncedComputeHunksEffectType)) {
174+
applyHunkComputation(
175+
hunksData,
176+
effect.value,
177+
transaction.state
178+
);
165179
}
180+
}
181+
if (hunksData.compareText !== undefined) {
166182
if (newCompare || transaction.docChanged) {
167-
const { hunks, chunks } = computeHunks(
168-
prev.compareText,
169-
editorText,
170-
prev.chunks,
171-
transaction.changes
172-
);
173-
// const headHunks = computeHunks(
174-
// prev.compareTextHead ?? "",
175-
// editorText
176-
// );
177-
prev.hunks = hunks;
178-
prev.chunks = chunks;
179-
// prev.stagedHunks = Hunks.computeStagedHunks(
180-
// headHunks,
181-
// hunks,
182-
// prev
183-
// );
184-
185-
const file = transaction.state.field(editorInfoField).file;
186-
pluginRef.plugin?.editorIntegration.signsFeature.changeStatusBar?.display(
187-
hunks,
188-
file
183+
hunksData.isDirty = true;
184+
const res = scheduleHunkComputation(
185+
transaction,
186+
hunksData.compareText,
187+
hunksData.chunks,
188+
hunksData.maxDiffTimeMs
189189
);
190+
if (res) {
191+
applyHunkComputation(hunksData, res, transaction.state);
192+
}
190193
}
191194
} else {
192-
prev.compareText = undefined;
193-
prev.compareTextHead = undefined;
194-
prev.chunks = undefined;
195-
prev.hunks = [];
196-
prev.stagedHunks = [];
195+
hunksData.compareText = undefined;
196+
hunksData.compareTextHead = undefined;
197+
hunksData.chunks = undefined;
198+
hunksData.hunks = [];
199+
hunksData.stagedHunks = [];
200+
hunksData.isDirty = false;
197201
}
198-
return prev;
202+
return hunksData;
199203
},
200204
});
201205

206+
function applyHunkComputation(
207+
hunkData: HunksData,
208+
computeData: ComputedHunksData,
209+
state: EditorState
210+
) {
211+
hunkData.hunks = computeData.hunks;
212+
hunkData.chunks = computeData.chunks;
213+
hunkData.isDirty = false;
214+
hunkData.maxDiffTimeMs = Math.max(
215+
0.95 * hunkData.maxDiffTimeMs,
216+
computeData.diffDuration
217+
);
218+
const file = state.field(editorInfoField).file;
219+
pluginRef.plugin?.editorIntegration.signsFeature.changeStatusBar?.display(
220+
hunkData.hunks,
221+
file
222+
);
223+
}
224+
225+
export const computeHunksDebouncerStateField = StateField.define<{
226+
changeDesc?: ChangeDesc;
227+
debouncer: Debouncer<
228+
[
229+
{
230+
state: EditorState;
231+
compareText: string;
232+
previousChunks: readonly Chunk[] | undefined;
233+
changeDesc: ChangeDesc | undefined;
234+
},
235+
],
236+
void
237+
>;
238+
}>({
239+
create: () => {
240+
return {
241+
debouncer: debounce(
242+
(data) => {
243+
const { state, compareText, previousChunks, changeDesc } =
244+
data;
245+
const res = computeHunksTimed(
246+
state,
247+
compareText,
248+
previousChunks,
249+
changeDesc
250+
);
251+
state.field(editorEditorField).dispatch({
252+
effects: DebouncedComputeHunksEffectType.of(res),
253+
});
254+
},
255+
1000,
256+
true
257+
),
258+
maxDiffTimeMs: 0,
259+
};
260+
},
261+
update: (data, transaction) => {
262+
for (const effect of transaction.effects) {
263+
if (effect.is(DebouncedComputeHunksEffectType)) {
264+
data.changeDesc = undefined;
265+
return data;
266+
}
267+
}
268+
if (!data.changeDesc && transaction.changes) {
269+
data.changeDesc = transaction.changes;
270+
} else {
271+
data.changeDesc = data.changeDesc?.composeDesc(transaction.changes);
272+
}
273+
return data;
274+
},
275+
});
276+
277+
function computeHunksTimed(
278+
state: EditorState,
279+
compareText: string,
280+
previousChunks: readonly Chunk[] | undefined,
281+
changeDesc: ChangeDesc | undefined
282+
): ComputedHunksData {
283+
const editorText = state.doc.toString();
284+
285+
const startTime = performance.now();
286+
const { hunks, chunks } = computeHunks(
287+
compareText,
288+
editorText,
289+
previousChunks,
290+
changeDesc
291+
);
292+
const diffDuration = performance.now() - startTime;
293+
return { hunks, chunks, diffDuration };
294+
}
295+
296+
function scheduleHunkComputation(
297+
transaction: Transaction,
298+
compareText: string,
299+
previousChunks: readonly Chunk[] | undefined,
300+
maxDiffTimeMs: number
301+
): ComputedHunksData | undefined {
302+
const state = transaction.state;
303+
const changeLength = Math.abs(
304+
transaction.changes.length - transaction.changes.newLength
305+
);
306+
307+
const debouncerField = state.field(computeHunksDebouncerStateField);
308+
309+
// Debounce large changes or if a previous diff took long time
310+
if (changeLength > 1000 || maxDiffTimeMs > 10) {
311+
debouncerField.debouncer({
312+
state,
313+
compareText,
314+
previousChunks,
315+
changeDesc: debouncerField.changeDesc,
316+
});
317+
} else {
318+
// This technically breaks the immutability of the StateField, but I
319+
// think it's acceptable here. The debouncer itself is not very
320+
// immutable either way.
321+
debouncerField.changeDesc = undefined;
322+
323+
return computeHunksTimed(
324+
state,
325+
compareText,
326+
previousChunks,
327+
transaction.changes
328+
);
329+
}
330+
}
331+
202332
export const GitCompareResultEffectType =
203333
StateEffect.define<GitCompareResult>();
204334

335+
export const DebouncedComputeHunksEffectType =
336+
StateEffect.define<ComputedHunksData>();
337+
338+
export type ComputedHunksData = {
339+
hunks: Hunk[];
340+
chunks: readonly Chunk[] | undefined;
341+
diffDuration: number;
342+
};
343+
205344
export type HunksData = {
206345
hunks: Hunk[];
207346
stagedHunks: Hunk[];
208347
chunks: readonly Chunk[] | undefined;
348+
isDirty: boolean;
349+
maxDiffTimeMs: number;
209350
} & GitCompareResult;
210351

211352
export type GitCompareResult = {

0 commit comments

Comments
 (0)