Make the download card visible, and the events arrive at all

Two separate faults, both hiding the same feature.

The events never reached the shell. They were emitted from inside
WebKit's download delegate, which runs on the main thread, and delivering
an event means running JavaScript in a webview - which cannot happen from
in there. They are now emitted from a spawned task, like every other
event in the app that works.

And the card was drawn over the page, where it could never be seen: an
app's webview is a native view painted above everything the shell draws,
so a card over the page is a card behind the page. It now lives in the
nav, which is the shell's own - full detail when the nav is open, just
the state icon on the rail.

Verified end to end on a Gmail attachment: requested, named, saved, and
shown as "Downloaded" with a Show button that reveals it.
This commit is contained in:
2026-09-02 11:07:22 +02:00
parent 04b731cf37
commit 36506718c2
6 changed files with 136 additions and 80 deletions
+3 -7
View File
@@ -3,7 +3,6 @@ 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";
@@ -172,7 +171,7 @@ export default function App() {
// stays, because it is still telling you something.
setTimeout(
() => setDownloads((d) => d.filter((x) => x.id !== e.payload.id)),
12000,
30000,
);
}),
];
@@ -248,6 +247,8 @@ export default function App() {
unread={unread}
chrome={chrome}
rail={rail}
downloads={downloads}
onDismissDownload={(id) => setDownloads((d) => d.filter((x) => x.id !== id))}
/>
{/* The hole an app's native webview is positioned into. It stays empty
@@ -321,11 +322,6 @@ export default function App() {
</Dialog>
)}
<Downloads
items={downloads}
onDismiss={(id) => setDownloads((d) => d.filter((x) => x.id !== id))}
/>
{settingsOpen && (
<Settings
config={config}
+92 -68
View File
@@ -1,92 +1,116 @@
import type { Download } from "../types";
import * as api from "../api";
import type { Download } from "../types";
/**
* What is being downloaded, and what just was.
* What is being downloaded, and what just was — in the nav.
*
* 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.
* Not floating over the page, which is where this started and where it could
* never be seen: an app's webview is a native view painted above everything the
* shell draws, so a card over the page is a card behind the page. The nav is
* the shell's own, and the only place a message like this is guaranteed to be
* visible.
*/
export default function Downloads({
items,
collapsed,
onDismiss,
}: {
items: Download[];
collapsed: boolean;
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>
<div className="flex flex-col gap-1 border-t border-slate-200 px-2 py-2 dark:border-slate-800">
{items.map((d) => {
const status =
d.state === "done"
? "Downloaded"
: d.state === "failed"
? "Failed"
: d.bytes > 0
? `${formatBytes(d.bytes)} so far`
: "Starting…";
<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"
return (
<div
key={d.id}
title={`${d.name}${status}`}
className={
"flex items-center gap-2 rounded-lg px-1.5 py-1 " +
(collapsed ? "justify-center" : "")
}
>
<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>
))}
<Mark state={d.state} />
{!collapsed && (
<>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-tight">{d.name}</span>
<span className="block text-[10px] text-slate-500 dark:text-slate-400">
{status}
</span>
</span>
{d.state === "done" && (
<button
onClick={() => api.reveal(d.path)}
title="Show in Finder"
className="shrink-0 cursor-pointer rounded px-1 text-[10px] 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 p-0.5 text-slate-400
hover:text-slate-700 dark:hover:text-slate-200"
>
<svg viewBox="0 0 24 24" className="size-3" fill="none" stroke="currentColor"
strokeWidth="2.4" strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</>
)}
</div>
);
})}
</div>
);
}
function Mark({ state }: { state: Download["state"] }) {
if (state === "done") {
return (
<svg viewBox="0 0 24 24" className="size-4 shrink-0 text-emerald-500" fill="none"
stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 13l4 4L19 7" />
</svg>
);
}
if (state === "failed") {
return (
<svg viewBox="0 0 24 24" className="size-4 shrink-0 text-red-500" fill="none"
stroke="currentColor" strokeWidth="2.4" 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. */
return (
<svg viewBox="0 0 24 24" className="size-4 shrink-0 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>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
+8 -2
View File
@@ -1,5 +1,6 @@
import type { Config, Group, WorkApp } from "../types";
import type { Config, Download, Group, WorkApp } from "../types";
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
import Downloads from "./Downloads";
import { Favicon, GROUP_LABEL, ICON_CHROME } from "./ui";
interface Props {
@@ -23,6 +24,8 @@ interface Props {
* the buttons themselves, since that inset is macOS's to choose.
*/
rail: number;
downloads: Download[];
onDismissDownload: (id: number) => void;
}
const PANEL = 240;
@@ -30,7 +33,7 @@ const PANEL = 240;
export default function Nav({
config, activeId, collapsed, unread,
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload, chrome, rail,
onBack, onForward, onReload, chrome, rail, downloads, onDismissDownload,
}: Props) {
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) =>
@@ -150,6 +153,7 @@ export default function Nav({
);
})}
</nav>
<Downloads items={downloads} collapsed onDismiss={onDismissDownload} />
<div className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800">
{settingsButton}
</div>
@@ -226,6 +230,8 @@ export default function Nav({
)}
</nav>
<Downloads items={downloads} collapsed={false} onDismiss={onDismissDownload} />
<footer className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800">
{settingsButton}
</footer>