Files
AeroFetch/src/renderer/src/components/TerminalView.tsx
T
debont80 661a4e7572 feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:

- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
  (non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
  tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
  and toasts the main-process reason instead of silently no-oping on a moved/typed-
  out file. safeShowInFolder upgraded to return an error string like safeOpenPath
  (preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
  active (downloading + queued) count from any tab, via a count-only App selector
  that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
  explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
  no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
  earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".

Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:05:26 -04:00

238 lines
7.7 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Body1,
Caption1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
id: number
text: string
kind: LineKind
}
let lineSeq = 0
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
// lines in seconds. Cap the visible log to prevent unbounded React state growth
// and keep the <pre> scrollable (H6); older lines are dropped from the front.
const MAX_LOG_LINES = 2000
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
gate: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '8px',
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
buttons: { display: 'flex', gap: '8px' },
log: {
flexGrow: 1,
minHeight: '160px',
overflowY: 'auto',
margin: 0,
padding: '12px 14px',
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
// Center the "no output yet" placeholder in the log box, so it reads as an
// empty state rather than a stray line of text at the top (L117).
logEmpty: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 }
})
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main -- see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
const logRef = useRef<HTMLPreElement>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
} else if (ev.type === 'error') {
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
setRunning(false)
runId.current = null
} else {
setLines((ls) =>
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
)
setRunning(false)
runId.current = null
}
}),
[]
)
// Keep the log pinned to the latest output.
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [lines])
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId('t')
runId.current = id
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd'
? styles.cmd
: kind === 'stderr'
? styles.stderr
: kind === 'sys'
? styles.sys
: undefined
return (
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Terminal"
description={
<>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</>
}
/>
{!customCommandEnabled && (
<div className={styles.gate}>
<Body1>
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.
</Body1>
<Button
appearance="primary"
size="small"
onClick={() => updateSettings({ customCommandEnabled: true })}
>
Enable custom commands
</Button>
</div>
)}
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
>
Run
</Button>
)}
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setLines([])}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
{lines.length === 0 ? (
<EmptyState compact message="No output yet." hint="Run yt-dlp above to see its output." />
) : (
lines.map((l) => (
<div key={l.id} className={lineClass(l.kind)}>
{l.text}
</div>
))
)}
</pre>
</div>
)
}