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).
- [ ] **H2 — Consolidate scattered utilities.** `youtubeId` ×2; 4 YouTube-URL-parsing variants;
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.
- [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships
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
**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.
- [ ] **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
"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
@@ -426,7 +426,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
report includes all).
- [ ] **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`.
- [ ] **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
tick); throttle or dedupe by computed value.
- [ ] **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).
- [ ] **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.
- [ ] **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.
- [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
+1 -1
View File
@@ -1,7 +1,7 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { fmtBytes } from './lib/formatters'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
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 { Sidebar, type TabValue } from './components/Sidebar'
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
// 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(() => {
let lastFraction = -1
let lastMode = ''
let lastBadge = -1
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items)
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 })
}
push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items))
}, [])
const paletteActions: PaletteAction[] = [
{
id: 'go-downloads',
label: 'Go to Downloads',
hint: 'Navigate',
run: () => setTab('downloads')
},
{ 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: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
{ 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)
// Memoized so CommandPalette sees a stable array reference between renders —
// only rebuilds when the theme label needs to change (L32).
const paletteActions = useMemo<PaletteAction[]>(
() => [
{
id: 'go-downloads',
label: 'Go to Downloads',
hint: 'Navigate',
run: () => setTab('downloads')
},
{ 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: 'go-terminal',
label: 'Go to Terminal',
hint: 'Navigate',
run: () => setTab('terminal')
},
{
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' })
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
],
[isDark, setTab, updateSettings]
)
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
+3 -3
View File
@@ -659,9 +659,9 @@ export interface BackupImportResult {
// --- Sources & media-manager indexing (Pinchflat-style) ---------------------
// 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
// that list a batch at a time, so "download an entire channel" never has to hold
// the whole collection in the queue at once.
// persisted list of MediaItem records. When the user downloads pending items,
// all selected entries are enqueued at once — add a batch cap here if large
// channel queues (thousands of items) cause performance issues (M33).
/** Whether a Source is a whole channel or a single playlist. */
export type SourceKind = 'channel' | 'playlist'