This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 14:39:31 +04:00
parent 14cdf39afb
commit d49bdc5257
2 changed files with 154 additions and 2 deletions

View file

@ -14,6 +14,7 @@
import ConfirmDialog from './ConfirmDialog.svelte';
import FileCodeEditor from '$lib/components/chat/FileNav/FileCodeEditor.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { lineAlignment, alignChangedLines } from './diffEditorAlignment';
import {
copyChunk,
selectedChunk,
@ -186,6 +187,7 @@
loading = true;
const extensions = (side: 'a' | 'b', index: number) => [
basicSetup,
lineAlignment,
keymap.of([indentWithTab, { key: 'Mod-s', run: () => true }]),
indentUnit.of(' '),
EditorView.lineWrapping,
@ -196,11 +198,19 @@
language[index].of([]),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.selectionSet) refresh(update, side);
if ((update.heightChanged || update.viewportChanged) && !alignmentFrame) {
if (
(update.geometryChanged ||
update.viewportChanged ||
update.transactions.some((tr) => tr.effects.length)) &&
!alignmentFrame
) {
// Align after both virtualized editors have measured their visible lines.
alignmentFrame = requestAnimationFrame(() => {
alignmentFrame = 0;
if (!disposed) view?.reconfigure({});
if (!disposed && view) {
alignChangedLines(view.a, view.b, view.chunks);
view.reconfigure({});
}
});
}
}),

View file

@ -0,0 +1,142 @@
import { StateEffect, StateField } from '@codemirror/state';
import {
BlockType,
Decoration,
EditorView,
WidgetType,
type DecorationSet
} from '@codemirror/view';
import type { Chunk } from '@codemirror/merge';
class RowSpacer extends WidgetType {
constructor(readonly height: number) {
super();
}
eq(other: RowSpacer) {
return Math.abs(this.height - other.height) <= 1;
}
toDOM() {
const element = document.createElement('div');
element.className = 'cm-mergeSpacer cm-diff-rowSpacer';
element.style.height = `${this.height}px`;
return element;
}
get estimatedHeight() {
return this.height;
}
}
const setSpacers = StateEffect.define<DecorationSet>();
export const lineAlignment = StateField.define<DecorationSet>({
create: () => Decoration.none,
update: (value, transaction) => {
for (const effect of transaction.effects) if (effect.is(setSpacers)) return effect.value;
return value.map(transaction.changes);
},
provide: (field) => EditorView.decorations.from(field)
});
// Only remeasure paired rows present in both virtualized viewports. Recomputing
// offscreen spacers from estimated heights would move already aligned content.
export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly Chunk[]) {
const previous = (view: EditorView) => {
const result = new Map<number, number>();
for (let cursor = view.state.field(lineAlignment).iter(); cursor.value; cursor.next())
result.set(cursor.from, cursor.value.spec.widget.height);
return result;
};
const oldA = previous(a),
oldB = previous(b);
const nextA = new Map(oldA),
nextB = new Map(oldB);
let offsetA = 0,
offsetB = 0;
const textTop = (view: EditorView, pos: number) => {
const block = view.lineBlockAt(pos);
const text = Array.isArray(block.type)
? block.type.find((part) => part.type === BlockType.Text && part.from === pos)
: block;
return text?.from === pos ? text.top : null;
};
const align = (posA: number, posB: number) => {
if (
posA < a.viewport.from ||
posB < b.viewport.from ||
posA > a.viewport.to ||
posB > b.viewport.to
)
return;
const topA = textTop(a, posA),
topB = textTop(b, posB);
if (topA === null || topB === null) return;
const previousA = oldA.get(posA) ?? 0,
previousB = oldB.get(posB) ?? 0;
const difference = topA - previousA + offsetA - topB + previousB - offsetB;
const sizeA = difference < -1 ? -difference : 0,
sizeB = difference > 1 ? difference : 0;
nextA.delete(posA);
nextB.delete(posB);
if (sizeA) nextA.set(posA, sizeA);
if (sizeB) nextB.set(posB, sizeB);
offsetA += sizeA - previousA;
offsetB += sizeB - previousB;
};
let fromA = 0,
fromB = 0;
for (let index = 0; index <= chunks.length; index++) {
const chunk = chunks[index];
const endA = chunk ? chunk.fromA : a.state.doc.length + 1;
let posA = Math.max(
fromA,
a.state.doc.lineAt(
Math.min(
a.state.doc.length,
Math.max(fromA, a.viewport.from, fromA + b.viewport.from - fromB)
)
).from
);
for (; posA < endA && posA <= a.viewport.to; posA = a.state.doc.lineAt(posA).to + 1) {
const posB = fromB + posA - fromA;
if (posB > b.viewport.to) break;
align(posA, posB);
}
if (!chunk) break;
if (chunk.fromA < chunk.toA && chunk.fromB < chunk.toB) {
const lineA = a.state.doc.lineAt(chunk.fromA).number,
lineB = b.state.doc.lineAt(chunk.fromB).number;
let row = Math.max(
0,
a.state.doc.lineAt(a.viewport.from).number - lineA,
b.state.doc.lineAt(b.viewport.from).number - lineB
);
while (lineA + row <= a.state.doc.lines && lineB + row <= b.state.doc.lines) {
const posA = a.state.doc.line(lineA + row).from,
posB = b.state.doc.line(lineB + row).from;
if (posA >= chunk.toA || posB >= chunk.toB || posA > a.viewport.to || posB > b.viewport.to)
break;
align(posA, posB);
row++;
}
}
fromA = chunk.toA;
fromB = chunk.toB;
}
const apply = (view: EditorView, old: Map<number, number>, next: Map<number, number>) => {
if (
old.size === next.size &&
[...next].every(([pos, size]) => Math.abs((old.get(pos) ?? 0) - size) <= 1)
)
return;
view.dispatch({
effects: setSpacers.of(
Decoration.set(
[...next].map(([pos, height]) =>
Decoration.widget({ widget: new RowSpacer(height), block: true, side: -1 }).range(pos)
),
true
)
)
});
};
apply(a, oldA, nextA);
apply(b, oldB, nextB);
}