LCOV - code coverage report
Current view: top level - dashql-app/src/shell_app - format_dialog.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 94.1 % 338 318
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1