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

            Line data    Source code
       1              : import * as dashql from '@ankoh/dashql-core';
       2              : 
       3              : import { codeFolding, foldEffect, foldedRanges, foldKeymap, foldService, unfoldEffect } from '@codemirror/language';
       4              : import { EditorState, Extension, Transaction } from '@codemirror/state';
       5              : import { EditorView, GutterMarker, keymap } from '@codemirror/view';
       6              : 
       7              : import { DashQLProcessorPlugin, DashQLUpdateEffect } from './dashql_processor.js';
       8              : 
       9              : import './dashql_vis_folding.css';
      10              : 
      11              : /// The range between the braces of a Vega-Lite spec
      12              : export interface VegaLiteSpecRange {
      13              :     from: number;
      14              :     to: number;
      15              : }
      16              : 
      17              : /// The ranges between the braces of the non-empty `@vis vegalite` specs of an update
      18            1 : export function findVegaLiteSpecs(update: dashql.buffers.editor.EditorUpdateT | null | undefined): VegaLiteSpecRange[] {
      19            1 :     const specs: VegaLiteSpecRange[] = [];
      20            1 :     for (const span of update?.vegaliteSpecs ?? []) {
      21              :         // The core sends the JSON objects with their braces
      22            1 :         const from = Number(span.offset) + 1;
      23            1 :         const to = Number(span.offset + span.length) - 1;
      24            1 :         if (to > from) specs.push({ from, to });
      25            1 :     }
      26            1 :     return specs;
      27            1 : }
      28              : 
      29            1 : export function isFolded(state: EditorState, spec: VegaLiteSpecRange): boolean {
      30            1 :     let folded = false;
      31            1 :     foldedRanges(state).between(spec.from, spec.to, (from, to) => {
      32            1 :         if (from === spec.from && to === spec.to) folded = true;
      33            1 :     });
      34            1 :     return folded;
      35            1 : }
      36              : 
      37              : /// Folds the Vega-Lite specs that a script brings into the editor, e.g. when it is opened or when the agent writes
      38              : /// a chart. Specs that the user types or unfolded stay open.
      39            1 : const autoFoldVegaLiteSpecs = EditorState.transactionExtender.of(tr => {
      40            1 :     if (tr.annotation(Transaction.userEvent) != null) return null;
      41            1 :     let update: dashql.buffers.editor.EditorUpdateT | null = null;
      42            1 :     let scriptSession: dashql.DashQLScriptSession | null | undefined = null;
      43            1 :     for (const effect of tr.effects) {
      44            1 :         if (effect.is(DashQLUpdateEffect)) {
      45            1 :             update = effect.value.editorUpdate ?? null;
      46            1 :             scriptSession = effect.value.scriptSession;
      47            1 :         }
      48              :     }
      49            1 :     if (update == null) return null;
      50            1 :     const prev = tr.startState.field(DashQLProcessorPlugin, false);
      51            1 :     const initial = prev?.editorUpdate == null || prev.scriptSession !== scriptSession;
      52            1 :     if (!initial && !tr.docChanged) return null;
      53              : 
      54              :     // A spec that is open before and kept by the new text stays open
      55            1 :     const open = new Set<string>();
      56            1 :     if (!initial) {
      57            1 :         for (const spec of findVegaLiteSpecs(prev.editorUpdate)) {
      58            1 :             if (!isFolded(tr.startState, spec)) open.add(tr.startState.doc.sliceString(spec.from, spec.to));
      59              :         }
      60              :     }
      61            1 :     const head = tr.newSelection.main.head;
      62            1 :     const effects = findVegaLiteSpecs(update)
      63            1 :         .filter(spec => (head <= spec.from || head > spec.to) && !open.has(tr.newDoc.sliceString(spec.from, spec.to)))
      64            1 :         .map(spec => foldEffect.of(spec));
      65            1 :     return effects.length > 0 ? { effects } : null;
      66            1 : });
      67              : 
      68              : /// The spec that is folded from the line of its opening brace
      69            1 : function findVegaLiteSpecAtLine(state: EditorState, lineStart: number, lineEnd: number): VegaLiteSpecRange | null {
      70            1 :     const processor = state.field(DashQLProcessorPlugin, false);
      71            1 :     return findVegaLiteSpecs(processor?.editorUpdate).find(spec => spec.from >= lineStart && spec.from <= lineEnd) ?? null;
      72            1 : }
      73              : 
      74              : /// Lets the Vega-Lite specs be folded from the line of their opening brace
      75            1 : const vegaLiteSpecFolds = foldService.of(findVegaLiteSpecAtLine);
      76              : 
      77              : /// Folds or unfolds the spec of a line, returns false if the line opens no spec
      78            1 : export function toggleVegaLiteSpecAtLine(view: EditorView, lineStart: number, lineEnd: number): boolean {
      79            1 :     const spec = findVegaLiteSpecAtLine(view.state, lineStart, lineEnd);
      80            1 :     if (spec == null) return false;
      81            1 :     view.dispatch({ effects: isFolded(view.state, spec) ? unfoldEffect.of(spec) : foldEffect.of(spec) });
      82            1 :     return true;
      83            1 : }
      84              : 
      85              : /// Folds and unfolds a spec from the gutter next to the line of its opening brace.
      86              : /// The marker shares the gutter of the error markers and turns red if the line has an error.
      87            1 : export class VegaLiteFoldMarker extends GutterMarker {
      88            1 :     constructor(readonly open: boolean, readonly error: boolean) {
      89            1 :         super();
      90            1 :     }
      91            1 :     eq(other: GutterMarker): boolean {
      92            1 :         return other instanceof VegaLiteFoldMarker && other.open === this.open && other.error === this.error;
      93            1 :     }
      94            1 :     toDOM(): Node {
      95            1 :         const marker = document.createElement('span');
      96            1 :         marker.className = this.error ? 'dashql-vis-fold-marker dashql-vis-fold-marker-error' : 'dashql-vis-fold-marker';
      97            1 :         marker.dataset.testid = 'vis-fold-marker';
      98            1 :         marker.setAttribute('aria-expanded', String(this.open));
      99            1 :         marker.title = this.open ? 'Fold the Vega-Lite spec' : 'Unfold the Vega-Lite spec';
     100            1 :         marker.textContent = this.open ? '▾' : '▸';
     101            1 :         return marker;
     102            1 :     }
     103            1 : }
     104              : 
     105            1 : export const DashQLVisFoldingPlugin: Extension = [
     106            1 :     codeFolding(),
     107            1 :     vegaLiteSpecFolds,
     108            1 :     autoFoldVegaLiteSpecs,
     109            1 :     keymap.of(foldKeymap),
     110            1 : ];
        

Generated by: LCOV version 2.0-1