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;
|