LCOV - code coverage report
Current view: top level - dashql-app/src/session/editor - dashql_diff_decorations.ts Coverage Total Hit
Test: DashQL 915828b Lines: 93.2 % 103 96
Test Date: 2026-11-06 19:30:32

            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();
        

Generated by: LCOV version 2.0-1