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 : }
|