LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - script_editor_dialog.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 81.0 % 126 102
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import type { EditorView } from '@codemirror/view';
       3              : import * as styles from './script_editor_dialog.module.css';
       4              : 
       5              : import { type ConnectionState } from '../../connections/connection_state.js';
       6              : import { useCancelQuery, useQueryState } from '../../connections/query_executor.js';
       7              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       8              : import { ButtonGroup } from '../../ui/foundations/button_group.js';
       9              : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
      10              : import { IndicatorStatus } from '../../ui/foundations/status_indicator.js';
      11              : import { CheckIcon, ScreenFullIcon, ScreenNormalIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
      12              : import { DashQLCompletionAbortEffect, DashQLCompletionStatus, DashQLProcessorPlugin } from '../editor/dashql_processor.js';
      13              : import { acceptPendingDiff, rejectPendingDiff } from '../editor/dashql_diff_hint.js';
      14              : import { EntryStatusBar } from '../entry_status_bar.js';
      15              : import { deriveEntryStatus, EntryStatusKind } from '../entry_status_model.js';
      16              : import { ScriptEntryHeader } from '../feed/script_entry_header.js';
      17              : import { ACCEPT_PENDING_DIFF, DELETE_SCRIPT, REJECT_PENDING_DIFF, type ScriptData, type SessionScripts } from './session_scripts.js';
      18              : import type { ModifySessionScripts } from './session_scripts_registry.js';
      19              : import { ScriptEditor } from './script_editor.js';
      20              : import { checkSessionScriptReferences } from './script_reference_reports.js';
      21              : import type { RenameDraftReferences } from './script_rename.js';
      22              : import { scriptDisplayName } from './script_types.js';
      23              : import { useSessionExecution } from './session_execution_provider.js';
      24              : 
      25              : export interface ScriptEditorDialogProps {
      26              :     sessionScripts: SessionScripts;
      27              :     modifySessionScripts: ModifySessionScripts;
      28              :     scriptKey: number;
      29              :     /// The connections that the scripts run on
      30              :     scriptConnections: ReadonlyMap<string, ConnectionState>;
      31              :     /// The function that renames the references in the draft of the composer
      32              :     draftRenameRef?: React.RefObject<RenameDraftReferences | null>;
      33              :     /// Delete the script, the session deletes it by default. The dialog closes.
      34              :     onDelete?: (script: ScriptData) => void;
      35              :     /// Open another script, e.g. one that the script references
      36              :     onNavigateToScript?: (scriptKey: number) => void;
      37              :     onClose: () => void;
      38              : }
      39              : 
      40              : /// A script in a dialog over a view without editors, e.g. the dataflow: the header of the feed entry next to the
      41              : /// expand and close buttons, the editor and the status of the last run. A staged rewrite shows as a diff in the
      42              : /// editor, accepted or rejected over it.
      43            1 : export function ScriptEditorDialog(props: ScriptEditorDialogProps): React.ReactElement | null {
      44            1 :     const { sessionScripts, modifySessionScripts, scriptKey, onClose } = props;
      45            1 :     const script = sessionScripts.scripts[scriptKey];
      46            1 :     const sessionId = sessionScripts.sessionId;
      47            1 :     const [editorView, setEditorView] = React.useState<EditorView | null>(null);
      48              :     // An expanded dialog fills the window
      49            1 :     const [expanded, setExpanded] = React.useState(false);
      50            1 :     const scriptReferences = React.useMemo(
      51            1 :         () => checkSessionScriptReferences(sessionScripts).get(scriptKey) ?? null,
      52            1 :         [sessionScripts, scriptKey],
      53            1 :     );
      54            1 :     const queryState = useQueryState(sessionId, script?.latestQueryId ?? null);
      55            1 :     const status = deriveEntryStatus(queryState);
      56            1 :     const cancelQuery = useCancelQuery();
      57            1 :     const sessionExecution = useSessionExecution(sessionId);
      58            1 :     const cancel = React.useCallback(() => {
      59            0 :         if (script?.latestQueryId == null) return;
      60            0 :         if (sessionExecution.busy) sessionExecution.stop();
      61            0 :         else cancelQuery(script.connection.connectionId, script.latestQueryId);
      62            1 :     }, [script, sessionExecution.busy, sessionExecution.stop, cancelQuery]);
      63              : 
      64              :     // Escape closes an open completion first, then the dialog
      65            1 :     const onEscape = React.useCallback(() => {
      66            0 :         const processor = editorView?.state.field(DashQLProcessorPlugin, false);
      67            0 :         if (editorView != null && processor?.scriptCompletion?.status === DashQLCompletionStatus.AVAILABLE) {
      68            0 :             editorView.dispatch({ effects: DashQLCompletionAbortEffect.of(null) });
      69            0 :             return;
      70            0 :         }
      71            0 :         onClose();
      72            1 :     }, [editorView, onClose]);
      73              :     // The menus of the header open outside of the dialog, a click outside keeps it open
      74            1 :     const ignoreOutsideClick = React.useCallback(() => {}, []);
      75            1 :     const remove = React.useCallback((target: ScriptData) => {
      76            0 :         if (props.onDelete != null) props.onDelete(target);
      77            0 :         else modifySessionScripts({ type: DELETE_SCRIPT, value: target.fileName });
      78            0 :         onClose();
      79            1 :     }, [props.onDelete, modifySessionScripts, onClose]);
      80              :     // The editor accepts or rejects a diff itself and clears the staged rewrite through the session
      81            1 :     const acceptDiff = React.useCallback(() => {
      82            0 :         if (editorView != null) acceptPendingDiff(editorView);
      83            0 :         else modifySessionScripts({ type: ACCEPT_PENDING_DIFF, value: scriptKey });
      84            1 :     }, [editorView, modifySessionScripts, scriptKey]);
      85            1 :     const rejectDiff = React.useCallback(() => {
      86            0 :         if (editorView != null) rejectPendingDiff(editorView);
      87            0 :         else modifySessionScripts({ type: REJECT_PENDING_DIFF, value: scriptKey });
      88            1 :     }, [editorView, modifySessionScripts, scriptKey]);
      89              :     // The editor takes the focus once it is there
      90            1 :     React.useEffect(() => {
      91            1 :         if (editorView == null) return;
      92            1 :         const frame = requestAnimationFrame(() => editorView.focus());
      93            1 :         return () => cancelAnimationFrame(frame);
      94            1 :     }, [editorView]);
      95              : 
      96            1 :     if (script == null) return null;
      97            1 :     const displayName = scriptDisplayName(script.fileName);
      98            1 :     return (
      99            1 :         <Overlay
     100            1 :             centered
     101            1 :             width={OverlaySize.XXL}
     102              :             // No max height, the overlay keeps the dialog inside a short window
     103            1 :             height={OverlaySize.XL}
     104            1 :             expanded={expanded}
     105            1 :             onEscape={onEscape}
     106            1 :             onClickOutside={ignoreOutsideClick}
     107              :         >
     108            1 :             <section
     109            1 :                 className={styles.dialog}
     110            1 :                 role="dialog"
     111            1 :                 aria-modal="true"
     112            1 :                 aria-label={`Edit ${displayName}`}
     113            1 :                 data-expanded={expanded || undefined}
     114            1 :                 data-testid="script-editor-dialog"
     115              :             >
     116            1 :                 <div className={styles.titlebar}>
     117              :                     {/* The entry header hides its optional controls when it gets narrow, the window controls stay */}
     118            1 :                     <div className={styles.header_container}>
     119            1 :                         <ScriptEntryHeader
     120            1 :                             sessionScripts={sessionScripts}
     121            1 :                             modifySessionScripts={modifySessionScripts}
     122            1 :                             scriptKey={scriptKey}
     123            1 :                             scriptConnections={props.scriptConnections}
     124            1 :                             scriptReferences={scriptReferences}
     125            1 :                             draftRenameRef={props.draftRenameRef}
     126            1 :                             editorView={editorView}
     127            1 :                             onDelete={remove}
     128            1 :                             className={styles.header}
     129            1 :                         />
     130            1 :                     </div>
     131            1 :                     <IconButton
     132            1 :                         variant={ButtonVariant.Invisible}
     133            1 :                         size={ButtonSize.Small}
     134            1 :                         aria-label={expanded ? 'Restore size' : 'Expand'}
     135            1 :                         aria-pressed={expanded}
     136            1 :                         data-testid="script-editor-dialog-expand"
     137            1 :                         onClick={() => setExpanded(e => !e)}
     138              :                     >
     139            1 :                         {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
     140            1 :                     </IconButton>
     141            1 :                     <IconButton
     142            1 :                         variant={ButtonVariant.Invisible}
     143            1 :                         size={ButtonSize.Small}
     144            1 :                         aria-label={`Close ${displayName}`}
     145            1 :                         data-testid="script-editor-dialog-close"
     146            1 :                         onClick={onClose}
     147              :                     >
     148            1 :                         <XIcon size={16} />
     149            1 :                     </IconButton>
     150            1 :                 </div>
     151            1 :                 <div className={styles.body}>
     152            1 :                     <ScriptEditor
     153            1 :                         key={scriptKey}
     154            1 :                         sessionId={sessionId}
     155            1 :                         scriptKey={scriptKey}
     156            1 :                         className={styles.editor}
     157            1 :                         setView={setEditorView}
     158            1 :                         onNavigateToScript={props.onNavigateToScript}
     159            1 :                     />
     160            1 :                     {script.pendingDiff != null && (
     161            0 :                         <ButtonGroup className={styles.diff_actions} aria-label="Accept or reject the rewrite">
     162            0 :                             <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Accept rewrite" data-testid="diff-accept" onClick={acceptDiff}>
     163            0 :                                 <CheckIcon size={14} />
     164            0 :                             </IconButton>
     165            0 :                             <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Reject rewrite" data-testid="diff-reject" onClick={rejectDiff}>
     166            0 :                                 <XIcon size={14} />
     167            0 :                             </IconButton>
     168            0 :                         </ButtonGroup>
     169              :                     )}
     170            1 :                 </div>
     171            1 :                 {status.kind !== EntryStatusKind.Idle && (
     172            1 :                     <div className={styles.status}>
     173            1 :                         <EntryStatusBar
     174            1 :                             status={status}
     175            1 :                             onCancel={status.indicator === IndicatorStatus.Running ? cancel : undefined}
     176            1 :                             cancelLabel="Cancel query"
     177            1 :                             compact
     178            1 :                         />
     179            1 :                     </div>
     180              :                 )}
     181            1 :             </section>
     182            1 :         </Overlay>
     183              :     );
     184            1 : }
        

Generated by: LCOV version 2.0-1