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

            Line data    Source code
       1              : import * as dashql from '@ankoh/dashql-core';
       2              : 
       3              : import { Range } from '@codemirror/state';
       4              : import { EditorView, Decoration, DecorationSet, WidgetType, ViewPlugin, ViewUpdate } from '@codemirror/view';
       5              : 
       6              : import { DashQLCompletionState, DashQLProcessorPlugin } from './dashql_processor.js';
       7              : import { CompletionPatch, PATCH_DELETE_TEXT, PATCH_INSERT_TEXT, CompletionPatchTarget, TextAnchor, comparePatches } from './dashql_completion_patches.js';
       8              : 
       9              : import symbols from '@ankoh/dashql-svg-symbols';
      10              : 
      11              : import * as styles from './dashql_completion_hint.module.css';
      12              : 
      13              : type Hint = CompletionPatch & {
      14              :     /// Should we render the category controls for the user?
      15              :     /// We want to hint the user that he can click certain keys to apply a patch.
      16              :     controls: boolean;
      17              : };
      18              : 
      19              : interface CompletionHints {
      20              :     /// The candidate completion hint
      21              :     candidateHints: Hint[];
      22              :     /// The qualifier for the candidate
      23              :     catalogObjectHints: Hint[];
      24              : }
      25              : 
      26            1 : function selectControls(hints: Hint[], preferFirst: boolean) {
      27            1 :     let firstInsert: number | null = null;
      28            1 :     let firstDelete: number | null = null;
      29            1 :     let lastInsert: number | null = null;
      30            1 :     let lastDelete: number | null = null;
      31              : 
      32              :     // Determine the first inserts & deletes
      33            1 :     for (let i = 0; i < hints.length; ++i) {
      34            1 :         const hint = hints[i];
      35            1 :         if (hint.type == PATCH_INSERT_TEXT && firstInsert == null) {
      36            1 :             firstInsert = i;
      37              :         }
      38            1 :         if (hint.type == PATCH_DELETE_TEXT && firstDelete == null) {
      39            0 :             firstDelete = i;
      40              :         }
      41            1 :         if (firstInsert != null && firstDelete != null) {
      42            0 :             break;
      43              :         }
      44            1 :     }
      45              : 
      46              :     // Determine the last inserts & deletes
      47            1 :     for (let i = hints.length - 1; i >= 0; --i) {
      48            1 :         const hint = hints[i];
      49            1 :         if (hint.type == PATCH_INSERT_TEXT && lastInsert == null) {
      50            1 :             lastInsert = i;
      51              :         }
      52            1 :         if (hint.type == PATCH_DELETE_TEXT && lastDelete == null) {
      53            0 :             lastDelete = i;
      54              :         }
      55            1 :         if (lastInsert != null && lastDelete != null) {
      56            0 :             break;
      57              :         }
      58            1 :     }
      59              :     // Select the hint with controls
      60            1 :     let controlsAt = null;
      61            1 :     if (preferFirst) {
      62            1 :         controlsAt = firstInsert != null ? firstInsert : firstDelete;
      63              :     } else {
      64            1 :         controlsAt = lastInsert != null ? lastInsert : lastDelete;
      65              :     }
      66            1 :     if (controlsAt != null) {
      67            1 :         hints[controlsAt].controls = true;
      68              :     }
      69            1 : }
      70              : 
      71              : /// Helper to compute the completion hints given a completion candidate a new editor state
      72            1 : export function deriveCompletionHints(state: DashQLCompletionState): CompletionHints {
      73            1 :     const hints: CompletionHints = {
      74            1 :         candidateHints: state.candidatePatch.map(p => ({ ...p, controls: false })),
      75            1 :         catalogObjectHints: state.catalogObjectPatch.map(p => ({ ...p, controls: false })),
      76            1 :     };
      77            1 :     selectControls(hints.candidateHints, false);
      78            1 :     selectControls(hints.catalogObjectHints, true);
      79            1 :     return hints;
      80            1 : }
      81              : 
      82            1 : const INSERT_CLASSNAMES = [
      83            1 :     styles.hint_candidate_insert,
      84            1 :     styles.hint_qualification_insert,
      85            1 : ];
      86            1 : const DELETE_CLASSNAMES = [
      87            1 :     styles.hint_candidate_delete,
      88            1 :     styles.hint_qualification_delete,
      89            1 : ];
      90            1 : function getInsertClassNameForCategory(category: CompletionPatchTarget): string {
      91            1 :     return INSERT_CLASSNAMES[category as number - 1];
      92            1 : }
      93            0 : function getDeleteClassNameForCategory(category: CompletionPatchTarget): string {
      94            0 :     return DELETE_CLASSNAMES[category as number - 1];
      95            0 : }
      96              : 
      97            1 : export class InsertPatchWidget extends WidgetType {
      98            1 :     constructor(
      99            1 :         protected text: string,
     100            1 :         protected className: string
     101            1 :     ) {
     102            1 :         super();
     103            1 :     }
     104            1 :     eq(other: InsertPatchWidget): boolean {
     105            1 :         return this.text === other.text;
     106            1 :     }
     107            1 :     toDOM(): HTMLElement {
     108            1 :         const span = document.createElement('span');
     109            1 :         span.className = this.className;
     110            1 :         span.textContent = this.text;
     111            1 :         span.style.lineHeight = '0';
     112            1 :         return span;
     113            1 :     }
     114            1 :     get estimatedHeight(): number {
     115              :         // Inline hints must not make an otherwise empty editor line taller.
     116            1 :         return 0;
     117            1 :     }
     118            1 : }
     119              : 
     120            1 : class HintKeyWidget extends WidgetType {
     121            1 :     constructor(
     122            1 :         protected n: number | null,
     123            1 :     ) {
     124            1 :         super();
     125            1 :     }
     126            1 :     eq(other: HintKeyWidget): boolean {
     127            1 :         return this.n == other.n;
     128            1 :     }
     129            1 :     toDOM(): HTMLElement {
     130            1 :         const span = document.createElement('span');
     131              : 
     132              :         // Create the inner span with hint_ctrl_overlap_container class
     133            1 :         const innerSpan = document.createElement('span');
     134            1 :         innerSpan.className = styles.hint_ctrl_overlap_container;
     135              : 
     136              :         // Create the label for the counter (if we have one)
     137            1 :         if (this.n != null) {
     138            0 :             const labelSpan = document.createElement('span');
     139            0 :             labelSpan.className = styles.hint_ctrl_label;
     140            0 :             labelSpan.textContent = String(this.n);
     141            0 :             innerSpan.appendChild(labelSpan);
     142            0 :         }
     143              : 
     144              :         // Create the SVG element
     145            1 :         const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
     146            1 :         svg.setAttribute('width', '10px');
     147            1 :         svg.setAttribute('height', '10px');
     148            1 :         const svgSymbol = document.createElementNS('http://www.w3.org/2000/svg', 'use');
     149            1 :         svgSymbol.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `${symbols}#keyboard_tab_24`);
     150            1 :         svg.appendChild(svgSymbol);
     151              : 
     152            1 :         innerSpan.appendChild(svg);
     153            1 :         span.appendChild(innerSpan);
     154              : 
     155            1 :         span.className = styles.hint_ctrl_container;
     156            1 :         return span;
     157            1 :     }
     158            0 :     get estimatedHeight(): number {
     159            0 :         return -1; // Use line height
     160            0 :     }
     161            1 : }
     162              : 
     163            1 : function computeCompletionHintDecorations(viewUpdate: ViewUpdate): DecorationSet {
     164            1 :     const processor = viewUpdate.state.field(DashQLProcessorPlugin);
     165              : 
     166              :     // Find the selected completion
     167            1 :     if (processor.scriptCompletion == null || processor.editorUpdate?.primaryCursorState == null) {
     168            1 :         return Decoration.none;
     169              :     }
     170            1 :     const isPassive = processor.scriptCompletion.passiveHint;
     171              : 
     172              :     // Suppress the inline hint when the selected candidate has an incompatible suffix.
     173            1 :     const completionBuffer = processor.scriptCompletion.buffer.read();
     174            1 :     const selectedCandidate = completionBuffer.candidates(processor.scriptCompletion.candidateId);
     175            1 :     if (selectedCandidate != null
     176            1 :         && (selectedCandidate.candidateTags()! & dashql.buffers.completion.CandidateTag.SUFFIX_INCOMPATIBLE) != 0) {
     177            1 :         return Decoration.none;
     178              :     }
     179              : 
     180              :     // Compute the new completion hints
     181            1 :     const hints = deriveCompletionHints(processor.scriptCompletion);
     182            1 :     if (hints == null) {
     183            1 :         return Decoration.none;
     184              :     }
     185              : 
     186              :     // Merge all hints.
     187              :     // Codemirror requires Decorations to be ordered by `at` & `side`
     188            1 :     const mergedPatches = [
     189            1 :         ...hints.candidateHints,
     190            1 :         ...hints.catalogObjectHints
     191            1 :     ];
     192            1 :     mergedPatches.sort(comparePatches);
     193              : 
     194              :     // Create decorations
     195            1 :     const decorations: Range<Decoration>[] = [];
     196            1 :     for (const patch of mergedPatches) {
     197            1 :         switch (patch.type) {
     198            1 :             case PATCH_INSERT_TEXT: {
     199            1 :                 const side = (patch.value.textAnchor == TextAnchor.Left ? 1 : -1) * (patch.target as number);
     200              : 
     201              :                 /// Construct the insert widget
     202            1 :                 const insertClassname = getInsertClassNameForCategory(patch.target);
     203            1 :                 const insertWidget = new InsertPatchWidget(patch.value.text, insertClassname);
     204            1 :                 const insertDeco = Decoration.widget({ widget: insertWidget, side }).range(patch.value.at);
     205            1 :                 decorations.push(insertDeco);
     206              : 
     207              :                 // Insert controls after?
     208            1 :                 if (patch.controls && !isPassive) {
     209            1 :                     const controlsWidget = new HintKeyWidget(null);
     210            1 :                     const controlDeco = Decoration.widget({ widget: controlsWidget, side }).range(patch.value.at);
     211            1 :                     decorations.push(controlDeco);
     212            1 :                 }
     213            1 :                 break;
     214            1 :             }
     215            1 :             case PATCH_DELETE_TEXT:
     216              :                 // Construct the deletion widget
     217            1 :                 const deleteClassName = getDeleteClassNameForCategory(patch.target);
     218            1 :                 const deleteDeco = Decoration.mark({ class: deleteClassName }).range(patch.value.at, patch.value.at + patch.value.length);
     219            1 :                 decorations.push(deleteDeco);
     220              : 
     221              :                 // Emit controls?
     222            1 :                 if (patch.controls && !isPassive) {
     223            1 :                     const controlsWidget = new HintKeyWidget(null);
     224            1 :                     const controlDeco = Decoration.widget({ widget: controlsWidget }).range(patch.value.at);
     225            1 :                     decorations.push(controlDeco);
     226            1 :                 }
     227              :                 break;
     228            1 :         }
     229              :     }
     230            1 :     return Decoration.set(decorations);
     231            1 : };
     232              : 
     233            1 : export const DashQLCompletionHintPlugin = ViewPlugin.fromClass(
     234            1 :     class {
     235            1 :         decorations: DecorationSet;
     236            1 :         constructor(readonly view: EditorView) {
     237            1 :             this.decorations = Decoration.none;
     238            1 :         }
     239            1 :         update(u: ViewUpdate) {
     240            1 :             const next = computeCompletionHintDecorations(u);
     241            1 :             if (next !== this.decorations) {
     242            1 :                 this.decorations = next;
     243              :             }
     244            1 :         }
     245            1 :     },
     246            1 :     { decorations: v => v.decorations }
     247            1 : );
        

Generated by: LCOV version 2.0-1