LCOV - code coverage report
Current view: top level - dashql-app/src/shell_app - shell_page.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 57.8 % 294 170
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1