LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - script_editor.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 100.0 % 178 178
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './script_editor.module.css';
       3              : 
       4              : import { EditorView } from '@codemirror/view';
       5              : import { ChangeSpec, EditorSelection, StateEffect, EditorState } from '@codemirror/state';
       6              : 
       7              : import { CodeMirror, createCodeMirrorExtensions } from '../editor/codemirror.js';
       8              : import { DashQLProcessorPlugin, DashQLProcessorUpdateOut, DashQLUpdateEffect } from '../editor/dashql_processor.js';
       9              : import { ScriptData, ANALYZE_OUTDATED_SCRIPT, UPDATE_FROM_PROCESSOR, SessionScripts, getSelectedScriptRef } from './session_scripts.js';
      10              : import { AppConfig, useAppConfig } from '../../config/app_config.js';
      11              : import { useLogger } from '../../platform/logger/logger_provider.js';
      12              : import { ModifySessionScripts, useSessionScripts } from './session_scripts_registry.js';
      13              : import { Logger } from '../../platform/logger/logger.js';
      14              : import { getKeywordCase } from './script_format.js';
      15              : import { checkSessionScriptReferences } from './script_reference_reports.js';
      16              : import { applyScriptTextChanges } from './script_reference_check.js';
      17              : import { DashQLScriptReferencesEffect, shownScriptReferences } from '../editor/dashql_script_references.js';
      18              : 
      19            1 : const LOG_CTX = "session_editor";
      20              : /// Typing in one script marks all other scripts outdated.
      21              : /// Re-analyze the scripts in the background once the user pauses instead of on every keystroke.
      22            1 : const BACKGROUND_ANALYSIS_DELAY_MS = 150;
      23            1 : const WRITABLE_SESSION_VIEWS = new WeakMap<object, EditorView>();
      24              : 
      25            1 : function leaseWritableSession(scriptSession: object, view: EditorView): () => void {
      26            1 :     const current = WRITABLE_SESSION_VIEWS.get(scriptSession);
      27            1 :     if (current != null && current !== view) {
      28            1 :         throw new Error('A DashQLScriptSession can only be bound to one writable editor view');
      29              :     }
      30            1 :     WRITABLE_SESSION_VIEWS.set(scriptSession, view);
      31            1 :     return () => {
      32            1 :         if (WRITABLE_SESSION_VIEWS.get(scriptSession) === view) {
      33            1 :             WRITABLE_SESSION_VIEWS.delete(scriptSession);
      34              :         }
      35            1 :     };
      36            1 : }
      37              : 
      38              : export interface ScriptEditorProps {
      39              :     sessionId: string;
      40              :     scriptKey: number;
      41              :     className?: string;
      42              :     autoHeight?: boolean;
      43              :     lineWrapping?: boolean;
      44              :     setView?: (view: EditorView) => void;
      45              :     onFocus?: () => void;
      46              :     onNavigateToScript?: (scriptKey: number) => void;
      47              :     dependencies?: ScriptEditorDependencies;
      48              : }
      49              : 
      50              : export interface ScriptEditorDependencies {
      51              :     CodeMirror: typeof CodeMirror;
      52              :     createCodeMirrorExtensions: typeof createCodeMirrorExtensions;
      53              :     useSessionScripts: typeof useSessionScripts;
      54              :     useAppConfig: typeof useAppConfig;
      55              :     useLogger: typeof useLogger;
      56              : }
      57              : 
      58            1 : const DEFAULT_DEPENDENCIES: ScriptEditorDependencies = {
      59            1 :     CodeMirror,
      60            1 :     createCodeMirrorExtensions,
      61            1 :     useSessionScripts,
      62            1 :     useAppConfig,
      63            1 :     useLogger,
      64            1 : };
      65              : 
      66            1 : const LINE_WRAPPING_EXTENSIONS = [EditorView.lineWrapping];
      67              : 
      68            1 : export const ScriptEditor: React.FC<ScriptEditorProps> = (props) => {
      69            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
      70            1 :     const extraExtensions = props.lineWrapping ? LINE_WRAPPING_EXTENSIONS : undefined;
      71            1 :     const logger = dependencies.useLogger();
      72            1 :     const config = dependencies.useAppConfig();
      73            1 :     const [scripts, modifyScripts] = dependencies.useSessionScripts(props.sessionId);
      74              : 
      75            1 :     const scriptData = scripts?.scripts[props.scriptKey] ?? null;
      76              : 
      77              :     // Update outdated scripts that are displayed in the editor.
      78              :     // The selected script is analyzed right away, all others are deferred.
      79              :     // Deferring is safe since the script stays marked as outdated until the analysis ran,
      80              :     // and anything that needs the analysis analyzes it on demand (see ensureSessionScriptAnalyzed).
      81            1 :     React.useEffect(() => {
      82            1 :         if (scripts == null || !scriptData?.analysisOutdated) {
      83            1 :             return;
      84              :         }
      85            1 :         const scriptKey = scriptData.scriptKey;
      86            1 :         if (getSelectedScriptRef(scripts)?.scriptId === scriptKey) {
      87            1 :             modifyScripts({ type: ANALYZE_OUTDATED_SCRIPT, value: scriptKey });
      88            1 :             return;
      89            1 :         }
      90            1 :         const timeout = setTimeout(() => {
      91            1 :             modifyScripts({ type: ANALYZE_OUTDATED_SCRIPT, value: scriptKey });
      92            1 :         }, BACKGROUND_ANALYSIS_DELAY_MS);
      93            1 :         return () => clearTimeout(timeout);
      94            1 :     }, [scriptData]);
      95              : 
      96              :     // Track the current CodeMirror view
      97            1 :     const [view, setViewState] = React.useState<EditorView | null>(null);
      98            1 :     React.useEffect(() => {
      99            1 :         if (view == null || scriptData == null) return;
     100            1 :         return leaseWritableSession(scriptData.scriptSession, view);
     101            1 :     }, [view, scriptData?.scriptSession]);
     102              :     // Effect to update the editor script whenever the script changes
     103            1 :     React.useEffect(() => {
     104            1 :         if (config == null || view == null || scriptData == null || scripts == null) return;
     105              :         // Binding a script replaces the editor state, which has to keep the extensions of the mount
     106            1 :         const createExtensions = () => [...dependencies.createCodeMirrorExtensions(), ...(extraExtensions ?? [])];
     107            1 :         updateEditor(view, scripts, scriptData, modifyScripts, logger, config, createExtensions, props.onNavigateToScript);
     108            1 :     }, [
     109            1 :         config,
     110            1 :         view,
     111            1 :         scriptData?.scriptSession,
     112            1 :         scriptData?.editorUpdate,
     113            1 :         scriptData?.pendingDiff,
     114            1 :         scripts?.semanticUserFocus,
     115            1 :         scriptData?.connection.catalog,
     116            1 :         props.onNavigateToScript,
     117            1 :     ]);
     118              :     // The checked script references of the script, the check covers every script of the session
     119            1 :     const references = React.useMemo(
     120            1 :         () => scripts == null || scriptData == null ? null : checkSessionScriptReferences(scripts).get(scriptData.scriptKey) ?? null,
     121            1 :         [scripts],
     122            1 :     );
     123              :     // After the effect above, which may replace the editor state
     124            1 :     React.useEffect(() => {
     125            1 :         if (view == null || shownScriptReferences(view.state) === references) return;
     126            1 :         view.dispatch({ effects: DashQLScriptReferencesEffect.of(references) });
     127            1 :     }, [view, references, scriptData?.editorUpdate, scriptData?.scriptSession]);
     128              :     // Forward the view ref, if requested
     129            1 :     React.useEffect(() => {
     130            1 :         if (props.setView && view != null) {
     131            1 :             props.setView(view);
     132              :         }
     133            1 :     }, [view, props.setView]);
     134              : 
     135              :     // The CodeMirror view only reads the initial document when mounting, don't copy the text on every render
     136            1 :     const initialDoc = React.useMemo(() => scriptData?.scriptSession.getText() ?? '', [scriptData?.scriptSession]);
     137              : 
     138            1 :     const containerClass = [
     139            1 :         props.autoHeight ? styles.auto_height_editor : styles.editor,
     140            1 :         props.className,
     141            1 :     ].filter(Boolean).join(' ');
     142              : 
     143            1 :     return (
     144            1 :         <div className={containerClass} onFocusCapture={props.onFocus}>
     145            1 :             <dependencies.CodeMirror
     146            1 :                 ref={setViewState}
     147            1 :                 initialDoc={initialDoc}
     148            1 :                 extraExtensions={extraExtensions}
     149            1 :                 style={props.autoHeight ? { height: 'auto' } : undefined}
     150            1 :             />
     151            1 :         </div>
     152              :     );
     153            1 : };
     154              : 
     155            1 : function updateEditor(view: EditorView, scripts: SessionScripts, scriptData: ScriptData, modifyScripts: ModifySessionScripts, logger: Logger, config: AppConfig, createExtensions: typeof createCodeMirrorExtensions, onNavigateToScript?: (scriptKey: number) => void) {
     156            1 :     const state = view.state.field(DashQLProcessorPlugin);
     157            1 :     const changes: ChangeSpec[] = [];
     158            1 :     const effects: StateEffect<any>[] = [];
     159              : 
     160              :     // Script does not belong here?
     161              :     // Create a new editor state and update the view.
     162              :     // XXX Here's the place where we would restore a previous state, if one exists.
     163            1 :     if (state.scriptSession !== scriptData.scriptSession) {
     164            1 :         const scriptText = scriptData.scriptSession.getText();
     165            1 :         logger.debug("Resetting editor for a different native session", {
     166            1 :             sessionId: scripts.sessionId,
     167            1 :             scriptKey: scriptData.scriptKey.toString(),
     168            1 :             editorHadSession: (state.scriptSession != null).toString(),
     169            1 :             nativeTextVersion: scriptData.scriptSession.getTextVersion().toString(),
     170            1 :             projectedTextVersion: scriptData.editorUpdate?.textVersion.toString(),
     171            1 :         }, LOG_CTX);
     172              :         // When that happens we have to reset the editor state.
     173              :         // It means that someone gave us a new session script that requires a state update
     174            1 :         const extensions = createExtensions();
     175            1 :         const newState = EditorState.create({ doc: scriptText, extensions });
     176            1 :         view.setState(newState);
     177            1 :     }
     178              : 
     179              :     // Initial setup or unexpected script buffers?
     180              :     // Then we reset everything to make sure the script is ok.
     181              :     // Only replace if the doc content actually differs — the editor may already have the
     182              :     // correct text from its own transaction (e.g. autoclose inserting brackets).
     183            1 :     let mapsSelection = false;
     184            1 :     if (state.editorUpdate?.stateRevision !== scriptData.editorUpdate?.stateRevision) {
     185            1 :         const scriptText = scriptData.scriptSession.getText();
     186            1 :         const editorText = view.state.doc.toString();
     187            1 :         const external = scriptData.externalChanges;
     188              :         // The changes apply only to the text they were made for and only if they yield the text of the session
     189            1 :         if (scriptText !== editorText && external != null && external.fromText === editorText
     190            1 :             && applyScriptTextChanges(editorText, external.changes) === scriptText) {
     191              :             // A change of the app that the editor did not see, e.g. a renamed script reference.
     192              :             // Applied as changes, the editor keeps the cursor and can undo them.
     193            1 :             changes.push(...external.changes);
     194            1 :             mapsSelection = true;
     195            1 :         } else if (scriptText !== editorText) {
     196            1 :             logger.warn("Replacing CodeMirror text from native editor session", {
     197            1 :                 sessionId: scripts.sessionId,
     198            1 :                 scriptKey: scriptData.scriptKey.toString(),
     199            1 :                 editorTextLength: editorText.length.toString(),
     200            1 :                 scriptTextLength: scriptText.length.toString(),
     201            1 :                 editorStateRevision: state.editorUpdate?.stateRevision.toString(),
     202            1 :                 projectedStateRevision: scriptData.editorUpdate?.stateRevision.toString(),
     203            1 :             }, LOG_CTX);
     204            1 :             changes.push({
     205            1 :                 from: 0,
     206            1 :                 to: view.state.doc.length,
     207            1 :                 insert: scriptText,
     208            1 :             });
     209            1 :         }
     210            1 :     }
     211              : 
     212              :     // Did the cursor change externally (not from the editor itself)?
     213              :     // Only override the selection if the cursor was set from outside (e.g. clicking a table ref).
     214              :     // Never override when the cursor update originated from the editor's own selection changes,
     215              :     // as that would collapse an in-progress text selection.
     216            1 :     let selection: EditorSelection | null = null;
     217            1 :     if (!mapsSelection && state.editorUpdate?.stateRevision !== scriptData.editorUpdate?.stateRevision && state.scriptSession === scriptData.scriptSession) {
     218            1 :         const mainSel = view.state.selection.main;
     219            1 :         if (mainSel.empty) {
     220            1 :             const nextCursorOffset = scriptData.editorUpdate?.primaryCursorState?.textOffset == null
     221            1 :                 ? null
     222            1 :                 : Number(scriptData.editorUpdate.primaryCursorState.textOffset);
     223            1 :             if (nextCursorOffset != null && nextCursorOffset !== mainSel.head) {
     224            1 :                 const clampedOffset = Math.max(0, Math.min(nextCursorOffset, view.state.doc.length));
     225            1 :                 selection = EditorSelection.create([EditorSelection.cursor(clampedOffset)]);
     226            1 :                 logger.debug("Projecting external cursor into CodeMirror", {
     227            1 :                     sessionId: scripts.sessionId,
     228            1 :                     scriptKey: scriptData.scriptKey.toString(),
     229            1 :                     currentCursorOffset: mainSel.head.toString(),
     230            1 :                     nextCursorOffset: clampedOffset.toString(),
     231            1 :                 }, LOG_CTX);
     232            1 :             }
     233            1 :         }
     234            1 :     }
     235              : 
     236              :     // XXX Detect invalid selections
     237              : 
     238              :     // Helper to update a script.
     239              :     // Called when the script gets updated by the CodeMirror extension.
     240              :     // Note that this is also called when the state is set up initially.
     241            1 :     const updateScript = (update: DashQLProcessorUpdateOut) => {
     242            1 :         logger.debug("Dispatching editor processor update", {
     243            1 :             sessionId: scripts.sessionId,
     244            1 :             scriptKey: update.scriptKey.toString(),
     245            1 :             textVersion: update.editorUpdate?.textVersion.toString(),
     246            1 :             stateRevision: update.editorUpdate?.stateRevision.toString(),
     247            1 :             cursorOffset: update.editorUpdate?.primaryCursorState?.textOffset?.toString(),
     248            1 :         }, LOG_CTX);
     249            1 :         modifyScripts({
     250            1 :             type: UPDATE_FROM_PROCESSOR,
     251            1 :             value: update,
     252            1 :         });
     253            1 :     };
     254              : 
     255              : 
     256              :     // Notify the CodeMirror extension
     257            1 :     effects.push(
     258            1 :         DashQLUpdateEffect.of({
     259            1 :             scriptKey: scriptData.scriptKey,
     260            1 :             scriptSession: scriptData.scriptSession,
     261            1 :             editorUpdate: scriptData.editorUpdate,
     262            1 :             scriptBuffers: null,
     263            1 :             scriptCompletion: scriptData.completion,
     264            1 :             scriptPendingDiff: scriptData.pendingDiff,
     265              : 
     266            1 :             derivedFocus: scripts.semanticUserFocus,
     267            1 :             keywordCase: getKeywordCase(config.settings),
     268              : 
     269            1 :             lookupScriptSession: (scriptKey) => scripts.scripts[scriptKey]?.scriptSession ?? null,
     270            1 :             onNavigateToScript,
     271              : 
     272            1 :             onUpdate: updateScript,
     273            1 :         }),
     274            1 :     );
     275            1 :     view.dispatch({ changes, effects, selection: selection ?? undefined });
     276            1 : }
        

Generated by: LCOV version 2.0-1