import { EditorState, type Extension, RangeSet, StateField, Transaction } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView, gutterLineClass, GutterMarker, gutterWidgetClass, keymap, lineNumbers, } from "@codemirror/view"; import { defaultKeymap, history, historyField, historyKeymap, indentWithTab, isolateHistory, } from "@codemirror/commands"; import { diff, getChunks, MergeView, unifiedMergeView } from "@codemirror/merge"; import type { OpenFile } from "./edit-types.ts"; import { highlighting } from "./highlight.ts"; export type Layout = "unified" | "split"; class RowMarker extends GutterMarker { constructor(override readonly elementClass: string) { super(); } } const addedRow = new RowMarker("cm-addedRow"); const removedRow = new RowMarker("cm-removedRow"); const wholeLine = Decoration.line({ class: "cm-wholeChange" }); /** Tints whole rows as the read-only diff does, and marks lines a chunk adds or removes outright, whose words need no highlight. */ const rows = StateField.define<{ lines: DecorationSet; gutter: RangeSet }>({ create: (state) => changedRows(state), update: (value, transaction) => getChunks(transaction.state)?.chunks === getChunks(transaction.startState)?.chunks ? value : changedRows(transaction.state), provide: (field) => [ EditorView.decorations.from(field, (value) => value.lines), gutterLineClass.from(field, (value) => value.gutter), ], }); /** Unified deletions are widgets between lines, whose gutter rows need the tint too. */ const removedWidgetRows = gutterWidgetClass.of((view, _widget, block) => { const found = getChunks(view.state); return found?.side === null && found.chunks.some((chunk) => chunk.fromB === block.from && chunk.fromA !== chunk.toA) ? removedRow : null; }); function changedRows(state: EditorState) { const found = getChunks(state); const lines = []; const gutter = []; const a = found?.side === "a"; for (const chunk of found?.chunks ?? []) { const [from, to] = a ? [chunk.fromA, chunk.toA] : [chunk.fromB, chunk.toB]; if (from === to) continue; const whole = a ? chunk.fromB === chunk.toB : chunk.fromA === chunk.toA; for (let pos = from; pos < to && pos <= state.doc.length;) { const line = state.doc.lineAt(pos); gutter.push((a ? removedRow : addedRow).range(line.from)); if (whole) lines.push(wholeLine.range(line.from)); pos = line.to + 1; } } return { lines: Decoration.set(lines), gutter: RangeSet.of(gutter) }; } export function diffEditor( parent: HTMLElement, opened: OpenFile, layout: Layout, on: { changed(content: string): void; save(): void; /** Escape, unless codemirror had a use for it, such as collapsing several selections. */ escape(): void; }, ) { const separator = opened.content.includes("\r\n") ? "\r\n" : "\n"; const normalize = (text: string) => text.replace(/\r\n/g, "\n"); const original = normalize(opened.original); let view: EditorView; let merge: MergeView | null = null; const content = () => view.state.doc.toString().replace(/\n/g, separator); const config = { collapseUnchanged: { margin: 3, minSize: 4 }, // the default scan limit turns sparse edits in long files into one replacement diffConfig: { timeout: 100 }, }; const common: Extension[] = [ highlighting(opened.file.path), lineNumbers(), EditorView.lineWrapping, rows, removedWidgetRows, ]; function mount(previous?: EditorState) { const extensions: Extension[] = [ common, ...(previous ? [historyField.init(() => previous.field(historyField))] : []), history(), keymap.of([ { key: "Mod-Enter", run: () => (on.save(), true) }, { key: "Mod-s", run: () => (on.save(), true) }, { key: "Escape", run: (view) => view.state.selection.ranges.length === 1 && (on.escape(), true), }, ...defaultKeymap, ...historyKeymap, indentWithTab, ]), EditorView.contentAttributes.of({ "aria-label": `Edit ${opened.file.path}` }), EditorView.updateListener.of((update) => { if (update.docChanged) on.changed(content()); }), ]; const doc = previous?.doc ?? normalize(opened.content); const selection = previous?.selection; if (layout === "split") { merge = new MergeView({ parent, ...config, a: { doc: original, extensions: [ common, EditorState.readOnly.of(true), EditorView.editable.of(false), EditorView.contentAttributes.of({ "aria-label": `Original ${opened.file.path}` }), ], }, b: { doc, selection, extensions }, }); view = merge.b; } else { view = new EditorView({ parent, state: EditorState.create({ doc, selection, extensions: [ extensions, unifiedMergeView({ original, ...config, mergeControls: false, syntaxHighlightDeletions: false, }), ], }), }); } } function destroy() { if (merge) merge.destroy(); else view.destroy(); merge = null; } mount(); return { get view() { return view; }, content, /** Change the text to `next` by its differences, so the selection keeps its place. */ replace(next: string, undoable: boolean) { const current = view.state.doc.toString(); const target = normalize(next); if (current === target) return; view.dispatch({ changes: diff(current, target).map((change) => ({ from: change.fromA, to: change.toA, insert: target.slice(change.fromB, change.toB), })), annotations: undoable ? isolateHistory.of("full") : Transaction.addToHistory.of(false), }); }, layout(next: Layout) { if (next === layout) return; const previous = view.state; const focused = view.hasFocus; destroy(); parent.replaceChildren(); layout = next; mount(previous); if (focused) view.focus(); }, destroy, }; } export type DiffEditor = ReturnType;