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.
This commit is contained in:
2026-09-02 10:42:45 +02:00
parent e1ac3d7509
commit 04b731cf37
20 changed files with 750 additions and 4 deletions
+35
View File
@@ -3,11 +3,13 @@ import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import Nav from "./components/Nav";
import Downloads from "./components/Downloads";
import Settings from "./components/Settings";
import { BTN_PRIMARY, Dialog } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance";
import type {
Config,
Download,
Group,
HiddenEvent,
NotificationClick,
@@ -28,6 +30,7 @@ export default function App() {
shell has to keep clear of it or the nav lands on the traffic lights. */
const [chrome, setChrome] = useState(0);
const [rail, setRail] = useState(72);
const [downloads, setDownloads] = useState<Download[]>([]);
const [offer, setOffer] = useState<PasswordOffer | null>(null);
const [saved, setSaved] = useState<string | null>(null);
@@ -145,6 +148,33 @@ export default function App() {
}),
// A login was submitted. The password is held in Rust; this only asks.
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
listen<{ id: number; name: string; path: string }>("download-started", (e) =>
setDownloads((d) => [
...d.filter((x) => x.id !== e.payload.id),
{ ...e.payload, bytes: 0, state: "running" },
]),
),
listen<{ id: number; bytes: number }>("download-progress", (e) =>
setDownloads((d) =>
d.map((x) => (x.id === e.payload.id ? { ...x, bytes: e.payload.bytes } : x)),
),
),
listen<{ id: number; success: boolean; path: string }>("download-finished", (e) => {
setDownloads((d) =>
d.map((x) =>
x.id === e.payload.id
? { ...x, state: e.payload.success ? "done" : "failed", path: e.payload.path }
: x,
),
);
// A finished download clears itself after a while; an unfinished one
// stays, because it is still telling you something.
setTimeout(
() => setDownloads((d) => d.filter((x) => x.id !== e.payload.id)),
12000,
);
}),
];
return () => {
unlisten.forEach((p) => p.then((f) => f()));
@@ -291,6 +321,11 @@ export default function App() {
</Dialog>
)}
<Downloads
items={downloads}
onDismiss={(id) => setDownloads((d) => d.filter((x) => x.id !== id))}
/>
{settingsOpen && (
<Settings
config={config}
+5
View File
@@ -67,3 +67,8 @@ export const chromeHeight = () => invoke<number>("chrome_height");
export const setCountNotifications = (enabled: boolean) =>
invoke<Config>("set_count_notifications", { enabled });
export const railWidth = () => invoke<number>("rail_width");
export const downloadFolder = () => invoke<string>("download_folder");
export const setDownloadFolder = (path: string | null) =>
invoke<Config>("set_download_folder", { path });
export const reveal = (path: string) => invoke<void>("reveal", { path });
+94
View File
@@ -0,0 +1,94 @@
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`;
}
+43
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import * as api from "../api";
import type { Theme } from "../hooks/useAppearance";
import type { Config, Group, WorkApp } from "../types";
@@ -44,6 +46,7 @@ export default function Settings({
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const [hiddenOpen, setHiddenOpen] = useState(false);
const [folder, setFolder] = useState("");
const [confirmReset, setConfirmReset] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
@@ -59,6 +62,10 @@ export default function Settings({
);
useEffect(() => {
void api.downloadFolder().then(setFolder);
}, [config.settings.downloadDir]);
useEffect(() => {
if (!focusHiddenFor) return;
setHiddenOpen(true);
@@ -263,6 +270,42 @@ export default function Settings({
</p>
</section>
{/* ------------------------------------------------- downloads */}
<section className="space-y-2">
<SectionHeading>Downloads</SectionHeading>
<div className={`${SUBPANEL} flex items-center gap-2`}>
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{folder}</span>
<button
onClick={async () => {
const picked = await open({ directory: true, multiple: false });
if (typeof picked === "string") {
await run(() => api.setDownloadFolder(picked));
}
}}
className={BTN}
>
Choose…
</button>
<button onClick={() => api.reveal(folder)} className={BTN}>
Open
</button>
{config.settings.downloadDir && (
<button
onClick={() => run(() => api.setDownloadFolder(null))}
title="Back to the system Downloads folder"
className={BTN}
>
Reset
</button>
)}
</div>
<p className={HELP}>
A file that lands here is announced in the corner as it saves. Without that
a download is indistinguishable from a click that did nothing — the webview
saves it perfectly well and mentions it to nobody.
</p>
</section>
{/* --------------------------------------------- notifications */}
<section className="space-y-2">
<SectionHeading>Notifications</SectionHeading>
+10
View File
@@ -26,6 +26,7 @@ export interface Settings {
theme: "system" | "light" | "dark";
lastApp: string | null;
countNotifications: boolean;
downloadDir: string | null;
}
export interface Config {
@@ -66,3 +67,12 @@ export interface PasswordOffer {
host: string;
account: string;
}
/** A file the app is saving, or has just saved. */
export interface Download {
id: number;
name: string;
path: string;
bytes: number;
state: "running" | "done" | "failed";
}