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

            Line data    Source code
       1              : import * as core from '@ankoh/dashql-core';
       2              : 
       3              : import { Decoration, DecorationSet, EditorView, GutterMarker, gutter, lineNumbers, WidgetType } from '@codemirror/view';
       4              : import { EditorState, type Extension, Range, RangeSet, RangeSetBuilder, StateEffect, StateEffectType, StateField, Transaction } from '@codemirror/state';
       5              : 
       6              : interface StoryStatement {
       7              :     id: number;
       8              :     from: number;
       9              :     to: number;
      10              :     label: string;
      11              : }
      12              : 
      13              : interface StoryModel {
      14              :     statements: StoryStatement[];
      15              : }
      16              : 
      17              : export type StoryActivation = 'toggle' | 'open';
      18              : 
      19              : interface StoryConfig {
      20              :     activation: StoryActivation;
      21              :     onActivate?: (statementId: number) => void;
      22              :     showGutter?: boolean;
      23              : }
      24              : 
      25              : export interface StoryState {
      26              :     model: StoryModel | null;
      27              :     expanded: ReadonlySet<number>;
      28              :     decorations: DecorationSet;
      29              :     atomicRanges: DecorationSet;
      30              :     gutterMarkers: RangeSet<GutterMarker>;
      31              : }
      32              : 
      33            1 : export const DashQLStoryUpdateEffect: StateEffectType<core.buffers.editor.EditorUpdateT | null> =
      34            1 :     StateEffect.define<core.buffers.editor.EditorUpdateT | null>();
      35            1 : export const DashQLStoryToggleStatementEffect: StateEffectType<number> = StateEffect.define<number>();
      36              : 
      37              : /// Return whether any statement has description comments. This lets the preview preserve its compact
      38              : /// rendering for ordinary SQL while using raw source offsets only when descriptions are present.
      39              : export function hasStatementDescriptions(update: core.buffers.editor.EditorUpdateT | null | undefined): boolean {
      40              :     return (update?.scriptAnnotations?.statementDescriptions.length ?? 0) > 0;
      41              : }
      42              : 
      43            1 : function statementLabel(type: core.buffers.parser.StatementType): string {
      44            1 :     switch (type) {
      45            1 :         case core.buffers.parser.StatementType.CREATE_TABLE:
      46            1 :         case core.buffers.parser.StatementType.CREATE_TABLE_AS:
      47            1 :             return 'create table';
      48            1 :         case core.buffers.parser.StatementType.CREATE_VIEW:
      49            1 :             return 'create view';
      50            1 :         case core.buffers.parser.StatementType.SELECT:
      51            1 :             return 'select';
      52            1 :         case core.buffers.parser.StatementType.SET:
      53            1 :             return 'set';
      54            1 :         case core.buffers.parser.StatementType.CREATE_FUNCTION:
      55            1 :             return 'create function';
      56            1 :         case core.buffers.parser.StatementType.EXPLAIN:
      57            1 :             return 'explain';
      58            1 :         case core.buffers.parser.StatementType.DROP_TABLE:
      59            1 :             return 'drop table';
      60            1 :         case core.buffers.parser.StatementType.DROP_VIEW:
      61            1 :             return 'drop view';
      62            1 :         case core.buffers.parser.StatementType.SELECT_INTO:
      63            1 :             return 'select into';
      64            1 :         case core.buffers.parser.StatementType.ATTACH_DATABASE:
      65            1 :             return 'attach database';
      66            1 :         case core.buffers.parser.StatementType.INSERT:
      67            1 :             return 'insert into';
      68            0 :         default:
      69            0 :             return 'statement';
      70            1 :     }
      71            1 : }
      72              : 
      73            1 : function buildStoryModel(update: core.buffers.editor.EditorUpdateT | null, activation: StoryActivation): StoryModel | null {
      74            1 :     if (update == null || update.diagnostics.some(d => d.source !== core.buffers.editor.EditorDiagnosticSource.ANALYZER)) {
      75            0 :         return null;
      76              :     }
      77            1 :     const statements: StoryStatement[] = [];
      78            1 :     const annotations = update.scriptAnnotations;
      79            1 :     const source = activation === 'toggle' ? annotations?.statements : annotations?.statementDescriptions;
      80            1 :     for (const current of source ?? []) {
      81            1 :         const span = current.textSpan;
      82            1 :         if (span == null) continue;
      83            1 :         const from = Number(span.offset);
      84            1 :         const to = Number(span.offset + span.length);
      85            1 :         if (from >= to) return null;
      86            1 :         statements.push({
      87            1 :             id: current.statementId,
      88            1 :             from,
      89            1 :             to,
      90            1 :             label: statementLabel(current.statementType),
      91            1 :         });
      92            1 :     }
      93            1 :     return statements.length > 0 ? { statements } : null;
      94            1 : }
      95              : 
      96            1 : class ExpandStatementControl extends WidgetType {
      97            1 :     constructor(
      98            1 :         private readonly statementId: number,
      99            1 :         private readonly label: string,
     100            1 :         private readonly activation: StoryActivation,
     101            1 :         private readonly onActivate: ((statementId: number) => void) | undefined,
     102            1 :     ) { super(); }
     103              : 
     104            0 :     eq(other: ExpandStatementControl) {
     105            0 :         return this.statementId === other.statementId && this.label === other.label && this.activation === other.activation;
     106            0 :     }
     107              : 
     108            1 :     toDOM(view: EditorView): HTMLElement {
     109            1 :         const button = document.createElement('button');
     110            1 :         button.type = 'button';
     111            1 :         button.className = 'dashql-story-sql-control';
     112            1 :         button.dataset.testid = 'story-sql-control';
     113            1 :         button.dataset.dashqlStoryControl = 'true';
     114            1 :         button.dataset.dashqlStoryStatement = String(this.statementId);
     115            1 :         button.setAttribute('aria-expanded', 'false');
     116            1 :         button.textContent = this.label;
     117            1 :         button.addEventListener('click', (event) => {
     118            1 :             event.preventDefault();
     119            1 :             event.stopPropagation();
     120            1 :             if (this.activation === 'open') {
     121            1 :                 this.onActivate?.(this.statementId);
     122            1 :                 return;
     123            1 :             }
     124            0 :             view.dispatch({ effects: DashQLStoryToggleStatementEffect.of(this.statementId) });
     125            0 :             requestAnimationFrame(() => {
     126            0 :                 view.dom.querySelector<HTMLButtonElement>(`[data-dashql-story-statement="${this.statementId}"]`)?.focus();
     127            0 :             });
     128            1 :         });
     129            1 :         return button;
     130            1 :     }
     131              : 
     132            0 :     ignoreEvent() { return false; }
     133            1 : }
     134              : 
     135            1 : class StoryFoldMarker extends GutterMarker {
     136            1 :     constructor(
     137            1 :         private readonly statementId: number,
     138            1 :         private readonly expanded: boolean,
     139            1 :     ) { super(); }
     140              : 
     141            1 :     eq(other: StoryFoldMarker) { return this.statementId === other.statementId && this.expanded === other.expanded; }
     142              : 
     143            1 :     toDOM(view: EditorView): HTMLElement {
     144            1 :         const button = document.createElement('button');
     145            1 :         button.type = 'button';
     146            1 :         button.className = 'dashql-story-fold-control';
     147            1 :         button.dataset.testid = 'story-fold-control';
     148            1 :         button.dataset.dashqlStoryControl = 'true';
     149            1 :         button.dataset.dashqlStoryStatement = String(this.statementId);
     150            1 :         button.setAttribute('aria-expanded', String(this.expanded));
     151            1 :         button.setAttribute('aria-label', this.expanded ? 'Collapse SQL statement' : 'Expand SQL statement');
     152            1 :         button.textContent = this.expanded ? '▾' : '▸';
     153            1 :         button.addEventListener('click', event => {
     154            1 :             event.preventDefault();
     155            1 :             event.stopPropagation();
     156            1 :             view.dispatch({ effects: DashQLStoryToggleStatementEffect.of(this.statementId) });
     157            1 :             requestAnimationFrame(() => {
     158            1 :                 view.dom.querySelector<HTMLButtonElement>(`[data-dashql-story-statement="${this.statementId}"]`)?.focus();
     159            1 :             });
     160            1 :         });
     161            1 :         return button;
     162            1 :     }
     163              : 
     164            0 :     ignoreEvent() { return false; }
     165            1 : }
     166              : 
     167            1 : class StoryFoldSpacer extends GutterMarker {
     168            1 :     toDOM(): HTMLElement {
     169            1 :         const spacer = document.createElement('span');
     170            1 :         spacer.className = 'dashql-story-fold-spacer';
     171            1 :         return spacer;
     172            1 :     }
     173            1 : }
     174              : 
     175            1 : function buildDecorations(state: EditorState, model: StoryModel | null, expanded: ReadonlySet<number>, config: StoryConfig): Pick<StoryState, 'decorations' | 'atomicRanges' | 'gutterMarkers'> {
     176            1 :     if (model == null) return { decorations: Decoration.none, atomicRanges: Decoration.none, gutterMarkers: RangeSet.empty };
     177            1 :     const ranges: Range<Decoration>[] = [];
     178            1 :     const atomic: Range<Decoration>[] = [];
     179            1 :     const gutterBuilder = new RangeSetBuilder<GutterMarker>();
     180            1 :     for (const statement of model.statements) {
     181            1 :         const isExpanded = expanded.has(statement.id);
     182            1 :         if (config.activation === 'open' || !isExpanded) {
     183            1 :             const replacement = Decoration.replace({
     184            1 :                 widget: new ExpandStatementControl(statement.id, statement.label, config.activation, config.onActivate),
     185            1 :                 inclusive: false,
     186            1 :             });
     187            1 :             ranges.push(replacement.range(statement.from, statement.to));
     188            1 :             atomic.push(replacement.range(statement.from, statement.to));
     189            1 :         }
     190            1 :         if (config.activation === 'toggle') gutterBuilder.add(statement.from, statement.from, new StoryFoldMarker(statement.id, isExpanded));
     191            1 :     }
     192            1 :     return { decorations: Decoration.set(ranges, true), atomicRanges: Decoration.set(atomic, true), gutterMarkers: gutterBuilder.finish() };
     193            1 : }
     194              : 
     195            1 : export function createStoryDecorations(config: StoryConfig): { extensions: Extension[]; field: StateField<StoryState> } {
     196            1 :     const field = StateField.define<StoryState>({
     197            1 :         create(state) {
     198            1 :             return { model: null, expanded: new Set(), ...buildDecorations(state, null, new Set(), config) };
     199            1 :         },
     200            1 :         update(value, transaction: Transaction) {
     201            1 :             let model = value.model;
     202            1 :             let expanded = value.expanded;
     203            1 :             for (const effect of transaction.effects) {
     204            1 :                 if (effect.is(DashQLStoryUpdateEffect)) {
     205            1 :                     const nextModel = buildStoryModel(effect.value, config.activation);
     206            1 :                     if (config.activation === 'toggle') {
     207            1 :                         const collapsed = new Set(model?.statements
     208            1 :                             .filter(statement => !expanded.has(statement.id))
     209            1 :                             .map(statement => statement.id));
     210            1 :                         expanded = new Set(nextModel?.statements
     211            1 :                             .filter(statement => !collapsed.has(statement.id))
     212            1 :                             .map(statement => statement.id));
     213            1 :                     } else {
     214            1 :                         expanded = new Set();
     215              :                     }
     216            1 :                     model = nextModel;
     217            1 :                 } else if (effect.is(DashQLStoryToggleStatementEffect) && model?.statements.some(s => s.id === effect.value)) {
     218            1 :                     const nextExpanded = new Set(expanded);
     219            1 :                     nextExpanded.has(effect.value) ? nextExpanded.delete(effect.value) : nextExpanded.add(effect.value);
     220            1 :                     expanded = nextExpanded;
     221            1 :                 }
     222              :             }
     223            1 :             if (model === value.model && expanded === value.expanded && !transaction.docChanged) return value;
     224            1 :             return { model, expanded, ...buildDecorations(transaction.state, model, expanded, config) };
     225            1 :         },
     226            1 :         provide: field => [
     227            1 :             EditorView.decorations.from(field, value => value.decorations),
     228            1 :             EditorView.atomicRanges.of(view => view.state.field(field).atomicRanges),
     229            1 :         ],
     230            1 :     });
     231            1 :     const foldGutter = gutter({
     232            1 :         class: 'dashql-story-fold-gutter',
     233            1 :         markers: view => view.state.field(field).gutterMarkers,
     234            1 :         initialSpacer: () => new StoryFoldSpacer(),
     235            1 :     });
     236            1 :     return {
     237            1 :         extensions: config.showGutter === false ? [field] : [lineNumbers(), field, foldGutter],
     238            1 :         field,
     239            1 :     };
     240            1 : }
        

Generated by: LCOV version 2.0-1