Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {ProgressBar} from './components/progress-bar.js'
import {Shortcuts} from './components/shortcuts.js'
import {TextInput} from './components/text-input.js'
import {clickTargetAt, findFrameRow, frameRowSpan, type ClickTarget} from './lib/click-map.js'
import {copyFileToClipboard} from './lib/clipboard.js'
import {formatBytes, formatDuration, formatEta, formatSpeed, shortenPath, truncate, wrapText} from './lib/format.js'
import {addToHistory, loadHistory} from './lib/history.js'
import {loadSettings, saveSettings} from './lib/settings.js'
import {detectPlatform, isProbablyUrl, type Platform} from './lib/platforms.js'
import {useMouseClick} from './lib/use-mouse-click.js'
import {nextThemeMode, ThemeProvider, type ThemeMode, useTheme} from './theme.js'
Expand Down Expand Up @@ -96,7 +98,7 @@ type Phase =
processing: boolean
refreshing?: boolean
}
| {name: 'done'; filepath: string}
| {name: 'done'; filepath: string; copied?: boolean}
| {name: 'error'; message: string}

const HINTS: Record<Phase['name'], Array<[string, string]>> = {
Expand Down Expand Up @@ -162,6 +164,10 @@ function AppContent({
const [url, setUrl] = useState(initialUrl ?? '')
const [urlInput, setUrlInput] = useState('')
const [history, setHistory] = useState(loadHistory)
const [copyToClipboard, setCopyToClipboard] = useState(() => loadSettings().copyToClipboard)
const toggleCopyToClipboard = useCallback(() => {
setCopyToClipboard(prev => saveSettings({copyToClipboard: !prev}).copyToClipboard)
}, [])
const [platform, setPlatform] = useState<Platform>()
const [info, setInfo] = useState<VideoInfo>()
const [choices, setChoices] = useState<DownloadChoice[]>([])
Expand Down Expand Up @@ -225,6 +231,10 @@ function AppContent({
cycleTheme()
return
}
if (key.ctrl && input === 'd') {
toggleCopyToClipboard()
return
}
if (key.escape && (phase.name === 'picking' || phase.name === 'error' || phase.name === 'done')) resetToInput()
if (key.escape && (phase.name === 'probing' || phase.name === 'downloading')) cancelRun()
if (key.return && (phase.name === 'error' || phase.name === 'done')) resetToInput()
Expand Down Expand Up @@ -274,7 +284,8 @@ function AppContent({
}
onOutcome({filepath})
setHistory(addToHistory(url))
setPhase({name: 'done', filepath})
const copied = copyToClipboard ? copyFileToClipboard(filepath) : false
setPhase({name: 'done', filepath, copied})
} catch (error) {
if (controller.signal.aborted) return
setPhase({name: 'error', message: error instanceof Error ? error.message : String(error)})
Expand All @@ -283,6 +294,9 @@ function AppContent({
}

let hints: Array<[string, string]> = [...HINTS[phase.name], ['^t', `theme:${theme.mode}`]]
if (phase.name === 'input') {
hints = [...hints, ['^d', `copy:${copyToClipboard ? 'on' : 'off'}`]]
}
if (phase.name === 'input' && history.length > 0) {
hints = [hints[0]!, ['↑', 'history'], ...hints.slice(1)]
}
Expand All @@ -293,6 +307,7 @@ function AppContent({
const hintAction = (key: string): (() => void) | undefined => {
if (key === '^c') return () => exit()
if (key === '^t') return cycleTheme
if (key === '^d') return toggleCopyToClipboard
if (key === 'esc') return phase.name === 'probing' || phase.name === 'downloading' ? cancelRun : resetToInput
if (key === '↵') {
if (phase.name === 'input') return () => handleUrlSubmit(urlInput)
Expand Down Expand Up @@ -471,6 +486,9 @@ function AppContent({
<Text color={theme.primary}>find your file in:</Text>
</Text>
<Text color={theme.gray} dimColor={theme.dimSecondary}>{shortenPath(phase.filepath, os.homedir(), 60)}</Text>
{phase.copied ? (
<Text color={theme.gray} dimColor={theme.dimSecondary}>⧉ copied to clipboard — paste it anywhere</Text>
) : null}
<Gap />
<Box
borderStyle="round"
Expand Down
42 changes: 42 additions & 0 deletions src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,45 @@ export function readClipboard(): string {
}
return ''
}

/**
* Put the downloaded file itself on the clipboard (not its path) so it can be
* pasted into a file manager. Best-effort — returns false when no supported
* tool is available. Linux copies a file:// uri, the closest portable analog.
*/
export function copyFileToClipboard(filepath: string): boolean {
try {
if (process.platform === 'darwin') {
execFileSync('osascript', ['-e', `set the clipboard to (POSIX file ${JSON.stringify(filepath)})`], {
timeout: 2000,
stdio: 'ignore',
})
return true
}
if (process.platform === 'win32') {
// -LiteralPath so wildcard-looking names aren't glob-expanded; single
// quotes with doubled inner quotes are PowerShell's literal escape
const literal = `'${filepath.replace(/'/g, "''")}'`
execFileSync('powershell', ['-NoProfile', '-Command', `Set-Clipboard -LiteralPath ${literal}`], {
timeout: 2000,
stdio: 'ignore',
})
return true
}
const uri = `file://${filepath}`
for (const [command, args] of [
['wl-copy', ['--type', 'text/uri-list']],
['xclip', ['-selection', 'clipboard', '-t', 'text/uri-list']],
] as const) {
try {
execFileSync(command, args, {input: uri, timeout: 2000, stdio: ['pipe', 'ignore', 'ignore']})
return true
} catch {
// tool missing — try the next one
}
}
return false
} catch {
return false
}
}
36 changes: 36 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

const SETTINGS_FILE = path.join(os.homedir(), '.config', 'yoinks', 'settings.json')

export type Settings = {
copyToClipboard: boolean
}

const DEFAULTS: Settings = {
copyToClipboard: false,
}

export function loadSettings(): Settings {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
if (parsed && typeof parsed === 'object' && typeof (parsed as Settings).copyToClipboard === 'boolean') {
return {...DEFAULTS, copyToClipboard: (parsed as Settings).copyToClipboard}
}
} catch {
// no settings yet, or unreadable — fall back to defaults
}
return {...DEFAULTS}
}

/** Persist settings. Returns the value written so callers can update state. */
export function saveSettings(settings: Settings): Settings {
try {
fs.mkdirSync(path.dirname(SETTINGS_FILE), {recursive: true})
fs.writeFileSync(SETTINGS_FILE, `${JSON.stringify(settings, null, 2)}\n`)
} catch {
// settings are a nicety — never let it break the app
}
return settings
}