LCOV - code coverage report
Current view: top level - dashql-app/src/shell_app - plan_dialog.tsx Coverage Total Hit
Test: DashQL 6e74948 Lines: 95.9 % 242 232
Test Date: 2026-11-06 17:46:50

            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 { type Extension } from '@codemirror/state';
       7              : import { EditorView, drawSelection, keymap, lineNumbers } from '@codemirror/view';
       8              : 
       9              : import { createProfileLayoutConfig, QueryProfileView, type QueryProfileViewProps } from '../views/query_profile/query_profile_view.js';
      10              : import { rotateProfileLayoutOrientation } from '../views/query_profile/profile_layout_config.js';
      11              : import { ProfileExecutionState } from '../query/profile_execution_state.js';
      12              : import { CodeMirror } from '../session/editor/codemirror.js';
      13              : import type { PlatformEventListener } from '../platform/events/event_listener.js';
      14              : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
      15              : import { useFocusTrap } from '../ui/foundations/focus.js';
      16              : import { Overlay, OverlaySize } from '../ui/foundations/overlay.js';
      17              : import { SegmentedControl, SegmentedControlSize } from '../ui/foundations/segmented_control.js';
      18              : import { AlertIcon, ScreenFullIcon, ScreenNormalIcon, XIcon } from '../ui/foundations/symbol_icon.js';
      19              : import { CopyToClipboardButton } from '../ui/foundations/clipboard.js';
      20              : import * as styles from './plan_dialog.module.css';
      21              : 
      22            1 : const IGNORE_OUTSIDE_CLICK = () => {};
      23            1 : const LOG_CTX = 'shell_plan';
      24              : /// Edits are parsed once the user pauses typing, loading the plan per keystroke is too expensive
      25            1 : const VALIDATION_DELAY_MS = 150;
      26              : 
      27            1 : const enum PlanMode {
      28            1 :     Raw = 0,
      29            1 :     Viewer = 1,
      30            1 : }
      31              : 
      32              : interface PlanResources {
      33              :     core: dashql.DashQL;
      34              : }
      35              : 
      36              : interface PendingRequest {
      37              :     resolve: () => void;
      38              :     signal?: AbortSignal;
      39              :     onAbort?: () => void;
      40              : }
      41              : 
      42              : export interface PlanDialogController {
      43              :     request(core: dashql.DashQL, signal?: AbortSignal): Promise<void>;
      44              : }
      45              : 
      46              : export interface PlanDialogHookResult {
      47              :     controller: PlanDialogController;
      48              :     dialog: React.ReactElement | null;
      49              : }
      50              : 
      51              : type PlanDialogEvents = Pick<PlatformEventListener, 'subscribeFallbackPasteEvents' | 'unsubscribeFallbackPasteEvents'>;
      52              : 
      53              : export interface PlanDialogDependencies {
      54              :     createProfileLayoutConfig: typeof createProfileLayoutConfig;
      55              :     QueryProfileView: React.ComponentType<QueryProfileViewProps>;
      56              : }
      57              : 
      58            1 : const DEFAULT_DEPENDENCIES: PlanDialogDependencies = {
      59            1 :     createProfileLayoutConfig,
      60            1 :     QueryProfileView,
      61            1 : };
      62              : 
      63            1 : export function usePlanDialog(
      64            1 :     appEvents: PlanDialogEvents,
      65            1 :     dependencies: PlanDialogDependencies = DEFAULT_DEPENDENCIES,
      66            1 : ): PlanDialogHookResult {
      67            1 :     const [resources, setResources] = React.useState<PlanResources | null>(null);
      68            1 :     const pendingRequestRef = React.useRef<PendingRequest | null>(null);
      69              : 
      70            1 :     const dismiss = React.useCallback(() => {
      71            1 :         const pending = pendingRequestRef.current;
      72            1 :         pendingRequestRef.current = null;
      73            1 :         if (pending?.signal != null && pending.onAbort != null) {
      74            1 :             pending.signal.removeEventListener('abort', pending.onAbort);
      75              :         }
      76            1 :         setResources(null);
      77            1 :         pending?.resolve();
      78            1 :     }, []);
      79              : 
      80            1 :     const request = React.useCallback((core: dashql.DashQL, signal?: AbortSignal) => {
      81            1 :         if (pendingRequestRef.current != null) {
      82            0 :             return Promise.reject(new Error('A query plan request is already pending.'));
      83              :         }
      84            1 :         if (signal?.aborted) return Promise.resolve();
      85              : 
      86            1 :         setResources({ core });
      87            1 :         return new Promise<void>(resolve => {
      88            1 :             const pending: PendingRequest = { resolve, signal };
      89            1 :             pending.onAbort = dismiss;
      90            1 :             pendingRequestRef.current = pending;
      91            1 :             signal?.addEventListener('abort', pending.onAbort, { once: true });
      92            1 :         });
      93            1 :     }, [dismiss]);
      94              : 
      95            1 :     React.useEffect(() => () => {
      96            1 :         const pending = pendingRequestRef.current;
      97            1 :         pendingRequestRef.current = null;
      98            1 :         if (pending?.signal != null && pending.onAbort != null) {
      99            0 :             pending.signal.removeEventListener('abort', pending.onAbort);
     100              :         }
     101            1 :         pending?.resolve();
     102            1 :     }, []);
     103              : 
     104            1 :     const controller = React.useMemo<PlanDialogController>(() => ({ request }), [request]);
     105            1 :     return {
     106            1 :         controller,
     107            1 :         dialog: resources == null ? null : (
     108            1 :             <PlanDialog
     109            1 :                 appEvents={appEvents}
     110            1 :                 core={resources.core}
     111            1 :                 dependencies={dependencies}
     112            1 :                 onClose={dismiss}
     113            1 :             />
     114              :         ),
     115            1 :     };
     116            1 : }
     117              : 
     118              : interface PlanDialogProps extends PlanResources {
     119              :     appEvents: PlanDialogEvents;
     120              :     dependencies: PlanDialogDependencies;
     121              :     onClose: () => void;
     122              : }
     123              : 
     124            1 : function errorMessage(error: unknown): string {
     125            1 :     return error instanceof Error ? error.message : String(error);
     126            1 : }
     127              : 
     128            1 : function PlanDialog(props: PlanDialogProps) {
     129            1 :     const ProfileViewComponent = props.dependencies.QueryProfileView;
     130            1 :     const headingId = React.useId();
     131            1 :     const dialogRef = React.useRef<HTMLElement>(null);
     132            1 :     const editorFocusRef = React.useRef<HTMLElement>(null);
     133            1 :     const editorViewRef = React.useRef<EditorView | null>(null);
     134            1 :     const rawTextRef = React.useRef('');
     135            1 :     const viewModelRef = React.useRef<dashql.DashQLQueryProfile | null>(null);
     136            1 :     const [mode, setMode] = React.useState(PlanMode.Raw);
     137            1 :     const [plan, setPlan] = React.useState<dashql.FlatBufferPtr<dashql.buffers.profile.QueryProfile> | null>(null);
     138            1 :     const [execution, setExecution] = React.useState(ProfileExecutionState.EMPTY);
     139            1 :     const [diagnostic, setDiagnostic] = React.useState<string | null>(null);
     140            1 :     const [expanded, setExpanded] = React.useState(false);
     141            1 :     const onRawChangeRef = React.useRef<(text: string) => void>(() => {});
     142            1 :     const rawTextIsValidRef = React.useRef(false);
     143            1 :     const layoutConfig = React.useMemo(() => props.dependencies.createProfileLayoutConfig(), [props.dependencies]);
     144              : 
     145            1 :     const validate = React.useCallback((text: string): boolean => {
     146            1 :         setPlan(null);
     147            1 :         if (text.trim().length === 0) {
     148            0 :             setDiagnostic(null);
     149            0 :             return false;
     150            0 :         }
     151              : 
     152            1 :         viewModelRef.current ??= props.core.createQueryProfile(layoutConfig);
     153            1 :         try {
     154            1 :             const nextPlan = viewModelRef.current.loadHyperPlan(text);
     155            1 :             if (nextPlan.read().operatorsLength() === 0) {
     156            1 :                 throw new Error('Plan contains no operators');
     157              :             }
     158            1 :             setPlan(nextPlan);
     159            1 :             setExecution(ProfileExecutionState.EMPTY.withEvents(viewModelRef.current.snapshotEvents().read()));
     160            1 :             setDiagnostic(null);
     161            1 :             return true;
     162            1 :         } catch (error) {
     163            1 :             setDiagnostic(errorMessage(error));
     164            1 :             return false;
     165            1 :         }
     166            1 :     }, [layoutConfig, props.core]);
     167              : 
     168              :     // The view model keeps the orientation, the next plans of the editor are laid out with it as well
     169            1 :     const rotate = React.useCallback(() => {
     170            0 :         const viewModel = viewModelRef.current;
     171            0 :         if (viewModel == null || plan == null) return;
     172            0 :         const orientation = rotateProfileLayoutOrientation(viewModel.layout.orientation);
     173            0 :         setPlan(viewModel.relayout(props.dependencies.createProfileLayoutConfig(orientation)));
     174            1 :     }, [plan, props.dependencies]);
     175              : 
     176            1 :     const validationTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
     177            1 :     const cancelValidation = React.useCallback(() => {
     178            1 :         if (validationTimerRef.current != null) {
     179            1 :             clearTimeout(validationTimerRef.current);
     180            1 :             validationTimerRef.current = null;
     181            1 :         }
     182            1 :     }, []);
     183            1 :     const runValidation = React.useCallback(() => {
     184            1 :         cancelValidation();
     185            1 :         rawTextIsValidRef.current = validate(rawTextRef.current);
     186            1 :     }, [cancelValidation, validate]);
     187              :     // Validate pending edits right away, e.g. before showing the viewer
     188            1 :     const flushValidation = React.useCallback(() => {
     189            1 :         if (validationTimerRef.current != null) runValidation();
     190            1 :     }, [runValidation]);
     191              : 
     192            1 :     onRawChangeRef.current = text => {
     193            1 :         rawTextRef.current = text;
     194            1 :         cancelValidation();
     195            1 :         validationTimerRef.current = setTimeout(runValidation, VALIDATION_DELAY_MS);
     196            1 :     };
     197              : 
     198            1 :     const extensions = React.useMemo<Extension[]>(() => [
     199            1 :         themes.xcode,
     200            1 :         lineNumbers(),
     201            1 :         drawSelection(),
     202            1 :         history(),
     203            1 :         keymap.of([...defaultKeymap, ...historyKeymap]),
     204            1 :         EditorView.contentAttributes.of({ 'aria-label': 'Query plan JSON' }),
     205            1 :         EditorView.updateListener.of(update => {
     206            1 :             if (update.docChanged) onRawChangeRef.current(update.state.doc.toString());
     207            1 :         }),
     208            1 :     ], []);
     209              : 
     210            1 :     const setView = React.useCallback((view: EditorView | null) => {
     211            1 :         editorViewRef.current = view;
     212            1 :         editorFocusRef.current = view?.contentDOM ?? null;
     213            1 :         view?.focus();
     214            1 :     }, []);
     215              : 
     216            1 :     React.useEffect(() => () => {
     217              :         // A pending validation must not load a plan into the destroyed query profile
     218            1 :         cancelValidation();
     219            1 :         viewModelRef.current?.destroy();
     220            1 :         viewModelRef.current = null;
     221            1 :     }, [cancelValidation]);
     222              : 
     223            1 :     React.useEffect(() => {
     224            1 :         const handleFallbackPaste = (text: string) => {
     225            1 :             const view = editorViewRef.current;
     226            1 :             if (view?.contentDOM.isConnected) {
     227            1 :                 view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
     228              :             } else {
     229            0 :                 onRawChangeRef.current(text);
     230              :             }
     231            1 :             flushValidation();
     232            1 :             if (rawTextIsValidRef.current) setMode(PlanMode.Viewer);
     233            1 :         };
     234            1 :         props.appEvents.subscribeFallbackPasteEvents(handleFallbackPaste);
     235            1 :         return () => props.appEvents.unsubscribeFallbackPasteEvents(handleFallbackPaste);
     236            1 :     }, [props.appEvents, flushValidation]);
     237              : 
     238            1 :     const selectMode = React.useCallback((nextMode: PlanMode) => {
     239            1 :         flushValidation();
     240              :         // The viewer button was enabled for the text before the pending edits
     241            1 :         if (nextMode === PlanMode.Viewer && !rawTextIsValidRef.current) return;
     242            1 :         setMode(nextMode);
     243            1 :     }, [flushValidation]);
     244              : 
     245              :     /// Copy the raw plan, also from the viewer
     246            1 :     const getCopyText = React.useCallback(() => rawTextRef.current, []);
     247              : 
     248            1 :     useFocusTrap({
     249            1 :         containerRef: dialogRef as React.RefObject<HTMLElement>,
     250            1 :         initialFocusRef: editorFocusRef as React.RefObject<HTMLElement>,
     251            1 :         restoreFocusOnCleanUp: true,
     252            1 :     });
     253              : 
     254            1 :     return (
     255            1 :         <Overlay
     256            1 :             centered
     257            1 :             width={OverlaySize.XXL}
     258            1 :             height={OverlaySize.XL}
     259            1 :             maxHeight={OverlaySize.XL}
     260            1 :             preventFocusOnOpen
     261            1 :             onEscape={props.onClose}
     262            1 :             onClickOutside={IGNORE_OUTSIDE_CLICK}
     263            1 :             expanded={expanded}
     264              :         >
     265            1 :             <section
     266            1 :                 ref={dialogRef}
     267            1 :                 className={styles.dialog}
     268            1 :                 role="dialog"
     269            1 :                 aria-modal="true"
     270            1 :                 aria-labelledby={headingId}
     271            1 :                 data-expanded={expanded || undefined}
     272              :             >
     273            1 :                 <header className={styles.header}>
     274            1 :                     <h2 id={headingId} className={styles.title}>Query Plan</h2>
     275            1 :                     <div className={styles.spacer} />
     276            1 :                     {diagnostic != null && (
     277            1 :                         <div className={styles.diagnostic} role="alert" title={diagnostic}>
     278            1 :                             <AlertIcon size={16} aria-hidden="true" />
     279            1 :                             <span className={styles.diagnostic_text}>{diagnostic}</span>
     280            1 :                         </div>
     281              :                     )}
     282            1 :                     <SegmentedControl
     283            1 :                         aria-label="Plan view"
     284            1 :                         size={SegmentedControlSize.Small}
     285            1 :                         onChange={index => selectMode(index as PlanMode)}
     286              :                     >
     287            1 :                         <SegmentedControl.Button selected={mode === PlanMode.Raw} data-testid="plan-mode-raw">
     288              :                             Raw
     289            1 :                         </SegmentedControl.Button>
     290            1 :                         <SegmentedControl.Button
     291            1 :                             selected={mode === PlanMode.Viewer}
     292            1 :                             disabled={plan == null}
     293            1 :                             data-testid="plan-mode-viewer"
     294            1 :                         >
     295              :                             Viewer
     296            1 :                         </SegmentedControl.Button>
     297            1 :                     </SegmentedControl>
     298            1 :                     <CopyToClipboardButton
     299            1 :                         variant={ButtonVariant.Invisible}
     300            1 :                         size={ButtonSize.Small}
     301            1 :                         getValue={getCopyText}
     302            1 :                         logContext={LOG_CTX}
     303            1 :                         aria-label="Copy query plan JSON"
     304            1 :                         aria-labelledby=""
     305            1 :                         data-testid="dialog-copy"
     306            1 :                     />
     307            1 :                     <IconButton
     308            1 :                         variant={ButtonVariant.Invisible}
     309            1 :                         size={ButtonSize.Small}
     310            1 :                         aria-label={expanded ? 'Restore size' : 'Expand'}
     311            1 :                         aria-pressed={expanded}
     312            1 :                         data-testid="dialog-expand"
     313            1 :                         onClick={() => setExpanded(e => !e)}
     314              :                     >
     315            1 :                         {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
     316            1 :                     </IconButton>
     317            1 :                     <IconButton
     318            1 :                         variant={ButtonVariant.Invisible}
     319            1 :                         size={ButtonSize.Small}
     320            1 :                         aria-label="Close query plan"
     321            1 :                         data-testid="dialog-close"
     322            1 :                         onClick={props.onClose}
     323              :                     >
     324            1 :                         <XIcon size={16} />
     325            1 :                     </IconButton>
     326            1 :                 </header>
     327            1 :                 <div className={styles.body}>
     328            1 :                     {mode === PlanMode.Raw && (
     329            1 :                         <div className={styles.editor}>
     330            1 :                             <CodeMirror ref={setView} extensions={extensions} initialDoc={rawTextRef.current} />
     331            1 :                         </div>
     332              :                     )}
     333            1 :                     {mode === PlanMode.Viewer && plan != null && (
     334            1 :                         <div className={styles.viewer}>
     335            1 :                             <ProfileViewComponent plan={plan} execution={execution} onRotate={rotate} autoFocus />
     336            1 :                         </div>
     337              :                     )}
     338            1 :                 </div>
     339            1 :             </section>
     340            1 :         </Overlay>
     341              :     );
     342            1 : }
        

Generated by: LCOV version 2.0-1