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:
+35
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user