feat: Phase N power-user surface (terminal, palette, per-item, sort, url-templates)
- Built-in yt-dlp terminal: src/main/terminal.ts streams raw yt-dlp runs (binary fixed, gated on customCommandEnabled), new TerminalView + sidebar tab - Command palette (Ctrl/Cmd+K), portal-free CommandPalette.tsx wired in App - Per-playlist-item editing: per-row video/audio toggle + All video/All audio batch; addPlaylist enqueues via addMany with per-item kind - Weighted format sorting: DownloadOptions.formatSort -> raw -S (overrides codec) - URL-regex template auto-matching: CommandTemplate.urlPattern + selectExtraArgs matchesUrl, threaded through resolveExtraArgs; field in TemplateManager typecheck + test (192) + electron-vite build all clean. Roadmap: Phase N COMPLETE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
import { LibraryView } from './components/LibraryView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
import { TerminalView } from './components/TerminalView'
|
||||
import { SettingsView } from './components/SettingsView'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useResolvedDark } from './store/systemTheme'
|
||||
@@ -34,6 +36,43 @@ function App(): React.JSX.Element {
|
||||
const updateSettings = useSettings((s) => s.update)
|
||||
const isDark = useResolvedDark()
|
||||
const [tab, setTab] = useState<TabValue>('downloads')
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
|
||||
// Ctrl/Cmd+K toggles the command palette.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault()
|
||||
setPaletteOpen((o) => !o)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const paletteActions: 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', hint: 'Navigate', run: () => setTab('settings') },
|
||||
{
|
||||
id: 'new-download',
|
||||
label: 'New download',
|
||||
hint: 'Focus URL',
|
||||
run: () => {
|
||||
setTab('downloads')
|
||||
// Wait for the download bar to mount after the tab switch, then focus.
|
||||
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'toggle-theme',
|
||||
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
|
||||
hint: 'Appearance',
|
||||
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
|
||||
}
|
||||
]
|
||||
|
||||
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||
const [version, setVersion] = useState('')
|
||||
@@ -89,8 +128,13 @@ function App(): React.JSX.Element {
|
||||
{tab === 'downloads' && <DownloadsView />}
|
||||
{tab === 'library' && <LibraryView />}
|
||||
{tab === 'history' && <HistoryView />}
|
||||
{tab === 'terminal' && <TerminalView />}
|
||||
{tab === 'settings' && <SettingsView />}
|
||||
</main>
|
||||
|
||||
{paletteOpen && (
|
||||
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
|
||||
export interface PaletteAction {
|
||||
id: string
|
||||
label: string
|
||||
/** short right-aligned hint, e.g. a shortcut or category */
|
||||
hint?: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
|
||||
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
|
||||
backdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
paddingTop: '14vh',
|
||||
backgroundColor: 'rgba(0,0,0,0.32)'
|
||||
},
|
||||
panel: {
|
||||
width: 'min(560px, 92vw)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
boxShadow: tokens.shadow28,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
input: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '14px 16px',
|
||||
fontSize: tokens.fontSizeBase400,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground1,
|
||||
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
list: {
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
padding: '6px'
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
padding: '9px 12px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground1,
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium)
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
itemHint: {
|
||||
flexShrink: 0,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: tokens.fontSizeBase200
|
||||
},
|
||||
empty: {
|
||||
padding: '14px 12px',
|
||||
color: tokens.colorNeutralForeground3
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
|
||||
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
|
||||
*/
|
||||
export function CommandPalette({
|
||||
actions,
|
||||
onClose
|
||||
}: {
|
||||
actions: PaletteAction[]
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const [q, setQ] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
setSel(0)
|
||||
}, [q])
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSel((s) => Math.min(filtered.length - 1, s + 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSel((s) => Math.max(0, s - 1))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const a = filtered[sel]
|
||||
if (a) {
|
||||
a.run()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={onClose}>
|
||||
<div
|
||||
className={styles.panel}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Command palette"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Type a command…"
|
||||
aria-label="Command palette search"
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.empty}>No matching commands</div>
|
||||
) : (
|
||||
filtered.map((a, i) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||
onClick={() => {
|
||||
a.run()
|
||||
onClose()
|
||||
}}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
>
|
||||
<span>{a.label}</span>
|
||||
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -223,10 +223,35 @@ const useStyles = makeStyles({
|
||||
overflowY: 'auto',
|
||||
paddingRight: '4px'
|
||||
},
|
||||
plItemRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
plItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '2px 0'
|
||||
padding: '2px 0',
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
plKindBtn: {
|
||||
flexShrink: 0,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
}
|
||||
},
|
||||
plItemLabel: {
|
||||
display: 'flex',
|
||||
@@ -285,6 +310,7 @@ const useStyles = makeStyles({
|
||||
export function DownloadBar(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
const addMany = useDownloads((s) => s.addMany)
|
||||
const settingsLoaded = useSettings((s) => s.loaded)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
@@ -354,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
|
||||
// Probe state for a playlist URL.
|
||||
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
|
||||
// bar's global kind. Lets a playlist mix video and audio downloads.
|
||||
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
||||
|
||||
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
|
||||
// fresh link, offer it (without clobbering anything the user is already typing).
|
||||
@@ -424,6 +453,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
setFormatId('')
|
||||
setPlaylist(null)
|
||||
setSelected(new Set())
|
||||
setItemKinds({})
|
||||
}
|
||||
|
||||
function onUrlChange(next: string): void {
|
||||
@@ -489,6 +519,20 @@ export function DownloadBar(): React.JSX.Element {
|
||||
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
||||
}
|
||||
|
||||
// Per-entry kind: the override if set, else the bar's global kind.
|
||||
function effKind(index: number): MediaKind {
|
||||
return itemKinds[index] ?? kind
|
||||
}
|
||||
function toggleItemKind(index: number): void {
|
||||
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
|
||||
}
|
||||
function setAllKinds(k: MediaKind): void {
|
||||
if (!playlist) return
|
||||
const all: Record<number, MediaKind> = {}
|
||||
for (const e of playlist.entries) all[e.index] = k
|
||||
setItemKinds(all)
|
||||
}
|
||||
|
||||
function download(): void {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
@@ -553,13 +597,19 @@ export function DownloadBar(): React.JSX.Element {
|
||||
function addPlaylist(): void {
|
||||
if (!playlist) return
|
||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||
for (const e of chosen) {
|
||||
addFromUrl(e.url, kind, quality, {
|
||||
title: e.title,
|
||||
channel: e.uploader,
|
||||
durationLabel: e.durationLabel
|
||||
// Each entry uses its own kind override; quality falls back to that kind's
|
||||
// default unless the entry matches the bar's current kind/quality.
|
||||
addMany(
|
||||
chosen.map((e) => {
|
||||
const k = effKind(e.index)
|
||||
return {
|
||||
url: e.url,
|
||||
kind: k,
|
||||
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
|
||||
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
setUrl('')
|
||||
clearProbe()
|
||||
}
|
||||
@@ -574,6 +624,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<div className={styles.urlRow}>
|
||||
<Input
|
||||
className={styles.url}
|
||||
input={{ id: 'aerofetch-url' }}
|
||||
value={url}
|
||||
onChange={(_, d) => onUrlChange(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
||||
@@ -687,27 +738,50 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
||||
{allSelected ? 'Select none' : 'Select all'}
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
|
||||
All video
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
|
||||
All audio
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.plList}>
|
||||
{playlist.entries.map((e) => (
|
||||
<Checkbox
|
||||
key={e.index}
|
||||
className={styles.plItem}
|
||||
checked={selected.has(e.index)}
|
||||
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={styles.plItemTitle}>
|
||||
{e.index}. {e.title}
|
||||
<div key={e.index} className={styles.plItemRow}>
|
||||
<Checkbox
|
||||
className={styles.plItem}
|
||||
checked={selected.has(e.index)}
|
||||
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={styles.plItemTitle}>
|
||||
{e.index}. {e.title}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
<Caption1 className={styles.plItemMeta}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
|
||||
</Caption1>
|
||||
)}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
<Caption1 className={styles.plItemMeta}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
|
||||
</Caption1>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Hint
|
||||
label={
|
||||
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
|
||||
}
|
||||
placement="top"
|
||||
align="end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.plKindBtn}
|
||||
onClick={() => toggleItemKind(e.index)}
|
||||
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
||||
>
|
||||
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
|
||||
</button>
|
||||
</Hint>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Format sorting (advanced)"
|
||||
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
|
||||
>
|
||||
<Input
|
||||
value={value.formatSort}
|
||||
placeholder="res,fps,vcodec:av01"
|
||||
onChange={(_, d) => setOpt('formatSort', d.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowDownloadRegular,
|
||||
HistoryRegular,
|
||||
LibraryRegular,
|
||||
WindowConsoleRegular,
|
||||
SettingsRegular,
|
||||
WeatherMoonRegular,
|
||||
WeatherSunnyRegular,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -179,6 +180,7 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
||||
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
|
||||
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
|
||||
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
|
||||
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
|
||||
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -74,8 +74,10 @@ interface Draft {
|
||||
id: string | null
|
||||
name: string
|
||||
args: string
|
||||
/** optional regex; auto-applies the template to matching URLs */
|
||||
urlPattern: string
|
||||
}
|
||||
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
|
||||
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
|
||||
@@ -95,14 +97,20 @@ export function TemplateManager(): React.JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
|
||||
function startEdit(t: CommandTemplate): void {
|
||||
setDraft({ id: t.id, name: t.name, args: t.args })
|
||||
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!draft) return
|
||||
const name = draft.name.trim()
|
||||
if (!name) return
|
||||
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
|
||||
const urlPattern = draft.urlPattern.trim()
|
||||
save({
|
||||
id: draft.id ?? newId(),
|
||||
name,
|
||||
args: draft.args.trim(),
|
||||
...(urlPattern ? { urlPattern } : {})
|
||||
})
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
|
||||
resize="vertical"
|
||||
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.urlPattern}
|
||||
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
|
||||
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
|
||||
/>
|
||||
<div className={styles.formActions}>
|
||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Textarea,
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
makeStyles,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../store/settings'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
interface Line {
|
||||
text: string
|
||||
kind: LineKind
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
gate: {
|
||||
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'
|
||||
},
|
||||
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
|
||||
stderr: { color: tokens.colorPaletteRedForeground1 },
|
||||
sys: { color: tokens.colorNeutralForeground3 },
|
||||
empty: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||
|
||||
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, { text: ev.line, kind: ev.stream }])
|
||||
} else if (ev.type === 'error') {
|
||||
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
} else {
|
||||
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
|
||||
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()
|
||||
runId.current = id
|
||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
||||
setRunning(true)
|
||||
window.api
|
||||
.runTerminal(id, a)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setLines((ls) => [...ls, { text: String(e), kind: '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={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Terminal</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
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.
|
||||
</Caption1>
|
||||
</div>
|
||||
|
||||
{!customCommandEnabled && (
|
||||
<Body1 className={styles.gate}>
|
||||
The terminal is part of custom commands. Turn on “Run custom commands” in Settings →
|
||||
Custom commands to use it.
|
||||
</Body1>
|
||||
)}
|
||||
|
||||
<div className={styles.inputRow}>
|
||||
<div className={styles.inputCol}>
|
||||
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||
<Textarea
|
||||
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="secondary" 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={styles.log} ref={logRef}>
|
||||
{lines.length === 0 ? (
|
||||
<span className={styles.empty}>Output will appear here.</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className={lineClass(l.kind)}>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -220,7 +220,10 @@ if (import.meta.env.DEV && !window.api) {
|
||||
durationLabel: `${10 + i}:0${i}`,
|
||||
downloaded: i < 2
|
||||
})),
|
||||
onIndexProgress: () => () => {}
|
||||
onIndexProgress: () => () => {},
|
||||
runTerminal: async () => ({ ok: true }),
|
||||
cancelTerminal: async () => {},
|
||||
onTerminalOutput: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user