Skip to content

Commit c4516cf

Browse files
committed
feat(ProgressDialog): implement generic cancellable progress dialog and integrate into app
1 parent 26dfc53 commit c4516cf

5 files changed

Lines changed: 320 additions & 18 deletions

File tree

‎src/App.tsx‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
} from "./utils/consoleLog";
7676
import { DialogBridge } from "./components/DialogBridge";
7777
import { useConfirm, useMessage, usePrompt } from "./components/ConfirmDialog";
78+
import { useProgress } from "./components/ProgressDialog";
7879
import {
7980
SeparateViewDialog,
8081
type SeparateViewContent,
@@ -229,6 +230,7 @@ export default function App() {
229230
const confirm = useConfirm();
230231
const notify = useMessage();
231232
const prompt = usePrompt();
233+
const runWithProgress = useProgress();
232234
const {
233235
dirty: workspaceDirty,
234236
filename: workspaceFilename,
@@ -2549,20 +2551,27 @@ export default function App() {
25492551
setBusy(true);
25502552
let lastId: string | null = null;
25512553
try {
2552-
for (const file of Array.from(files)) {
2553-
const buf = new Uint8Array(await file.arrayBuffer());
2554-
const ids = isImage
2555-
? await runtime.openImageFromBytes(file.name, buf)
2556-
: await runtime.openSignalFromBytes(file.name, buf);
2557-
if (ids.length > 0) lastId = ids[ids.length - 1];
2558-
}
2554+
const fileList = Array.from(files);
2555+
await runWithProgress({
2556+
title: isImage ? "Opening images…" : "Opening signals…",
2557+
total: fileList.length,
2558+
step: async (i, { setLabel }) => {
2559+
const file = fileList[i];
2560+
setLabel(`${i + 1} / ${fileList.length} — ${file.name}`);
2561+
const buf = new Uint8Array(await file.arrayBuffer());
2562+
const ids = isImage
2563+
? await runtime.openImageFromBytes(file.name, buf)
2564+
: await runtime.openSignalFromBytes(file.name, buf);
2565+
if (ids.length > 0) lastId = ids[ids.length - 1];
2566+
},
2567+
});
25592568
await refresh(lastId);
25602569
} finally {
25612570
setBusy(false);
25622571
}
25632572
};
25642573
input.click();
2565-
}, [runtime, refresh, treeKind]);
2574+
}, [runtime, refresh, treeKind, runWithProgress]);
25662575

25672576
const hasObjects = useMemo(() => {
25682577
if (!tree) return false;

‎src/components/H5BrowserDialog.tsx‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
H5BrowserPreview,
2828
} from "../runtime/runtime";
2929
import { useRuntime } from "../runtime/RuntimeContext";
30+
import { useProgress } from "./ProgressDialog";
3031

3132
interface Props {
3233
/** Optional already-opened files (e.g. opened by the caller before
@@ -67,6 +68,7 @@ function findNode(root: H5BrowserNode, id: string): H5BrowserNode | null {
6768

6869
export function H5BrowserDialog({ initial, onImport, onCancel }: Props) {
6970
const { runtime } = useRuntime();
71+
const runWithProgress = useProgress();
7072
const [files, setFiles] = useState<OpenFileState[]>(() =>
7173
(initial ?? []).map((f) => ({
7274
file: f,
@@ -353,15 +355,30 @@ export function H5BrowserDialog({ initial, onImport, onCancel }: Props) {
353355
setError(null);
354356
const allOids: string[] = [];
355357
let uintClipped = false;
358+
// Flatten (file, node) pairs so the progress bar reflects per-node
359+
// granularity. Each node is imported individually to keep the bar
360+
// responsive and cancellation reactive between nodes.
361+
const tasks: { fileId: string; nodeId: string }[] = [];
362+
for (const f of files) {
363+
for (const nid of f.checked) {
364+
tasks.push({ fileId: f.file.file_id, nodeId: nid });
365+
}
366+
}
356367
try {
357-
for (const f of files) {
358-
if (f.checked.size === 0) continue;
359-
const result = await runtime.importH5BrowserNodes(
360-
f.file.file_id,
361-
Array.from(f.checked),
362-
);
363-
allOids.push(...result.oids);
364-
if (result.uint32_clipped) uintClipped = true;
368+
const { cancelled } = await runWithProgress({
369+
title: "Importing HDF5 nodes…",
370+
total: tasks.length,
371+
step: async (i, { setLabel }) => {
372+
const { fileId, nodeId } = tasks[i];
373+
setLabel(`${i + 1} / ${tasks.length} — ${nodeId}`);
374+
const result = await runtime.importH5BrowserNodes(fileId, [nodeId]);
375+
allOids.push(...result.oids);
376+
if (result.uint32_clipped) uintClipped = true;
377+
},
378+
});
379+
if (cancelled && allOids.length === 0) {
380+
setBusy(false);
381+
return;
365382
}
366383
// Close all owned files before notifying the caller.
367384
const ids = Array.from(ownedFileIds.current);
@@ -374,7 +391,7 @@ export function H5BrowserDialog({ initial, onImport, onCancel }: Props) {
374391
setError(err instanceof Error ? err.message : String(err));
375392
setBusy(false);
376393
}
377-
}, [runtime, files, totalChecked, onImport]);
394+
}, [runtime, files, totalChecked, onImport, runWithProgress]);
378395

379396
// ------------------------------------------------------------------
380397
// Render

‎src/components/ProgressDialog.tsx‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/**
2+
* Generic cancellable progress dialog, mirroring DataLab Qt's
3+
* ``QProgressDialog`` behaviour: the dialog only becomes visible
4+
* after ``minDuration`` (default 400 ms) has elapsed, so short
5+
* operations don't flash a modal at the user.
6+
*
7+
* A single :class:`ProgressProvider` is mounted near the application
8+
* root (see ``src/main.tsx``) and exposes :func:`useProgress`, which
9+
* returns a ``runWithProgress`` function. Callers describe a sequence
10+
* of asynchronous steps; the hook iterates over them, updates the
11+
* bar, and surfaces a Cancel button that aborts the iteration at the
12+
* next step boundary.
13+
*
14+
* Example usage::
15+
*
16+
* const runWithProgress = useProgress();
17+
* const { results, cancelled } = await runWithProgress({
18+
* title: "Opening files…",
19+
* total: files.length,
20+
* step: async (i, { signal, setLabel }) => {
21+
* setLabel(files[i].name);
22+
* return await openOne(files[i], signal);
23+
* },
24+
* });
25+
*/
26+
27+
import {
28+
createContext,
29+
useCallback,
30+
useContext,
31+
useEffect,
32+
useMemo,
33+
useRef,
34+
useState,
35+
type ReactNode,
36+
} from "react";
37+
38+
// ---------------------------------------------------------------------
39+
// Public API
40+
// ---------------------------------------------------------------------
41+
42+
export interface ProgressStepContext {
43+
/** Aborted when the user clicks Cancel. */
44+
signal: AbortSignal;
45+
/** Update the secondary label shown under the progress bar. */
46+
setLabel: (label: string) => void;
47+
}
48+
49+
export interface ProgressOptions<T> {
50+
title: string;
51+
/** Total number of steps. Used both for the bar and the iteration. */
52+
total: number;
53+
/**
54+
* Per-step worker. Receives the step index (0-based) and a context
55+
* with an :class:`AbortSignal` plus a label setter. Throw to abort.
56+
*/
57+
step: (index: number, ctx: ProgressStepContext) => Promise<T>;
58+
/**
59+
* Minimum elapsed milliseconds before the dialog becomes visible.
60+
* Mirrors ``QProgressDialog.setMinimumDuration``. Defaults to 400.
61+
*/
62+
minDuration?: number;
63+
/** Show the Cancel button. Defaults to ``true``. */
64+
cancellable?: boolean;
65+
/** Initial secondary label (shown under the progress bar). */
66+
initialLabel?: string;
67+
}
68+
69+
export interface ProgressResult<T> {
70+
/** Successful per-step results, in order. Length may be < total on cancel. */
71+
results: T[];
72+
/** ``true`` if the user clicked Cancel before completion. */
73+
cancelled: boolean;
74+
}
75+
76+
export type RunWithProgressFn = <T>(
77+
options: ProgressOptions<T>,
78+
) => Promise<ProgressResult<T>>;
79+
80+
// ---------------------------------------------------------------------
81+
// Internal state shape
82+
// ---------------------------------------------------------------------
83+
84+
interface ProgressState {
85+
title: string;
86+
value: number;
87+
total: number;
88+
label: string;
89+
cancellable: boolean;
90+
onCancel: () => void;
91+
}
92+
93+
// ---------------------------------------------------------------------
94+
// Presentational dialog
95+
// ---------------------------------------------------------------------
96+
97+
interface ProgressDialogProps {
98+
state: ProgressState;
99+
}
100+
101+
/** Pure presentational progress dialog. */
102+
export function ProgressDialog({ state }: ProgressDialogProps) {
103+
const { title, value, total, label, cancellable, onCancel } = state;
104+
const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0;
105+
useEffect(() => {
106+
if (!cancellable) return;
107+
const handler = (e: KeyboardEvent) => {
108+
if (e.key === "Escape") onCancel();
109+
};
110+
window.addEventListener("keydown", handler);
111+
return () => window.removeEventListener("keydown", handler);
112+
}, [cancellable, onCancel]);
113+
return (
114+
<div className="overlay" role="dialog" aria-modal="true">
115+
<div className="card progress-dialog">
116+
<h2>{title}</h2>
117+
<div
118+
className="progress-bar"
119+
role="progressbar"
120+
aria-valuemin={0}
121+
aria-valuemax={total}
122+
aria-valuenow={value}
123+
>
124+
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
125+
</div>
126+
<div className="progress-meta">
127+
<span className="progress-count">
128+
{value} / {total}
129+
</span>
130+
<span className="progress-pct">{pct}%</span>
131+
</div>
132+
{label && <p className="progress-label">{label}</p>}
133+
{cancellable && (
134+
<div className="actions">
135+
<button onClick={onCancel}>Cancel</button>
136+
</div>
137+
)}
138+
</div>
139+
</div>
140+
);
141+
}
142+
143+
// ---------------------------------------------------------------------
144+
// Provider + hook
145+
// ---------------------------------------------------------------------
146+
147+
const ProgressContext = createContext<RunWithProgressFn | null>(null);
148+
149+
export function ProgressProvider(props: { children: ReactNode }) {
150+
const [state, setState] = useState<ProgressState | null>(null);
151+
// Keep latest state in a ref so the runWithProgress closure can mutate
152+
// without being re-created on every render of consumers.
153+
const stateRef = useRef<ProgressState | null>(null);
154+
stateRef.current = state;
155+
156+
const runWithProgress = useCallback<RunWithProgressFn>(async (options) => {
157+
const {
158+
title,
159+
total,
160+
step,
161+
minDuration = 400,
162+
cancellable = true,
163+
initialLabel = "",
164+
} = options;
165+
166+
const controller = new AbortController();
167+
const results: unknown[] = [];
168+
let visible = false;
169+
let labelText = initialLabel;
170+
const current: ProgressState = {
171+
title,
172+
value: 0,
173+
total,
174+
label: labelText,
175+
cancellable,
176+
onCancel: () => controller.abort(),
177+
};
178+
179+
const showTimer = window.setTimeout(() => {
180+
visible = true;
181+
setState({ ...current });
182+
}, minDuration);
183+
184+
const refresh = () => {
185+
if (visible) setState({ ...current });
186+
};
187+
188+
try {
189+
for (let i = 0; i < total; i++) {
190+
if (controller.signal.aborted) break;
191+
const ctx: ProgressStepContext = {
192+
signal: controller.signal,
193+
setLabel: (l: string) => {
194+
labelText = l;
195+
current.label = l;
196+
refresh();
197+
},
198+
};
199+
try {
200+
const r = await step(i, ctx);
201+
results.push(r);
202+
} catch (err) {
203+
if (controller.signal.aborted) break;
204+
throw err;
205+
}
206+
current.value = i + 1;
207+
refresh();
208+
}
209+
} finally {
210+
window.clearTimeout(showTimer);
211+
setState(null);
212+
}
213+
214+
return {
215+
results: results as never[],
216+
cancelled: controller.signal.aborted,
217+
};
218+
}, []);
219+
220+
const value = useMemo(() => runWithProgress, [runWithProgress]);
221+
222+
return (
223+
<ProgressContext.Provider value={value}>
224+
{props.children}
225+
{state && <ProgressDialog state={state} />}
226+
</ProgressContext.Provider>
227+
);
228+
}
229+
230+
export function useProgress(): RunWithProgressFn {
231+
const ctx = useContext(ProgressContext);
232+
if (!ctx) {
233+
throw new Error("useProgress must be used within a ProgressProvider");
234+
}
235+
return ctx;
236+
}

‎src/main.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
22
import { createRoot } from "react-dom/client";
33
import App from "./App";
44
import { DialogProvider } from "./components/ConfirmDialog";
5+
import { ProgressProvider } from "./components/ProgressDialog";
56
import { RuntimeProvider } from "./runtime/RuntimeContext";
67
import { WorkspaceProvider } from "./runtime/WorkspaceContext";
78
import { installConsoleCapture } from "./utils/consoleLog";
@@ -22,7 +23,9 @@ createRoot(container).render(
2223
<RuntimeProvider>
2324
<WorkspaceProvider>
2425
<DialogProvider>
25-
<App />
26+
<ProgressProvider>
27+
<App />
28+
</ProgressProvider>
2629
</DialogProvider>
2730
</WorkspaceProvider>
2831
</RuntimeProvider>

0 commit comments

Comments
 (0)