9134e7d216
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>
286 lines
11 KiB
TypeScript
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
|