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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as dashql from '@ankoh/dashql-core';
       3              : import * as themes from './themes/index.js';
       4              : 
       5              : import { Compartment, EditorState, Extension, Prec } from '@codemirror/state';
       6              : import { EditorView, drawSelection, keymap, placeholder } from '@codemirror/view';
       7              : import { defaultKeymap, history, historyKeymap, insertNewlineAndIndent } from '@codemirror/commands';
       8              : 
       9              : import { CodeMirrorSearchExtensions } from './codemirror.js';
      10              : import { DashQLExtensions, DashQLFocusCompletionEffect } from './dashql_extension.js';
      11              : import { DashQLCompletionAbortEffect, DashQLProcessorPlugin, DashQLProcessorUpdateOut, DashQLUpdateEffect } from './dashql_processor.js';
      12              : import { replaceScriptSessionText } from '../scripts/session_scripts.js';
      13              : 
      14              : /// Callbacks of the prompt editor, read through a ref so the editor extensions are built only once.
      15              : interface PromptEditorCallbacks {
      16              :     /// Called with the current text when the user submits the prompt
      17              :     onSubmit?: (text: string) => void;
      18              :     /// Called with the current text whenever the document changes
      19              :     onTextChange?: (text: string) => void;
      20              :     /// Called with every editor update of the script session
      21              :     onEditorUpdate?: (update: dashql.buffers.editor.EditorUpdateT) => void;
      22              : }
      23              : 
      24              : /// The SQL assistance: completion, highlighting and auto-closing.
      25              : /// An empty prompt shows its placeholder rather than a completion hint.
      26            1 : const SQL_ASSIST_EXTENSIONS = DashQLExtensions.filter(extension => extension !== DashQLFocusCompletionEffect && extension !== DashQLProcessorPlugin);
      27              : 
      28              : /// Create CodeMirror extensions for a prompt editor.
      29              : /// This is a minimal setup without line numbers and gutters that grows with its content.
      30              : /// Long lines wrap, the prompt scrolls beyond its maximum height without showing scrollbars.
      31              : /// Enter submits, Shift+Enter inserts a newline.
      32            1 : function createPromptExtensions(configurable: Extension[], callbacks: React.RefObject<PromptEditorCallbacks>): Extension[] {
      33            1 :     const submit = (view: EditorView) => {
      34            1 :         const text = view.state.doc.toString();
      35            1 :         if (text.trim().length > 0) {
      36            1 :             callbacks.current?.onSubmit?.(text);
      37              :         }
      38            1 :         return true;
      39            1 :     };
      40            1 :     return [
      41            1 :         themes.xcode,
      42            1 :         drawSelection(),
      43            1 :         history(),
      44              :         // The processor keeps running with any assistance, the editor updates of the script session are reported
      45            1 :         DashQLProcessorPlugin,
      46            1 :         ...CodeMirrorSearchExtensions,
      47              :         // Below the completion keymap (Prec.highest), above the default Enter binding
      48            1 :         Prec.high(keymap.of([
      49            1 :             { key: 'Enter', run: submit },
      50            1 :             { key: 'Mod-Enter', run: submit },
      51            1 :             { key: 'Shift-Enter', run: insertNewlineAndIndent },
      52            1 :         ])),
      53            1 :         keymap.of([...defaultKeymap, ...historyKeymap]),
      54            1 :         ...configurable,
      55            1 :         EditorView.updateListener.of(update => {
      56            1 :             if (update.docChanged) {
      57            1 :                 callbacks.current?.onTextChange?.(update.state.doc.toString());
      58              :             }
      59            1 :         }),
      60            1 :         EditorView.lineWrapping,
      61            1 :         EditorView.theme({
      62            1 :             '&': { minHeight: '24px', backgroundColor: 'transparent' },
      63            1 :             '&.cm-focused': { outline: 'none' },
      64            1 :             '.cm-scroller': { overflow: 'auto', maxHeight: 'var(--prompt_editor_max_height, 40vh)', scrollbarWidth: 'none' },
      65            1 :             '.cm-content': { minHeight: '24px', padding: '0' },
      66            1 :             '.cm-line': { padding: '0' },
      67            1 :             '.cm-gutters': { display: 'none' },
      68            1 :         }),
      69            1 :     ];
      70            1 : }
      71              : 
      72              : export interface PromptEditorProps {
      73              :     /// The script session that backs analysis and completion, null while it is being set up
      74              :     scriptSession: dashql.DashQLScriptSession | null;
      75              :     /// The text shown while the prompt is empty
      76              :     placeholder: string;
      77              :     /// The keyword case for completions
      78              :     keywordCase?: dashql.buffers.formatting.KeywordCase;
      79              :     /// The class name of the container
      80              :     className?: string;
      81              :     /// Called with the current text when the user submits the prompt
      82              :     onSubmit?: (text: string) => void;
      83              :     /// Called with the current text whenever the document changes
      84              :     onTextChange?: (text: string) => void;
      85              :     /// Called with every editor update of the script session, e.g. to tell SQL from natural language
      86              :     onEditorUpdate?: (update: dashql.buffers.editor.EditorUpdateT) => void;
      87              :     /// Replaces the SQL assistance, e.g. to assist natural language
      88              :     assist?: Extension;
      89              :     /// Receives the CodeMirror view once it is mounted
      90              :     setView?: (view: EditorView | null) => void;
      91              : }
      92              : 
      93              : /// A minimal SQL prompt backed by CodeMirror and wired with a DashQL script session.
      94              : ///
      95              : /// The session belongs to the caller, a replaced session can be destroyed in an effect. It is not a
      96              : /// session script, so analysis results are kept local to the editor and never loaded into the catalog.
      97            1 : export const PromptEditor: React.FC<PromptEditorProps> = (props) => {
      98            1 :     const callbacks = React.useRef<PromptEditorCallbacks>({});
      99            1 :     callbacks.current = { onSubmit: props.onSubmit, onTextChange: props.onTextChange, onEditorUpdate: props.onEditorUpdate };
     100              : 
     101              :     // Create the CodeMirror view when the DOM node is mounted
     102            1 :     const [editorNode, setEditorNode] = React.useState<HTMLDivElement | null>(null);
     103            1 :     const [editorView, setEditorView] = React.useState<EditorView | null>(null);
     104            1 :     const compartments = React.useMemo(() => ({
     105            1 :         placeholder: new Compartment(),
     106            1 :         assist: new Compartment(),
     107            1 :     }), []);
     108            1 :     React.useEffect(() => {
     109            1 :         if (editorNode == null) {
     110            1 :             return;
     111              :         }
     112            1 :         const configurable = [
     113            1 :             compartments.placeholder.of(placeholder(props.placeholder)),
     114            1 :             compartments.assist.of(props.assist ?? SQL_ASSIST_EXTENSIONS),
     115            1 :         ];
     116            1 :         const view = new EditorView({
     117            1 :             state: EditorState.create({ extensions: createPromptExtensions(configurable, callbacks) }),
     118            1 :             parent: editorNode,
     119            1 :         });
     120            1 :         setEditorView(view);
     121            1 :         return () => {
     122            1 :             view.destroy();
     123            1 :             setEditorView(null);
     124            1 :         };
     125            1 :     }, [editorNode]);
     126              : 
     127              :     // Swap the placeholder and the assistance without recreating the view
     128            1 :     React.useEffect(() => {
     129            1 :         editorView?.dispatch({ effects: compartments.placeholder.reconfigure(placeholder(props.placeholder)) });
     130            1 :     }, [editorView, props.placeholder]);
     131            1 :     React.useEffect(() => {
     132            1 :         if (editorView == null) return;
     133            1 :         const next = props.assist ?? SQL_ASSIST_EXTENSIONS;
     134            1 :         if (compartments.assist.get(editorView.state) === next) return;
     135            1 :         editorView.dispatch({
     136            1 :             effects: [
     137            1 :                 compartments.assist.reconfigure(next),
     138              :                 // A SQL completion makes no sense with a different assistance
     139            1 :                 ...(props.assist != null ? [DashQLCompletionAbortEffect.of(null)] : []),
     140            1 :             ],
     141            1 :         });
     142            1 :     }, [editorView, props.assist]);
     143              : 
     144              :     // Forward the view
     145            1 :     React.useEffect(() => {
     146            1 :         props.setView?.(editorView);
     147            1 :     }, [editorView, props.setView]);
     148              : 
     149              :     // Bind the script session to the editor.
     150              :     // A new session (e.g. after the connection catalog changed) takes over the current text. The
     151              :     // binding is a layout effect, the caller may destroy the previous session in a regular effect.
     152            1 :     React.useLayoutEffect(() => {
     153            1 :         const scriptSession = props.scriptSession;
     154            1 :         if (editorView == null || scriptSession == null) {
     155            1 :             return;
     156              :         }
     157            1 :         const text = editorView.state.doc.toString();
     158            1 :         if (scriptSession.getText() !== text) {
     159            1 :             replaceScriptSessionText(scriptSession, text);
     160              :         }
     161            1 :         const onUpdate = (update: DashQLProcessorUpdateOut) => {
     162            1 :             if (update.editorUpdate != null) {
     163            1 :                 callbacks.current?.onEditorUpdate?.(update.editorUpdate);
     164              :             }
     165            1 :         };
     166            1 :         editorView.dispatch({
     167            1 :             effects: DashQLUpdateEffect.of({
     168            1 :                 scriptKey: scriptSession.getCatalogEntryId(),
     169            1 :                 scriptSession,
     170            1 :                 editorUpdate: scriptSession.analyze(),
     171            1 :                 scriptBuffers: null,
     172            1 :                 scriptCompletion: null,
     173            1 :                 scriptPendingDiff: null,
     174            1 :                 derivedFocus: null,
     175            1 :                 keywordCase: props.keywordCase,
     176            1 :                 onUpdate,
     177            1 :             }),
     178            1 :         });
     179            1 :     }, [editorView, props.scriptSession, props.keywordCase]);
     180              : 
     181            1 :     return <div className={props.className} ref={setEditorNode} />;
     182            1 : };
        

Generated by: LCOV version 2.0-1