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