LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - session_import_card.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.8 % 224 219
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import * as baseStyles from '../../ui/foundations/banner_page.module.css';
       4              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       5              : import { ProgressBar } from '../../ui/foundations/progress_bar.js';
       6              : import { IndicatorStatus, StatusIndicator } from '../../ui/foundations/status_indicator.js';
       7              : import { AlertIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
       8              : import { ParticleFlowBackground } from '../../ui/particle_flow/particle_flow_background.js';
       9              : import { formatBytes } from '../../utils/format.js';
      10              : import type { SessionBundle } from '../../storage/session_bundle.js';
      11              : import { CompactNavBar } from '../../app/navbar.js';
      12              : import { useAppFlavor } from '../../config/flavor_provider.js';
      13              : import type { HttpSessionLoadProgress, HttpSessionLoadResult } from './http_session_bundle.js';
      14              : import * as styles from './session_import_card.module.css';
      15              : 
      16              : interface LoadingState {
      17              :     phase: 'loading';
      18              :     sourceUrl: string;
      19              :     progress: HttpSessionLoadProgress;
      20              :     onClose(): void;
      21              : }
      22              : 
      23              : interface ReadyState {
      24              :     phase: 'ready';
      25              :     result: HttpSessionLoadResult;
      26              :     conflictLocation: string | null;
      27              :     conflictIsNative: boolean;
      28              :     busy: boolean;
      29              :     onImport(): void;
      30              :     onReplace(): void;
      31              :     onCreateNew(): void;
      32              :     onClose(): void;
      33              : }
      34              : 
      35              : interface ConflictState {
      36              :     phase: 'conflict';
      37              :     sessionName: string;
      38              :     sessionUuid: string;
      39              :     existingLocation: string;
      40              :     existingIsNative: boolean;
      41              :     folderCount: number;
      42              :     scriptCount: number;
      43              :     busy: boolean;
      44              :     onReplace(): void;
      45              :     onCreateNew(): void;
      46              :     onClose(): void;
      47              : }
      48              : 
      49              : export type SessionFileLoadStage = 'reading' | 'validating';
      50              : export type SessionFileFailureStage = SessionFileLoadStage | 'importing';
      51              : 
      52              : interface FileLoadingState {
      53              :     phase: 'file-loading';
      54              :     sourcePath: string;
      55              :     stage: SessionFileLoadStage;
      56              :     fileByteCount: number | null;
      57              :     onClose(): void;
      58              : }
      59              : 
      60              : interface FileReadyState {
      61              :     phase: 'file-ready';
      62              :     sourcePath: string;
      63              :     fileByteCount: number;
      64              :     bundle: SessionBundle;
      65              :     busy: boolean;
      66              :     onImport(): void;
      67              :     onClose(): void;
      68              : }
      69              : 
      70              : interface FileErrorState {
      71              :     phase: 'file-error';
      72              :     sourcePath: string;
      73              :     fileByteCount: number | null;
      74              :     failedStage: SessionFileFailureStage;
      75              :     errorMessage: string;
      76              :     onRetry(): void;
      77              :     onClose(): void;
      78              : }
      79              : 
      80              : export type SessionImportCardState = LoadingState | ReadyState | ConflictState
      81              :     | FileLoadingState | FileReadyState | FileErrorState;
      82              : 
      83              : export interface SessionImportCardDependencies {
      84              :     CompactNavBar: typeof CompactNavBar;
      85              :     ParticleFlowBackground: typeof ParticleFlowBackground;
      86              : }
      87              : 
      88            1 : const DEFAULT_DEPENDENCIES: SessionImportCardDependencies = {
      89            1 :     CompactNavBar,
      90            1 :     ParticleFlowBackground,
      91            1 : };
      92              : 
      93              : export type SessionImportCardProps = SessionImportCardState & {
      94              :     dependencies?: SessionImportCardDependencies;
      95              : };
      96              : 
      97            1 : export function SessionImportCard(props: SessionImportCardProps): React.ReactElement {
      98            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
      99            1 :     if (props.phase === 'loading') return <LoadingCard state={props} dependencies={dependencies} />;
     100            1 :     if (props.phase === 'conflict') return <ConflictCard state={props} dependencies={dependencies} />;
     101            1 :     if (props.phase === 'file-loading') return <FileLoadingCard state={props} dependencies={dependencies} />;
     102            1 :     if (props.phase === 'file-ready') return <FileReadyCard state={props} dependencies={dependencies} />;
     103            1 :     if (props.phase === 'file-error') return <FileErrorCard state={props} dependencies={dependencies} />;
     104            1 :     return <ReadyCard state={props} dependencies={dependencies} />;
     105            1 : }
     106              : 
     107            1 : function LoadingCard({ state, dependencies }: { state: LoadingState; dependencies: SessionImportCardDependencies }): React.ReactElement {
     108            1 :     const details = progressDetails(state.progress);
     109            1 :     const fileProgress = state.progress.phase === 'files' ? state.progress : null;
     110            1 :     const percentage = fileProgress == null
     111            1 :         ? null
     112            1 :         : fileProgress.completedFileCount / fileProgress.totalFileCount * 100;
     113            1 :     return (
     114            1 :         <CardShell title="Loading Session" busy onClose={state.onClose} dependencies={dependencies}>
     115            1 :             <div className={styles.status} role="status" aria-live="polite">
     116            1 :                 <span aria-hidden="true">
     117            1 :                     <StatusIndicator status={IndicatorStatus.Running} width="18px" height="18px" />
     118            1 :                 </span>
     119            1 :                 <span>{details.status}</span>
     120            1 :             </div>
     121            1 :             {percentage != null && (
     122            1 :                 <ProgressBar
     123            1 :                     progress={percentage}
     124            1 :                     aria-label="Session files loaded"
     125            1 :                     aria-valuetext={`${fileProgress!.completedFileCount} of ${fileProgress!.totalFileCount} files`}
     126            1 :                 />
     127              :             )}
     128            1 :             <SessionImportDetails>
     129            1 :                 {details.sessionName != null && <SessionImportDetail label="Session">{details.sessionName}</SessionImportDetail>}
     130            1 :                 {details.sessionId != null && <SessionImportDetail label="UUID" mono>{details.sessionId}</SessionImportDetail>}
     131            1 :                 <SessionImportDetail label="Source" mono>{state.sourceUrl}</SessionImportDetail>
     132            1 :             </SessionImportDetails>
     133            1 :         </CardShell>
     134              :     );
     135            1 : }
     136              : 
     137            1 : function ReadyCard({ state, dependencies }: { state: ReadyState; dependencies: SessionImportCardDependencies }): React.ReactElement {
     138            1 :     const { bundle, listedScriptCount, loadedScriptCount, incomplete } = state.result;
     139            1 :     const sessionName = bundle.session.name?.trim() || bundle.session.metadata.originalFileName || 'Unnamed session';
     140            1 :     const scriptCount = incomplete
     141            1 :         ? listedScriptCount > 0 ? `${loadedScriptCount} of ${listedScriptCount}` : String(loadedScriptCount)
     142            1 :         : String(loadedScriptCount);
     143            1 :     const displayedScriptCount = incomplete && listedScriptCount > 0 ? listedScriptCount : loadedScriptCount;
     144            1 :     const scriptSummary = formatScriptSummary(scriptCount, displayedScriptCount, 0);
     145            1 :     const hasConflict = state.conflictLocation != null;
     146            1 :     const actions = hasConflict ? (
     147            1 :         <>
     148            1 :             <Button data-testid="session-import-replace" disabled={state.busy} onClick={state.onReplace}>Replace</Button>
     149            1 :             <Button data-testid="session-import-create-new" disabled={state.busy} onClick={state.onCreateNew}>Create New</Button>
     150            1 :         </>
     151              :     ) : (
     152            1 :         <Button data-testid="session-import-confirm" autoFocus variant={ButtonVariant.Primary} disabled={state.busy} onClick={state.onImport}>Import</Button>
     153              :     );
     154              : 
     155            1 :     return (
     156            1 :         <CardShell title="Import Session" busy={state.busy} closeDisabled={state.busy} onClose={state.onClose} actions={actions} dependencies={dependencies}>
     157            1 :             {hasConflict && <ConflictWarning native={state.conflictIsNative} />}
     158            1 :             {incomplete && (
     159            1 :                 <div className={styles.warning} role="status" data-testid="session-import-incomplete">
     160            1 :                     <AlertIcon size={16} aria-hidden="true" />
     161            1 :                     <span>Some session files could not be resolved. The available content will still be imported.</span>
     162            1 :                 </div>
     163              :             )}
     164            1 :             <SessionImportDetails>
     165            1 :                 {state.conflictLocation != null && <SessionImportDetail label="Existing" mono>{state.conflictLocation}</SessionImportDetail>}
     166            1 :                 <SessionImportDetail label="Source" mono>{bundle.session.metadata.originalHttpUrl}</SessionImportDetail>
     167            1 :                 <SessionImportDetail label="UUID" mono>{bundle.session.sessionId}</SessionImportDetail>
     168            1 :                 <SessionImportDetail label="Name">{sessionName}</SessionImportDetail>
     169            1 :                 <SessionImportDetail label="Scripts">{scriptSummary}</SessionImportDetail>
     170            1 :             </SessionImportDetails>
     171            1 :         </CardShell>
     172              :     );
     173            1 : }
     174              : 
     175            1 : function FileLoadingCard({ state, dependencies }: { state: FileLoadingState; dependencies: SessionImportCardDependencies }): React.ReactElement {
     176            1 :     const status = state.stage === 'reading' ? 'Reading session file...' : 'Checking the session archive...';
     177            1 :     return (
     178            1 :         <CardShell title="Loading Session" busy onClose={state.onClose} dependencies={dependencies}>
     179            1 :             <div className={styles.status} role="status" aria-live="polite" data-testid="session-import-file-status" data-stage={state.stage}>
     180            1 :                 <span aria-hidden="true">
     181            1 :                     <StatusIndicator status={IndicatorStatus.Running} width="18px" height="18px" />
     182            1 :                 </span>
     183            1 :                 <span>{status}</span>
     184            1 :             </div>
     185            1 :             <SessionImportDetails>
     186            1 :                 <SessionImportDetail label="File" mono>{state.sourcePath}</SessionImportDetail>
     187            1 :                 {state.fileByteCount != null && <SessionImportDetail label="Size">{formatBytes(state.fileByteCount)}</SessionImportDetail>}
     188            1 :             </SessionImportDetails>
     189            1 :         </CardShell>
     190              :     );
     191            1 : }
     192              : 
     193            1 : function FileReadyCard({ state, dependencies }: { state: FileReadyState; dependencies: SessionImportCardDependencies }): React.ReactElement {
     194            1 :     const sessionName = state.bundle.session.name?.trim()
     195            0 :         || state.bundle.session.metadata.originalFileName
     196            0 :         || 'Unnamed session';
     197            1 :     return (
     198            1 :         <CardShell
     199            1 :             title="Import Session"
     200            1 :             busy={state.busy}
     201            1 :             closeDisabled={state.busy}
     202            1 :             onClose={state.onClose}
     203            1 :             actions={<Button data-testid="session-import-confirm" autoFocus variant={ButtonVariant.Primary} disabled={state.busy} onClick={state.onImport}>Import</Button>}
     204            1 :             dependencies={dependencies}
     205              :         >
     206            1 :             {state.busy ? (
     207            1 :                 <div className={styles.status} role="status" aria-live="polite">
     208            1 :                     <span aria-hidden="true"><StatusIndicator status={IndicatorStatus.Running} width="18px" height="18px" /></span>
     209            1 :                     <span>Adding the session to DashQL...</span>
     210            1 :                 </div>
     211            1 :             ) : null}
     212            1 :             <SessionImportDetails>
     213            1 :                 <SessionImportDetail label="File" mono>{state.sourcePath}</SessionImportDetail>
     214            1 :                 <SessionImportDetail label="Size">{formatBytes(state.fileByteCount)}</SessionImportDetail>
     215            1 :                 <SessionImportDetail label="Session">{sessionName}</SessionImportDetail>
     216            1 :                 <SessionImportDetail label="UUID" mono>{state.bundle.session.sessionId}</SessionImportDetail>
     217            1 :                 <SessionImportDetail label="Scripts">{formatCount(state.bundle.scripts.length, 'script')}</SessionImportDetail>
     218            1 :             </SessionImportDetails>
     219            1 :         </CardShell>
     220              :     );
     221            1 : }
     222              : 
     223            1 : function FileErrorCard({ state, dependencies }: { state: FileErrorState; dependencies: SessionImportCardDependencies }): React.ReactElement {
     224            1 :     const flavor = useAppFlavor();
     225            1 :     const summary = state.failedStage === 'reading'
     226            0 :         ? `${flavor.displayName} could not read this file.`
     227            1 :         : state.failedStage === 'validating'
     228            1 :             ? `This file is not a valid ${flavor.displayName} session archive.`
     229            1 :             : `${flavor.displayName} could not import this session.`;
     230            1 :     return (
     231            1 :         <CardShell
     232            1 :             title="Session Import Failed"
     233            1 :             onClose={state.onClose}
     234            1 :             actions={<Button data-testid="session-import-retry" autoFocus variant={ButtonVariant.Primary} onClick={state.onRetry}>Try Again</Button>}
     235            1 :             dependencies={dependencies}
     236              :         >
     237            1 :             <div className={styles.error} role="alert" data-stage={state.failedStage}>
     238            1 :                 <AlertIcon size={16} aria-hidden="true" />
     239            1 :                 <div>
     240            1 :                     <div className={styles.errorSummary}>{summary}</div>
     241            1 :                     <div className={styles.errorDetail}>{state.errorMessage}</div>
     242            1 :                 </div>
     243            1 :             </div>
     244            1 :             <SessionImportDetails>
     245            1 :                 <SessionImportDetail label="File" mono>{state.sourcePath}</SessionImportDetail>
     246            1 :                 {state.fileByteCount != null && <SessionImportDetail label="Size">{formatBytes(state.fileByteCount)}</SessionImportDetail>}
     247            1 :             </SessionImportDetails>
     248            1 :         </CardShell>
     249              :     );
     250            1 : }
     251              : 
     252            1 : function ConflictCard({ state, dependencies }: { state: ConflictState; dependencies: SessionImportCardDependencies }): React.ReactElement {
     253            1 :     return (
     254            1 :         <CardShell
     255            1 :             title="Import Session"
     256            1 :             busy={state.busy}
     257            1 :             closeDisabled={state.busy}
     258            1 :             onClose={state.onClose}
     259            1 :             actions={<>
     260            1 :                 <Button data-testid="session-import-replace" disabled={state.busy} onClick={state.onReplace}>Replace</Button>
     261            1 :                 <Button data-testid="session-import-create-new" disabled={state.busy} onClick={state.onCreateNew}>Create New</Button>
     262            1 :             </>}
     263            1 :             dependencies={dependencies}
     264              :         >
     265            1 :             <ConflictWarning native={state.existingIsNative} />
     266            1 :             <SessionImportDetails>
     267            1 :                 <SessionImportDetail label="Existing" mono>{state.existingLocation}</SessionImportDetail>
     268            1 :                 <SessionImportDetail label="UUID" mono>{state.sessionUuid}</SessionImportDetail>
     269            1 :                 <SessionImportDetail label="Name">{state.sessionName}</SessionImportDetail>
     270            1 :                 <SessionImportDetail label="Scripts">{formatScriptSummary(String(state.scriptCount), state.scriptCount, state.folderCount)}</SessionImportDetail>
     271            1 :             </SessionImportDetails>
     272            1 :         </CardShell>
     273              :     );
     274            1 : }
     275              : 
     276            1 : function ConflictWarning({ native }: { native: boolean }): React.ReactElement {
     277            1 :     return (
     278            1 :         <div className={styles.warning} role="status" data-testid="session-import-conflict" data-native={native || undefined}>
     279            1 :             <AlertIcon size={16} aria-hidden="true" />
     280            1 :             <span>
     281              :                 A session with this UUID already exists. Replace it, or create a new session with a different UUID.
     282            1 :                 {native && ' Replacing removes the old session without overwriting existing native files.'}
     283            1 :             </span>
     284            1 :         </div>
     285              :     );
     286            1 : }
     287              : 
     288              : interface ShellProps {
     289              :     title: string;
     290              :     children: React.ReactNode;
     291              :     actions?: React.ReactNode;
     292              :     busy?: boolean;
     293              :     closeDisabled?: boolean;
     294              :     onClose?: () => void;
     295              :     dependencies: SessionImportCardDependencies;
     296              : }
     297              : 
     298            1 : function CardShell(props: ShellProps): React.ReactElement {
     299            1 :     const { CompactNavBar: NavBar, ParticleFlowBackground: Background } = props.dependencies;
     300            1 :     return (
     301            1 :         <div className={`${baseStyles.page} ${styles.page}`} data-electron-drag-region>
     302            1 :             <Background />
     303            1 :             <NavBar />
     304            1 :             <main className={`${baseStyles.banner_and_content_container} ${styles.foreground}`}>
     305            1 :                 <div className={baseStyles.content_container}>
     306            1 :                     <section className={`${baseStyles.card} ${styles.card}`} data-testid="session-import-card" aria-labelledby="session-import-card-title" aria-busy={props.busy || undefined}>
     307            1 :                         <div className={baseStyles.card_header} data-electron-drag-region>
     308            1 :                             <div className={baseStyles.card_header_left_container}>
     309            1 :                                 <h1 id="session-import-card-title" className={`${baseStyles.card_header_left_title} ${styles.title}`}>{props.title}</h1>
     310            1 :                             </div>
     311            1 :                             {props.onClose != null && (
     312            1 :                                 <div className={baseStyles.card_header_right_container}>
     313            1 :                                     <IconButton data-testid="dialog-close" aria-label="Close" disabled={props.closeDisabled} size={ButtonSize.Small} variant={ButtonVariant.Invisible} onClick={props.onClose}>
     314            1 :                                         <XIcon size={16} />
     315            1 :                                     </IconButton>
     316            1 :                                 </div>
     317              :                             )}
     318            1 :                         </div>
     319            1 :                         <div className={baseStyles.card_section}>
     320            1 :                             <div className={baseStyles.section_entries}>{props.children}</div>
     321            1 :                             {props.actions != null && <div className={baseStyles.card_actions}><div className={baseStyles.card_actions_right}>{props.actions}</div></div>}
     322            1 :                         </div>
     323            1 :                     </section>
     324            1 :                 </div>
     325            1 :             </main>
     326            1 :         </div>
     327              :     );
     328            1 : }
     329              : 
     330            1 : function SessionImportDetails(props: React.PropsWithChildren): React.ReactElement {
     331            1 :     return <dl className={styles.details}>{props.children}</dl>;
     332            1 : }
     333              : 
     334            1 : function SessionImportDetail(props: { label: string; children: React.ReactNode; mono?: boolean }): React.ReactElement {
     335            1 :     return <><dt>{props.label}</dt><dd className={props.mono ? styles.mono : undefined}>{props.children}</dd></>;
     336            1 : }
     337              : 
     338            1 : function formatScriptSummary(count: string, numericCount: number, folderCount: number): string {
     339            1 :     return `${count} ${numericCount === 1 ? 'script' : 'scripts'} in ${folderCount} ${folderCount === 1 ? 'folder' : 'folders'}`;
     340            1 : }
     341              : 
     342            1 : function formatCount(count: number, noun: string): string {
     343            1 :     return `${count} ${noun}${count === 1 ? '' : 's'}`;
     344            1 : }
     345              : 
     346            1 : function progressDetails(progress: HttpSessionLoadProgress): { status: string; sessionName: string | null; sessionId: string | null } {
     347            1 :     switch (progress.phase) {
     348            0 :         case 'preparing': return { status: 'Preparing session loader...', sessionName: null, sessionId: null };
     349            1 :         case 'manifest': return { status: 'Loading session manifest...', sessionName: null, sessionId: null };
     350            0 :         case 'listing': return { status: 'Discovering session files...', sessionName: progress.sessionName, sessionId: progress.sessionId };
     351            1 :         case 'files': return {
     352            1 :             status: progress.totalScriptCount === 0 ? 'No indexed scripts' : `Loading scripts: ${progress.completedScriptCount} of ${progress.totalScriptCount}`,
     353            1 :             sessionName: progress.sessionName,
     354            1 :             sessionId: progress.sessionId,
     355            1 :         };
     356            1 :     }
     357            1 : }
        

Generated by: LCOV version 2.0-1