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