LCOV - code coverage report
Current view: top level - dashql-app/src/session/shell - session_shell_page.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.3 % 177 174
Test Date: 2026-11-06 19:30:32

            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 { createSessionShell } from './session_shell_catalog.js';
      14              : import {
      15              :     createSessionShellEnvironment,
      16              : } from './session_shell_environment.js';
      17              : import { ShellQueryResultOverlay } from './shell_query_result_overlay.js';
      18              : import { createShellOutputCommand, type ShellOutputMode } 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            1 : function formatInstantiationProgress(bytesLoaded: number, bytesTotal: number): string {
      35            1 :     if (bytesTotal <= 0) return `Loading ${Math.round(bytesLoaded / 1000)} kB`;
      36            1 :     const blocks = Math.max(0, Math.min(10, Math.floor(bytesLoaded / bytesTotal * 10)));
      37            1 :     return `Loading [${'#'.repeat(blocks)}${'-'.repeat(10 - blocks)}]`;
      38            1 : }
      39              : 
      40              : export interface SessionShellPageDependencies {
      41              :     ShellQueryResultOverlay: typeof ShellQueryResultOverlay;
      42              : }
      43              : 
      44            1 : const DEFAULT_DEPENDENCIES: SessionShellPageDependencies = {
      45            1 :     ShellQueryResultOverlay,
      46            1 : };
      47              : 
      48              : interface Props {
      49              :     sessionId: string;
      50              :     sessionName?: string | null;
      51              :     /// The connection the shell runs on
      52              :     connectionId: string;
      53              :     /// The ordinal that tells the connection apart from others of its connector, see `connectionOrdinals`
      54              :     ordinal?: number | null;
      55              :     active: boolean;
      56              :     dependencies?: SessionShellPageDependencies;
      57              : }
      58              : 
      59            1 : export const SessionShellPage: React.FC<Props> = ({ sessionId, sessionName, connectionId, ordinal = null, active, dependencies = DEFAULT_DEPENDENCIES }) => {
      60            1 :     const ResultOverlay = dependencies.ShellQueryResultOverlay;
      61            1 :     const activeRef = React.useRef(active);
      62            1 :     activeRef.current = active;
      63            1 :     const [connection] = useConnectionById(connectionId);
      64            1 :     const keywordCase = getKeywordCase(useAppConfig()?.settings);
      65            1 :     const keywordCaseRef = React.useRef(keywordCase);
      66            1 :     keywordCaseRef.current = keywordCase;
      67            1 :     const logger = useLogger();
      68            1 :     const executeQuery = useQueryExecutor();
      69            1 :     const cancelQuery = useCancelQuery();
      70            1 :     const { setMode } = useSessionViewMode();
      71            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
      72            1 :     const controllerRef = React.useRef<BrowserShellController | null>(null);
      73            1 :     const shellRef = React.useRef<DashQLShell | null>(null);
      74            1 :     React.useEffect(() => {
      75            1 :         shellRef.current?.setKeywordCase(keywordCase);
      76            1 :     }, [keywordCase]);
      77            1 :     const generationRef = React.useRef(0);
      78            1 :     const outputModeRef = React.useRef<ShellOutputMode>('auto');
      79            1 :     const terminalColumnsRef = React.useRef(100);
      80            1 :     const [status, setStatus] = React.useState('Instantiating Shell');
      81            1 :     const [resultQueryId, setResultQueryId] = React.useState<number | null>(null);
      82            1 :     const resultQuery = useQueryState(sessionId, resultQueryId);
      83              :     // The overlay shows one result, the computation of a result is released once it is no longer shown
      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 connectorName = connection?.connectorInfo.names.displayShort ?? '';
      93              :     // The shell names its connection like the workbench does, e.g. "Hyper #2 Http Shell" with the prompt "hyper#2> "
      94            1 :     const shellName = connection == null ? 'Shell' : `${connectionLabelText(connectionLabelParts(connection, ordinal))} Shell`;
      95            1 :     const prompt = `${connectorName.toLowerCase()}${ordinal == null ? '' : `#${ordinal}`}> `;
      96            1 :     const relationsSql = connection?.catalogRelationScript.toString() ?? '';
      97            1 :     const functionsSql = connection?.catalogFunctionScript.toString() ?? '';
      98            1 :     const { controller: snippetsDialog, dialog: snippetsBrowserDialog } = useSnippetsDialog(
      99            1 :         connection?.connectorInfo.connectorType ?? ConnectorType.HYPER,
     100            1 :     );
     101              : 
     102              :     // The shell mounts files into the embedded Hyper that a Hyper WASM connection queries.
     103              :     // The mounts belong to the shell, they are removed when the shell closes or switches the connection.
     104            1 :     const database = useComputeDatabase();
     105            1 :     const mountsFiles = connection != null && isHyperWasmConnection(connection);
     106            1 :     const fileRegistryRef = React.useRef<ShellFileRegistry | null>(null);
     107            1 :     const { command: filesCommand, dialogs: fileDialogs } = useShellFiles(fileRegistryRef);
     108            1 :     React.useEffect(() => {
     109            1 :         if (!mountsFiles || database == null) return;
     110            1 :         const registry = new ShellFileRegistry(logger, externalFileDatabase(database), externalDirectoryDatabase(database));
     111            1 :         fileRegistryRef.current = registry;
     112            1 :         return () => {
     113            1 :             if (fileRegistryRef.current === registry) fileRegistryRef.current = null;
     114            1 :             void registry.dispose();
     115            1 :         };
     116            1 :     }, [connection?.connectionId, database, logger, mountsFiles]);
     117              : 
     118            1 :     React.useEffect(() => {
     119            1 :         if (connection == null || containerRef.current == null) return;
     120            1 :         const generation = ++generationRef.current;
     121            1 :         let cancelled = false;
     122            1 :         const getOutputMode = () => outputModeRef.current;
     123            1 :         const environment = createSessionShellEnvironment(
     124            1 :             sessionId,
     125            1 :             connection.connectionId,
     126            1 :             executeQuery,
     127            1 :             cancelQuery,
     128            1 :             getOutputMode,
     129            1 :             () => terminalColumnsRef.current,
     130            1 :             releaseResult,
     131            1 :         );
     132            1 :         const outputCommand = createShellOutputCommand(getOutputMode, mode => {
     133            1 :             outputModeRef.current = mode;
     134            1 :         });
     135            1 :         const examplesCommand = createExamplesCommand({
     136            1 :             requestSnippet: snippetsDialog.request,
     137            1 :             splitPrompts: text => {
     138            1 :                 const shell = shellRef.current;
     139            1 :                 if (shell == null) throw new Error('Shell is not ready');
     140            1 :                 return splitShellScript(shell.core, shell.catalog, text);
     141            1 :             },
     142              :             // The prompts are filled in once the command finished
     143            1 :             queuePrompts: prompts => controllerRef.current?.queuePrompts(prompts),
     144            1 :         });
     145            1 :         setStatus(shellRef.current == null ? 'Instantiating Shell' : 'Refreshing shell catalog');
     146            1 :         void createSessionShell({ relationsSql, functionsSql }, environment, {
     147            1 :             commands: mountsFiles ? [examplesCommand, outputCommand, filesCommand] : [examplesCommand, outputCommand],
     148            1 :             onProgress: progress => {
     149            1 :                 if (!cancelled && generation === generationRef.current) {
     150            1 :                     setStatus(`Instantiating Shell: ${formatInstantiationProgress(progress.bytesLoaded, progress.bytesTotal)}`);
     151              :                 }
     152            1 :             },
     153            1 :         }).then(async nextShell => {
     154            1 :             nextShell.setKeywordCase(keywordCaseRef.current);
     155            1 :             if (cancelled || generation !== generationRef.current) {
     156            1 :                 nextShell.destroy();
     157            1 :                 return;
     158            1 :             }
     159            1 :             if (controllerRef.current == null) {
     160            1 :                 const { embedDashQLShell } = await import('../../shell/browser_shell.js');
     161            1 :                 if (cancelled || generation !== generationRef.current || containerRef.current == null) {
     162            0 :                     nextShell.destroy();
     163            0 :                     return;
     164            0 :                 }
     165            1 :                 let controller: BrowserShellController;
     166            1 :                 try {
     167            1 :                     controller = await embedDashQLShell({
     168            1 :                         container: containerRef.current,
     169            1 :                         shell: nextShell,
     170            1 :                         greeter: sessionName ? [shellName, sessionName, 'Enter .help for usage hints.'] : [shellName, 'Enter .help for usage hints.'],
     171            1 :                         prompt,
     172            1 :                         onExit: () => setMode(SessionViewMode.Session),
     173            1 :                         onQueryResult: setResultQueryId,
     174            1 :                         onTerminalResize: columns => { terminalColumnsRef.current = columns; },
     175            1 :                         mayFocus: () => activeRef.current,
     176            1 :                     });
     177            1 :                 } catch (error) {
     178            1 :                     nextShell.destroy();
     179            1 :                     throw error;
     180            1 :                 }
     181              :                 // The inputs changed or the page closed while the terminal opened, the terminal of a newer run takes over
     182            1 :                 if (cancelled || generation !== generationRef.current) {
     183            1 :                     controller.dispose();
     184            1 :                     nextShell.destroy();
     185            1 :                     return;
     186            1 :                 }
     187            1 :                 controllerRef.current = controller;
     188            1 :             } else {
     189            1 :                 controllerRef.current.replaceShell(nextShell, prompt);
     190            1 :                 controllerRef.current.writeStatus('Catalog refreshed');
     191            1 :             }
     192            1 :             const previous = shellRef.current;
     193            1 :             shellRef.current = nextShell;
     194            1 :             previous?.destroy();
     195            1 :             setStatus('');
     196            1 :             if (activeRef.current) controllerRef.current.focus();
     197            1 :         }).catch(error => {
     198            1 :             if (cancelled || generation !== generationRef.current) return;
     199            1 :             const message = `Failed to load shell: ${stringifyError(error)}`;
     200            1 :             logger.warn(message, {}, LOG_CTX);
     201            1 :             controllerRef.current?.writeStatus(message);
     202            1 :             setStatus(message);
     203            1 :         });
     204            1 :         return () => {
     205            1 :             cancelled = true;
     206            1 :         };
     207            1 :     }, [connection?.connectionId, prompt, sessionName, shellName, relationsSql, functionsSql, executeQuery, cancelQuery, setMode, snippetsDialog, mountsFiles, filesCommand, releaseResult]);
     208              : 
     209            1 :     React.useEffect(() => {
     210            1 :         if (active) controllerRef.current?.focus();
     211            1 :     }, [active]);
     212              : 
     213            1 :     React.useEffect(() => {
     214            1 :         setResultQueryId(null);
     215            1 :     }, [connection?.connectionId]);
     216              : 
     217            1 :     React.useEffect(() => () => {
     218            1 :         ++generationRef.current;
     219            1 :         controllerRef.current?.dispose();
     220            1 :         controllerRef.current = null;
     221            1 :         shellRef.current?.destroy();
     222            1 :         shellRef.current = null;
     223            1 :     }, []);
     224              : 
     225            1 :     return (
     226            1 :         <main className={styles.page} aria-label={shellName}>
     227            1 :             <div className={styles.terminal}>
     228            1 :                 <div ref={containerRef} className={styles.terminalHost} />
     229            1 :             </div>
     230            1 :             {status && (
     231            1 :                 <div className={styles.status} role="status" aria-live="polite">
     232            1 :                     <strong>[ RUN ]</strong> {status}
     233            1 :                 </div>
     234              :             )}
     235            1 :             {resultQuery != null && (
     236            1 :                 <ResultOverlay query={resultQuery} onClose={() => setResultQueryId(null)} />
     237              :             )}
     238            1 :             {snippetsBrowserDialog}
     239            1 :             {fileDialogs}
     240            1 :         </main>
     241              :     );
     242            1 : };
     243              : 
     244              : export default SessionShellPage;
        

Generated by: LCOV version 2.0-1