diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..d953f98 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "ui", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "ui", "--", "--port", "4321", "--strictPort"], + "port": 4321 + } + ] +} diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index b20d7cc..153244e 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -992,13 +992,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n `` (Fetch/Index/Check-for-new/yt-dlp update/app-update/PO-mint/version-check); the only remaining ``s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.* -- [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`. - *Deferred (bounded; needs the L161 layout rework + live verification): History is capped at 500 rows (L141), - so the un-virtualized render is bounded — not a real perf problem. Virtualizing it requires giving the list a - fixed-height internal scroll container (the same `height:100%`/`overflow` rework L161 flags as needing - live verification at multiple window sizes) *and* re-homing its container-level Ctrl+A and per-row Delete - keyboard handling onto the virtualized scroller. That's a layout+interaction change best done watched, so it - rides with L161 rather than shipping unattended for a list that's already capped.* +- [x] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`. + *Fixed (Batch 17, with L161): HistoryView renders through the shared `VirtualList` inside the new `fill` + screen shell — the list owns an internal scroll region (flex + 160px floor, DownloadsView's contract). The + container-level Ctrl+A moved onto a wrapper around the virtualized scroller (key events bubble up from the + rows), and per-row Delete stays on each row so it works wherever the row mounts. Verified live in an + Electron probe against the vite preview at 1100×720 and 800×520: rows render via `[data-index]` virtual + wrappers, the list scrolls internally, and `
`/body never scroll (probe metrics + screenshots).* - [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and [store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it. *Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local @@ -1301,12 +1301,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n 8px) instead of style classes (extends L5). *Fixed: the margin literals now draw from `SPACE` (`hairline`/`xtight`/`tight`) — same values, one source. (The broader inline-`style` vs makeStyles split is still L5.)* -- [ ] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set +- [x] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set `height: 100%` while App's `
` is `overflowY: auto` + 24/28px padding — a fragile coupling that can - double-scroll or misalign the virtualized list's viewport. *Deferred (layout, needs live verification): it - works today (the virtualized queue scrolls correctly), and reworking the `
` scroll/height contract is - a layout change best validated with the app open at several window sizes — a watched pass, not an unattended - edit.* + double-scroll or misalign the virtualized list's viewport. *Fixed (Batch 17): `
` no longer scrolls or + pads — it's a plain flex slot, and each screen owns its scrolling through two shared shells in + [ui/Screen.tsx](src/renderer/src/components/ui/Screen.tsx): `page` (document screens — Library, Settings, + empty History: the shell scrolls) and `fill` (internally-scrolling screens — Downloads, Terminal, History: + the screen's list/log is the scroller, with the shell scrolling only as the short-window fallback). The + `height:100%` couplings are gone (`flexGrow:1; minHeight:0` inside the shell instead). The rework surfaced a + real pre-existing edge: on a very short window the flexed list collapsed to 0px — now floored at 160px with + the shell absorbing the overflow. Verified with an Electron probe at 1100×720 and 800×520 per screen: + exactly one active scroller per screen in the normal case, `
`/body never scroll, floors hold + (probe metrics + screenshots).* - [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. *Deferred — same as PERF6: virtualization/the 500-cap bounds the mounted count, and a prop conflicts with PERF5's hoisted diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b20bd3d..5762253 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,7 +7,6 @@ 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' import { useNav } from './store/nav' import { useDownloads } from './store/downloads' @@ -55,9 +54,13 @@ const useStyles = makeStyles({ content: { flexGrow: 1, minWidth: 0, - overflowY: 'auto', - // Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else. - padding: SPACE.page + // L161:
no longer scrolls or pads — each screen owns its scroll region + // via the shared `page`/`fill` shells (ui/Screen.tsx), so a page scroll and an + // internal list scroll can never stack. Padding (SPACE.page, UI4) lives on the + // shells. + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' } }) diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx index e1c9ca2..d309f78 100644 --- a/src/renderer/src/components/DownloadsView.tsx +++ b/src/renderer/src/components/DownloadsView.tsx @@ -26,14 +26,15 @@ const renderQueueRow = (item: DownloadItem): React.JSX.Element => i.status === 'downloading' || i.status === 'queued').length return ( -
- +
+
+ - + - {summary.active && ( -
- + + + {summary.downloading} downloading + {summary.queued ? `, ${summary.queued} queued` : ''} + {summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''} + {summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''} + +
+ )} + +
+ Queue ({queueCount}) +
+ {summary.failed > 0 && ( + + )} + {cancelable > 0 && ( + + )} + {hasFinished && ( + + )} +
+
+ + {items.length === 0 ? ( + } + message="Nothing queued yet. Paste a URL above, then click Download." /> - - {summary.downloading} downloading - {summary.queued ? `, ${summary.queued} queued` : ''} - {summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''} - {summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''} - -
- )} - -
- Queue ({queueCount}) -
- {summary.failed > 0 && ( - - )} - {cancelable > 0 && ( - - )} - {hasFinished && ( - - )} -
+ ) : ( + + )}
- - {items.length === 0 ? ( - } - message="Nothing queued yet. Paste a URL above, then click Download." - /> - ) : ( - - )}
) } diff --git a/src/renderer/src/components/HistoryView.tsx b/src/renderer/src/components/HistoryView.tsx index 1b35081..d7116b1 100644 --- a/src/renderer/src/components/HistoryView.tsx +++ b/src/renderer/src/components/HistoryView.tsx @@ -30,6 +30,7 @@ import { thumbUrl } from '../thumb' import { MediaThumb } from './MediaThumb' import { Hint } from './Hint' import { Select } from './Select' +import { VirtualList } from './VirtualList' import { ScreenHeader, useScreenStyles } from './ui/Screen' import { EmptyState } from './ui/EmptyState' import { useFocusStyles } from './ui/focusRing' @@ -43,6 +44,21 @@ const useStyles = makeStyles({ flexDirection: 'column', gap: SPACE.section }, + // Fills the `fill` shell (L161) so the virtualized list below owns the scroll. + fill: { + flexGrow: 1, + minHeight: 0 + }, + listScroll: { + flexGrow: 1, + // The floor keeps the virtualized list visible on a very short window (the + // `fill` shell scrolls the overflow); above it, the list shrinks below its + // content height so it, not the page, owns the scrolling (same contract as + // DownloadsView). + minHeight: '160px', + display: 'flex', + flexDirection: 'column' + }, header: { display: 'flex', alignItems: 'center', @@ -65,11 +81,6 @@ const useStyles = makeStyles({ spacer: { flexGrow: 1 }, - list: { - display: 'flex', - flexDirection: 'column', - gap: '8px' - }, row: { display: 'flex', alignItems: 'center', @@ -127,6 +138,11 @@ const KIND_FILTER_OPTIONS = [ { value: 'audio', label: 'Audio' } ] +// Hoisted so the virtualizer gets stable function identities (PERF5). The row +// renderer itself stays a component-scoped closure — it needs select-mode state. +const estimateRowSize = (): number => 76 +const getEntryKey = (h: HistoryEntry): string => h.id + export function HistoryView(): React.JSX.Element { const styles = useStyles() const screen = useScreenStyles() @@ -214,230 +230,246 @@ export function HistoryView(): React.JSX.Element { exitSelectMode() } + // One history row; rendered through the virtualized list (L104). The per-row + // Delete key handling stays on the row itself, so it works wherever the row is + // mounted in the window. + function renderRow(h: HistoryEntry): React.JSX.Element { + return ( +
{ + // Delete removes the focused row (not when a child button is focused). + if (e.key === 'Delete' && e.target === e.currentTarget) { + e.preventDefault() + remove(h.id) + } + }} + > + {selectMode && ( + toggleSelected(h.id, !!d.checked)} + aria-label={`Select ${h.title}`} + /> + )} + +
+ {h.title} + + {[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(META_SEP)} + +
+
+ +
+
+ ) + } + if (entries.length === 0) { return ( -
- - - -
- } - message="No downloads yet." - hint="Finished downloads will show up here." - /> +
+
+ + + +
+ } + message="No downloads yet." + hint="Finished downloads will show up here." + /> +
) } return ( -
- -
- {selectMode ? ( - <> - {selected.size} selected - -
- {confirmDelete ? ( - <> - - Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}? - - - - - ) : ( - <> - - - - )} - - ) : ( - <> - - {entries.length} {entries.length === 1 ? 'download' : 'downloads'} - - setQuery(d.value)} - placeholder="Search title, channel, URL…" - contentBefore={} - /> - setQuery(d.value)} + placeholder="Search title, channel, URL…" + contentBefore={} + /> + setUrl(d.value)} - onKeyDown={(e) => e.key === 'Enter' && onIndex()} - placeholder="Paste a channel or playlist URL to add it…" - size="large" - contentBefore={} - disabled={indexing.active} +
+
+ - -
- {clip.suggestion && ( - { - const link = clip.accept() - if (link) setUrl(link) - }} - onDismiss={clip.dismiss} - dismissLabel="Dismiss suggested link" - /> - )} - -
- - - - {syncNote && {syncNote}} -
- - Auto-download new - updateSettings({ autoDownloadNew: d.checked })} - aria-label="Auto-download new uploads" +
+ setUrl(d.value)} + onKeyDown={(e) => e.key === 'Enter' && onIndex()} + placeholder="Paste a channel or playlist URL to add it…" + size="large" + contentBefore={} + disabled={indexing.active} /> - - - Daily sync - toggleScheduled(d.checked)} - aria-label="Daily background sync" - /> - {scheduled && ( - onSyncTimeChange(d.value)} - onBlur={onSyncTimeCommit} - aria-label="Daily sync time" - /> - )} - -
- - {indexing.active && ( -
- - - {indexing.message ?? 'Adding…'} - {indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''} - - {/* B2: a big channel's index walk can take minutes — let the user abort it. */}
- )} - {error && {error}} - {sources.length === 0 && !indexing.active ? ( - } - message="No channels or playlists yet. Paste one above to add it." - /> - ) : ( -
- {sources.map((src) => ( - selectSource(selectedSourceId === src.id ? null : src.id)} + {clip.suggestion && ( + { + const link = clip.accept() + if (link) setUrl(link) + }} + onDismiss={clip.dismiss} + dismissLabel="Dismiss suggested link" + /> + )} + +
+ + + Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new + + + {syncNote && {syncNote}} +
+ + Auto-download new + updateSettings({ autoDownloadNew: d.checked })} + aria-label="Auto-download new uploads" + /> + + + Daily sync + toggleScheduled(d.checked)} + aria-label="Daily background sync" + /> + {scheduled && ( + onSyncTimeChange(d.value)} + onBlur={onSyncTimeCommit} + aria-label="Daily sync time" + /> + )} + +
+ + {indexing.active && ( +
+ + + {indexing.message ?? 'Adding…'} + {indexing.current && indexing.total + ? ` (${indexing.current}/${indexing.total})` + : ''} + + {/* B2: a big channel's index walk can take minutes — let the user abort it. */} + +
+ )} + {error && {error}} + + {sources.length === 0 && !indexing.active ? ( + } + message="No channels or playlists yet. Paste one above to add it." + /> + ) : ( +
+ {sources.map((src) => ( + selectSource(selectedSourceId === src.id ? null : src.id)} + > +
+
+ 0 ? 'mixed' : false} + onChange={(_, d) => toggleAll(!!d.checked)} + label="Select all" + disabled={actionableItems.length === 0} + /> + + {items.length} videos{META_SEP} + {pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)} + +
+ + value={enqueueKind} + options={[ + { value: 'video', label: 'Video' }, + { value: 'audio', label: 'Audio' } + ]} + onChange={setEnqueueKind} + ariaLabel="Download as video or audio" + /> + {selected.size > 0 ? ( + <> + + + + ) : ( - - ) : ( - - )} - - Watch - setWatched(src.id, !!d.checked)} - aria-label={`Watch ${src.title} for new uploads`} - /> - - - {confirmRemoveId === src.id ? ( - <> - Remove {src.title}? - - - - ) : ( + )} + + Watch + setWatched(src.id, !!d.checked)} + aria-label={`Watch ${src.title} for new uploads`} + /> + + {confirmRemoveId === src.id ? ( + <> + Remove {src.title}? + + + + ) : ( + + )} +
+ + {batchNote && {batchNote}} + + {flatRows.length > VIRTUALIZE_AT ? ( + // Big source: a fixed-height, internally-scrolling virtualized + // panel so 1000s of rows stay light. A definite height (not + // max-height) gives the virtualizer a viewport to measure. + (flatRows[i]?.kind === 'header' ? 40 : 46)} + getKey={(row) => rowKey(row)} + renderItem={(row) => renderRow(row)} + /> + ) : ( + // Small source: render inline so the card grows naturally. +
+ {flatRows.map((row) => ( +
{renderRow(row)}
+ ))} +
)}
- - {batchNote && {batchNote}} - - {flatRows.length > VIRTUALIZE_AT ? ( - // Big source: a fixed-height, internally-scrolling virtualized - // panel so 1000s of rows stay light. A definite height (not - // max-height) gives the virtualizer a viewport to measure. - (flatRows[i]?.kind === 'header' ? 40 : 46)} - getKey={(row) => rowKey(row)} - renderItem={(row) => renderRow(row)} - /> - ) : ( - // Small source: render inline so the card grows naturally. -
- {flatRows.map((row) => ( -
{renderRow(row)}
- ))} -
- )} -
-
- ))} -
- )} + + ))} +
+ )} +
) } diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index adca76c..623bd72 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -66,36 +66,41 @@ export function SettingsView(): React.JSX.Element { const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean) return ( -
- -
- setSearch(d.value)} - placeholder="Search settings…" - contentBefore={} - style={{ width: '100%' }} +
+
+ - {noResults && ( - - No settings match "{search}". - - )} -
- - {CARDS.map((Card, i) => ( -
{ - cardRefs.current[i] = el - }} - style={hidden[i] ? { display: 'none' } : undefined} - > - +
+ setSearch(d.value)} + placeholder="Search settings…" + contentBefore={} + style={{ width: '100%' }} + /> + {noResults && ( + + No settings match "{search}". + + )}
- ))} + + {CARDS.map((Card, i) => ( +
{ + cardRefs.current[i] = el + }} + style={hidden[i] ? { display: 'none' } : undefined} + > + +
+ ))} +
) } diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 9693ef0..d5be666 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -17,7 +17,8 @@ import { SPACE } from './ui/tokens' import { useTerminalRun, type LineKind } from './useTerminalRun' const useStyles = makeStyles({ - root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' }, + // Fills the `fill` shell (L161); the log
 below is the screen's scroller.
+  root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, flexGrow: 1, minHeight: 0 },
   gate: {
     display: 'flex',
     flexDirection: 'column',
@@ -86,87 +87,96 @@ export function TerminalView(): React.JSX.Element {
           : undefined
 
   return (
-    
- - Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} - -F https://youtu.be/…. ffmpeg is wired up automatically. - - } - /> +
+
+ + Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} + -F https://youtu.be/…. ffmpeg is wired up automatically. + + } + /> - {!customCommandEnabled && ( -
- - 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. - - -
- )} - -
-
- yt-dlp -