LCOV - code coverage report
Current view: top level - dashql-app/src/app - file_loader.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.8 % 83 82
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { PlatformFile } from '../platform/file/file.js';
       4              : import type { SessionBundle } from '../storage/session_bundle.js';
       5              : import { readSessionBundleFromZip } from '../session/persistence/session_import.js';
       6              : import { useSessionImport } from '../session/persistence/session_import_provider.js';
       7              : import { SessionImportCard, type SessionImportCardDependencies } from '../session/persistence/session_import_card.js';
       8              : import { OPEN_LINK_SESSION, useRouterNavigate } from './router/router.js';
       9              : 
      10              : type PreparationPhase = 'reading' | 'validating';
      11              : 
      12              : type FileLoadState =
      13              :     | { phase: 'reading' }
      14              :     | { phase: 'validating'; fileByteCount: number }
      15              :     | { phase: 'ready'; bundle: SessionBundle; fileByteCount: number }
      16              :     | { phase: 'importing'; bundle: SessionBundle; fileByteCount: number }
      17              :     | { phase: 'failed'; failedPhase: PreparationPhase | 'importing'; error: Error; fileByteCount: number | null };
      18              : 
      19              : export interface FileLoaderDependencies {
      20              :     sessionImportCard?: SessionImportCardDependencies;
      21              :     readSessionBundleFromZip: typeof readSessionBundleFromZip;
      22              :     useSessionImport(): Pick<ReturnType<typeof useSessionImport>, 'importPortableBundle'>;
      23              :     useRouterNavigate: typeof useRouterNavigate;
      24              : }
      25              : 
      26            1 : const DEFAULT_DEPENDENCIES: FileLoaderDependencies = {
      27            1 :     readSessionBundleFromZip,
      28            1 :     useSessionImport,
      29            1 :     useRouterNavigate,
      30            1 : };
      31              : 
      32              : interface Props {
      33              :     file: PlatformFile;
      34              :     onDone(): void;
      35              :     dependencies?: FileLoaderDependencies;
      36              : }
      37              : 
      38            1 : export function FileLoader({ file, onDone, dependencies = DEFAULT_DEPENDENCIES }: Props): React.ReactElement {
      39            1 :     const navigate = dependencies.useRouterNavigate();
      40            1 :     const { importPortableBundle } = dependencies.useSessionImport();
      41            1 :     const [attempt, setAttempt] = React.useState(0);
      42            1 :     const [state, setState] = React.useState<FileLoadState>({ phase: 'reading' });
      43            1 :     const prepareFile = React.useEffectEvent(async (input: PlatformFile, signal: AbortSignal) => {
      44            1 :         try {
      45            1 :             setState({ phase: 'reading' });
      46            1 :             const bytes = await input.readAsArrayBuffer();
      47            1 :             signal.throwIfAborted();
      48              : 
      49            1 :             setState({ phase: 'validating', fileByteCount: bytes.byteLength });
      50            1 :             const zipBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
      51            1 :             const zipBlob = new Blob([zipBuffer], { type: 'application/zip' });
      52            1 :             const bundle = await dependencies.readSessionBundleFromZip(zipBlob);
      53            1 :             signal.throwIfAborted();
      54              : 
      55            1 :             setState({ phase: 'ready', bundle, fileByteCount: bytes.byteLength });
      56            1 :         } catch (error) {
      57            1 :             if (signal.aborted) return;
      58            1 :             setState(current => ({
      59            1 :                 phase: 'failed',
      60            1 :                 failedPhase: current.phase === 'validating' ? 'validating' : 'reading',
      61            1 :                 error: toError(error),
      62            1 :                 fileByteCount: 'fileByteCount' in current ? current.fileByteCount : null,
      63            1 :             }));
      64            1 :         }
      65            1 :     });
      66              : 
      67            1 :     React.useEffect(() => {
      68            1 :         const abortController = new AbortController();
      69            1 :         void prepareFile(file, abortController.signal);
      70            1 :         return () => abortController.abort();
      71            1 :     }, [file, attempt]);
      72              : 
      73            1 :     const requestClose = React.useCallback(() => {
      74            0 :         if (state.phase !== 'importing') onDone();
      75            1 :     }, [onDone, state.phase]);
      76            1 :     const retry = React.useCallback(() => setAttempt(value => value + 1), []);
      77            1 :     const startImport = React.useEffectEvent(async (bundle: SessionBundle, fileByteCount: number) => {
      78            1 :         setState({ phase: 'importing', bundle, fileByteCount });
      79            1 :         try {
      80            1 :             const sessionId = await importPortableBundle(bundle, {
      81            1 :                 presentation: { mode: 'centered' },
      82            1 :             });
      83            1 :             if (sessionId == null) {
      84            1 :                 setState({ phase: 'ready', bundle, fileByteCount });
      85            1 :                 return;
      86            1 :             }
      87            1 :             navigate({ type: OPEN_LINK_SESSION, value: sessionId });
      88            1 :             onDone();
      89            1 :         } catch (error) {
      90            1 :             setState({
      91            1 :                 phase: 'failed',
      92            1 :                 failedPhase: 'importing',
      93            1 :                 error: toError(error),
      94            1 :                 fileByteCount,
      95            1 :             });
      96            1 :         }
      97            1 :     });
      98            1 :     const requestImport = React.useCallback(() => {
      99            1 :         if (state.phase === 'ready') void startImport(state.bundle, state.fileByteCount);
     100            1 :     }, [state]);
     101              : 
     102            1 :     switch (state.phase) {
     103            1 :         case 'reading':
     104            1 :             return <SessionImportCard phase="file-loading" sourcePath={file.path} stage="reading" fileByteCount={null} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
     105            1 :         case 'validating':
     106            1 :             return <SessionImportCard phase="file-loading" sourcePath={file.path} stage="validating" fileByteCount={state.fileByteCount} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
     107            1 :         case 'ready':
     108            1 :             return <SessionImportCard phase="file-ready" sourcePath={file.path} fileByteCount={state.fileByteCount}
     109            1 :                 bundle={state.bundle} busy={false} onImport={requestImport} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
     110            1 :         case 'importing':
     111            1 :             return <SessionImportCard phase="file-ready" sourcePath={file.path} fileByteCount={state.fileByteCount}
     112            1 :                 bundle={state.bundle} busy onImport={requestImport} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
     113            1 :         case 'failed':
     114            1 :             return <SessionImportCard phase="file-error" sourcePath={file.path} fileByteCount={state.fileByteCount}
     115            1 :                 failedStage={state.failedPhase} errorMessage={state.error.message} onRetry={retry} onClose={requestClose} dependencies={dependencies.sessionImportCard} />;
     116            1 :     }
     117            1 : }
     118              : 
     119            1 : function toError(error: unknown): Error {
     120            1 :     return error instanceof Error ? error : new Error(String(error));
     121            1 : }
        

Generated by: LCOV version 2.0-1