LCOV - code coverage report
Current view: top level - dashql-app/src/session/shell - session_shell_page.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 98.3 % 176 173
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './session_shell_page.module.css';
       3              : 
       4              : import { useConnectionById } from '../../connections/connection_registry.js';
       5              : import { useCancelQuery, useQueryExecutor, useQueryState } from '../../connections/query_executor.js';
       6              : import { SessionViewMode, useSessionViewMode } from '../session_view_mode.js';
       7              : import { useLogger } from '../../platform/logger/logger_provider.js';
       8              : import { stringifyError } from '../../platform/logger/logger.js';
       9              : import {
      10              :     DashQLShell,
      11              : } from '../../shell/api.js';
      12              : import type { BrowserShellController } from '../../shell/browser_shell.js';
      13              : import {
      14              :     createSessionShellEnvironment,
      15              :     resolveClientFunctionCall,
      16              : } from './session_shell_environment.js';
      17              : import { ShellQueryResultOverlay } from './shell_query_result_overlay.js';
      18              : import { createShellLastCommand } from '../../shell/shell_result.js';
      19              : import { useAppConfig } from '../../config/app_config.js';
      20              : import { getKeywordCase } from '../scripts/script_format.js';
      21              : import { ConnectorType } from '../../connections/connector_info.js';
      22              : import { connectionLabelParts, connectionLabelText, isHyperWasmConnection } from '../../connections/connection_label.js';
      23              : import { useComputeDatabase } from '../../compute/compute_connection_provider.js';
      24              : import { useComputationDispatch } from '../../compute/computation_registry.js';
      25              : import { DELETE_COMPUTATION } from '../../compute/computation_state.js';
      26              : import { externalDirectoryDatabase, externalFileDatabase, ShellFileRegistry } from '../../shell/shell_files.js';
      27              : import { useShellFiles } from '../../shell/filesystem_dialog.js';
      28              : import { createExamplesCommand } from '../../shell/shell_examples.js';
      29              : import { splitShellScript } from '../../shell/shell_script.js';
      30              : import { useSnippetsDialog } from '../snippets/snippets_dialog.js';
      31              : 
      32            1 : const LOG_CTX = 'session_shell_page';
      33              : 
      34              : export interface SessionShellPageDependencies {
      35              :     ShellQueryResultOverlay: typeof ShellQueryResultOverlay;
      36              : }
      37              : 
      38            1 : const DEFAULT_DEPENDENCIES: SessionShellPageDependencies = {
      39            1 :     ShellQueryResultOverlay,
      40            1 : };
      41              : 
      42              : interface Props {
      43              :     sessionId: string;
      44              :     sessionName?: string | null;
      45              :     /// The connection the shell runs on
      46              :     connectionId: string;
      47              :     /// The ordinal that tells the connection apart from others of its connector, see `connectionOrdinals`
      48              :     ordinal?: number | null;
      49              :     active: boolean;
      50              :     dependencies?: SessionShellPageDependencies;
      51              : }
      52              : 
      53            1 : export const SessionShellPage: React.FC<Props> = ({ sessionId, sessionName, connectionId, ordinal = null, active, dependencies = DEFAULT_DEPENDENCIES }) => {
      54            1 :     const ResultOverlay = dependencies.ShellQueryResultOverlay;
      55            1 :     const activeRef = React.useRef(active);
      56            1 :     activeRef.current = active;
      57            1 :     const [connection] = useConnectionById(connectionId);
      58            1 :     const keywordCase = getKeywordCase(useAppConfig()?.settings);
      59            1 :     const keywordCaseRef = React.useRef(keywordCase);
      60            1 :     keywordCaseRef.current = keywordCase;
      61            1 :     const logger = useLogger();
      62            1 :     const executeQuery = useQueryExecutor();
      63            1 :     const cancelQuery = useCancelQuery();
      64            1 :     const { setMode } = useSessionViewMode();
      65            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
      66            1 :     const controllerRef = React.useRef<BrowserShellController | null>(null);
      67            1 :     const shellRef = React.useRef<DashQLShell | null>(null);
      68            1 :     React.useEffect(() => {
      69            1 :         shellRef.current?.setKeywordCase(keywordCase);
      70            1 :     }, [keywordCase]);
      71            1 :     const generationRef = React.useRef(0);
      72            1 :     const [status, setStatus] = React.useState('Instantiating Shell');
      73            1 :     const [resultQueryId, setResultQueryId] = React.useState<number | null>(null);
      74            1 :     const [resultOpen, setResultOpen] = React.useState(false);
      75            1 :     const resultQuery = useQueryState(sessionId, resultQueryId);
      76            1 :     const resultQueryRef = React.useRef(resultQuery);
      77            1 :     resultQueryRef.current = resultQuery;
      78            1 :     const showResult = React.useCallback((queryId: number) => {
      79            1 :         setResultQueryId(queryId);
      80            1 :         setResultOpen(true);
      81            1 :     }, []);
      82              :     // Closing the overlay keeps the result so that it can open again, the computation of a result is released once
      83              :     // another result replaces it
      84            1 :     const computationDispatch = useComputationDispatch();
      85            1 :     const releaseResult = React.useCallback((queryId: number) => {
      86            1 :         computationDispatch({ type: DELETE_COMPUTATION, value: [queryId] });
      87            1 :     }, [computationDispatch]);
      88            1 :     React.useEffect(() => {
      89            1 :         if (resultQueryId == null) return;
      90            1 :         return () => releaseResult(resultQueryId);
      91            1 :     }, [resultQueryId, releaseResult]);
      92            1 :     const showResultError = (error: unknown, info: React.ErrorInfo) => {
      93              :         // A result that failed to render doesn't open again
      94            1 :         setResultQueryId(null);
      95              :         // The terminal says what happened, an error would also raise a toast
      96            1 :         logger.warn('Failed to show a query result', { error: stringifyError(error), stack: info.componentStack ?? '' }, LOG_CTX);
      97            1 :         controllerRef.current?.writeStatus(`Failed to show the result: ${stringifyError(error)}`);
      98            1 :     };
      99            1 :     const connectorName = connection?.connectorInfo.names.displayShort ?? '';
     100              :     // The shell names its connection like the workbench does, e.g. "Hyper #2 Http Shell" with the prompt "hyper#2> "
     101            1 :     const shellName = connection == null ? 'Shell' : `${connectionLabelText(connectionLabelParts(connection, ordinal))} Shell`;
     102            1 :     const prompt = `${connectorName.toLowerCase()}${ordinal == null ? '' : `#${ordinal}`}> `;
     103            1 :     const { controller: snippetsDialog, dialog: snippetsBrowserDialog } = useSnippetsDialog(
     104            1 :         connection?.connectorInfo.connectorType ?? ConnectorType.HYPER,
     105            1 :     );
     106              : 
     107              :     // The shell mounts files into the embedded Hyper that a Hyper WASM connection queries.
     108              :     // The mounts belong to the shell, they are removed when the shell closes or switches the connection.
     109            1 :     const database = useComputeDatabase();
     110            1 :     const mountsFiles = connection != null && isHyperWasmConnection(connection);
     111            1 :     const fileRegistryRef = React.useRef<ShellFileRegistry | null>(null);
     112            1 :     const { command: filesCommand, dialogs: fileDialogs } = useShellFiles(fileRegistryRef);
     113            1 :     React.useEffect(() => {
     114            1 :         if (!mountsFiles || database == null) return;
     115            1 :         const registry = new ShellFileRegistry(logger, externalFileDatabase(database), externalDirectoryDatabase(database));
     116            1 :         fileRegistryRef.current = registry;
     117            1 :         return () => {
     118            1 :             if (fileRegistryRef.current === registry) fileRegistryRef.current = null;
     119            1 :             void registry.dispose();
     120            1 :         };
     121            1 :     }, [connection?.connectionId, database, logger, mountsFiles]);
     122              : 
     123            1 :     React.useEffect(() => {
     124            1 :         if (connection == null || containerRef.current == null) return;
     125            1 :         const generation = ++generationRef.current;
     126            1 :         let cancelled = false;
     127            1 :         const environment = createSessionShellEnvironment(
     128            1 :             sessionId,
     129            1 :             connection.connectionId,
     130            1 :             executeQuery,
     131            1 :             cancelQuery,
     132            1 :             releaseResult,
     133            1 :             query => resolveClientFunctionCall(connection.catalog, query),
     134            1 :         );
     135            1 :         const lastCommand = createShellLastCommand(() => {
     136            1 :             if (resultQueryRef.current == null) return false;
     137            1 :             setResultOpen(true);
     138            1 :             return true;
     139            1 :         });
     140            1 :         const examplesCommand = createExamplesCommand({
     141            1 :             requestSnippet: snippetsDialog.request,
     142            1 :             splitPrompts: text => {
     143            1 :                 const shell = shellRef.current;
     144            1 :                 if (shell == null) throw new Error('Shell is not ready');
     145            1 :                 return splitShellScript(shell.core, shell.catalog, text);
     146            1 :             },
     147              :             // The prompts are filled in once the command finished
     148            1 :             queuePrompts: prompts => controllerRef.current?.queuePrompts(prompts),
     149            1 :         });
     150            1 :         setStatus('Instantiating Shell');
     151              :         // The shell runs on the catalog of its connection, so it follows the catalog as it changes
     152            1 :         void DashQLShell.create({
     153            1 :             core: connection.catalog.ptr.api,
     154            1 :             catalog: connection.catalog,
     155            1 :             environment,
     156            1 :             commands: mountsFiles ? [examplesCommand, lastCommand, filesCommand] : [examplesCommand, lastCommand],
     157            1 :         }).then(async nextShell => {
     158            1 :             nextShell.setKeywordCase(keywordCaseRef.current);
     159            1 :             if (cancelled || generation !== generationRef.current) {
     160            1 :                 nextShell.destroy();
     161            1 :                 return;
     162            1 :             }
     163            1 :             if (controllerRef.current == null) {
     164            1 :                 const { embedDashQLShell } = await import('../../shell/browser_shell.js');
     165            1 :                 if (cancelled || generation !== generationRef.current || containerRef.current == null) {
     166            0 :                     nextShell.destroy();
     167            0 :                     return;
     168            0 :                 }
     169            1 :                 let controller: BrowserShellController;
     170            1 :                 try {
     171            1 :                     controller = await embedDashQLShell({
     172            1 :                         container: containerRef.current,
     173            1 :                         shell: nextShell,
     174            1 :                         greeter: sessionName ? [shellName, `Session: ${sessionName}`, 'Enter .help for usage hints.'] : [shellName, 'Enter .help for usage hints.'],
     175            1 :                         prompt,
     176            1 :                         onExit: () => setMode(SessionViewMode.Session),
     177            1 :                         onQueryResult: showResult,
     178            1 :                         mayFocus: () => activeRef.current,
     179            1 :                     });
     180            1 :                 } catch (error) {
     181            1 :                     nextShell.destroy();
     182            1 :                     throw error;
     183            1 :                 }
     184              :                 // The inputs changed or the page closed while the terminal opened, the terminal of a newer run takes over
     185            1 :                 if (cancelled || generation !== generationRef.current) {
     186            1 :                     controller.dispose();
     187            1 :                     nextShell.destroy();
     188            1 :                     return;
     189            1 :                 }
     190            1 :                 controllerRef.current = controller;
     191            1 :             } else {
     192            1 :                 controllerRef.current.replaceShell(nextShell, prompt);
     193              :             }
     194            1 :             const previous = shellRef.current;
     195            1 :             shellRef.current = nextShell;
     196            1 :             previous?.destroy();
     197            1 :             setStatus('');
     198            1 :             if (activeRef.current) controllerRef.current.focus();
     199            1 :         }).catch(error => {
     200            1 :             if (cancelled || generation !== generationRef.current) return;
     201            1 :             const message = `Failed to load shell: ${stringifyError(error)}`;
     202            1 :             logger.warn(message, {}, LOG_CTX);
     203            1 :             controllerRef.current?.writeStatus(message);
     204            1 :             setStatus(message);
     205            1 :         });
     206            1 :         return () => {
     207            1 :             cancelled = true;
     208            1 :         };
     209            1 :     }, [connection?.connectionId, connection?.catalog, prompt, sessionName, shellName, executeQuery, cancelQuery, setMode, snippetsDialog, mountsFiles, filesCommand, releaseResult, showResult]);
     210              : 
     211            1 :     React.useEffect(() => {
     212            1 :         if (active) controllerRef.current?.focus();
     213            1 :     }, [active]);
     214              : 
     215            1 :     React.useEffect(() => {
     216            1 :         setResultQueryId(null);
     217            1 :     }, [connection?.connectionId]);
     218              : 
     219            1 :     React.useEffect(() => () => {
     220            1 :         ++generationRef.current;
     221            1 :         controllerRef.current?.dispose();
     222            1 :         controllerRef.current = null;
     223            1 :         shellRef.current?.destroy();
     224            1 :         shellRef.current = null;
     225            1 :     }, []);
     226              : 
     227            1 :     return (
     228            1 :         <main className={styles.page} aria-label={shellName}>
     229            1 :             <div className={styles.terminal}>
     230            1 :                 <div ref={containerRef} className={styles.terminalHost} />
     231            1 :             </div>
     232            1 :             {status && (
     233            1 :                 <div className={styles.status} role="status" aria-live="polite">
     234            1 :                     <strong>[ RUN ]</strong> {status}
     235            1 :                 </div>
     236              :             )}
     237            1 :             {resultOpen && resultQuery != null && (
     238            1 :                 <ResultOverlay query={resultQuery} onClose={() => setResultOpen(false)} onError={showResultError} />
     239              :             )}
     240            1 :             {snippetsBrowserDialog}
     241            1 :             {fileDialogs}
     242            1 :         </main>
     243              :     );
     244            1 : };
     245              : 
     246              : export default SessionShellPage;
        

Generated by: LCOV version 2.0-1