LCOV - code coverage report
Current view: top level - dashql-app/src/shell_app - shell_page.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 61.9 % 312 193
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import * as connectionTypes from '@ankoh/dashql-jsonschema/connection.js';
       4              : 
       5              : import type { EmbeddedConnection } from '../platform/database/embedded_database.js';
       6              : import { useEmbeddedDatabaseSetup } from '../platform/database/embedded_database_provider.js';
       7              : import { stringifyError } from '../platform/logger/logger.js';
       8              : import { useLogger } from '../platform/logger/logger_provider.js';
       9              : import { useDashQLCoreSetup, useDashQLCoreSetupProgress } from '../runtime/core_provider.js';
      10              : import { createDashQLShell, type DashQLShell, type DashQLShellCommand } from '../shell/api.js';
      11              : import type { BrowserShellController } from '../shell/browser_shell.js';
      12              : import { analyzeTable } from '../compute/analyze_table.js';
      13              : import { useComputationDispatch } from '../compute/computation_registry.js';
      14              : import { DELETE_COMPUTATION } from '../compute/computation_state.js';
      15              : import { ShellQueryResultOverlay } from '../session/shell/shell_query_result_overlay.js';
      16              : import { EmbeddedDatabaseCatalog } from './embedded_database_catalog.js';
      17              : import { createEmbeddedDatabaseShellEnvironment } from './embedded_database_shell_environment.js';
      18              : import { createLoginCommand, type SalesforceLoginAuthentication } from './commands/login.js';
      19              : import { SalesforceLoginHistoryStore } from './salesforce_login_history.js';
      20              : import { createExamplesCommand } from '../shell/shell_examples.js';
      21              : import { useShellQueryHistory } from './shell_query_history.js';
      22              : import { createShellLastCommand } from '../shell/shell_result.js';
      23              : import { externalDirectoryDatabase, externalFileDatabase, ShellFileRegistry } from '../shell/shell_files.js';
      24              : import { useShellFiles } from '../shell/filesystem_dialog.js';
      25              : import { createFormatCommand } from './commands/format.js';
      26              : import { createPlanCommand } from './commands/plan.js';
      27              : import { createShareCommand } from './commands/share.js';
      28              : import { useSalesforceLoginDialog } from './salesforce_login_dialog.js';
      29              : import { SalesforceApiClient } from '../connections/salesforce/salesforce_api_client.js';
      30              : import { authenticateSalesforce } from '../connections/salesforce/salesforce_authentication.js';
      31              : import { useHttpClient } from '../platform/http/http_client_provider.js';
      32              : import { usePlatformEventListener } from '../platform/events/event_listener_provider.js';
      33              : import { PlatformType, usePlatformType } from '../platform/platform_type.js';
      34              : import { useAppConfig } from '../config/app_config.js';
      35              : import { getKeywordCase } from '../session/scripts/script_format.js';
      36              : import { useShellQueryResult } from './use_shell_query_result.js';
      37              : import { useFormatDialog } from './format_dialog.js';
      38              : import { usePlanDialog } from './plan_dialog.js';
      39              : import { useShareDialog } from './share_dialog.js';
      40              : import { useSnippetsDialog } from '../session/snippets/snippets_dialog.js';
      41              : import { decodeShareScript, readShareScriptFromLocation } from './shell_share.js';
      42              : import { splitShellScript } from '../shell/shell_script.js';
      43              : import { ConnectorType } from '../connections/connector_info.js';
      44              : import { getHyperDBWebSupportError } from '../platform/hyperdb/hyperdb_web_support.js';
      45              : import * as styles from './shell_page.module.css';
      46              : 
      47            1 : const LOG_CTX = 'standalone_shell';
      48              : 
      49              : interface ShellPageProps {
      50              :     onEngineVersion: (version: string) => void;
      51              : }
      52              : 
      53            1 : function formatInstantiationProgress(bytesLoaded: number, bytesTotal: number): string {
      54            1 :     if (bytesTotal <= 0) return `Loading ${Math.round(bytesLoaded / 1000)} kB`;
      55            1 :     const blocks = Math.max(0, Math.min(10, Math.floor(bytesLoaded / bytesTotal * 10)));
      56            1 :     return `Loading [${'#'.repeat(blocks)}${'-'.repeat(10 - blocks)}]`;
      57            1 : }
      58              : 
      59            1 : const INSTANTIATING_SHELL = 'Instantiating shell';
      60              : 
      61            1 : export const ShellPage: React.FC<ShellPageProps> = (props: ShellPageProps) => {
      62            1 :     const logger = useLogger();
      63            1 :     const setupCore = useDashQLCoreSetup();
      64            1 :     const coreProgress = useDashQLCoreSetupProgress();
      65            1 :     const setupEmbeddedDatabase = useEmbeddedDatabaseSetup();
      66            1 :     const queryExecutions = useShellQueryHistory();
      67            1 :     const dispatchComputation = useComputationDispatch();
      68            1 :     const httpClient = useHttpClient();
      69            1 :     const appEvents = usePlatformEventListener();
      70            1 :     const platformType = usePlatformType();
      71            1 :     const appConfig = useAppConfig();
      72            1 :     const keywordCase = getKeywordCase(appConfig?.settings);
      73            1 :     const hyperDBSupportError = platformType === PlatformType.WEB ? getHyperDBWebSupportError() : null;
      74            1 :     const keywordCaseRef = React.useRef(keywordCase);
      75            1 :     keywordCaseRef.current = keywordCase;
      76              :     // The login reads the config when it runs, a config that arrives later must not tear down the shell
      77            1 :     const appConfigRef = React.useRef(appConfig);
      78            1 :     appConfigRef.current = appConfig;
      79            1 :     const loginHistoryRef = React.useRef(new SalesforceLoginHistoryStore());
      80            1 :     const { controller: loginDialog, dialog } = useSalesforceLoginDialog({
      81            1 :         nativeOAuth: platformType !== PlatformType.WEB,
      82            1 :         loadHistory: () => loginHistoryRef.current.load(),
      83            1 :         deleteHistoryEntry: organizationId => loginHistoryRef.current.delete(organizationId),
      84            1 :     });
      85            1 :     const { controller: formatDialog, dialog: formatterDialog } = useFormatDialog();
      86            1 :     const { controller: planDialog, dialog: queryPlanDialog } = usePlanDialog(appEvents);
      87            1 :     const { controller: shareDialog, dialog: shareScriptDialog } = useShareDialog();
      88            1 :     const { controller: snippetsDialog, dialog: snippetsBrowserDialog } = useSnippetsDialog(ConnectorType.HYPER);
      89            1 :     const fileRegistryRef = React.useRef<ShellFileRegistry | null>(null);
      90            1 :     const { command: filesCommand, dialogs: fileDialogs } = useShellFiles(fileRegistryRef);
      91            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
      92            1 :     const [status, setStatus] = React.useState('Instantiating hyperdb-wasm');
      93            1 :     const shellRef = React.useRef<DashQLShell | null>(null);
      94            1 :     const controllerRef = React.useRef<BrowserShellController | null>(null);
      95            1 :     React.useEffect(() => {
      96            1 :         shellRef.current?.setKeywordCase(keywordCase);
      97            1 :     }, [keywordCase]);
      98            1 :     const releaseResult = React.useCallback((queryId: number) => {
      99            1 :         dispatchComputation({ type: DELETE_COMPUTATION, value: [queryId] });
     100            1 :     }, [dispatchComputation]);
     101            1 :     const { resultQuery, showResultQuery, closeResultQuery, reopenResultQuery, dropResultQuery } = useShellQueryResult(queryExecutions, releaseResult);
     102            1 :     const showResultError = (error: unknown, info: React.ErrorInfo) => {
     103            0 :         dropResultQuery();
     104              :         // The terminal says what happened, an error would also raise a toast
     105            0 :         logger.warn('Failed to show a query result', { error: stringifyError(error), stack: info.componentStack ?? '' }, LOG_CTX);
     106            0 :         controllerRef.current?.writeStatus(`Failed to show the result: ${stringifyError(error)}`);
     107            0 :     };
     108              : 
     109            1 :     React.useEffect(() => {
     110            1 :         if (hyperDBSupportError != null) return;
     111            1 :         if (containerRef.current == null) return;
     112              : 
     113            1 :         let cancelled = false;
     114            1 :         let connection: EmbeddedConnection | null = null;
     115            1 :         let shell: DashQLShell | null = null;
     116            1 :         let shellCatalog: EmbeddedDatabaseCatalog | null = null;
     117            1 :         let controller: BrowserShellController | null = null;
     118            1 :         let fileRegistry: ShellFileRegistry | null = null;
     119              : 
     120            1 :         const setup = async () => {
     121            1 :             setStatus('Instantiating hyperdb-wasm');
     122            1 :             const database = await setupEmbeddedDatabase(LOG_CTX, progress => {
     123            1 :                 if (!cancelled) {
     124            1 :                     setStatus(`Instantiating hyperdb-wasm: ${formatInstantiationProgress(progress.bytesLoaded, progress.bytesTotal)}`);
     125              :                 }
     126            1 :             });
     127            1 :             void database.getVersion()
     128            1 :                 .then(version => {
     129            1 :                     if (!cancelled) props.onEngineVersion(version);
     130            1 :                 })
     131            1 :                 .catch(error => {
     132            0 :                     logger.warn('Failed to load engine version', { error: stringifyError(error) }, LOG_CTX);
     133            1 :                 });
     134            1 :             const nextConnection = await database.connect({ defaultDatabase: 'hyper' });
     135            1 :             if (cancelled) {
     136            0 :                 await nextConnection.close();
     137            0 :                 return;
     138            0 :             }
     139            1 :             connection = nextConnection;
     140              : 
     141            1 :             setStatus(INSTANTIATING_SHELL);
     142            1 :             const core = await setupCore(LOG_CTX);
     143            1 :             const loginCommand = createLoginCommand({
     144            1 :                 requestForm: loginDialog.request,
     145            1 :                 authenticate: async (form, signal, onProgress) => {
     146            0 :                     if (!httpClient) throw new Error('HTTP client is not ready');
     147            0 :                     const oauthRedirect = appConfigRef.current?.connectors?.salesforce?.auth?.oauthRedirect;
     148            0 :                     if (!oauthRedirect) throw new Error('Salesforce OAuth redirect is not configured');
     149            0 :                     const abortSignal = signal ?? new AbortController().signal;
     150            0 :                     const params: connectionTypes.SalesforceConnectionParams = {
     151            0 :                         instanceUrl: form.instanceUrl,
     152            0 :                         appConsumerKey: form.appConsumerKey,
     153            0 :                         appConsumerSecret: '',
     154            0 :                         login: form.loginHint,
     155            0 :                     };
     156            0 :                     return await authenticateSalesforce({
     157            0 :                         logger,
     158            0 :                         params,
     159            0 :                         authConfig: { oauthRedirect },
     160            0 :                         platformType,
     161            0 :                         apiClient: new SalesforceApiClient(logger, httpClient),
     162            0 :                         appEvents,
     163            0 :                         forceReLogin: appConfigRef.current?.settings?.forceReLogin ?? false,
     164            0 :                         abortSignal,
     165            0 :                         oauthPopup: form.oauthPopup,
     166            0 :                         onProgress: progress => {
     167              :                             const messages: Partial<Record<typeof progress.stage, string>> = {
     168            0 :                                 GENERATING_PKCE_CHALLENGE: 'Preparing secure Salesforce login',
     169            0 :                                 OAUTH_NATIVE_LINK_OPENED: 'Waiting for Salesforce authorization in your browser',
     170            0 :                                 OAUTH_WEB_WINDOW_OPENED: 'Waiting for Salesforce authorization',
     171            0 :                                 REQUESTING_CORE_AUTH_TOKEN: 'Exchanging Salesforce authorization code',
     172            0 :                                 REQUESTING_DATA_CLOUD_ACCESS_TOKEN: 'Requesting Data Cloud credentials',
     173              :                             };
     174            0 :                             const message = messages[progress.stage];
     175            0 :                             if (message) {
     176            0 :                                 onProgress?.(message);
     177            0 :                                 loginDialog.update({ status: message });
     178            0 :                             }
     179            0 :                             if (progress.stage === 'RECEIVED_CORE_AUTH_TOKEN') {
     180            0 :                                 loginDialog.update({
     181            0 :                                     status: 'Received Core access token',
     182            0 :                                     coreAccessToken: progress.coreAccessToken,
     183            0 :                                 });
     184            0 :                             } else if (progress.stage === 'RECEIVED_CORE_USER_INFO') {
     185            0 :                                 loginDialog.update({
     186            0 :                                     login: progress.coreUserInfo.preferredUsername ?? progress.coreUserInfo.email ?? '',
     187            0 :                                 });
     188            0 :                             } else if (progress.stage === 'RECEIVED_DATA_CLOUD_ACCESS_TOKEN') {
     189            0 :                                 loginDialog.update({
     190            0 :                                     status: 'Received Data Cloud access token',
     191            0 :                                     dataCloudAccessToken: progress.dataCloudAccessToken,
     192            0 :                                 });
     193              :                             }
     194            0 :                         },
     195            0 :                     });
     196            0 :                 },
     197            1 :                 onSuccess: async (form, authentication) => {
     198            0 :                     const organizationId = authentication.coreUserInfo?.organizationId
     199            0 :                         ?? authentication.dataCloudAccessToken.jwt.payload.orgId;
     200            0 :                     if (organizationId) {
     201            0 :                         try {
     202            0 :                             await loginHistoryRef.current.record({
     203            0 :                                 organizationId,
     204            0 :                                 name: form.alias,
     205            0 :                                 instanceUrl: authentication.coreAccessToken.instanceUrl ?? form.instanceUrl,
     206            0 :                                 appConsumerKey: form.appConsumerKey,
     207            0 :                                 loginHint: authentication.coreUserInfo?.preferredUsername
     208            0 :                                     ?? authentication.coreUserInfo?.email,
     209            0 :                             });
     210            0 :                         } catch (error) {
     211            0 :                             logger.warn('Failed to persist Salesforce login history', {
     212            0 :                                 error: stringifyError(error),
     213            0 :                             }, LOG_CTX);
     214            0 :                         }
     215              :                     } else {
     216            0 :                         logger.warn('Salesforce login did not return an organization ID; history was not updated', {}, LOG_CTX);
     217              :                     }
     218            0 :                     loginDialog.succeed(`Authenticated with Salesforce as ${form.alias}`);
     219            0 :                 },
     220            1 :                 onError: error => {
     221            0 :                     loginDialog.fail(stringifyError(error));
     222            0 :                     return 'retry';
     223            0 :                 },
     224            1 :             });
     225            1 :             fileRegistry = new ShellFileRegistry(
     226            1 :                 logger,
     227            1 :                 externalFileDatabase(database),
     228            1 :                 externalDirectoryDatabase(database),
     229            1 :             );
     230            1 :             fileRegistryRef.current = fileRegistry;
     231            1 :             const commands: DashQLShellCommand[] = [
     232            1 :                 createExamplesCommand({
     233            1 :                     requestSnippet: snippetsDialog.request,
     234            1 :                     splitPrompts: text => {
     235            0 :                         if (shell == null) throw new Error('Shell is not ready');
     236            0 :                         return splitShellScript(shell.core, shell.catalog, text);
     237            0 :                     },
     238              :                     // The prompts are filled in once the command finished
     239            1 :                     queuePrompts: prompts => controller?.queuePrompts(prompts),
     240            1 :                 }),
     241            1 :                 createFormatCommand({
     242            1 :                     requestDialog: signal => {
     243            0 :                         if (shell == null) throw new Error('Shell is not ready');
     244            0 :                         return formatDialog.request(shell.core, shell.catalog, signal);
     245            0 :                     },
     246            1 :                 }),
     247            1 :                 createPlanCommand({
     248            1 :                     requestDialog: signal => {
     249            0 :                         if (shell == null) throw new Error('Shell is not ready');
     250            0 :                         return planDialog.request(shell.core, signal);
     251            0 :                     },
     252            1 :                 }),
     253            1 :                 createShareCommand({
     254            1 :                     requestDialog: signal => {
     255            0 :                         if (shell == null) throw new Error('Shell is not ready');
     256            0 :                         return shareDialog.request(shell.core, shell.catalog, signal);
     257            0 :                     },
     258            1 :                 }),
     259            1 :                 loginCommand,
     260            1 :                 createShellLastCommand(reopenResultQuery),
     261            1 :                 filesCommand,
     262            1 :             ];
     263            1 :             const nextShell = await createDashQLShell({
     264            1 :                 core,
     265            1 :                 environment: createEmbeddedDatabaseShellEnvironment(connection, queryExecutions, {
     266            1 :                     prepareResult: (queryId, table) => analyzeTable(
     267            1 :                         queryId,
     268            1 :                         table,
     269            1 :                         dispatchComputation,
     270            1 :                         database,
     271            1 :                         logger,
     272            1 :                     ),
     273              :                     // The statement succeeded, following the catalog must not fail it
     274            1 :                     afterStatement: async statement => {
     275            1 :                         try {
     276            1 :                             await shellCatalog?.afterStatement(statement);
     277            1 :                         } catch (error) {
     278            0 :                             logger.warn('Failed to update the catalog of the shell', { error: stringifyError(error) }, LOG_CTX);
     279            0 :                         }
     280            1 :                     },
     281            1 :                 }),
     282            1 :                 autoQualifyNonDefaultDatabaseTables: true,
     283            1 :                 commands,
     284            1 :             });
     285            1 :             if (cancelled || containerRef.current == null) {
     286            0 :                 nextShell.destroy();
     287            0 :                 return;
     288            0 :             }
     289            1 :             shell = nextShell;
     290            1 :             shellCatalog = new EmbeddedDatabaseCatalog(nextShell.core, nextShell.catalog, text => connection!.query(text));
     291            1 :             shellRef.current = nextShell;
     292            1 :             nextShell.setKeywordCase(keywordCaseRef.current);
     293            1 :             const { embedDashQLShell } = await import('../shell/browser_shell.js');
     294            1 :             if (cancelled || containerRef.current == null) return;
     295            1 :             const nextController = await embedDashQLShell({
     296            1 :                 container: containerRef.current,
     297            1 :                 shell,
     298            1 :                 greeter: [
     299            1 :                     'HyperDB Web Shell',
     300            1 :                     'This is an embedded version of the Hyper Database Engine.',
     301            1 :                     'Enter .help for usage hints.'
     302            1 :                 ],
     303            1 :                 prompt: 'hyperdb> ',
     304            1 :                 inputAriaLabel: 'HyperDB shell input',
     305            1 :                 onQueryResult: showResultQuery,
     306            1 :             });
     307            1 :             if (cancelled) {
     308            0 :                 nextController.dispose();
     309            0 :                 return;
     310            0 :             }
     311            1 :             controller = nextController;
     312            1 :             controllerRef.current = nextController;
     313            1 :             setStatus('');
     314              : 
     315              :             // Offer the statements of a shared script one after another
     316            1 :             const sharedScript = readShareScriptFromLocation();
     317            1 :             if (sharedScript == null) return;
     318            0 :             try {
     319            0 :                 const text = await decodeShareScript(sharedScript);
     320            0 :                 if (cancelled) return;
     321            0 :                 const prompts = splitShellScript(nextShell.core, nextShell.catalog, text);
     322            0 :                 if (prompts.length === 0) return;
     323            1 :                 nextController.writeStatus(`Shared script with ${prompts.length} ${prompts.length === 1 ? 'statement' : 'statements'}. Press Enter to run each, Ctrl+C to skip the rest.`);
     324            1 :                 nextController.queuePrompts(prompts);
     325            1 :             } catch (error) {
     326            0 :                 if (cancelled) return;
     327            0 :                 logger.warn('Failed to load shared script', { error: stringifyError(error) }, LOG_CTX);
     328            0 :                 nextController.writeStatus(`Failed to load shared script: ${stringifyError(error)}`);
     329            0 :             }
     330            1 :         };
     331              : 
     332            1 :         void setup().catch(error => {
     333            0 :             if (cancelled) return;
     334            0 :             const message = `Failed to load shell: ${stringifyError(error)}`;
     335            0 :             logger.warn(message, {}, LOG_CTX);
     336            0 :             setStatus(message);
     337            1 :         });
     338              : 
     339            1 :         return () => {
     340            1 :             cancelled = true;
     341            1 :             controller?.dispose();
     342            1 :             controllerRef.current = null;
     343            1 :             shellCatalog?.destroy();
     344            1 :             shell?.destroy();
     345            1 :             shellRef.current = null;
     346            1 :             fileRegistryRef.current = null;
     347            1 :             void fileRegistry?.dispose().finally(() => connection?.close());
     348            1 :         };
     349            1 :     }, [appEvents, dispatchComputation, filesCommand, setupCore, formatDialog, httpClient, hyperDBSupportError, logger, loginDialog, planDialog, platformType, props.onEngineVersion, queryExecutions, reopenResultQuery, setupEmbeddedDatabase, shareDialog, showResultQuery, snippetsDialog]);
     350              : 
     351            1 :     if (hyperDBSupportError != null) {
     352            0 :         return (
     353            0 :             <main className={styles.page} aria-label="HyperDB Shell">
     354            0 :                 <section className={styles.unsupported} role="alert">
     355            0 :                     <h1>Unsupported platform</h1>
     356            0 :                     <p>{hyperDBSupportError}</p>
     357            0 :                 </section>
     358            0 :             </main>
     359              :         );
     360              :     }
     361              : 
     362            1 :     return (
     363            1 :         <main className={styles.page} aria-label="HyperDB Shell">
     364            1 :             <div className={styles.terminal}>
     365            1 :                 <div ref={containerRef} className={styles.terminalHost} />
     366            1 :             </div>
     367            1 :             {status && (
     368            1 :                 <div className={styles.status} role="status" aria-live="polite">
     369            1 :                     <strong>[ RUN ]</strong> {status === INSTANTIATING_SHELL && coreProgress != null
     370            1 :                         ? `${status}: ${formatInstantiationProgress(Number(coreProgress.bytesLoaded), Number(coreProgress.bytesTotal))}`
     371            1 :                         : status}
     372            1 :                 </div>
     373              :             )}
     374            1 :             {resultQuery != null && (
     375            1 :                 <ShellQueryResultOverlay
     376            1 :                     query={resultQuery}
     377            1 :                     onClose={closeResultQuery}
     378            1 :                     onError={showResultError}
     379            1 :                     dismissOnClickOutside={false}
     380            1 :                 />
     381              :             )}
     382            1 :             {dialog}
     383            1 :             {formatterDialog}
     384            1 :             {queryPlanDialog}
     385            1 :             {shareScriptDialog}
     386            1 :             {snippetsBrowserDialog}
     387            1 :             {fileDialogs}
     388            1 :         </main>
     389              :     );
     390            1 : };
        

Generated by: LCOV version 2.0-1