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