Files
AeroFetch/src/renderer/src/App.tsx
T
debont80 9134e7d216 feat(audit): Batch 25 — project reorg (CC12), leading DI args (CC7), CC10 by-design
CC12: renderer views/ (5 screens + Onboarding + settings cards) +
lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats);
main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved
history; all src+test imports updated. Build emits view chunks from views/,
344 tests + typecheck green, live probe rendered all screens.
CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url),
indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url).
CC10: closed by-design — jsonStore backs all records; settings stay in
electron-store for DPAPI secret encryption (a deliberate two-store split).
Also closes the UI24/W4 context-menu cross-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:37:28 -04:00

286 lines
11 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './views/DownloadsView'
import { Onboarding } from './views/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
// Eagerly load the sources store for its startup side-effects (load persisted
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
// subscription (coordinator's downloadCompleted → markDownloaded). Before C2 this
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources'
import { queueSummaryOf } from './lib/queueStats'
import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
// Code-split the non-default tabs (PERF8): Downloads is the launch tab and stays
// eager, but Library/History/Terminal/Settings load their chunks on first visit so
// they (and their slice of Fluent) aren't in the initial bundle. The stores these
// views use are imported eagerly above (`useDownloads` + `./store/sources`, and
// downloads → history), so lazy-loading the *view* never delays store startup.
const LibraryView = lazy(() =>
import('./views/LibraryView').then((m) => ({ default: m.LibraryView }))
)
const HistoryView = lazy(() =>
import('./views/HistoryView').then((m) => ({ default: m.HistoryView }))
)
const TerminalView = lazy(() =>
import('./views/TerminalView').then((m) => ({ default: m.TerminalView }))
)
const SettingsView = lazy(() =>
import('./views/SettingsView').then((m) => ({ default: m.SettingsView }))
)
// How often to check whether a scheduled ('saved' + due) download should promote
// to the queue. 15s is plenty for the minute-granularity times the picker offers.
const SCHEDULE_TICK_MS = 15_000
const useStyles = makeStyles({
provider: {
height: '100vh'
},
root: {
height: '100vh',
display: 'flex',
color: tokens.colorNeutralForeground1
},
content: {
flexGrow: 1,
minWidth: 0,
// L161: <main> 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'
}
})
function App(): React.JSX.Element {
const styles = useStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const showTerminal = useSettings((s) => s.enableCustomCommands)
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<Element | null>(null)
// Ctrl+K: command palette. Ctrl+,: Settings. (W8/W9)
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
prePaletteRef.current = document.activeElement
setPaletteOpen((o) => !o)
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
setTab('settings')
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// 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 = queueSummaryOf(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
void window.api?.setTaskbarProgress?.({
fraction: s.progress,
mode,
badgeCount: s.downloading
})
}
push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items))
}, [])
// Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
// other pending items return from the previous session.
useEffect(() => {
useDownloads.getState().hydrate()
}, [])
// UX16: focus the URL field on launch so the user can paste + type immediately
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
// register its focuser (L118), matching the command palette's "New download" focus.
useEffect(() => {
if (useNav.getState().tab !== 'downloads') return
requestAnimationFrame(() => requestAnimationFrame(() => useNav.getState().focusUrlField()))
}, [])
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// The queue is persisted (M4), so a scheduled item survives a quit and fires on the
// next launch once its time has passed. A 15s tick is plenty for minute-granularity
// times. Lives here (not at the downloads store's module load) so importing the store
// in tests/preview doesn't start a stray timer, and the interval is cleared cleanly
// on unmount (L1).
useEffect(() => {
const tick = setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (
st.items.some(
(i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
)
) {
st.promoteDueScheduled()
}
}, SCHEDULE_TICK_MS)
return () => clearInterval(tick)
}, [])
// 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',
// Surface the real shortcut here so the palette doubles as shortcut
// discovery (UX21).
hint: 'Ctrl ,',
run: () => setTab('settings')
},
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Double-rAF waits for React's re-render + paint (so the DownloadBar has
// mounted and registered its focuser) before requesting focus (L118).
requestAnimationFrame(() =>
requestAnimationFrame(() => useNav.getState().focusUrlField())
)
}
},
{
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('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
}, [])
const collapsed = useSettings((s) => s.isSidebarCollapsed)
function toggleCollapsed(): void {
updateSettings({ isSidebarCollapsed: !collapsed })
}
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
return (
<FluentProvider
theme={getTheme(accentColor, isDark ? 'dark' : 'light')}
className={styles.provider}
>
<div
className={styles.root}
style={{
backgroundColor: isDark ? pageBackground.dark : pageBackground.light,
colorScheme: isDark ? 'dark' : 'light'
}}
>
{showOnboarding ? (
<Onboarding />
) : (
<>
<Sidebar
tab={tab}
onTabChange={setTab}
theme={theme}
isDark={isDark}
onSetTheme={(mode) => updateSettings({ theme: mode })}
version={version}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
showTerminal={showTerminal}
downloadCount={activeCount}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
<Suspense fallback={null}>
{tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />}
</Suspense>
</main>
{paletteOpen && (
<CommandPalette
actions={paletteActions}
onClose={() => {
setPaletteOpen(false)
const el = prePaletteRef.current
if (el instanceof HTMLElement) requestAnimationFrame(() => el.focus())
}}
/>
)}
</>
)}
<LiveRegion />
<Toaster />
</div>
</FluentProvider>
)
}
export default App