Line data Source code
1 : import * as dashql from '@ankoh/dashql-core';
2 :
3 : import { Decoration, DecorationSet, EditorView, gutter, GutterMarker } from '@codemirror/view';
4 : import { Extension, Range, StateField, Text, Transaction } from '@codemirror/state';
5 :
6 : import { DashQLPendingDiff, DashQLProcessorPlugin } from './dashql_processor.js';
7 :
8 : import './dashql_diff_decorations.css';
9 :
10 : /// The sentinel that ScriptDiffOp uses for an absent statement (no source for INSERT, no target
11 : /// for DELETE). Must match the `.fbs` default (0xFFFFFFFF).
12 1 : const STATEMENT_NONE = 0xffffffff;
13 :
14 1 : const OpCode = dashql.buffers.diff.ScriptDiffOpCode;
15 :
16 : /// Whole inserted statement (present in the new text, absent from the prior one)
17 1 : const InsertStatementDecoration = Decoration.mark({ class: 'cm-dashql-diff-insert' });
18 : /// An updated statement — subtle tint over the whole statement
19 1 : const UpdateStatementDecoration = Decoration.mark({ class: 'cm-dashql-diff-update' });
20 : /// A statement that only moved (order changed but content equal)
21 1 : const MoveStatementDecoration = Decoration.mark({ class: 'cm-dashql-diff-move' });
22 : /// A changed sub-range within an updated statement — stronger highlight
23 1 : const ChangeDecoration = Decoration.mark({ class: 'cm-dashql-diff-change' });
24 :
25 : /// A gutter marker for a deleted statement.
26 : ///
27 : /// A deletion has no target span (the statement is gone from the new text), so we cannot highlight
28 : /// it in place. Instead we drop a thin marker in the gutter at the nearest surviving boundary.
29 1 : class DeleteMarker extends GutterMarker {
30 0 : toDOM() {
31 0 : const span = document.createElement('span');
32 0 : span.className = 'dashql-gutter-diff-delete';
33 0 : return span;
34 0 : }
35 1 : }
36 :
37 : export interface DiffDecorationState {
38 : /// The pending diff this state was built from (identity used to skip rebuilds)
39 : pendingDiff: DashQLPendingDiff | null;
40 : /// The in-place highlight decorations
41 : decorations: DecorationSet;
42 : /// The `line.from` offsets that carry a "statement deleted here" gutter marker
43 : deleteLines: Set<number>;
44 : }
45 :
46 : /// Build the in-place diff decorations and delete-gutter anchors from a pending diff.
47 : ///
48 : /// The editor holds the *new* (target) text, so `target_span` / `target_changes` are text offsets
49 : /// into the current document; no token resolution is needed on the JS side. Deletions have no
50 : /// target span, so we anchor their gutter marker at the end of the most recently seen surviving
51 : /// statement (falling back to the document start).
52 1 : export function buildDiffDecorations(pending: DashQLPendingDiff | null, doc: Text): DiffDecorationState {
53 1 : if (pending == null) {
54 1 : return { pendingDiff: null, decorations: Decoration.none, deleteLines: new Set() };
55 : }
56 1 : const diff = pending.diffBuffer.read();
57 1 : const docLength = doc.length;
58 1 : const ranges: Range<Decoration>[] = [];
59 1 : const deleteLines = new Set<number>();
60 :
61 1 : const tmpOp = new dashql.buffers.diff.ScriptDiffOp();
62 1 : const tmpSpan = new dashql.buffers.parser.TextSpan();
63 1 : const tmpChange = new dashql.buffers.parser.TextSpan();
64 :
65 : // Clamp a span to the current document, dropping empty/inverted ranges. Guards against any
66 : // transient mismatch between the diff and the editor doc.
67 1 : const clamp = (from: number, to: number): [number, number] | null => {
68 1 : const f = Math.max(0, Math.min(from, docLength));
69 1 : const t = Math.max(f, Math.min(to, docLength));
70 1 : return f < t ? [f, t] : null;
71 1 : };
72 :
73 : // The end offset of the most recently seen surviving statement — the anchor for the next
74 : // deletion's gutter marker.
75 1 : let lastTargetEnd = 0;
76 :
77 1 : for (let i = 0; i < diff.opsLength(); ++i) {
78 1 : const op = diff.ops(i, tmpOp)!;
79 1 : const code = op.code();
80 :
81 1 : if (code === OpCode.DELETE || op.targetStatement() === STATEMENT_NONE) {
82 : // A deletion (or any op without a target) drops a gutter marker at the nearest boundary.
83 1 : if (code === OpCode.DELETE) {
84 1 : const anchor = Math.max(0, Math.min(lastTargetEnd, docLength));
85 1 : deleteLines.add(doc.lineAt(anchor).from);
86 1 : }
87 1 : continue;
88 1 : }
89 :
90 1 : const targetSpan = op.targetSpan(tmpSpan);
91 1 : if (targetSpan == null) {
92 0 : continue;
93 : }
94 1 : const range = clamp(
95 1 : targetSpan.offset(),
96 1 : targetSpan.offset() + targetSpan.length(),
97 1 : );
98 1 : if (range == null) {
99 0 : continue;
100 : }
101 1 : lastTargetEnd = range[1];
102 :
103 1 : switch (code) {
104 1 : case OpCode.INSERT:
105 1 : ranges.push(InsertStatementDecoration.range(range[0], range[1]));
106 1 : break;
107 1 : case OpCode.MOVE:
108 1 : ranges.push(MoveStatementDecoration.range(range[0], range[1]));
109 1 : break;
110 1 : case OpCode.UPDATE: {
111 1 : ranges.push(UpdateStatementDecoration.range(range[0], range[1]));
112 1 : for (let j = 0; j < op.targetChangesLength(); ++j) {
113 1 : const change = op.targetChanges(j, tmpChange);
114 1 : if (change == null) continue;
115 1 : const cr = clamp(
116 1 : change.offset(),
117 1 : change.offset() + change.length(),
118 1 : );
119 1 : if (cr == null) continue;
120 1 : ranges.push(ChangeDecoration.range(cr[0], cr[1]));
121 1 : }
122 1 : break;
123 : }
124 1 : case OpCode.KEEP:
125 : default:
126 : // Unchanged statements are not decorated.
127 : break;
128 1 : }
129 1 : }
130 :
131 1 : return {
132 1 : pendingDiff: pending,
133 : // `sort` handles the nested change-within-update ranges and the emit order.
134 1 : decorations: Decoration.set(ranges, true),
135 1 : deleteLines,
136 1 : };
137 1 : }
138 :
139 : /// Build the staged-rewrite decorations for the writable editor.
140 1 : function createDiffDecorationExtension(): Extension {
141 : /// Decorations for a pending, staged rewrite shown as an in-place diff.
142 1 : const field: StateField<DiffDecorationState> = StateField.define<DiffDecorationState>({
143 1 : create: () => ({ pendingDiff: null, decorations: Decoration.none, deleteLines: new Set() }),
144 1 : update: (state: DiffDecorationState, transaction: Transaction) => {
145 1 : const pending = transaction.state.field(DashQLProcessorPlugin).scriptPendingDiff;
146 : // Pending diff unchanged and doc untouched? Keep the previous decorations.
147 1 : if (pending === state.pendingDiff && !transaction.docChanged) {
148 1 : return state;
149 : }
150 1 : return buildDiffDecorations(pending, transaction.state.doc);
151 1 : },
152 1 : });
153 :
154 1 : const decorations = EditorView.decorations.from(field, state => state.decorations);
155 :
156 : /// A dedicated gutter that marks lines where statements were deleted by the pending rewrite.
157 1 : const diffGutter = gutter({
158 1 : lineMarker(view, line) {
159 1 : const state = view.state.field(field);
160 1 : return state.deleteLines.has(line.from) ? new DeleteMarker() : null;
161 1 : },
162 1 : lineMarkerChange: (update) => {
163 1 : const prev = update.startState.field(field);
164 1 : const next = update.state.field(field);
165 1 : return prev.deleteLines !== next.deleteLines;
166 1 : },
167 1 : });
168 :
169 1 : return [field, decorations, diffGutter];
170 1 : }
171 :
172 : /// Bundle the diff decoration extensions for the integrated (editable) editor — the pending diff
173 : /// comes from the `DashQLProcessorPlugin` state field, kept in sync with the session's ScriptData.
174 1 : export const DashQLDiffDecorationPlugin = createDiffDecorationExtension();
|