LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - script_editor.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 99.4 % 174 173
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1