LCOV - code coverage report
Current view: top level - dashql-app/src/app/loading - shared_session_import.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 53.5 % 142 76
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { AppLoadingStatus } from './app_loading_status.js';
       4              : import { OPEN_LINK_SESSION, useRouteContext, useRouterNavigate } from '../router/router.js';
       5              : import { useDashQLCoreSetup } from '../../runtime/core_provider.js';
       6              : import { useLogger } from '../../platform/logger/logger_provider.js';
       7              : import { createTrace } from '../../platform/logger/trace_context.js';
       8              : import { stringifyError } from '../../platform/logger/logger.js';
       9              : import { usePlatformEventListener } from '../../platform/events/event_listener_provider.js';
      10              : import { SETUP_SESSION, SETUP_SESSION_URL, SetupEventVariant } from '../../platform/events/event.js';
      11              : import { AppHost, getAppHost } from '../../platform/native_globals.js';
      12              : import { useHttpClient } from '../../platform/http/http_client_provider.js';
      13              : import { readSessionBundleFromZip } from '../../session/persistence/session_import.js';
      14              : import { useSessionImport } from '../../session/persistence/session_import_provider.js';
      15              : import {
      16              :     readSessionBundleFromHttp,
      17              :     type HttpSessionLoadProgress,
      18              :     type HttpSessionLoadResult,
      19              : } from '../../session/persistence/http_session_bundle.js';
      20              : import { SessionImportCard } from '../../session/persistence/session_import_card.js';
      21              : 
      22            1 : const LOG_CTX = "app_loader";
      23              : 
      24              : /// A shared session that loads from its URL
      25              : interface RemoteSessionLoad {
      26              :     /// The URL of the session
      27              :     sourceUrl: string;
      28              :     /// The progress of the load
      29              :     progress: HttpSessionLoadProgress;
      30              : }
      31              : 
      32              : /// A loaded shared session that waits for the user to import it
      33              : interface LoadedSharedSession {
      34              :     /// The loaded session
      35              :     result: HttpSessionLoadResult;
      36              :     /// The registered session with the same id, if any
      37              :     conflict: { displayLocation: string; isNative: boolean } | null;
      38              : }
      39              : 
      40              : /// Import the sessions that the platform hands to the app.
      41              : ///
      42              : /// A deep link or an opened `.dashql` file carries the session itself, it is imported right away. A
      43              : /// shared URL is loaded while the app sets up and then shown in a card until the user imports it.
      44              : /// Imports wait for `setupDone` since they use the restored registries. The import of a session that
      45              : /// the app was started with is stored in `initialImport`, the setup doesn't open another session
      46              : /// when it opens one.
      47              : ///
      48              : /// Returns the card of a shared session, null if there is none.
      49            1 : export function useSharedSessionImport(
      50            1 :     setupDone: Promise<void>,
      51            1 :     initialImport: React.RefObject<Promise<boolean> | null>,
      52            1 : ): React.ReactElement | null {
      53            1 :     const logger = useLogger();
      54            1 :     const navigate = useRouterNavigate();
      55            1 :     const routeContext = useRouteContext();
      56            1 :     const setupCore = useDashQLCoreSetup();
      57            1 :     const appEvents = usePlatformEventListener();
      58            1 :     const sessionImport = useSessionImport();
      59            1 :     const httpClient = useHttpClient();
      60              : 
      61            1 :     const [remoteLoad, setRemoteLoad] = React.useState<RemoteSessionLoad | null>(null);
      62            1 :     const [loaded, setLoaded] = React.useState<LoadedSharedSession | null>(null);
      63            1 :     const [importBusy, setImportBusy] = React.useState(false);
      64            1 :     const remoteAbortRef = React.useRef<AbortController | null>(null);
      65            1 :     React.useEffect(() => () => remoteAbortRef.current?.abort(), []);
      66              : 
      67              :     // Returns true if the event opened a session
      68            1 :     const consumeSetupEvent = React.useEffectEvent(async (data: SetupEventVariant): Promise<boolean> => {
      69              :         // Start trace for setup event handling
      70            1 :         const traced = logger.withTrace(createTrace());
      71            1 :         traced.debug("Consuming setup event", { "event_type": String(data.type) }, LOG_CTX);
      72              : 
      73            1 :         let remoteAbort: AbortController | null = null;
      74            1 :         let remoteResult: Promise<HttpSessionLoadResult> | null = null;
      75            1 :         if (data.type === SETUP_SESSION_URL) {
      76            0 :             remoteAbortRef.current?.abort();
      77            0 :             remoteAbort = new AbortController();
      78            0 :             remoteAbortRef.current = remoteAbort;
      79            0 :             setRemoteLoad({ sourceUrl: data.value, progress: { phase: 'preparing' } });
      80            0 :             setLoaded(null);
      81            0 :             traced.info('Loading shared session', { sourceUrl: data.value }, LOG_CTX);
      82            0 :             remoteResult = readSessionBundleFromHttp(
      83            0 :                 new URL(data.value),
      84            0 :                 httpClient,
      85            0 :                 remoteAbort.signal,
      86            0 :                 progress => {
      87            0 :                     if (remoteAbortRef.current === remoteAbort) {
      88            0 :                         setRemoteLoad({ sourceUrl: data.value, progress });
      89              :                     }
      90            0 :                 },
      91            0 :             );
      92            0 :         }
      93              : 
      94              :         // Remote session I/O can proceed while the application initializes. Import and conflict
      95              :         // detection still wait for setup because they use the initialized storage registry.
      96            1 :         const setup = Promise.all([setupCore("app_setup"), setupDone]);
      97            1 :         const result = remoteResult == null ? null : await remoteResult;
      98            0 :         await setup;
      99            1 :         remoteAbort?.signal.throwIfAborted();
     100            1 :         if (data.type === SETUP_SESSION) {
     101            1 :             const zipBlob = new Blob([data.value.buffer as ArrayBuffer], { type: 'application/zip' });
     102            1 :             const bundle = await readSessionBundleFromZip(zipBlob);
     103            1 :             const sessionId = await sessionImport.importPortableBundle(bundle, {
     104            1 :                 presentation: { mode: 'centered' },
     105            1 :             });
     106            1 :             if (sessionId != null) {
     107            1 :                 traced.debug("Finished link setup", { sessionId }, LOG_CTX);
     108            1 :                 navigate({ type: OPEN_LINK_SESSION, value: sessionId });
     109            1 :                 return true;
     110            1 :             }
     111            1 :         } else if (data.type === SETUP_SESSION_URL && remoteAbortRef.current === remoteAbort) {
     112            0 :             traced.info('Loaded shared session', {
     113            0 :                 sessionId: result!.bundle.session.sessionId,
     114            0 :                 loadedScriptCount: String(result!.loadedScriptCount),
     115            0 :                 listedScriptCount: String(result!.listedScriptCount),
     116            0 :                 incomplete: String(result!.incomplete),
     117            0 :             }, LOG_CTX);
     118            0 :             const conflict = await sessionImport.findPortableBundleConflict(result!.bundle);
     119            0 :             remoteAbort!.signal.throwIfAborted();
     120            0 :             if (remoteAbortRef.current !== remoteAbort) return false;
     121            0 :             traced.info('Checked shared session import conflict', {
     122            0 :                 sessionId: result!.bundle.session.sessionId,
     123            0 :                 conflict: String(conflict != null),
     124            0 :                 existingLocation: conflict?.displayLocation ?? '',
     125            0 :             }, LOG_CTX);
     126            0 :             remoteAbortRef.current = null;
     127            0 :             setRemoteLoad(null);
     128            0 :             setLoaded({ result: result!, conflict });
     129            0 :         }
     130            0 :         return false;
     131            1 :     });
     132              : 
     133            1 :     const handleSetupEvent = React.useEffectEvent((data: SetupEventVariant) => {
     134            1 :         const handling = consumeSetupEvent(data).catch(error => {
     135            0 :             if ((error as Error)?.name === 'AbortError') return false;
     136            0 :             remoteAbortRef.current = null;
     137            0 :             setRemoteLoad(null);
     138            0 :             logger.error('Failed to open shared session', { error: stringifyError(error) }, LOG_CTX);
     139            0 :             return false;
     140            1 :         });
     141              :         // Normal startup must not replace the route selected by an inline session import
     142            1 :         if (data.type === SETUP_SESSION && routeContext.appLoadingStatus === AppLoadingStatus.NOT_STARTED) {
     143            1 :             initialImport.current = handling;
     144              :         }
     145              :         void handling;
     146            1 :     });
     147              : 
     148              :     // Effect Events are non-reactive. Depending on handleSetupEvent would resubscribe after every
     149              :     // render even though it always reads the latest callback state.
     150            1 :     React.useEffect(() => {
     151            1 :         appEvents.subscribeSetupEvents(handleSetupEvent);
     152            1 :         return () => appEvents.unsubscribeSetupEvents(handleSetupEvent);
     153            1 :     }, [appEvents]);
     154              : 
     155            1 :     const cancelRemoteLoad = React.useCallback(() => {
     156            0 :         remoteAbortRef.current?.abort();
     157            0 :         remoteAbortRef.current = null;
     158            0 :         setRemoteLoad(null);
     159            1 :     }, []);
     160              : 
     161            1 :     const importLoaded = React.useCallback((choice?: 'replace' | 'create-new') => {
     162            0 :         if (loaded == null || importBusy) return;
     163            0 :         setImportBusy(true);
     164            0 :         const { result } = loaded;
     165            0 :         const importChoice = choice ?? 'import';
     166            0 :         logger.info('Importing shared session', {
     167            0 :             sessionId: result.bundle.session.sessionId,
     168            0 :             choice: importChoice,
     169            0 :         }, LOG_CTX);
     170            0 :         void sessionImport.importPortableBundleWithChoice(result.bundle, choice ?? 'create-new').then(sessionId => {
     171              :             // The user cancelled the import while choosing connections
     172            0 :             if (sessionId == null) return;
     173            0 :             logger.info('Imported shared session', {
     174            0 :                 sourceSessionId: result.bundle.session.sessionId,
     175            0 :                 sessionId,
     176            0 :                 choice: importChoice,
     177            0 :             }, LOG_CTX);
     178            0 :             setLoaded(null);
     179            0 :             navigate({ type: OPEN_LINK_SESSION, value: sessionId });
     180            0 :         }).catch(error => {
     181            0 :             logger.error('Failed to import loaded session', { error: stringifyError(error) }, LOG_CTX);
     182            0 :         }).finally(() => setImportBusy(false));
     183            1 :     }, [loaded, importBusy, logger, navigate, sessionImport]);
     184              : 
     185            1 :     const cancelLoaded = React.useCallback(() => {
     186            0 :         if (importBusy) return;
     187            0 :         setLoaded(null);
     188            1 :     }, [importBusy]);
     189              : 
     190            1 :     if (remoteLoad != null) {
     191            1 :         return <SessionImportCard
     192            1 :             phase="loading"
     193            1 :             sourceUrl={remoteLoad.sourceUrl}
     194            1 :             progress={remoteLoad.progress}
     195            1 :             onClose={cancelRemoteLoad}
     196            1 :         />;
     197              :     }
     198            1 :     if (loaded != null) {
     199            1 :         return <SessionImportCard
     200            1 :             phase="ready"
     201            1 :             result={loaded.result}
     202            1 :             conflictLocation={loaded.conflict?.displayLocation ?? null}
     203            1 :             conflictIsNative={getAppHost() === AppHost.ELECTRON && (loaded.conflict?.isNative ?? false)}
     204            1 :             busy={importBusy}
     205            1 :             onImport={() => importLoaded()}
     206            1 :             onReplace={() => importLoaded('replace')}
     207            1 :             onCreateNew={() => importLoaded('create-new')}
     208            1 :             onClose={cancelLoaded}
     209            1 :         />;
     210              :     }
     211            1 :     return null;
     212            1 : }
        

Generated by: LCOV version 2.0-1