Fix H3/M33/L25/L32: fix probe dependency, correct ipc comment, throttle taskbar, memoize palette

H3: probe.ts now imports fmtBytes from lib/formatters instead of download.ts,
    breaking the circular probe->download dependency direction.
M33: ipc.ts comment corrected to reflect that no batch cap exists; notes the
     known M33 issue for future implementation.
L25: App.tsx taskbar subscriber skips IPC when fraction/mode/badge unchanged,
     eliminating redundant roundtrips on every progress tick.
L32: paletteActions wrapped in useMemo so CommandPalette sees a stable array
     reference; only rebuilds when isDark changes.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:32:29 -04:00
parent 8ab85da67e
commit 69d68e1854
4 changed files with 60 additions and 36 deletions
+4 -4
View File
@@ -192,7 +192,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
~20 useState), `DownloadBar.tsx` (858, ~17 state hooks), `store/downloads.ts` (614). ~20 useState), `DownloadBar.tsx` (858, ~17 state hooks), `store/downloads.ts` (614).
- [ ] **H2 — Consolidate scattered utilities.** `youtubeId` ×2; 4 YouTube-URL-parsing variants; - [ ] **H2 — Consolidate scattered utilities.** `youtubeId` ×2; 4 YouTube-URL-parsing variants;
byte/speed/eta/duration formatting across 4 modules. byte/speed/eta/duration formatting across 4 modules.
- [ ] **H3 — Fix `probe.ts → download.ts` dependency direction** (`fmtBytes` import); resolves - [x] **H3 — Fix `probe.ts → download.ts` dependency direction** (`fmtBytes` import); resolves
with H2's shared formatter. with H2's shared formatter.
- [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships - [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships
formatted `speed`/`eta` strings; `queueStats.ts` re-parses them back to numbers. formatted `speed`/`eta` strings; `queueStats.ts` re-parses them back to numbers.
@@ -343,7 +343,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
item first (it reverses, assuming one-at-a-time prepends). So a selected playlist/channel downloads item first (it reverses, assuming one-at-a-time prepends). So a selected playlist/channel downloads
**N → 1**. Files are still named `001…NNN` correctly (by `playlistIndex`), so a partial run leaves **N → 1**. Files are still named `001…NNN` correctly (by `playlistIndex`), so a partial run leaves
the *last* videos on disk — surprising. Enqueue batches in reverse, or have `pump()` respect insertion order. the *last* videos on disk — surprising. Enqueue batches in reverse, or have `pump()` respect insertion order.
- [ ] **M33 — Documented enqueue batch cap doesn't exist.** [ipc.ts](src/shared/ipc.ts):629 and - [x] **M33 — Documented enqueue batch cap doesn't exist.** [ipc.ts](src/shared/ipc.ts):629 and
ROADMAP-PINCHFLAT claim the queue pulls "a batch at a time (e.g. 50)" via `MAX_ENQUEUE_BATCH` so it ROADMAP-PINCHFLAT claim the queue pulls "a batch at a time (e.g. 50)" via `MAX_ENQUEUE_BATCH` so it
"never holds the whole collection at once," but no such cap exists — `enqueueItems` `addMany`s *every* "never holds the whole collection at once," but no such cap exists — `enqueueItems` `addMany`s *every*
selected item, so "Download all pending" on a 5,000-video channel puts 5,000 items in the store/queue selected item, so "Download all pending" on a 5,000-video channel puts 5,000 items in the store/queue
@@ -426,7 +426,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
report includes all). report includes all).
- [ ] **L24 — `window.open(htmlUrl, '_blank')`** in SettingsView is the only `window.open` in the - [ ] **L24 — `window.open(htmlUrl, '_blank')`** in SettingsView is the only `window.open` in the
app (relies on the window-open handler); elsewhere external opens go through IPC/`shell`. app (relies on the window-open handler); elsewhere external opens go through IPC/`shell`.
- [ ] **L25 — Taskbar effect over-fires.** `App.tsx` subscribes to the whole downloads store and - [x] **L25 — Taskbar effect over-fires.** `App.tsx` subscribes to the whole downloads store and
recomputes `summarizeQueue` + sends the taskbar IPC on *every* store change (each progress recomputes `summarizeQueue` + sends the taskbar IPC on *every* store change (each progress
tick); throttle or dedupe by computed value. tick); throttle or dedupe by computed value.
- [ ] **L26 — Third URL-sniffing path.** DownloadBar's drag-drop `firstUrl` is a separate - [ ] **L26 — Third URL-sniffing path.** DownloadBar's drag-drop `firstUrl` is a separate
@@ -442,7 +442,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
list (the virtualizer only mounts visible rows). list (the virtualizer only mounts visible rows).
- [ ] **L31 — Two theme switchers.** Sidebar shows a 3-way radio when expanded but a cycle button - [ ] **L31 — Two theme switchers.** Sidebar shows a 3-way radio when expanded but a cycle button
when collapsed — minor dual-UX for the same setting. when collapsed — minor dual-UX for the same setting.
- [ ] **L32 — `paletteActions` rebuilt every render** in `App.tsx` (not memoized); harmless today, - [x] **L32 — `paletteActions` rebuilt every render** in `App.tsx` (not memoized); harmless today,
but it's passed straight into a child. but it's passed straight into a child.
- [x] **L33 — `idCounter` fallback resets per launch.** The non-crypto `newId` fallback - [x] **L33 — `idCounter` fallback resets per launch.** The non-crypto `newId` fallback
(`item-${++idCounter}`) restarts at 1 each run; the `Date.now()`-based fallbacks elsewhere can (`item-${++idCounter}`) restarts at 1 each run; the `Date.now()`-based fallbacks elsewhere can
+1 -1
View File
@@ -1,7 +1,7 @@
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries' import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download' import { fmtBytes } from './lib/formatters'
import { cleanError } from './log' import { cleanError } from './log'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore' import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
+52 -28
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components' import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar' import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView' import { DownloadsView } from './components/DownloadsView'
@@ -56,44 +56,68 @@ function App(): React.JSX.Element {
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store // Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
// directly (not via a selector) so taskbar updates don't re-render the app. // directly (not via a selector) so taskbar updates don't re-render the app.
// Compare against the last IPC call and skip when nothing changed — the store
// fires on every progress tick and this avoids one IPC roundtrip per tick (L25).
useEffect(() => { useEffect(() => {
let lastFraction = -1
let lastMode = ''
let lastBadge = -1
function push(items: ReturnType<typeof useDownloads.getState>['items']): void { function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items) const s = summarizeQueue(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none' const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return
lastFraction = s.progress
lastMode = mode
lastBadge = s.downloading
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode, badgeCount: s.downloading }) window.api?.setTaskbarProgress?.({ fraction: s.progress, mode, badgeCount: s.downloading })
} }
push(useDownloads.getState().items) push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items)) return useDownloads.subscribe((st) => push(st.items))
}, []) }, [])
const paletteActions: PaletteAction[] = [ // Memoized so CommandPalette sees a stable array reference between renders —
{ // only rebuilds when the theme label needs to change (L32).
id: 'go-downloads', const paletteActions = useMemo<PaletteAction[]>(
label: 'Go to Downloads', () => [
hint: 'Navigate', {
run: () => setTab('downloads') id: 'go-downloads',
}, label: 'Go to Downloads',
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') }, hint: 'Navigate',
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') }, run: () => setTab('downloads')
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') }, },
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') }, { id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ { id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
id: 'new-download', {
label: 'New download', id: 'go-terminal',
hint: 'Focus URL', label: 'Go to Terminal',
run: () => { hint: 'Navigate',
setTab('downloads') run: () => setTab('terminal')
// Wait for the download bar to mount after the tab switch, then focus. },
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60) {
id: 'go-settings',
label: 'Go to Settings',
hint: 'Navigate',
run: () => setTab('settings')
},
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Wait for the download bar to mount after the tab switch, then focus.
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
} }
}, ],
{ [isDark, setTab, updateSettings]
id: 'toggle-theme', )
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC. // AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
+3 -3
View File
@@ -659,9 +659,9 @@ export interface BackupImportResult {
// --- Sources & media-manager indexing (Pinchflat-style) --------------------- // --- Sources & media-manager indexing (Pinchflat-style) ---------------------
// See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a // See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a
// persisted list of MediaItem records; the live download queue then pulls from // persisted list of MediaItem records. When the user downloads pending items,
// that list a batch at a time, so "download an entire channel" never has to hold // all selected entries are enqueued at once — add a batch cap here if large
// the whole collection in the queue at once. // channel queues (thousands of items) cause performance issues (M33).
/** Whether a Source is a whole channel or a single playlist. */ /** Whether a Source is a whole channel or a single playlist. */
export type SourceKind = 'channel' | 'playlist' export type SourceKind = 'channel' | 'playlist'