char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somdiff: preserve sparse edit boundaries in long files2396910

main
6.2 KiB206 linesraw
1import { EditorState, type Extension, RangeSet, StateField, Transaction } from "@codemirror/state";
2import {
3  Decoration,
4  type DecorationSet,
5  EditorView,
6  gutterLineClass,
7  GutterMarker,
8  gutterWidgetClass,
9  keymap,
10  lineNumbers,
11} from "@codemirror/view";
12import {
13  defaultKeymap,
14  history,
15  historyField,
16  historyKeymap,
17  indentWithTab,
18  isolateHistory,
19} from "@codemirror/commands";
20import { diff, getChunks, MergeView, unifiedMergeView } from "@codemirror/merge";
21import type { OpenFile } from "./edit-types.ts";
22import { highlighting } from "./highlight.ts";
23
24export type Layout = "unified" | "split";
25
26class RowMarker extends GutterMarker {
27  constructor(override readonly elementClass: string) {
28    super();
29  }
30}
31
32const addedRow = new RowMarker("cm-addedRow");
33const removedRow = new RowMarker("cm-removedRow");
34const wholeLine = Decoration.line({ class: "cm-wholeChange" });
35
36/** Tints whole rows as the read-only diff does, and marks lines a chunk adds or removes outright, whose words need no highlight. */
37const rows = StateField.define<{ lines: DecorationSet; gutter: RangeSet<GutterMarker> }>({
38  create: (state) => changedRows(state),
39  update: (value, transaction) =>
40    getChunks(transaction.state)?.chunks === getChunks(transaction.startState)?.chunks
41      ? value
42      : changedRows(transaction.state),
43  provide: (field) => [
44    EditorView.decorations.from(field, (value) => value.lines),
45    gutterLineClass.from(field, (value) => value.gutter),
46  ],
47});
48
49/** Unified deletions are widgets between lines, whose gutter rows need the tint too. */
50const removedWidgetRows = gutterWidgetClass.of((view, _widget, block) => {
51  const found = getChunks(view.state);
52  return found?.side === null &&
53      found.chunks.some((chunk) => chunk.fromB === block.from && chunk.fromA !== chunk.toA)
54    ? removedRow
55    : null;
56});
57
58function changedRows(state: EditorState) {
59  const found = getChunks(state);
60  const lines = [];
61  const gutter = [];
62  const a = found?.side === "a";
63  for (const chunk of found?.chunks ?? []) {
64    const [from, to] = a ? [chunk.fromA, chunk.toA] : [chunk.fromB, chunk.toB];
65    if (from === to) continue;
66    const whole = a ? chunk.fromB === chunk.toB : chunk.fromA === chunk.toA;
67    for (let pos = from; pos < to && pos <= state.doc.length;) {
68      const line = state.doc.lineAt(pos);
69      gutter.push((a ? removedRow : addedRow).range(line.from));
70      if (whole) lines.push(wholeLine.range(line.from));
71      pos = line.to + 1;
72    }
73  }
74  return { lines: Decoration.set(lines), gutter: RangeSet.of(gutter) };
75}
76
77export function diffEditor(
78  parent: HTMLElement,
79  opened: OpenFile,
80  layout: Layout,
81  on: {
82    changed(content: string): void;
83    save(): void;
84    /** Escape, unless codemirror had a use for it, such as collapsing several selections. */
85    escape(): void;
86  },
87) {
88  const separator = opened.content.includes("\r\n") ? "\r\n" : "\n";
89  const normalize = (text: string) => text.replace(/\r\n/g, "\n");
90  const original = normalize(opened.original);
91  let view: EditorView;
92  let merge: MergeView | null = null;
93  const content = () => view.state.doc.toString().replace(/\n/g, separator);
94  const config = {
95    collapseUnchanged: { margin: 3, minSize: 4 },
96    // the default scan limit turns sparse edits in long files into one replacement
97    diffConfig: { timeout: 100 },
98  };
99  const common: Extension[] = [
100    highlighting(opened.file.path),
101    lineNumbers(),
102    EditorView.lineWrapping,
103    rows,
104    removedWidgetRows,
105  ];
106
107  function mount(previous?: EditorState) {
108    const extensions: Extension[] = [
109      common,
110      ...(previous ? [historyField.init(() => previous.field(historyField))] : []),
111      history(),
112      keymap.of([
113        { key: "Mod-Enter", run: () => (on.save(), true) },
114        { key: "Mod-s", run: () => (on.save(), true) },
115        {
116          key: "Escape",
117          run: (view) => view.state.selection.ranges.length === 1 && (on.escape(), true),
118        },
119        ...defaultKeymap,
120        ...historyKeymap,
121        indentWithTab,
122      ]),
123      EditorView.contentAttributes.of({ "aria-label": `Edit ${opened.file.path}` }),
124      EditorView.updateListener.of((update) => {
125        if (update.docChanged) on.changed(content());
126      }),
127    ];
128    const doc = previous?.doc ?? normalize(opened.content);
129    const selection = previous?.selection;
130    if (layout === "split") {
131      merge = new MergeView({
132        parent,
133        ...config,
134        a: {
135          doc: original,
136          extensions: [
137            common,
138            EditorState.readOnly.of(true),
139            EditorView.editable.of(false),
140            EditorView.contentAttributes.of({ "aria-label": `Original ${opened.file.path}` }),
141          ],
142        },
143        b: { doc, selection, extensions },
144      });
145      view = merge.b;
146    } else {
147      view = new EditorView({
148        parent,
149        state: EditorState.create({
150          doc,
151          selection,
152          extensions: [
153            extensions,
154            unifiedMergeView({
155              original,
156              ...config,
157              mergeControls: false,
158              syntaxHighlightDeletions: false,
159            }),
160          ],
161        }),
162      });
163    }
164  }
165
166  function destroy() {
167    if (merge) merge.destroy();
168    else view.destroy();
169    merge = null;
170  }
171
172  mount();
173  return {
174    get view() {
175      return view;
176    },
177    content,
178    /** Change the text to `next` by its differences, so the selection keeps its place. */
179    replace(next: string, undoable: boolean) {
180      const current = view.state.doc.toString();
181      const target = normalize(next);
182      if (current === target) return;
183      view.dispatch({
184        changes: diff(current, target).map((change) => ({
185          from: change.fromA,
186          to: change.toA,
187          insert: target.slice(change.fromB, change.toB),
188        })),
189        annotations: undoable ? isolateHistory.of("full") : Transaction.addToHistory.of(false),
190      });
191    },
192    layout(next: Layout) {
193      if (next === layout) return;
194      const previous = view.state;
195      const focused = view.hasFocus;
196      destroy();
197      parent.replaceChildren();
198      layout = next;
199      mount(previous);
200      if (focused) view.focus();
201    },
202    destroy,
203  };
204}
205
206export type DiffEditor = ReturnType<typeof diffEditor>;