feat: progress bar for extraction

This commit is contained in:
zhom
2026-07-11 15:01:04 +04:00
parent f95e6332fa
commit 06e34527b6
6 changed files with 423 additions and 58 deletions
+46 -14
View File
@@ -159,6 +159,23 @@ function formatEtaCompact(seconds: number): string {
return `${Math.round(seconds)}s`;
}
function ProgressBar({
percentage,
className = "w-full",
}: {
percentage: number;
className?: string;
}) {
return (
<div className={`h-1.5 rounded-full bg-muted ${className}`}>
<div
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
style={{ width: `${percentage}%` }}
/>
</div>
);
}
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
@@ -232,12 +249,31 @@ export function UnifiedToast(props: ToastProps) {
`${t("toasts.progress.remaining", { time: progress.eta })}`}
</p>
</div>
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
style={{ width: `${progress.percentage}%` }}
/>
</div>
<ProgressBar percentage={progress.percentage} />
</div>
)}
{/* Extraction / verification progress. Extraction reports a real
percentage for most archive formats; when none is available yet
(or the format can't measure progress) show an indeterminate bar. */}
{type === "download" &&
(stage === "extracting" || stage === "verifying") && (
<div className="mt-2 space-y-1">
{stage === "extracting" &&
progress &&
"percentage" in progress &&
progress.percentage > 0 ? (
<>
<p className="text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
</p>
<ProgressBar percentage={progress.percentage} />
</>
) : (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-1.5 w-1/3 animate-progress-indeterminate rounded-full bg-foreground" />
</div>
)}
</div>
)}
@@ -253,14 +289,10 @@ export function UnifiedToast(props: ToastProps) {
})}
</p>
<div className="flex items-center gap-x-2">
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
/>
</div>
<ProgressBar
percentage={(progress.current / progress.total) * 100}
className="min-w-0 flex-1"
/>
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
{progress.current}/{progress.total}
</span>
+8 -1
View File
@@ -354,7 +354,14 @@ export function useBrowserDownload() {
}
} else if (progress.stage === "extracting") {
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "extracting");
showDownloadToast(
browserName,
progress.version,
"extracting",
progress.percentage > 0
? { percentage: progress.percentage }
: undefined,
);
}
} else if (progress.stage === "verifying") {
if (!isOnboardingActive()) {
+28 -11
View File
@@ -27,10 +27,11 @@ export interface SetupError {
stage: SetupErrorStage;
}
// The backend emits a real percentage only while downloading; extraction sends
// a single "extracting" event with no incremental progress (it takes ~2 min).
// So we estimate extraction progress from elapsed time vs. a learned average,
// seeded at 2 minutes and refined with the real durations we record.
// The backend reports real extraction percentages for most archive formats
// (zip, tar.*, dmg). For formats that can't measure progress (e.g. MSI) the
// "extracting" events carry percentage 0, so we fall back to estimating from
// elapsed time vs. a learned average, seeded at 2 minutes and refined with
// the real durations we record.
const DEFAULT_EXTRACT_MS = 2 * 60 * 1000;
const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations
@@ -89,8 +90,9 @@ function toErrorStage(stage: string): SetupErrorStage {
}
/**
* Tracks first-launch setup of a browser: real download progress plus an
* estimated extraction progress (no countdown timer, percentages only).
* Tracks first-launch setup of a browser: real download progress plus
* extraction progress — real backend percentages when the archive format
* supports them, otherwise a time-based estimate (percentages only).
* `active` should be true while the owning dialog is open.
*/
export function useBrowserSetup(browser: string, active: boolean) {
@@ -108,8 +110,12 @@ export function useBrowserSetup(browser: string, active: boolean) {
const extractStartRef = useRef<number | null>(null);
const estimateRef = useRef(DEFAULT_EXTRACT_MS);
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
// misses the single "extracting" event) can still show extraction progress.
// True once an "extracting" event carried a real percentage — from then on
// the backend drives the bar and the time-based estimate stays out of it.
const sawRealExtractionRef = useRef(false);
// Fallback bookkeeping so a listener that mounts mid-flight, or that only
// ever receives percentage-0 "extracting" events (formats that can't
// measure progress), can still show extraction progress.
const sawDownloadingRef = useRef(false);
const lastProgressAtRef = useRef<number | null>(null);
const lastDownloadPercentRef = useRef(0);
@@ -133,6 +139,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
setExtractionOvertime(false);
setError(null);
extractStartRef.current = null;
sawRealExtractionRef.current = false;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
@@ -143,6 +150,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
let alive = true;
estimateRef.current = average(readDurations(browser));
extractStartRef.current = null;
sawRealExtractionRef.current = false;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
@@ -182,6 +190,11 @@ export function useBrowserSetup(browser: string, active: boolean) {
}
lastProgressAtRef.current = Date.now();
setPhase("extracting");
if (p.percentage > 0) {
sawRealExtractionRef.current = true;
setExtractionPercent(Math.min(99, Math.round(p.percentage)));
setExtractionOvertime(false);
}
break;
case "verifying":
lastStageRef.current = "verifying";
@@ -257,9 +270,9 @@ export function useBrowserSetup(browser: string, active: boolean) {
// Drive the estimated extraction percentage while extracting.
const tick = setInterval(() => {
if (!alive || doneRef.current) return;
// If the download visibly finished but we never saw the (single)
// "extracting" event, start estimating extraction anyway — anchored to
// the last download event, which is roughly when extraction began.
// If the download visibly finished but we never saw any "extracting"
// event, start estimating extraction anyway — anchored to the last
// download event, which is roughly when extraction began.
if (
extractStartRef.current == null &&
sawDownloadingRef.current &&
@@ -272,6 +285,9 @@ export function useBrowserSetup(browser: string, active: boolean) {
setPhase("extracting");
}
if (extractStartRef.current == null) return;
// Real backend percentages drive the bar; the estimate would only
// fight them (and flag bogus "overtime" on a healthy extraction).
if (sawRealExtractionRef.current) return;
const elapsed = Date.now() - extractStartRef.current;
const est = estimateRef.current || DEFAULT_EXTRACT_MS;
if (elapsed >= est) {
@@ -310,6 +326,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
setExtractionOvertime(false);
setError(null);
extractStartRef.current = null;
sawRealExtractionRef.current = false;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
+11
View File
@@ -6,6 +6,17 @@
@theme {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--animate-progress-indeterminate: progress-indeterminate 1.4s ease-in-out
infinite;
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
}
}
}
@theme inline {