Files
work-app/src/components/Downloads.tsx
T
Vincent 04b731cf37 Say when something is downloading, and where it goes
The webview saves the file perfectly well and mentions it to nobody,
which makes a download indistinguishable from a click that did nothing.
Each one is now announced in the corner as it saves, and offers to show
the finished file in the Finder. The folder is settable and defaults to
the system's Downloads.

Two gaps in the API shape this. There is no progress - DownloadEvent
reports a start and a finish and nothing between - so the destination
file is polled as it grows and the bytes written are shown; the indicator
spins rather than fills, because there is no total to divide by. And on
macOS the finish always reports no path at all, so the destination
assigned at request time is remembered against the URL and read back at
the end.

A name already taken gets "(2)" appended. Silently overwriting is the
last thing anyone wants from a download they were not told about.
2026-09-02 10:42:45 +02:00

95 lines
3.6 KiB
TypeScript

import type { Download } from "../types";
import * as api from "../api";
/**
* What is being downloaded, and what just was.
*
* WebKit saves the file on its own and says nothing about it, so without this
* a download is indistinguishable from a click that did nothing.
*/
export default function Downloads({
items,
onDismiss,
}: {
items: Download[];
onDismiss: (id: number) => void;
}) {
if (items.length === 0) return null;
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[80] flex w-[320px] flex-col gap-2">
{items.map((d) => (
<div
key={d.id}
className="pointer-events-auto flex items-center gap-3 rounded-xl border border-slate-200
bg-white/95 px-3 py-2.5 shadow-xl backdrop-blur
dark:border-slate-800 dark:bg-slate-900/95"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-slate-100 dark:bg-slate-800">
{d.state === "done" ? (
<svg viewBox="0 0 24 24" className="size-4 text-emerald-500" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 13l4 4L19 7" />
</svg>
) : d.state === "failed" ? (
<svg viewBox="0 0 24 24" className="size-4 text-red-500" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
) : (
/* No total to divide by — the webview reports a start and a
finish and nothing between — so this spins rather than fills. */
<svg viewBox="0 0 24 24" className="size-4 animate-spin text-sky-500" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-medium">{d.name}</span>
<span className="block text-[11px] text-slate-500 dark:text-slate-400">
{d.state === "done"
? "Downloaded"
: d.state === "failed"
? "Download failed"
: d.bytes > 0
? `${formatBytes(d.bytes)} so far`
: "Starting…"}
</span>
</span>
{d.state === "done" && (
<button
onClick={() => api.reveal(d.path)}
title="Show in Finder"
className="shrink-0 cursor-pointer rounded-lg px-2 py-1 text-[11px] font-medium
text-sky-600 hover:bg-sky-500/10 dark:text-sky-400"
>
Show
</button>
)}
<button
onClick={() => onDismiss(d.id)}
title="Dismiss"
aria-label="Dismiss"
className="shrink-0 cursor-pointer rounded-lg p-1 text-slate-400 hover:text-slate-700
dark:hover:text-slate-200"
>
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
))}
</div>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}