From 661a4e75729e62b11d34a3a98b25e5475602340c Mon Sep 17 00:00:00 2001 From: debont80 Date: Wed, 1 Jul 2026 17:05:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(audit):=20Batch=2011=20=E2=80=94=20UX=20be?= =?UTF-8?q?havior=20gaps=20(toast=20surface,=20sidebar=20badge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CODE-AUDIT.md | 80 ++++++++++++++-- src/main/reveal.ts | 9 +- src/preload/index.ts | 3 +- src/renderer/src/App.tsx | 9 ++ src/renderer/src/components/DownloadBar.tsx | 5 +- src/renderer/src/components/DownloadsView.tsx | 11 ++- src/renderer/src/components/LibraryView.tsx | 30 ++++-- src/renderer/src/components/Sidebar.tsx | 41 ++++++++- src/renderer/src/components/TerminalView.tsx | 22 ++++- .../components/downloadBar/useDownloadBar.ts | 16 ++++ src/renderer/src/components/ui/Toaster.tsx | 92 +++++++++++++++++++ src/renderer/src/mockApi.ts | 2 +- src/renderer/src/reveal.ts | 19 ++++ src/renderer/src/store/downloadTypes.ts | 2 + src/renderer/src/store/downloads.ts | 24 ++++- src/renderer/src/store/history.ts | 5 +- src/renderer/src/store/toasts.ts | 45 +++++++++ test/toasts.test.ts | 40 ++++++++ 18 files changed, 420 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/ui/Toaster.tsx create mode 100644 src/renderer/src/reveal.ts create mode 100644 src/renderer/src/store/toasts.ts create mode 100644 test/toasts.test.ts diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 78e9b3a..af1eb31 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -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 + 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 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, @@ -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. - [ ] **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). + *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`). *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), @@ -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 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). + *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; 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). -- [ ] **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). + *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 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 @@ -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; 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." -- [ ] **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). **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 @@ -1388,35 +1418,60 @@ root cause is already filed. ### 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) 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. + *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 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. + *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 + 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." -- [ ] **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. **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).* - [ ] **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 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 - 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" 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).* - [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. *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 - 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 @@ -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. - [ ] **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. + *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 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.* @@ -1438,13 +1495,18 @@ root cause is already filed. - [ ] **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. - [ ] **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. + *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 started," but the actual path is Fetch/Download. *Resolved by L78 (DownloadsView empty-state copy rewritten to match the Fetch/Download flow).* - [ ] **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. + *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.* --- diff --git a/src/main/reveal.ts b/src/main/reveal.ts index 6457b49..0a699df 100644 --- a/src/main/reveal.ts +++ b/src/main/reveal.ts @@ -53,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise { return shell.openPath(p) } -/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */ -export function safeShowInFolder(p: unknown): void { - if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return +/** Reveal a path in the OS file manager. Returns '' on success, or an error + * string (mirroring safeOpenPath) so the renderer can surface it (UX6). */ +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) + return '' } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8bd09f6..4fb8d76 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -80,7 +80,8 @@ const api = { openUrl: (url: string): Promise => ipcRenderer.invoke(IpcChannels.openUrl, url), - showInFolder: (path: string): Promise => ipcRenderer.invoke(IpcChannels.showInFolder, path), + showInFolder: (path: string): Promise => + ipcRenderer.invoke(IpcChannels.showInFolder, path), readClipboard: (): Promise => ipcRenderer.invoke(IpcChannels.clipboardRead), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2ea1d49..f97ebae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsView } from './components/SettingsView' import { Onboarding } from './components/Onboarding' import { CommandPalette, type PaletteAction } from './components/CommandPalette' import { LiveRegion } from './components/LiveRegion' +import { Toaster } from './components/ui/Toaster' import { getTheme, pageBackground } from './theme' import { SPACE } from './components/ui/tokens' import { useSettings } from './store/settings' @@ -55,6 +56,12 @@ function App(): React.JSX.Element { const isDark = useResolvedDark() const tab = useNav((s) => s.tab) 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) // Track the element that was focused before the palette opened so we can restore it on close. const prePaletteRef = useRef(null) @@ -225,6 +232,7 @@ function App(): React.JSX.Element { collapsed={collapsed} onToggleCollapsed={toggleCollapsed} showTerminal={showTerminal} + downloadCount={activeCount} />
@@ -248,6 +256,7 @@ function App(): React.JSX.Element { )} + ) diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 81c7377..d499b25 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -52,6 +52,7 @@ export function DownloadBar(): React.JSX.Element { scheduleAt, dragActive, dup, + dupInHistory, showAdvanced, downloadOptions, incognito, @@ -170,7 +171,9 @@ export function DownloadBar(): React.JSX.Element { } > - Already in your queue: "{dup}". + + {dupInHistory ? 'Already downloaded' : 'Already in your queue'}: "{dup}". + )} diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx index 547e34b..e1c9ca2 100644 --- a/src/renderer/src/components/DownloadsView.tsx +++ b/src/renderer/src/components/DownloadsView.tsx @@ -8,7 +8,7 @@ tokens, shorthands } 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 { queueSummaryOf } from '../store/queueStats' import { DownloadBar } from './DownloadBar' @@ -75,6 +75,7 @@ export function DownloadsView(): React.JSX.Element { const items = useDownloads((s) => s.items) const clearFinished = useDownloads((s) => s.clearFinished) const retryAll = useDownloads((s) => s.retryAll) + const cancelAll = useDownloads((s) => s.cancelAll) const summary = queueSummaryOf(items) const hasFinished = items.some( @@ -89,6 +90,9 @@ export function DownloadsView(): React.JSX.Element { i.status === 'paused' || i.status === 'saved' ).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 (
@@ -126,6 +130,11 @@ export function DownloadsView(): React.JSX.Element { Retry all failed ({summary.failed}) )} + {cancelable > 0 && ( + + )} {hasFinished && ( + + {syncNote && {syncNote}}
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 58e2d22..4bdaf67 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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 { ArrowDownloadFilled, ArrowDownloadRegular, @@ -88,6 +95,8 @@ const useStyles = makeStyles({ alignSelf: 'stretch' }, navItem: { + // relative so the collapsed count badge can pin to the icon's corner. + position: 'relative', display: 'flex', alignItems: 'center', gap: '11px', @@ -105,6 +114,16 @@ const useStyles = makeStyles({ 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: { justifyContent: 'center', padding: '9px 0' @@ -155,6 +174,8 @@ interface SidebarProps { onToggleCollapsed: () => void /** Show the Terminal nav item (only when custom commands are enabled) */ showTerminal: boolean + /** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */ + downloadCount: number } export function Sidebar({ @@ -166,7 +187,8 @@ export function Sidebar({ version, collapsed, onToggleCollapsed, - showTerminal + showTerminal, + downloadCount }: SidebarProps): React.JSX.Element { const styles = useStyles() const focus = useFocusStyles() @@ -238,10 +260,23 @@ export function Sidebar({ } onClick={() => onTabChange(n.value)} aria-current={active ? 'page' : undefined} - aria-label={n.label} + aria-label={ + n.value === 'downloads' && downloadCount > 0 + ? `${n.label}, ${downloadCount} active` + : n.label + } > {n.icon} {!collapsed && n.label} + {n.value === 'downloads' && downloadCount > 0 && ( + + )} ) return collapsed ? ( diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 8afefb9..87f0f7f 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -33,6 +33,10 @@ const MAX_LOG_LINES = 2000 const useStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' }, gate: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '8px', padding: '12px 14px', backgroundColor: tokens.colorStatusWarningBackground1, color: tokens.colorStatusWarningForeground1, @@ -75,6 +79,7 @@ export function TerminalView(): React.JSX.Element { const styles = useStyles() const screen = useScreenStyles() const customCommandEnabled = useSettings((s) => s.customCommandEnabled) + const updateSettings = useSettings((s) => s.update) const [args, setArgs] = useState('') const [lines, setLines] = useState([]) @@ -158,10 +163,19 @@ export function TerminalView(): React.JSX.Element { /> {!customCommandEnabled && ( - - The terminal is part of custom commands. Turn on "Run custom commands" in Settings → - 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. + + +
)}
diff --git a/src/renderer/src/components/downloadBar/useDownloadBar.ts b/src/renderer/src/components/downloadBar/useDownloadBar.ts index cb9bd3e..22a56df 100644 --- a/src/renderer/src/components/downloadBar/useDownloadBar.ts +++ b/src/renderer/src/components/downloadBar/useDownloadBar.ts @@ -9,6 +9,7 @@ import { type CommandPreviewResult } from '@shared/ipc' import { useDownloads, type MediaKind } from '../../store/downloads' +import { useHistory } from '../../store/history' import { QUALITY_OPTIONS } from '../../qualityOptions' import { sameVideo } from '../../store/queueStats' import { useSettings } from '../../store/settings' @@ -55,6 +56,8 @@ export interface DownloadBarController { setDragActive: Dispatch> dup: string | null setDup: Dispatch> + /** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */ + dupInHistory: boolean // advanced / preview showAdvanced: boolean setShowAdvanced: Dispatch> @@ -141,6 +144,9 @@ export function useDownloadBar(): DownloadBarController { // Duplicate guard: warn before enqueuing a URL already in the active queue. const [dup, setDup] = useState(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) // Per-download options (M5/M6/UX1): advanced features. @@ -431,6 +437,15 @@ export function useDownloadBar(): DownloadBarController { existing.title.endsWith(' download') || existing.title === 'New download' 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 } } @@ -513,6 +528,7 @@ export function useDownloadBar(): DownloadBarController { scheduleAt, setScheduleAt, // drag / dup + dupInHistory, dragActive, setDragActive, dup, diff --git a/src/renderer/src/components/ui/Toaster.tsx b/src/renderer/src/components/ui/Toaster.tsx new file mode 100644 index 0000000..0866a8d --- /dev/null +++ b/src/renderer/src/components/ui/Toaster.tsx @@ -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 = { + info: , + error: , + success: +} + +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 = { + info: styles.info, + error: styles.error, + success: styles.success + } + + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( +
+ {ICONS[t.tone]} + {t.message} +
+ ))} +
+ ) +} diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts index 2b69add..9fe9d6d 100644 --- a/src/renderer/src/mockApi.ts +++ b/src/renderer/src/mockApi.ts @@ -135,7 +135,7 @@ export const mockApi: Api = { chooseFolder: async () => null, openPath: async () => '', openUrl: async () => {}, - showInFolder: async () => {}, + showInFolder: async () => '', readClipboard: async () => '', logError: () => {}, getSettings: async () => MOCK_SETTINGS, diff --git a/src/renderer/src/reveal.ts b/src/renderer/src/reveal.ts new file mode 100644 index 0000000..55cc28e --- /dev/null +++ b/src/renderer/src/reveal.ts @@ -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')) +} diff --git a/src/renderer/src/store/downloadTypes.ts b/src/renderer/src/store/downloadTypes.ts index 1e2a215..1598e04 100644 --- a/src/renderer/src/store/downloadTypes.ts +++ b/src/renderer/src/store/downloadTypes.ts @@ -116,6 +116,8 @@ export interface DownloadState { /** internal: promote any 'saved' item whose scheduledFor time has arrived */ promoteDueScheduled: () => void cancel: (id: string) => void + /** cancel every active (downloading + queued) item at once (UX15) */ + cancelAll: () => void remove: (id: string) => void clearFinished: () => void openFile: (id: string) => void diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 73bcc4a..f44e369 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { type MediaKind } from '@shared/ipc' import { isPreview as PREVIEW } from '../isPreview' import { logError } from '../reportError' +import { revealFile } from '../reveal' import { useSettings } from './settings' import { useHistory } from './history' import { emit, on } from './coordinator' @@ -335,6 +336,25 @@ export const useDownloads = create((set, get) => { 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) => { set((s) => ({ items: s.items.filter((i) => i.id !== id) })) pump() @@ -353,12 +373,12 @@ export const useDownloads = create((set, get) => { openFile: (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) => { 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) => { diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts index c37f4cd..7b7a1a9 100644 --- a/src/renderer/src/store/history.ts +++ b/src/renderer/src/store/history.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc' import { isPreview as PREVIEW } from '../isPreview' import { logError } from '../reportError' +import { revealFile } from '../reveal' // Mock history so the History tab has content during UI design. const seed: HistoryEntry[] = PREVIEW @@ -87,12 +88,12 @@ export const useHistory = create((set, get) => ({ openFile: (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) => { 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) } })) diff --git a/src/renderer/src/store/toasts.ts b/src/renderer/src/store/toasts.ts new file mode 100644 index 0000000..8b9032c --- /dev/null +++ b/src/renderer/src/store/toasts.ts @@ -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((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) +} diff --git a/test/toasts.test.ts b/test/toasts.test.ts new file mode 100644 index 0000000..23c14e6 --- /dev/null +++ b/test/toasts.test.ts @@ -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) + }) +})