Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as themes from '../session/editor/themes/index.js';
4 :
5 : import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
6 : import { Compartment, EditorState, type Extension } from '@codemirror/state';
7 : import { EditorView, drawSelection, keymap, lineNumbers } from '@codemirror/view';
8 :
9 : import { createScriptFormatConfig, getKeywordCase, measureScriptFormatWidth } from '../session/scripts/script_format.js';
10 : import { useAppConfig } from '../config/app_config.js';
11 : import { CodeMirror } from '../session/editor/codemirror.js';
12 : import {
13 : DashQLScannerDecorationUpdateEffect,
14 : DashQLStandaloneScannerDecorationPlugin,
15 : } from '../session/editor/dashql_decorations_standalone.js';
16 : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
17 : import { useFocusTrap } from '../ui/foundations/focus.js';
18 : import { Overlay, OverlaySize } from '../ui/foundations/overlay.js';
19 : import { SegmentedControl, SegmentedControlSize } from '../ui/foundations/segmented_control.js';
20 : import { AlertIcon, ScreenFullIcon, ScreenNormalIcon, XIcon } from '../ui/foundations/symbol_icon.js';
21 : import { CopyToClipboardButton } from '../ui/foundations/clipboard.js';
22 : import * as styles from './format_dialog.module.css';
23 :
24 1 : const IGNORE_OUTSIDE_CLICK = () => {};
25 1 : const LOG_CTX = 'shell_format';
26 : /// Edits are validated once the user pauses typing, analyzing the script per keystroke is too expensive
27 1 : const VALIDATION_DELAY_MS = 150;
28 :
29 1 : const enum FormatMode {
30 1 : Raw = 0,
31 1 : Compact = 1,
32 1 : Pretty = 2,
33 1 : }
34 :
35 1 : const FORMAT_LABELS = ['Raw', 'Compact', 'Pretty'] as const;
36 1 : const FORMAT_TEST_IDS = ['format-mode-raw', 'format-mode-compact', 'format-mode-pretty'] as const;
37 :
38 : interface FormatResources {
39 : core: dashql.DashQL;
40 : catalog: dashql.DashQLCatalog;
41 : }
42 :
43 : interface PendingRequest {
44 : resolve: () => void;
45 : signal?: AbortSignal;
46 : onAbort?: () => void;
47 : }
48 :
49 : export interface FormatDialogController {
50 : request(core: dashql.DashQL, catalog: dashql.DashQLCatalog, signal?: AbortSignal): Promise<void>;
51 : }
52 :
53 : export interface FormatDialogHookResult {
54 : controller: FormatDialogController;
55 : dialog: React.ReactElement | null;
56 : }
57 :
58 1 : export function useFormatDialog(): FormatDialogHookResult {
59 1 : const [resources, setResources] = React.useState<FormatResources | null>(null);
60 1 : const pendingRequestRef = React.useRef<PendingRequest | null>(null);
61 :
62 1 : const dismiss = React.useCallback(() => {
63 1 : const pending = pendingRequestRef.current;
64 1 : pendingRequestRef.current = null;
65 1 : if (pending?.signal != null && pending.onAbort != null) {
66 1 : pending.signal.removeEventListener('abort', pending.onAbort);
67 : }
68 1 : setResources(null);
69 1 : pending?.resolve();
70 1 : }, []);
71 :
72 1 : const request = React.useCallback((
73 1 : core: dashql.DashQL,
74 1 : catalog: dashql.DashQLCatalog,
75 1 : signal?: AbortSignal,
76 1 : ) => {
77 1 : if (pendingRequestRef.current != null) {
78 0 : return Promise.reject(new Error('A SQL formatter request is already pending.'));
79 : }
80 1 : if (signal?.aborted) return Promise.resolve();
81 :
82 1 : setResources({ core, catalog });
83 1 : return new Promise<void>(resolve => {
84 1 : const pending: PendingRequest = { resolve, signal };
85 1 : pending.onAbort = dismiss;
86 1 : pendingRequestRef.current = pending;
87 1 : signal?.addEventListener('abort', pending.onAbort, { once: true });
88 1 : });
89 1 : }, [dismiss]);
90 :
91 1 : React.useEffect(() => () => {
92 1 : const pending = pendingRequestRef.current;
93 1 : pendingRequestRef.current = null;
94 1 : if (pending?.signal != null && pending.onAbort != null) {
95 0 : pending.signal.removeEventListener('abort', pending.onAbort);
96 : }
97 1 : pending?.resolve();
98 1 : }, []);
99 :
100 1 : const controller = React.useMemo<FormatDialogController>(() => ({ request }), [request]);
101 1 : return {
102 1 : controller,
103 1 : dialog: resources == null ? null : (
104 1 : <FormatDialog core={resources.core} catalog={resources.catalog} onClose={dismiss} />
105 : ),
106 1 : };
107 1 : }
108 :
109 : interface FormatDialogProps extends FormatResources {
110 : onClose: () => void;
111 : }
112 :
113 : interface ValidationResult {
114 : update: dashql.buffers.editor.EditorUpdateT | null;
115 : diagnostic: string | null;
116 : }
117 :
118 0 : function errorMessage(error: unknown): string {
119 0 : return error instanceof Error ? error.message : String(error);
120 0 : }
121 :
122 1 : function bufferString(value: string | Uint8Array | null | undefined, fallback: string): string {
123 1 : if (typeof value === 'string') return value || fallback;
124 0 : return value == null ? fallback : new TextDecoder().decode(value) || fallback;
125 1 : }
126 :
127 1 : function analyzeSql(
128 1 : core: dashql.DashQL,
129 1 : catalog: dashql.DashQLCatalog,
130 1 : text: string,
131 1 : compactWidth: number,
132 1 : ): ValidationResult {
133 1 : if (text.length === 0) return { update: null, diagnostic: null };
134 :
135 1 : const session = core.createScriptSession(catalog);
136 1 : const script = core.createScript(catalog);
137 1 : let parsed: dashql.FlatBufferPtr<dashql.buffers.parser.ParsedScript> | null = null;
138 1 : try {
139 1 : session.replaceText(0n, text);
140 1 : const update = session.analyze();
141 1 : script.replaceText(text);
142 1 : script.parse();
143 1 : parsed = script.getParsed();
144 1 : const parsedReader = parsed.read();
145 1 : if (parsedReader.scannerErrorsLength() > 0) {
146 0 : return {
147 0 : update,
148 0 : diagnostic: bufferString(parsedReader.scannerErrors(0)?.message(), 'SQL could not be scanned'),
149 0 : };
150 : }
151 1 : if (parsedReader.parserErrorsLength() > 0) {
152 1 : return {
153 1 : update,
154 1 : diagnostic: bufferString(parsedReader.parserErrors(0)?.message(), 'SQL could not be parsed'),
155 1 : };
156 : }
157 1 : if (update.status !== dashql.buffers.editor.EditorUpdateStatus.OK || !update.analysisAvailable) {
158 0 : return { update, diagnostic: bufferString(update.statusMessage, 'SQL could not be parsed') };
159 : }
160 1 : const compactConfig = createScriptFormatConfig(
161 1 : dashql.buffers.formatting.FormattingMode.COMPACT,
162 1 : false,
163 1 : compactWidth,
164 1 : );
165 1 : const prettyConfig = createScriptFormatConfig(dashql.buffers.formatting.FormattingMode.PRETTY);
166 1 : if (!script.isFullyFormattable(compactConfig, false) || !script.isFullyFormattable(prettyConfig, false)) {
167 0 : return { update, diagnostic: 'This SQL contains syntax that cannot be formatted' };
168 : }
169 1 : return { update, diagnostic: null };
170 1 : } catch (error) {
171 0 : return { update: null, diagnostic: errorMessage(error) };
172 1 : } finally {
173 1 : script.destroy();
174 1 : session.destroy();
175 1 : }
176 1 : }
177 :
178 1 : function formatSql(
179 1 : core: dashql.DashQL,
180 1 : catalog: dashql.DashQLCatalog,
181 1 : text: string,
182 1 : mode: FormatMode,
183 1 : compactWidth: number,
184 1 : keywordCase: dashql.buffers.formatting.KeywordCase,
185 1 : ): string {
186 1 : if (text.length === 0) return '';
187 :
188 1 : const session = core.createScriptSession(catalog);
189 1 : let formatted: dashql.DashQLScript | null = null;
190 1 : try {
191 1 : session.replaceText(0n, text);
192 1 : const formattingMode = mode === FormatMode.Compact
193 1 : ? dashql.buffers.formatting.FormattingMode.COMPACT
194 1 : : dashql.buffers.formatting.FormattingMode.PRETTY;
195 1 : const width = mode === FormatMode.Compact ? compactWidth : 80;
196 1 : const config = createScriptFormatConfig(formattingMode, false, width, keywordCase);
197 1 : formatted = session.format(config);
198 1 : return formatted.toString();
199 1 : } finally {
200 1 : formatted?.destroy();
201 1 : session.destroy();
202 1 : }
203 1 : }
204 :
205 1 : function FormatDialog(props: FormatDialogProps) {
206 1 : const keywordCase = getKeywordCase(useAppConfig()?.settings);
207 1 : const headingId = React.useId();
208 1 : const dialogRef = React.useRef<HTMLElement>(null);
209 1 : const editorFocusRef = React.useRef<HTMLElement>(null);
210 1 : const editorViewRef = React.useRef<EditorView | null>(null);
211 1 : const rawTextRef = React.useRef('');
212 1 : const rawUpdateRef = React.useRef<dashql.buffers.editor.EditorUpdateT | null>(null);
213 1 : const changingViewRef = React.useRef(false);
214 1 : const [mode, setMode] = React.useState(FormatMode.Raw);
215 1 : const [diagnostic, setDiagnostic] = React.useState<string | null>(null);
216 1 : const [expanded, setExpanded] = React.useState(false);
217 1 : const readonlyCompartmentRef = React.useRef(new Compartment());
218 1 : const editableCompartmentRef = React.useRef(new Compartment());
219 1 : const attributesCompartmentRef = React.useRef(new Compartment());
220 1 : const onRawChangeRef = React.useRef<(text: string) => void>(() => {});
221 1 : const validationTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
222 : /// The editor width for validating edits, measured when the editor resizes instead of per edit
223 1 : const validationWidthRef = React.useRef(80);
224 1 : const widthObserverRef = React.useRef<ResizeObserver | null>(null);
225 :
226 1 : const measureWidth = React.useCallback(() => {
227 1 : const view = editorViewRef.current;
228 1 : return view == null ? 80 : measureScriptFormatWidth(view);
229 1 : }, []);
230 :
231 1 : const validateRaw = React.useCallback((text: string, width: number = measureWidth()) => {
232 1 : const validation = analyzeSql(props.core, props.catalog, text, width);
233 1 : rawUpdateRef.current = validation.update;
234 1 : setDiagnostic(validation.diagnostic);
235 1 : return validation;
236 1 : }, [measureWidth, props.catalog, props.core]);
237 :
238 1 : const cancelValidation = React.useCallback(() => {
239 1 : if (validationTimerRef.current != null) {
240 1 : clearTimeout(validationTimerRef.current);
241 1 : validationTimerRef.current = null;
242 1 : }
243 1 : }, []);
244 :
245 : // The core and the catalog belong to the request, never validate after the dialog is gone
246 1 : React.useEffect(() => () => {
247 1 : cancelValidation();
248 1 : widthObserverRef.current?.disconnect();
249 1 : widthObserverRef.current = null;
250 1 : }, [cancelValidation]);
251 :
252 : // Validate the raw text and update the decorations of the raw view
253 1 : const runValidation = React.useCallback(() => {
254 1 : cancelValidation();
255 1 : const validation = validateRaw(rawTextRef.current, validationWidthRef.current);
256 1 : const view = editorViewRef.current;
257 1 : if (view == null || changingViewRef.current || view.state.doc.toString() !== rawTextRef.current) return;
258 1 : view.dispatch({ effects: DashQLScannerDecorationUpdateEffect.of(validation.update) });
259 1 : }, [cancelValidation, validateRaw]);
260 :
261 1 : onRawChangeRef.current = (text: string) => {
262 1 : rawTextRef.current = text;
263 1 : cancelValidation();
264 1 : validationTimerRef.current = setTimeout(runValidation, VALIDATION_DELAY_MS);
265 1 : };
266 :
267 1 : const extensions = React.useMemo<Extension[]>(() => [
268 1 : themes.xcode,
269 1 : lineNumbers(),
270 1 : drawSelection(),
271 1 : history(),
272 1 : keymap.of([...defaultKeymap, ...historyKeymap]),
273 1 : ...DashQLStandaloneScannerDecorationPlugin,
274 1 : readonlyCompartmentRef.current.of(EditorState.readOnly.of(false)),
275 1 : editableCompartmentRef.current.of(EditorView.editable.of(true)),
276 1 : attributesCompartmentRef.current.of(EditorView.contentAttributes.of({
277 1 : 'aria-label': 'SQL formatter editor',
278 1 : 'aria-readonly': 'false',
279 1 : })),
280 1 : EditorView.updateListener.of(update => {
281 1 : if (update.docChanged && !changingViewRef.current) {
282 1 : onRawChangeRef.current(update.state.doc.toString());
283 : }
284 1 : }),
285 1 : ], []);
286 :
287 1 : const setView = React.useCallback((view: EditorView | null) => {
288 1 : editorViewRef.current = view;
289 1 : editorFocusRef.current = view?.contentDOM ?? null;
290 1 : widthObserverRef.current?.disconnect();
291 1 : widthObserverRef.current = null;
292 1 : if (view != null) {
293 1 : validationWidthRef.current = measureScriptFormatWidth(view);
294 1 : const observer = new ResizeObserver(() => {
295 1 : validationWidthRef.current = measureScriptFormatWidth(view);
296 1 : });
297 1 : observer.observe(view.scrollDOM);
298 1 : widthObserverRef.current = observer;
299 1 : }
300 1 : view?.focus();
301 1 : }, []);
302 :
303 1 : const showMode = React.useCallback((nextMode: FormatMode, refresh = false) => {
304 1 : const view = editorViewRef.current;
305 1 : if (view == null || (nextMode === mode && !refresh)) return;
306 : // Validate pending edits right away
307 1 : if (validationTimerRef.current != null) runValidation();
308 :
309 1 : let text = rawTextRef.current;
310 1 : let update = rawUpdateRef.current;
311 1 : let readonly = false;
312 1 : if (nextMode !== FormatMode.Raw) {
313 1 : const validation = validateRaw(text);
314 1 : if (validation.diagnostic != null) {
315 0 : setMode(FormatMode.Raw);
316 0 : return;
317 0 : }
318 1 : try {
319 1 : text = formatSql(props.core, props.catalog, text, nextMode, measureWidth(), keywordCase);
320 1 : update = analyzeSql(props.core, props.catalog, text, measureWidth()).update;
321 1 : readonly = true;
322 1 : } catch (error) {
323 0 : setDiagnostic(errorMessage(error));
324 0 : setMode(FormatMode.Raw);
325 0 : return;
326 0 : }
327 1 : }
328 :
329 1 : changingViewRef.current = true;
330 1 : try {
331 1 : view.dispatch({
332 1 : changes: { from: 0, to: view.state.doc.length, insert: text },
333 1 : effects: [
334 1 : readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readonly)),
335 1 : editableCompartmentRef.current.reconfigure(EditorView.editable.of(!readonly)),
336 1 : attributesCompartmentRef.current.reconfigure(EditorView.contentAttributes.of({
337 1 : 'aria-label': `SQL formatter editor, ${FORMAT_LABELS[nextMode]} view`,
338 1 : 'aria-readonly': String(readonly),
339 1 : })),
340 1 : DashQLScannerDecorationUpdateEffect.of(update),
341 1 : ],
342 1 : });
343 1 : } finally {
344 1 : changingViewRef.current = false;
345 1 : }
346 1 : setMode(nextMode);
347 1 : }, [measureWidth, mode, props.catalog, props.core, validateRaw, keywordCase, runValidation]);
348 :
349 1 : const renderedCase = React.useRef(keywordCase);
350 1 : React.useEffect(() => {
351 1 : if (renderedCase.current !== keywordCase && mode !== FormatMode.Raw) showMode(mode, true);
352 1 : renderedCase.current = keywordCase;
353 1 : }, [keywordCase, mode, showMode]);
354 :
355 : /// Copy the text of the current view, the formatted SQL in Compact and Pretty
356 1 : const getCopyText = React.useCallback(() => editorViewRef.current?.state.doc.toString() ?? rawTextRef.current, []);
357 :
358 1 : useFocusTrap({
359 1 : containerRef: dialogRef as React.RefObject<HTMLElement>,
360 1 : initialFocusRef: editorFocusRef as React.RefObject<HTMLElement>,
361 1 : restoreFocusOnCleanUp: true,
362 1 : });
363 :
364 1 : return (
365 1 : <Overlay
366 1 : centered
367 1 : width={OverlaySize.XXL}
368 1 : height={OverlaySize.XL}
369 1 : maxHeight={OverlaySize.XL}
370 1 : preventFocusOnOpen
371 1 : onEscape={props.onClose}
372 1 : onClickOutside={IGNORE_OUTSIDE_CLICK}
373 1 : expanded={expanded}
374 : >
375 1 : <section
376 1 : ref={dialogRef}
377 1 : className={styles.dialog}
378 1 : role="dialog"
379 1 : aria-modal="true"
380 1 : aria-labelledby={headingId}
381 1 : data-expanded={expanded || undefined}
382 : >
383 1 : <header className={styles.header}>
384 1 : <h2 id={headingId} className={styles.title}>SQL Formatter</h2>
385 1 : <div className={styles.spacer} />
386 1 : {diagnostic != null && (
387 1 : <div className={styles.diagnostic} role="alert" title={diagnostic}>
388 1 : <AlertIcon size={16} aria-hidden="true" />
389 1 : <span className={styles.diagnostic_text}>{diagnostic}</span>
390 1 : </div>
391 : )}
392 1 : <SegmentedControl
393 1 : aria-label="Format mode"
394 1 : size={SegmentedControlSize.Small}
395 1 : onChange={index => showMode(index as FormatMode)}
396 : >
397 1 : {FORMAT_LABELS.map((label, index) => (
398 1 : <SegmentedControl.Button
399 1 : key={label}
400 1 : selected={mode === index}
401 1 : disabled={index !== FormatMode.Raw && diagnostic != null}
402 1 : data-testid={FORMAT_TEST_IDS[index]}
403 : >
404 1 : {label}
405 1 : </SegmentedControl.Button>
406 1 : ))}
407 1 : </SegmentedControl>
408 1 : <CopyToClipboardButton
409 1 : variant={ButtonVariant.Invisible}
410 1 : size={ButtonSize.Small}
411 1 : getValue={getCopyText}
412 1 : logContext={LOG_CTX}
413 1 : aria-label="Copy SQL"
414 1 : aria-labelledby=""
415 1 : data-testid="dialog-copy"
416 1 : />
417 1 : <IconButton
418 1 : variant={ButtonVariant.Invisible}
419 1 : size={ButtonSize.Small}
420 1 : aria-label={expanded ? 'Restore size' : 'Expand'}
421 1 : aria-pressed={expanded}
422 1 : data-testid="dialog-expand"
423 1 : onClick={() => setExpanded(e => !e)}
424 : >
425 1 : {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
426 1 : </IconButton>
427 1 : <IconButton
428 1 : variant={ButtonVariant.Invisible}
429 1 : size={ButtonSize.Small}
430 1 : aria-label="Close SQL formatter"
431 1 : data-testid="dialog-close"
432 1 : onClick={props.onClose}
433 : >
434 1 : <XIcon size={16} />
435 1 : </IconButton>
436 1 : </header>
437 1 : <div className={styles.editor}>
438 1 : <CodeMirror ref={setView} extensions={extensions} />
439 1 : </div>
440 1 : </section>
441 1 : </Overlay>
442 : );
443 1 : }
|