661a4e7572
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>
238 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|