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