From d49bdc525727bf53d3eca2f7ff95e86e8af31c70 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 14 Oct 2026 14:39:31 +0400 Subject: [PATCH] refac --- .../components/common/DiffEditorModal.svelte | 14 +- .../components/common/diffEditorAlignment.ts | 142 ++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/common/diffEditorAlignment.ts diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index ed5f1bc08e..6f14bc331a 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -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({}); + } }); } }), diff --git a/src/lib/components/common/diffEditorAlignment.ts b/src/lib/components/common/diffEditorAlignment.ts new file mode 100644 index 0000000000..80e5d568a4 --- /dev/null +++ b/src/lib/components/common/diffEditorAlignment.ts @@ -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(); +export const lineAlignment = StateField.define({ + 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(); + 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, next: Map) => { + 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); +}