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 : }
|