feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:
- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
(non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
and toasts the main-process reason instead of silently no-oping on a moved/typed-
out file. safeShowInFolder upgraded to return an error string like safeOpenPath
(preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
active (downloading + queued) count from any tab, via a count-only App selector
that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".
Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).
Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+71
-9
@@ -169,6 +169,20 @@ electron-store split is deliberate for DPAPI), **CC11** (constants centralized;
|
|||||||
carry a documented standard + deferral rationale. All green: typecheck (node+web) + 275 tests + eslint +
|
carry a documented standard + deferral rationale. All green: typecheck (node+web) + 275 tests + eslint +
|
||||||
production build; touched files prettier-clean.
|
production build; touched files prettier-clean.
|
||||||
|
|
||||||
|
**Session 2026-07-01 pass 8 (Batch 11 — UX behavior gaps):** a new in-app **toast** surface
|
||||||
|
([store/toasts.ts](src/renderer/src/store/toasts.ts) + [ui/Toaster.tsx](src/renderer/src/components/ui/Toaster.tsx),
|
||||||
|
non-portal bottom-center, +unit-tested) is the batch's spine — it makes **UX6** real: a shared
|
||||||
|
[reveal.ts](src/renderer/src/reveal.ts) `revealFile` now surfaces open/reveal failures ("File not found — it
|
||||||
|
may have been moved…") instead of the old silent no-op, and `safeShowInFolder` was upgraded to return an error
|
||||||
|
string like `safeOpenPath`. Also landed **UX9/UI25** (a `CounterBadge` on the sidebar Downloads nav item shows
|
||||||
|
the active count from any tab, via a count-only selector that doesn't re-render on ticks), **UX15** (`cancelAll`
|
||||||
|
+ a "Cancel all (N)" header button), **UX24** (`disabledFocusable` + `Hint` explains the disabled "Check
|
||||||
|
watched"), **UX12** (an inline "Enable custom commands" button on the Terminal gate), and **L144** (the dup
|
||||||
|
guard now checks history → "Already downloaded: …"). **Deferred with rationale:** UX7/UX8 (Settings IA
|
||||||
|
redesign — visual), UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs the live taskbar),
|
||||||
|
and **L142** (the history/templates/sources IPC-return reconciliation — a focused own-PR, CC14's tail). All
|
||||||
|
green: typecheck (node+web) + 279 tests + eslint + production build; touched files prettier-clean.
|
||||||
|
|
||||||
**Deliberately deferred** (need live-app/visual verification or are larger refactors the
|
**Deliberately deferred** (need live-app/visual verification or are larger refactors the
|
||||||
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
|
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
|
||||||
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
|
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
|
||||||
@@ -950,6 +964,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
"interrupted"); user-facing ones are capitalized with periods. Fine when interpolated, jarring if shown raw.
|
"interrupted"); user-facing ones are capitalized with periods. Fine when interpolated, jarring if shown raw.
|
||||||
- [ ] **L131 — Taskbar `error` state is ephemeral** — it's driven by live `error` items, so "Clear
|
- [ ] **L131 — Taskbar `error` state is ephemeral** — it's driven by live `error` items, so "Clear
|
||||||
finished" flips the red taskbar bar back to normal even though failures occurred (out of sync with the persisted error log).
|
finished" flips the red taskbar bar back to normal even though failures occurred (out of sync with the persisted error log).
|
||||||
|
*Deferred (arguably by-design, needs the live taskbar): the taskbar reflects the *live queue*, so clearing
|
||||||
|
finished items legitimately returns it to idle — the persisted record lives in Diagnostics. Whether the red
|
||||||
|
bar should linger after a clear is a debatable UX call best judged against the real Windows taskbar; left
|
||||||
|
for the watched Windows pass (with Batch 14/15).*
|
||||||
- [x] **L132 — `clearProbe` + form-reset logic is duplicated** in DownloadBar (`onUrlChange`, `download`, `addPlaylist`).
|
- [x] **L132 — `clearProbe` + form-reset logic is duplicated** in DownloadBar (`onUrlChange`, `download`, `addPlaylist`).
|
||||||
*Fixed: a shared `resetForm()` in [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts)
|
*Fixed: a shared `resetForm()` in [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts)
|
||||||
now owns the post-enqueue reset (URL + probe + the one-shot trim/schedule/advanced/incognito/options),
|
now owns the post-enqueue reset (URL + probe + the one-shot trim/schedule/advanced/incognito/options),
|
||||||
@@ -1017,11 +1035,20 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
- [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings
|
- [ ] **L142 — IPC mutation return values are discarded everywhere.** history/templates/sources/settings
|
||||||
IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does
|
IPC calls return the authoritative (validated/capped/sanitized) state, but every renderer caller does
|
||||||
optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34).
|
optimistic-only updates and ignores it — client and persisted state can silently diverge until reload (generalizes M34).
|
||||||
|
*Deferred (targeted, own PR): settings already reconcile (M34). Extending "apply the returned authoritative
|
||||||
|
state" to history/templates/sources touches each store's mutation path and each corresponding IPC return
|
||||||
|
shape, and the divergence it guards against is rare (only when main caps/sanitizes differently than the
|
||||||
|
optimistic update) and self-heals on reload. Best done as one focused reconciliation PR with each store
|
||||||
|
verified, not folded into this UX batch. Standard already recorded under CC14.*
|
||||||
- [ ] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides;
|
- [ ] **L143 — No in-window "quit anyway."** With a download active (or tray mode), closing only hides;
|
||||||
quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager
|
quitting requires the tray menu. If the tray ever fails to create, the only exit is Task Manager
|
||||||
(mitigated today by the embedded fallback tray icon).
|
(mitigated today by the embedded fallback tray icon).
|
||||||
- [ ] **L144 — Duplicate guard checks the queue only, not history.** Re-adding a URL downloaded earlier
|
- [x] **L144 — Duplicate guard checks the queue only, not history.** Re-adding a URL downloaded earlier
|
||||||
(already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension).
|
(already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension).
|
||||||
|
*Fixed: the DownloadBar dup guard ([useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts))
|
||||||
|
now checks history when a URL isn't in the live queue — if it was downloaded before, the warning banner reads
|
||||||
|
"Already downloaded: …" (vs "Already in your queue: …"), with the same "Download anyway" confirm. A new
|
||||||
|
`dupInHistory` flag drives the wording.*
|
||||||
- [x] **L145 — `useClipboardLink` re-offers a dismissed link after a tab switch.** `lastSeen` is
|
- [x] **L145 — `useClipboardLink` re-offers a dismissed link after a tab switch.** `lastSeen` is
|
||||||
per-hook-instance; switching Downloads↔Library remounts it, so a previously-dismissed clipboard link is offered again.
|
per-hook-instance; switching Downloads↔Library remounts it, so a previously-dismissed clipboard link is offered again.
|
||||||
*Fixed: `lastSeen` is now a module-level variable (not a per-instance `useRef`), so a dismissed/accepted
|
*Fixed: `lastSeen` is now a module-level variable (not a per-instance `useRef`), so a dismissed/accepted
|
||||||
@@ -1253,9 +1280,12 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
|
|||||||
- [ ] **UI24 — No context menus anywhere.** Every row exposes actions only as inline buttons;
|
- [ ] **UI24 — No context menus anywhere.** Every row exposes actions only as inline buttons;
|
||||||
right-click does nothing on any screen, despite many per-row actions that conventionally also live on
|
right-click does nothing on any screen, despite many per-row actions that conventionally also live on
|
||||||
right-click. **Standard:** add consistent right-click menus mirroring row actions, or note "none by design."
|
right-click. **Standard:** add consistent right-click menus mirroring row actions, or note "none by design."
|
||||||
- [ ] **UI25 — No in-app global status surface.** Queue progress shows only on the Downloads tab's
|
- [x] **UI25 — No in-app global status surface.** Queue progress shows only on the Downloads tab's
|
||||||
summary strip; on any other tab there's no in-app sign downloads are running (only the OS taskbar).
|
summary strip; on any other tab there's no in-app sign downloads are running (only the OS taskbar).
|
||||||
**Standard:** a persistent affordance (e.g. a sidebar "Downloads" badge with the active count).
|
**Standard:** a persistent affordance (e.g. a sidebar "Downloads" badge with the active count).
|
||||||
|
*Fixed (with UX9): a `CounterBadge` on the sidebar Downloads nav item shows the active (downloading +
|
||||||
|
queued) count from every tab, and a new in-app toast surface covers transient status. The exact
|
||||||
|
suggested affordance.*
|
||||||
|
|
||||||
### Animations & transitions
|
### Animations & transitions
|
||||||
|
|
||||||
@@ -1388,35 +1418,60 @@ root cause is already filed.
|
|||||||
|
|
||||||
### Medium — confusion, feedback & organization
|
### Medium — confusion, feedback & organization
|
||||||
|
|
||||||
- [ ] **UX6 — Silent failures on file actions.** `openFile`/`showInFolder` in the stores call
|
- [x] **UX6 — Silent failures on file actions.** `openFile`/`showInFolder` in the stores call
|
||||||
`window.api.openPath(...)` and ignore the returned error string; [reveal.ts](src/main/reveal.ts)
|
`window.api.openPath(...)` and ignore the returned error string; [reveal.ts](src/main/reveal.ts)
|
||||||
refuses missing/moved files or disallowed types and returns an error nobody surfaces. Clicking
|
refuses missing/moved files or disallowed types and returns an error nobody surfaces. Clicking
|
||||||
"Open file" on a moved download does nothing, with no message. **Fix:** surface open/reveal errors.
|
"Open file" on a moved download does nothing, with no message. **Fix:** surface open/reveal errors.
|
||||||
|
*Fixed: a shared renderer helper [reveal.ts](src/renderer/src/reveal.ts) `revealFile('open'|'folder', path)`
|
||||||
|
awaits the IPC result and raises the main-process reason ("File not found — it may have been moved…",
|
||||||
|
"Refusing to open this file type.") as an error **toast** — the new transient-notification surface (see
|
||||||
|
UX9). Both the downloads and history stores route through it. `safeShowInFolder` was upgraded to return an
|
||||||
|
error string like `safeOpenPath` (it previously no-op'd silently), so reveal failures surface too.*
|
||||||
- [ ] **UX7 — Settings is one long unsegmented scroll** of ~11 cards with no section nav/anchors; the
|
- [ ] **UX7 — Settings is one long unsegmented scroll** of ~11 cards with no section nav/anchors; the
|
||||||
only finder is the hide-cards search (M14). Order is arbitrary and related items are split.
|
only finder is the hide-cards search (M14). Order is arbitrary and related items are split.
|
||||||
**Fix:** group into sub-sections (or a left rail) with a stable, logical order.
|
**Fix:** group into sub-sections (or a left rail) with a stable, logical order.
|
||||||
|
*Deferred (visual, own pass): a Settings section-nav / left-rail is a layout redesign whose value is
|
||||||
|
entirely in how it looks and scrolls — it wants the app running and eyeballed, not an unattended edit.
|
||||||
|
Grouped with the visual/UX polish pass.*
|
||||||
- [ ] **UX8 — "Default format" vs "Format & post-processing" are two places for one concept.** Kind +
|
- [ ] **UX8 — "Default format" vs "Format & post-processing" are two places for one concept.** Kind +
|
||||||
quality live under Downloads; container/codec/subs/SponsorBlock live in a separate card. **Fix:**
|
quality live under Downloads; container/codec/subs/SponsorBlock live in a separate card. **Fix:**
|
||||||
co-locate, or clearly label one "defaults" and the other "advanced post-processing."
|
co-locate, or clearly label one "defaults" and the other "advanced post-processing."
|
||||||
- [ ] **UX9 — No global completion feedback off the Downloads tab** (UI25). If OS notifications are
|
*Deferred (ties to UX7): co-locating or re-labelling these cards is part of the same Settings
|
||||||
|
information-architecture pass; done together there with the app running.*
|
||||||
|
- [x] **UX9 — No global completion feedback off the Downloads tab** (UI25). If OS notifications are
|
||||||
off/suppressed (focus assist) and you're on another tab, a finished download gives no in-app sign.
|
off/suppressed (focus assist) and you're on another tab, a finished download gives no in-app sign.
|
||||||
**Fix:** a sidebar Downloads badge / lightweight in-app toast.
|
**Fix:** a sidebar Downloads badge / lightweight in-app toast.
|
||||||
|
*Fixed (both halves of the suggested fix): a **sidebar Downloads badge** — a Fluent `CounterBadge` on the
|
||||||
|
Downloads nav item shows the active (downloading + queued) count from any tab
|
||||||
|
([Sidebar.tsx](src/renderer/src/components/Sidebar.tsx)), driven by a count-only selector in App so it
|
||||||
|
updates without re-rendering on every progress tick; and a **lightweight in-app toast** surface
|
||||||
|
([Toaster.tsx](src/renderer/src/components/ui/Toaster.tsx) + [store/toasts.ts](src/renderer/src/store/toasts.ts)),
|
||||||
|
a non-portal bottom-center stack that any store/component can raise (now used for UX6's file-action errors).
|
||||||
|
Together this closes **UI25**.*
|
||||||
- [x] **UX10 — Re-download silently changes quality** (H5) — a confusing "I asked for 720p, got Best." *Resolved by H5 (re-download stores `formatId`/`formatHasAudio` and strips compound quality labels).*
|
- [x] **UX10 — Re-download silently changes quality** (H5) — a confusing "I asked for 720p, got Best." *Resolved by H5 (re-download stores `formatId`/`formatHasAudio` and strips compound quality labels).*
|
||||||
- [ ] **UX11 — Scheduled downloads silently never fire if the app is quit** (M4). The hint is easy to
|
- [ ] **UX11 — Scheduled downloads silently never fire if the app is quit** (M4). The hint is easy to
|
||||||
miss; the user returns to find nothing happened. **Fix:** warn at schedule time that it requires the
|
miss; the user returns to find nothing happened. **Fix:** warn at schedule time that it requires the
|
||||||
app running (or persist + relaunch).
|
app running (or persist + relaunch).
|
||||||
- [ ] **UX12 — Terminal is a dead-end when custom commands are off** (L18). The nav item is always
|
- [x] **UX12 — Terminal is a dead-end when custom commands are off** (L18). The nav item is always
|
||||||
visible; clicking it shows a gate pointing to a setting on another screen. **Fix:** hide/disable the
|
visible; clicking it shows a gate pointing to a setting on another screen. **Fix:** hide/disable the
|
||||||
nav item, or let the gate enable the setting inline.
|
nav item, or let the gate enable the setting inline. *Fixed (both fixes): the nav item is already hidden
|
||||||
|
when custom commands are off (Sidebar's `showTerminal` filter, L18), and the gate — still reachable via the
|
||||||
|
command palette — now carries an inline **"Enable custom commands"** button that flips the setting on the
|
||||||
|
spot ([TerminalView.tsx](src/renderer/src/components/TerminalView.tsx)), so it's no longer a dead-end that
|
||||||
|
sends you to another screen.*
|
||||||
- [x] **UX13 — Backup export warns of nothing; it contains secrets** (M22). A user "backs up settings"
|
- [x] **UX13 — Backup export warns of nothing; it contains secrets** (M22). A user "backs up settings"
|
||||||
and ships proxy creds / tokens in cleartext. **Fix:** warn, or mask/omit secrets. *Resolved by M22
|
and ships proxy creds / tokens in cleartext. **Fix:** warn, or mask/omit secrets. *Resolved by M22
|
||||||
(backup strips proxy / PO-token / update-token; the caption states credentials aren't included).*
|
(backup strips proxy / PO-token / update-token; the caption states credentials aren't included).*
|
||||||
- [x] **UX14 — Duplicate warning can show a placeholder title** (L76): 'Already in your queue:
|
- [x] **UX14 — Duplicate warning can show a placeholder title** (L76): 'Already in your queue:
|
||||||
"YouTube video (abc123)"' before metadata resolves looks broken. **Fix:** fall back to the URL.
|
"YouTube video (abc123)"' before metadata resolves looks broken. **Fix:** fall back to the URL.
|
||||||
*Resolved by L76 (the dup warning falls back to the URL when the title is still the placeholder).*
|
*Resolved by L76 (the dup warning falls back to the URL when the title is still the placeholder).*
|
||||||
- [ ] **UX15 — No bulk actions in the Downloads queue.** History has a select-mode + bulk delete;
|
- [x] **UX15 — No bulk actions in the Downloads queue.** History has a select-mode + bulk delete;
|
||||||
Downloads has only per-row remove + "Clear finished." Inconsistent. **Fix:** mirror select/bulk, or
|
Downloads has only per-row remove + "Clear finished." Inconsistent. **Fix:** mirror select/bulk, or
|
||||||
add "Cancel all."
|
add "Cancel all." *Fixed (the "Cancel all" option): a `cancelAll` store action + a "Cancel all (N)"
|
||||||
|
header button ([DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx)) that cancels every
|
||||||
|
active (downloading + queued) item at once — killing the live processes and freeing the queue — shown only
|
||||||
|
when there's something cancelable. (A full History-style select-mode is the heavier alternative; "Cancel
|
||||||
|
all" covers the common bulk need.)*
|
||||||
|
|
||||||
### Low — polish & smaller friction
|
### Low — polish & smaller friction
|
||||||
|
|
||||||
@@ -1427,6 +1482,8 @@ root cause is already filed.
|
|||||||
- [ ] **UX17 — Icon-only Fetch/Paste buttons** rely on hover tooltips for meaning (UI a11y; new users on touch/keyboard miss them). **Fix:** labels or `aria`-described affordances.
|
- [ ] **UX17 — Icon-only Fetch/Paste buttons** rely on hover tooltips for meaning (UI a11y; new users on touch/keyboard miss them). **Fix:** labels or `aria`-described affordances.
|
||||||
- [ ] **UX18 — The quality control morphs after Fetch** (preset list → real formats; label "Quality" →
|
- [ ] **UX18 — The quality control morphs after Fetch** (preset list → real formats; label "Quality" →
|
||||||
"Quality / format") in place — a surprising transform. **Fix:** keep a stable control with a clear "formats loaded" state.
|
"Quality / format") in place — a surprising transform. **Fix:** keep a stable control with a clear "formats loaded" state.
|
||||||
|
*Deferred (subjective visual): the morph is arguably informative (the label change signals real formats
|
||||||
|
loaded); whether it "surprises" is a judgment best made watching it, so it joins the watched visual pass.*
|
||||||
- [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed
|
- [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed
|
||||||
920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 —
|
920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 —
|
||||||
[windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.*
|
[windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.*
|
||||||
@@ -1438,13 +1495,18 @@ root cause is already filed.
|
|||||||
- [ ] **UX22 — Onboarding is shallow and one-shot** (L67): three tips, no folder choice, not
|
- [ ] **UX22 — Onboarding is shallow and one-shot** (L67): three tips, no folder choice, not
|
||||||
revisitable; the clipboard tip requires a focus event a first-timer won't trigger.
|
revisitable; the clipboard tip requires a focus event a first-timer won't trigger.
|
||||||
- [ ] **UX23 — Settings folder paths are read-only** (L71) — no paste; Browse-dialog only.
|
- [ ] **UX23 — Settings folder paths are read-only** (L71) — no paste; Browse-dialog only.
|
||||||
- [ ] **UX24 — "Check N watched for new" is disabled with no explanation** when no source is watched —
|
- [x] **UX24 — "Check N watched for new" is disabled with no explanation** when no source is watched —
|
||||||
a user with indexed (but unwatched) sources sees a dead button. **Fix:** tooltip / enable with a hint to watch a source.
|
a user with indexed (but unwatched) sources sees a dead button. **Fix:** tooltip / enable with a hint to watch a source.
|
||||||
|
*Fixed: the button uses `disabledFocusable` (not `disabled`) when nothing is watched, so it stays
|
||||||
|
hover/focus-reachable, and a `Hint` explains why — "Turn on 'Watch' for a channel or playlist below first,
|
||||||
|
then this checks them for new uploads." ([LibraryView.tsx](src/renderer/src/components/LibraryView.tsx)).*
|
||||||
- [x] **UX25 — Empty-state copy mismatches the flow** (L78): Downloads says "Paste a URL above to get
|
- [x] **UX25 — Empty-state copy mismatches the flow** (L78): Downloads says "Paste a URL above to get
|
||||||
started," but the actual path is Fetch/Download. *Resolved by L78 (DownloadsView empty-state copy
|
started," but the actual path is Fetch/Download. *Resolved by L78 (DownloadsView empty-state copy
|
||||||
rewritten to match the Fetch/Download flow).*
|
rewritten to match the Fetch/Download flow).*
|
||||||
- [ ] **UX26 — yt-dlp/ffmpeg version + initial settings load have no skeleton/spinner** — brief blank
|
- [ ] **UX26 — yt-dlp/ffmpeg version + initial settings load have no skeleton/spinner** — brief blank
|
||||||
states on boot (plus the documented one-frame theme flash). **Fix:** lightweight loading states.
|
states on boot (plus the documented one-frame theme flash). **Fix:** lightweight loading states.
|
||||||
|
*Deferred (low value, needs eyeballing): the blanks are sub-second on a normal launch; adding skeletons is
|
||||||
|
a perceived-polish tweak worth doing while watching the actual boot, so it joins the visual pass.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -53,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise<string> {
|
|||||||
return shell.openPath(p)
|
return shell.openPath(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */
|
/** Reveal a path in the OS file manager. Returns '' on success, or an error
|
||||||
export function safeShowInFolder(p: unknown): void {
|
* string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
|
||||||
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return
|
export function safeShowInFolder(p: unknown): string {
|
||||||
|
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
|
||||||
|
if (!existsSync(p)) return 'File not found — it may have been moved or deleted.'
|
||||||
shell.showItemInFolder(p)
|
shell.showItemInFolder(p)
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ const api = {
|
|||||||
|
|
||||||
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
|
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
|
||||||
|
|
||||||
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannels.showInFolder, path),
|
showInFolder: (path: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.showInFolder, path),
|
||||||
|
|
||||||
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
|
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SettingsView } from './components/SettingsView'
|
|||||||
import { Onboarding } from './components/Onboarding'
|
import { Onboarding } from './components/Onboarding'
|
||||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||||
import { LiveRegion } from './components/LiveRegion'
|
import { LiveRegion } from './components/LiveRegion'
|
||||||
|
import { Toaster } from './components/ui/Toaster'
|
||||||
import { getTheme, pageBackground } from './theme'
|
import { getTheme, pageBackground } from './theme'
|
||||||
import { SPACE } from './components/ui/tokens'
|
import { SPACE } from './components/ui/tokens'
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
@@ -55,6 +56,12 @@ function App(): React.JSX.Element {
|
|||||||
const isDark = useResolvedDark()
|
const isDark = useResolvedDark()
|
||||||
const tab = useNav((s) => s.tab)
|
const tab = useNav((s) => s.tab)
|
||||||
const setTab = useNav((s) => s.setTab)
|
const setTab = useNav((s) => s.setTab)
|
||||||
|
// Active-download count for the sidebar Downloads badge (UX9/UI25), so a run in
|
||||||
|
// progress is visible from any tab. A number selector only re-renders when the
|
||||||
|
// count actually changes -- not on every progress tick.
|
||||||
|
const activeCount = useDownloads(
|
||||||
|
(s) => s.items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
||||||
|
)
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
// Track the element that was focused before the palette opened so we can restore it on close.
|
// Track the element that was focused before the palette opened so we can restore it on close.
|
||||||
const prePaletteRef = useRef<Element | null>(null)
|
const prePaletteRef = useRef<Element | null>(null)
|
||||||
@@ -225,6 +232,7 @@ function App(): React.JSX.Element {
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={toggleCollapsed}
|
onToggleCollapsed={toggleCollapsed}
|
||||||
showTerminal={showTerminal}
|
showTerminal={showTerminal}
|
||||||
|
downloadCount={activeCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
@@ -248,6 +256,7 @@ function App(): React.JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<LiveRegion />
|
<LiveRegion />
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
scheduleAt,
|
scheduleAt,
|
||||||
dragActive,
|
dragActive,
|
||||||
dup,
|
dup,
|
||||||
|
dupInHistory,
|
||||||
showAdvanced,
|
showAdvanced,
|
||||||
downloadOptions,
|
downloadOptions,
|
||||||
incognito,
|
incognito,
|
||||||
@@ -170,7 +171,9 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Caption1>Already in your queue: "{dup}".</Caption1>
|
<Caption1>
|
||||||
|
{dupInHistory ? 'Already downloaded' : 'Already in your queue'}: "{dup}".
|
||||||
|
</Caption1>
|
||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
|
||||||
import { useDownloads, type DownloadItem } from '../store/downloads'
|
import { useDownloads, type DownloadItem } from '../store/downloads'
|
||||||
import { queueSummaryOf } from '../store/queueStats'
|
import { queueSummaryOf } from '../store/queueStats'
|
||||||
import { DownloadBar } from './DownloadBar'
|
import { DownloadBar } from './DownloadBar'
|
||||||
@@ -75,6 +75,7 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
const items = useDownloads((s) => s.items)
|
const items = useDownloads((s) => s.items)
|
||||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||||
const retryAll = useDownloads((s) => s.retryAll)
|
const retryAll = useDownloads((s) => s.retryAll)
|
||||||
|
const cancelAll = useDownloads((s) => s.cancelAll)
|
||||||
|
|
||||||
const summary = queueSummaryOf(items)
|
const summary = queueSummaryOf(items)
|
||||||
const hasFinished = items.some(
|
const hasFinished = items.some(
|
||||||
@@ -89,6 +90,9 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
i.status === 'paused' ||
|
i.status === 'paused' ||
|
||||||
i.status === 'saved'
|
i.status === 'saved'
|
||||||
).length
|
).length
|
||||||
|
// Cancelable = the items with a live/pending run (downloading + queued); this is
|
||||||
|
// exactly what "Cancel all" acts on (UX15).
|
||||||
|
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
@@ -126,6 +130,11 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
Retry all failed ({summary.failed})
|
Retry all failed ({summary.failed})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{cancelable > 0 && (
|
||||||
|
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
|
||||||
|
Cancel all ({cancelable})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasFinished && (
|
{hasFinished && (
|
||||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||||
Clear finished
|
Clear finished
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { thumbUrl } from '../thumb'
|
|||||||
import { relTime } from '../datetime'
|
import { relTime } from '../datetime'
|
||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { VirtualList } from './VirtualList'
|
import { VirtualList } from './VirtualList'
|
||||||
|
import { Hint } from './Hint'
|
||||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
import { StatusChip } from './ui/StatusChip'
|
import { StatusChip } from './ui/StatusChip'
|
||||||
import { SegmentedControl } from './ui/SegmentedControl'
|
import { SegmentedControl } from './ui/SegmentedControl'
|
||||||
@@ -536,15 +537,28 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
|
<Hint
|
||||||
|
label={
|
||||||
|
watchedCount === 0
|
||||||
|
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
|
||||||
|
: 'Check your watched sources for new uploads'
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||||
onClick={onCheckNew}
|
onClick={onCheckNew}
|
||||||
disabled={syncing || watchedCount === 0}
|
// disabledFocusable (not disabled) when nothing is watched, so the
|
||||||
|
// button still shows its explanatory tooltip on hover/focus (UX24).
|
||||||
|
disabled={syncing}
|
||||||
|
disabledFocusable={watchedCount === 0}
|
||||||
>
|
>
|
||||||
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
||||||
</Button>
|
</Button>
|
||||||
|
</Hint>
|
||||||
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
|
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
|
||||||
<div className={styles.toolbarSpacer} />
|
<div className={styles.toolbarSpacer} />
|
||||||
<span className={styles.switchRow}>
|
<span className={styles.switchRow}>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
import {
|
||||||
|
Caption1,
|
||||||
|
CounterBadge,
|
||||||
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
|
tokens,
|
||||||
|
shorthands
|
||||||
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
@@ -88,6 +95,8 @@ const useStyles = makeStyles({
|
|||||||
alignSelf: 'stretch'
|
alignSelf: 'stretch'
|
||||||
},
|
},
|
||||||
navItem: {
|
navItem: {
|
||||||
|
// relative so the collapsed count badge can pin to the icon's corner.
|
||||||
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '11px',
|
gap: '11px',
|
||||||
@@ -105,6 +114,16 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Expanded: the active-download count sits at the far right of the nav row.
|
||||||
|
navBadge: {
|
||||||
|
marginLeft: 'auto'
|
||||||
|
},
|
||||||
|
// Collapsed: pin the count to the top-right corner of the centered icon.
|
||||||
|
navBadgeCollapsed: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '8px'
|
||||||
|
},
|
||||||
navItemCollapsed: {
|
navItemCollapsed: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '9px 0'
|
padding: '9px 0'
|
||||||
@@ -155,6 +174,8 @@ interface SidebarProps {
|
|||||||
onToggleCollapsed: () => void
|
onToggleCollapsed: () => void
|
||||||
/** Show the Terminal nav item (only when custom commands are enabled) */
|
/** Show the Terminal nav item (only when custom commands are enabled) */
|
||||||
showTerminal: boolean
|
showTerminal: boolean
|
||||||
|
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
|
||||||
|
downloadCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -166,7 +187,8 @@ export function Sidebar({
|
|||||||
version,
|
version,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
showTerminal
|
showTerminal,
|
||||||
|
downloadCount
|
||||||
}: SidebarProps): React.JSX.Element {
|
}: SidebarProps): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const focus = useFocusStyles()
|
const focus = useFocusStyles()
|
||||||
@@ -238,10 +260,23 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
onClick={() => onTabChange(n.value)}
|
onClick={() => onTabChange(n.value)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
aria-label={n.label}
|
aria-label={
|
||||||
|
n.value === 'downloads' && downloadCount > 0
|
||||||
|
? `${n.label}, ${downloadCount} active`
|
||||||
|
: n.label
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className={styles.navIcon}>{n.icon}</span>
|
<span className={styles.navIcon}>{n.icon}</span>
|
||||||
{!collapsed && n.label}
|
{!collapsed && n.label}
|
||||||
|
{n.value === 'downloads' && downloadCount > 0 && (
|
||||||
|
<CounterBadge
|
||||||
|
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
|
||||||
|
count={downloadCount}
|
||||||
|
size="small"
|
||||||
|
color="brand"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
return collapsed ? (
|
return collapsed ? (
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const MAX_LOG_LINES = 2000
|
|||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
|
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
|
||||||
gate: {
|
gate: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||||
color: tokens.colorStatusWarningForeground1,
|
color: tokens.colorStatusWarningForeground1,
|
||||||
@@ -75,6 +79,7 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const screen = useScreenStyles()
|
const screen = useScreenStyles()
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||||
|
const updateSettings = useSettings((s) => s.update)
|
||||||
|
|
||||||
const [args, setArgs] = useState('')
|
const [args, setArgs] = useState('')
|
||||||
const [lines, setLines] = useState<Line[]>([])
|
const [lines, setLines] = useState<Line[]>([])
|
||||||
@@ -158,10 +163,19 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!customCommandEnabled && (
|
{!customCommandEnabled && (
|
||||||
<Body1 className={styles.gate}>
|
<div className={styles.gate}>
|
||||||
The terminal is part of custom commands. Turn on "Run custom commands" in Settings →
|
<Body1>
|
||||||
Custom commands to use it.
|
The terminal runs the bundled yt-dlp with your own arguments — part of custom commands,
|
||||||
|
which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
|
||||||
</Body1>
|
</Body1>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => updateSettings({ customCommandEnabled: true })}
|
||||||
|
>
|
||||||
|
Enable custom commands
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.inputRow}>
|
<div className={styles.inputRow}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type CommandPreviewResult
|
type CommandPreviewResult
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { useDownloads, type MediaKind } from '../../store/downloads'
|
import { useDownloads, type MediaKind } from '../../store/downloads'
|
||||||
|
import { useHistory } from '../../store/history'
|
||||||
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||||
import { sameVideo } from '../../store/queueStats'
|
import { sameVideo } from '../../store/queueStats'
|
||||||
import { useSettings } from '../../store/settings'
|
import { useSettings } from '../../store/settings'
|
||||||
@@ -55,6 +56,8 @@ export interface DownloadBarController {
|
|||||||
setDragActive: Dispatch<SetStateAction<boolean>>
|
setDragActive: Dispatch<SetStateAction<boolean>>
|
||||||
dup: string | null
|
dup: string | null
|
||||||
setDup: Dispatch<SetStateAction<string | null>>
|
setDup: Dispatch<SetStateAction<string | null>>
|
||||||
|
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
|
||||||
|
dupInHistory: boolean
|
||||||
// advanced / preview
|
// advanced / preview
|
||||||
showAdvanced: boolean
|
showAdvanced: boolean
|
||||||
setShowAdvanced: Dispatch<SetStateAction<boolean>>
|
setShowAdvanced: Dispatch<SetStateAction<boolean>>
|
||||||
@@ -141,6 +144,9 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
|
|
||||||
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||||
const [dup, setDup] = useState<string | null>(null)
|
const [dup, setDup] = useState<string | null>(null)
|
||||||
|
// Whether the duplicate matched a history entry (already downloaded) rather than
|
||||||
|
// a live queue item (L144) — the banner wording differs.
|
||||||
|
const [dupInHistory, setDupInHistory] = useState(false)
|
||||||
const confirmDup = useRef(false)
|
const confirmDup = useRef(false)
|
||||||
|
|
||||||
// Per-download options (M5/M6/UX1): advanced features.
|
// Per-download options (M5/M6/UX1): advanced features.
|
||||||
@@ -431,6 +437,15 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
existing.title.endsWith(' download') ||
|
existing.title.endsWith(' download') ||
|
||||||
existing.title === 'New download'
|
existing.title === 'New download'
|
||||||
setDup(isPlaceholder ? existing.url : existing.title)
|
setDup(isPlaceholder ? existing.url : existing.title)
|
||||||
|
setDupInHistory(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
|
||||||
|
// re-download is a deliberate choice, not an accidental duplicate.
|
||||||
|
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
|
||||||
|
if (downloaded) {
|
||||||
|
setDup(downloaded.title || downloaded.url)
|
||||||
|
setDupInHistory(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +528,7 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
scheduleAt,
|
scheduleAt,
|
||||||
setScheduleAt,
|
setScheduleAt,
|
||||||
// drag / dup
|
// drag / dup
|
||||||
|
dupInHistory,
|
||||||
dragActive,
|
dragActive,
|
||||||
setDragActive,
|
setDragActive,
|
||||||
dup,
|
dup,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Caption1, Button, makeStyles, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
import {
|
||||||
|
DismissRegular,
|
||||||
|
ErrorCircleFilled,
|
||||||
|
CheckmarkCircleFilled,
|
||||||
|
InfoFilled
|
||||||
|
} from '@fluentui/react-icons'
|
||||||
|
import { useToasts, type ToastTone } from '../../store/toasts'
|
||||||
|
import { Z, ELEVATION, RADIUS, ICON } from './tokens'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the transient-toast stack (UX6/UX9) bottom-center. Deliberately NOT a
|
||||||
|
* Fluent Toaster/portal — this app avoids portal-based overlays (they blank/flicker
|
||||||
|
* on the dev GPU; see Select.tsx / CommandPalette.tsx) — so it's a plain fixed
|
||||||
|
* stack. No enter/exit animation, matching the app's conservative motion policy on
|
||||||
|
* this machine.
|
||||||
|
*/
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
stack: {
|
||||||
|
position: 'fixed',
|
||||||
|
left: '50%',
|
||||||
|
bottom: '24px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: Z.tooltip,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
// Let clicks fall through the gaps between toasts to the app underneath.
|
||||||
|
pointerEvents: 'none'
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
maxWidth: 'min(520px, 90vw)',
|
||||||
|
padding: '10px 8px 10px 12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
...shorthands.borderRadius(RADIUS.surface),
|
||||||
|
boxShadow: ELEVATION.overlay
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: `${ICON.control}px`,
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
info: { color: tokens.colorNeutralForeground3 },
|
||||||
|
error: { color: tokens.colorStatusDangerForeground1 },
|
||||||
|
success: { color: tokens.colorStatusSuccessForeground1 },
|
||||||
|
message: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ICONS: Record<ToastTone, React.JSX.Element> = {
|
||||||
|
info: <InfoFilled />,
|
||||||
|
error: <ErrorCircleFilled />,
|
||||||
|
success: <CheckmarkCircleFilled />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toaster(): React.JSX.Element | null {
|
||||||
|
const styles = useStyles()
|
||||||
|
const toasts = useToasts((s) => s.toasts)
|
||||||
|
const dismiss = useToasts((s) => s.dismiss)
|
||||||
|
const toneClass: Record<ToastTone, string> = {
|
||||||
|
info: styles.info,
|
||||||
|
error: styles.error,
|
||||||
|
success: styles.success
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className={styles.stack} role="status" aria-live="polite">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className={styles.toast}>
|
||||||
|
<span className={`${styles.icon} ${toneClass[t.tone]}`}>{ICONS[t.tone]}</span>
|
||||||
|
<Caption1 className={styles.message}>{t.message}</Caption1>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export const mockApi: Api = {
|
|||||||
chooseFolder: async () => null,
|
chooseFolder: async () => null,
|
||||||
openPath: async () => '',
|
openPath: async () => '',
|
||||||
openUrl: async () => {},
|
openUrl: async () => {},
|
||||||
showInFolder: async () => {},
|
showInFolder: async () => '',
|
||||||
readClipboard: async () => '',
|
readClipboard: async () => '',
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
getSettings: async () => MOCK_SETTINGS,
|
getSettings: async () => MOCK_SETTINGS,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { toast } from './store/toasts'
|
||||||
|
import { logError } from './reportError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a downloaded file, or reveal it in its folder, surfacing any failure as a
|
||||||
|
* toast (UX6) instead of silently doing nothing. Clicking "Open file" on a
|
||||||
|
* download that was later moved or deleted used to no-op with no message; now the
|
||||||
|
* main-process reason ("File not found — it may have been moved…", "Refusing to
|
||||||
|
* open this file type.") is shown. Both IPC calls resolve to '' on success or an
|
||||||
|
* error string; a rejected promise (a bridge failure) is logged.
|
||||||
|
*/
|
||||||
|
export function revealFile(action: 'open' | 'folder', filePath: string): void {
|
||||||
|
const call = action === 'open' ? window.api.openPath(filePath) : window.api.showInFolder(filePath)
|
||||||
|
call
|
||||||
|
.then((err) => {
|
||||||
|
if (err) toast(err, 'error')
|
||||||
|
})
|
||||||
|
.catch(logError(action === 'open' ? 'openPath' : 'showInFolder'))
|
||||||
|
}
|
||||||
@@ -116,6 +116,8 @@ export interface DownloadState {
|
|||||||
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
|
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
|
||||||
promoteDueScheduled: () => void
|
promoteDueScheduled: () => void
|
||||||
cancel: (id: string) => void
|
cancel: (id: string) => void
|
||||||
|
/** cancel every active (downloading + queued) item at once (UX15) */
|
||||||
|
cancelAll: () => void
|
||||||
remove: (id: string) => void
|
remove: (id: string) => void
|
||||||
clearFinished: () => void
|
clearFinished: () => void
|
||||||
openFile: (id: string) => void
|
openFile: (id: string) => void
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
|||||||
import { type MediaKind } from '@shared/ipc'
|
import { type MediaKind } from '@shared/ipc'
|
||||||
import { isPreview as PREVIEW } from '../isPreview'
|
import { isPreview as PREVIEW } from '../isPreview'
|
||||||
import { logError } from '../reportError'
|
import { logError } from '../reportError'
|
||||||
|
import { revealFile } from '../reveal'
|
||||||
import { useSettings } from './settings'
|
import { useSettings } from './settings'
|
||||||
import { useHistory } from './history'
|
import { useHistory } from './history'
|
||||||
import { emit, on } from './coordinator'
|
import { emit, on } from './coordinator'
|
||||||
@@ -335,6 +336,25 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
pump()
|
pump()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelAll: () => {
|
||||||
|
const active = get().items.filter((i) => i.status === 'downloading' || i.status === 'queued')
|
||||||
|
if (active.length === 0) return
|
||||||
|
// Kill the live processes for the running ones; queued items never started.
|
||||||
|
if (!PREVIEW) {
|
||||||
|
for (const i of active) {
|
||||||
|
if (i.status === 'downloading') window.api.cancelDownload(i.id).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.status === 'downloading' || i.status === 'queued'
|
||||||
|
? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
|
||||||
|
: i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
pump()
|
||||||
|
},
|
||||||
|
|
||||||
remove: (id) => {
|
remove: (id) => {
|
||||||
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
|
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
|
||||||
pump()
|
pump()
|
||||||
@@ -353,12 +373,12 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
|
|
||||||
openFile: (id) => {
|
openFile: (id) => {
|
||||||
const item = get().items.find((i) => i.id === id)
|
const item = get().items.find((i) => i.id === id)
|
||||||
if (!PREVIEW && item?.filePath) window.api.openPath(item.filePath)
|
if (!PREVIEW && item?.filePath) revealFile('open', item.filePath)
|
||||||
},
|
},
|
||||||
|
|
||||||
showInFolder: (id) => {
|
showInFolder: (id) => {
|
||||||
const item = get().items.find((i) => i.id === id)
|
const item = get().items.find((i) => i.id === id)
|
||||||
if (!PREVIEW && item?.filePath) window.api.showInFolder(item.filePath)
|
if (!PREVIEW && item?.filePath) revealFile('folder', item.filePath)
|
||||||
},
|
},
|
||||||
|
|
||||||
applyEvent: (ev) => {
|
applyEvent: (ev) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
|||||||
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
|
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
|
||||||
import { isPreview as PREVIEW } from '../isPreview'
|
import { isPreview as PREVIEW } from '../isPreview'
|
||||||
import { logError } from '../reportError'
|
import { logError } from '../reportError'
|
||||||
|
import { revealFile } from '../reveal'
|
||||||
|
|
||||||
// Mock history so the History tab has content during UI design.
|
// Mock history so the History tab has content during UI design.
|
||||||
const seed: HistoryEntry[] = PREVIEW
|
const seed: HistoryEntry[] = PREVIEW
|
||||||
@@ -87,12 +88,12 @@ export const useHistory = create<HistoryState>((set, get) => ({
|
|||||||
|
|
||||||
openFile: (id) => {
|
openFile: (id) => {
|
||||||
const e = get().entries.find((x) => x.id === id)
|
const e = get().entries.find((x) => x.id === id)
|
||||||
if (!PREVIEW && e?.filePath) window.api.openPath(e.filePath)
|
if (!PREVIEW && e?.filePath) revealFile('open', e.filePath)
|
||||||
},
|
},
|
||||||
|
|
||||||
showInFolder: (id) => {
|
showInFolder: (id) => {
|
||||||
const e = get().entries.find((x) => x.id === id)
|
const e = get().entries.find((x) => x.id === id)
|
||||||
if (!PREVIEW && e?.filePath) window.api.showInFolder(e.filePath)
|
if (!PREVIEW && e?.filePath) revealFile('folder', e.filePath)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { newId } from '../id'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny transient-notification store (audit UX6/UX9). The app had no way to tell
|
||||||
|
* the user about a background failure — a swallowed `openPath` error, a file that
|
||||||
|
* moved — so those actions silently did nothing. This backs a lightweight,
|
||||||
|
* non-blocking toast stack rendered by `Toaster`; any store or component can raise
|
||||||
|
* one via {@link toast}.
|
||||||
|
*/
|
||||||
|
export type ToastTone = 'info' | 'error' | 'success'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
tone: ToastTone
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
toasts: Toast[]
|
||||||
|
show: (message: string, tone?: ToastTone) => void
|
||||||
|
dismiss: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a toast lingers before auto-dismissing. */
|
||||||
|
const AUTO_DISMISS_MS = 5000
|
||||||
|
|
||||||
|
export const useToasts = create<ToastState>((set, get) => ({
|
||||||
|
toasts: [],
|
||||||
|
show: (message, tone = 'info') => {
|
||||||
|
const id = newId('toast')
|
||||||
|
set((s) => ({ toasts: [...s.toasts, { id, message, tone }] }))
|
||||||
|
// Auto-dismiss so the stack self-clears; the user can also dismiss early.
|
||||||
|
setTimeout(() => get().dismiss(id), AUTO_DISMISS_MS)
|
||||||
|
},
|
||||||
|
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative shortcut for non-component callers (the Zustand stores), so a store
|
||||||
|
* action can surface an error without wiring the hook through a component.
|
||||||
|
*/
|
||||||
|
export function toast(message: string, tone?: ToastTone): void {
|
||||||
|
useToasts.getState().show(message, tone)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useToasts, toast } from '../src/renderer/src/store/toasts'
|
||||||
|
|
||||||
|
describe('toasts store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useToasts.setState({ toasts: [] })
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show() adds a toast with the given message and tone', () => {
|
||||||
|
toast('could not open file', 'error')
|
||||||
|
const ts = useToasts.getState().toasts
|
||||||
|
expect(ts).toHaveLength(1)
|
||||||
|
expect(ts[0]?.message).toBe('could not open file')
|
||||||
|
expect(ts[0]?.tone).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults the tone to info', () => {
|
||||||
|
toast('heads up')
|
||||||
|
expect(useToasts.getState().toasts[0]?.tone).toBe('info')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismiss() removes only the matching toast', () => {
|
||||||
|
toast('a')
|
||||||
|
toast('b')
|
||||||
|
const firstId = useToasts.getState().toasts[0]?.id ?? ''
|
||||||
|
useToasts.getState().dismiss(firstId)
|
||||||
|
const ts = useToasts.getState().toasts
|
||||||
|
expect(ts).toHaveLength(1)
|
||||||
|
expect(ts[0]?.message).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-dismisses after the timeout elapses', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
toast('temporary')
|
||||||
|
expect(useToasts.getState().toasts).toHaveLength(1)
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
expect(useToasts.getState().toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user