feat: copy a file from the explorer to the OS clipboard (#5990)

* feat: copy a file from the explorer to the OS clipboard

Add a "Copy" action to the file explorer context menu that puts the
actual file on the system clipboard, so pasting in Finder/Explorer/a
file manager drops the file itself instead of its path as text.

- macOS: write a public.file-url buffer; Finder synthesizes the legacy
  file types it needs for paste.
- Windows: Set-Clipboard -LiteralPath populates the CF_HDROP file drop
  list that Explorer pastes as a file.
- Linux: best-effort, picked by desktop — text/uri-list on KDE,
  x-special/gnome-copied-files on GNOME-family — via wl-copy or xclip.
- Local files only; the action is hidden for remote/SSH files and the
  web client, where no OS clipboard reference is possible.

The platform logic never throws: failures resolve to a structured
result and the renderer surfaces an error toast.

* Review copy-file clipboard safety

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Anddy Sahir Agudelo
2026-06-22 15:02:38 -07:00
committed by GitHub
co-authored by Orca Jinwoo-H
parent 41add74a08
commit b98fd2ff8b
16 changed files with 440 additions and 19 deletions
+1 -1
View File
@@ -420,7 +420,7 @@ describe('registerCoreHandlers', () => {
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
expect(registerClipboardHandlersMock).toHaveBeenCalled()
expect(registerClipboardHandlersMock).toHaveBeenCalledWith(store)
expect(registerUpdaterHandlersMock).toHaveBeenCalled()
expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null)
expect(setTrustedClipboardRendererWebContentsIdMock).toHaveBeenCalledWith(null)
+1 -1
View File
@@ -164,7 +164,7 @@ export function registerCoreHandlers(
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
})
registerClipboardHandlers()
registerClipboardHandlers(store)
registerUpdaterHandlers(store)
registerSpeechHandlers(store)
}
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest'
import { writeFileToClipboard, type ClipboardFileDeps } from './clipboard-file-copy'
function makeDeps(overrides: Partial<ClipboardFileDeps> = {}): ClipboardFileDeps {
return {
platform: 'darwin',
desktop: undefined,
resolveFilePath: async (path) => ({ ok: true, path }),
writeBuffer: vi.fn(),
runCommand: vi.fn(async () => {}),
...overrides
}
}
describe('writeFileToClipboard', () => {
it('rejects relative paths without touching the clipboard', async () => {
const writeBuffer = vi.fn()
expect(await writeFileToClipboard('relative/file.png', makeDeps({ writeBuffer }))).toEqual({
ok: false,
reason: 'invalid-path'
})
expect(writeBuffer).not.toHaveBeenCalled()
})
it('rejects files that no longer exist', async () => {
expect(
await writeFileToClipboard(
'/repo/gone.png',
makeDeps({ resolveFilePath: async () => ({ ok: false, reason: 'not-found' }) })
)
).toEqual({ ok: false, reason: 'not-found' })
})
it('rejects files outside authorized local roots', async () => {
expect(
await writeFileToClipboard(
'/etc/passwd',
makeDeps({ resolveFilePath: async () => ({ ok: false, reason: 'access-denied' }) })
)
).toEqual({ ok: false, reason: 'access-denied' })
})
it('writes a public.file-url buffer on macOS', async () => {
const writeBuffer = vi.fn()
const result = await writeFileToClipboard(
'/repo/a b.png',
makeDeps({ platform: 'darwin', writeBuffer })
)
expect(result).toEqual({ ok: true })
expect(writeBuffer).toHaveBeenCalledTimes(1)
const [format, buffer] = writeBuffer.mock.calls[0]
expect(format).toBe('public.file-url')
// spaces are percent-encoded into the file URL
expect(buffer.toString('utf8')).toBe('file:///repo/a%20b.png')
})
it('reports a failure when the macOS clipboard write throws', async () => {
const writeBuffer = vi.fn(() => {
throw new Error('clipboard unavailable')
})
await expect(
writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'darwin', writeBuffer }))
).resolves.toEqual({ ok: false, reason: 'clipboard-write-failed' })
})
it('uses the authorized resolved path for clipboard payloads', async () => {
const writeBuffer = vi.fn()
await writeFileToClipboard(
'/repo/link.png',
makeDeps({
platform: 'darwin',
resolveFilePath: async () => ({ ok: true, path: '/repo/actual.png' }),
writeBuffer
})
)
expect(writeBuffer).toHaveBeenCalledWith(
'public.file-url',
Buffer.from('file:///repo/actual.png', 'utf8')
)
})
it('shells out to Set-Clipboard on Windows, escaping quotes', async () => {
const runCommand = vi.fn(async (_command: string, _args: string[]) => {})
const result = await writeFileToClipboard(
"/repo/o'brien.png",
makeDeps({ platform: 'win32', runCommand })
)
expect(result).toEqual({ ok: true })
const [command, args] = runCommand.mock.calls[0]
expect(command).toBe('powershell.exe')
expect(args.join(' ')).toContain("Set-Clipboard -LiteralPath '/repo/o''brien.png'")
})
it('reports a failure (never throws) when PowerShell rejects on Windows', async () => {
const runCommand = vi.fn(async (_command: string, _args: string[]) => {
throw new Error('powershell.exe not found')
})
expect(
await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'win32', runCommand }))
).toEqual({ ok: false, reason: 'clipboard-command-failed' })
})
it('uses the KDE text/uri-list payload on a KDE desktop', async () => {
const runCommand = vi.fn(async (_command: string, _args: string[], _stdin?: string) => {})
const result = await writeFileToClipboard(
'/repo/a b.png',
makeDeps({ platform: 'linux', desktop: 'KDE', runCommand })
)
expect(result).toEqual({ ok: true })
const [command, args, stdin] = runCommand.mock.calls[0]
expect(command).toBe('wl-copy')
expect(args).toContain('text/uri-list')
expect(stdin).toBe('file:///repo/a%20b.png\r\n')
})
it('uses the GNOME copied-files payload on non-KDE desktops', async () => {
const runCommand = vi.fn(async (_command: string, _args: string[], _stdin?: string) => {})
await writeFileToClipboard(
'/repo/a.png',
makeDeps({ platform: 'linux', desktop: 'GNOME', runCommand })
)
const [, args, stdin] = runCommand.mock.calls[0]
expect(args).toContain('x-special/gnome-copied-files')
expect(stdin).toBe('copy\nfile:///repo/a.png')
})
it('tries each Linux tool and reports unsupported when all fail', async () => {
const runCommand = vi.fn(async (_command: string, _args: string[]) => {
throw new Error('command not found')
})
expect(
await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runCommand }))
).toEqual({ ok: false, reason: 'unsupported-platform' })
expect(runCommand).toHaveBeenCalledTimes(2) // wl-copy, then xclip
})
it('succeeds on Linux when a clipboard tool is available', async () => {
const runCommand = vi.fn(async (command: string, _args: string[]) => {
if (command === 'wl-copy') {
return
}
throw new Error('no xclip')
})
expect(
await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runCommand }))
).toEqual({ ok: true })
expect(runCommand.mock.calls[0][0]).toBe('wl-copy')
})
})
+85
View File
@@ -0,0 +1,85 @@
import { isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
export type ClipboardFileResult = { ok: boolean; reason?: string }
// Injected so the platform branching is unit-testable without the real OS
// clipboard or spawning processes.
export type ClipboardFileDeps = {
platform: NodeJS.Platform
// Linux only: the active desktop ($XDG_CURRENT_DESKTOP). KDE and GNOME-family
// file managers disagree on the clipboard format, so it picks the payload.
desktop?: string
resolveFilePath: (
path: string
) => Promise<{ ok: true; path: string } | { ok: false; reason: string }>
writeBuffer: (format: string, buffer: Buffer) => void
runCommand: (command: string, args: string[], stdin?: string) => Promise<void>
}
// Put a real OS-level file reference on the clipboard so pasting in Finder /
// Explorer / a file manager drops the actual file (not its path as text). Only
// local files work — remote/SSH files don't exist on this machine. Always
// resolves a result and never throws, so the renderer can report failures.
export async function writeFileToClipboard(
filePath: string,
deps: ClipboardFileDeps
): Promise<ClipboardFileResult> {
if (typeof filePath !== 'string' || !isAbsolute(filePath)) {
return { ok: false, reason: 'invalid-path' }
}
const resolvedFile = await deps.resolveFilePath(filePath)
if (!resolvedFile.ok) {
return { ok: false, reason: resolvedFile.reason }
}
const clipboardPath = resolvedFile.path
if (deps.platform === 'darwin') {
// macOS reads `public.file-url` and synthesizes the legacy file types Finder
// needs, so a single buffer is enough.
try {
deps.writeBuffer('public.file-url', Buffer.from(pathToFileURL(clipboardPath).href, 'utf8'))
return { ok: true }
} catch {
return { ok: false, reason: 'clipboard-write-failed' }
}
}
if (deps.platform === 'win32') {
// Set-Clipboard -LiteralPath populates CF_HDROP, which Explorer pastes as a
// file. Single-quote escaping for the PowerShell string literal. Guard the
// spawn so a missing/erroring PowerShell surfaces as a result, not a throw.
const escaped = clipboardPath.replace(/'/g, "''")
try {
await deps.runCommand('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-Command',
`Set-Clipboard -LiteralPath '${escaped}'`
])
return { ok: true }
} catch {
return { ok: false, reason: 'clipboard-command-failed' }
}
}
// Linux: best-effort and desktop-dependent. GNOME-family managers
// (Nautilus/Nemo/Caja) read the "copied-files" payload that carries the
// explicit copy verb; KDE/Qt managers (Dolphin) read text/uri-list instead.
const fileUrl = pathToFileURL(clipboardPath).href
const [mime, payload] = /kde/i.test(deps.desktop ?? '')
? ['text/uri-list', `${fileUrl}\r\n`]
: ['x-special/gnome-copied-files', `copy\n${fileUrl}`]
for (const [command, args] of [
['wl-copy', ['--type', mime]],
['xclip', ['-selection', 'clipboard', '-t', mime]]
] as const) {
try {
await deps.runCommand(command, [...args], payload)
return { ok: true }
} catch {
// try the next tool
}
}
return { ok: false, reason: 'unsupported-platform' }
}
+101 -16
View File
@@ -9,33 +9,67 @@ import {
const {
removeHandlerMock,
handleMock,
spawnMock,
childStdinEndMock,
resolveAuthorizedPathMock,
fsWriteFileMock,
fsStatMock,
clipboardReadTextMock,
clipboardWriteTextMock,
clipboardReadImageMock,
clipboardWriteImageMock,
clipboardWriteBufferMock,
nativeImageCreateFromBufferMock,
randomUUIDMock,
getSshFilesystemProviderMock
} = vi.hoisted(() => ({
removeHandlerMock: vi.fn(),
handleMock: vi.fn(),
childStdinEndMock: vi.fn(),
spawnMock: vi.fn(() => {
const child = {
stdin: { end: childStdinEndMock },
on: vi.fn((event: string, callback: (code?: number) => void) => {
if (event === 'exit') {
queueMicrotask(() => callback(0))
}
return child
})
}
return child
}),
resolveAuthorizedPathMock: vi.fn(),
fsWriteFileMock: vi.fn(),
fsStatMock: vi.fn(),
clipboardReadTextMock: vi.fn(),
clipboardWriteTextMock: vi.fn(),
clipboardReadImageMock: vi.fn(),
clipboardWriteImageMock: vi.fn(),
clipboardWriteBufferMock: vi.fn(),
nativeImageCreateFromBufferMock: vi.fn(),
randomUUIDMock: vi.fn(() => '00000000-0000-4000-8000-000000000000'),
getSshFilesystemProviderMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
spawn: spawnMock
}))
vi.mock('node:fs/promises', () => ({
stat: fsStatMock,
default: {
writeFile: fsWriteFileMock
}
}))
vi.mock('../ipc/filesystem-auth', () => ({
PATH_ACCESS_DENIED_MESSAGE:
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.',
isENOENT: (error: unknown): boolean =>
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT',
resolveAuthorizedPath: resolveAuthorizedPathMock
}))
vi.mock('node:crypto', () => ({
randomUUID: randomUUIDMock
}))
@@ -48,7 +82,8 @@ vi.mock('electron', () => ({
readText: clipboardReadTextMock,
writeText: clipboardWriteTextMock,
readImage: clipboardReadImageMock,
writeImage: clipboardWriteImageMock
writeImage: clipboardWriteImageMock,
writeBuffer: clipboardWriteBufferMock
},
ipcMain: {
removeHandler: removeHandlerMock,
@@ -120,11 +155,18 @@ describe('registerClipboardHandlers', () => {
vi.spyOn(Date, 'now').mockReturnValue(1760000000000)
removeHandlerMock.mockReset()
handleMock.mockReset()
spawnMock.mockClear()
childStdinEndMock.mockClear()
resolveAuthorizedPathMock.mockReset()
resolveAuthorizedPathMock.mockImplementation(async (path: string) => path)
fsWriteFileMock.mockReset()
fsStatMock.mockReset()
fsStatMock.mockResolvedValue({})
clipboardReadTextMock.mockReset()
clipboardWriteTextMock.mockReset()
clipboardReadImageMock.mockReset()
clipboardWriteImageMock.mockReset()
clipboardWriteBufferMock.mockReset()
nativeImageCreateFromBufferMock.mockReset()
randomUUIDMock.mockReset()
randomUUIDMock.mockReturnValue('00000000-0000-4000-8000-000000000000')
@@ -143,7 +185,7 @@ describe('registerClipboardHandlers', () => {
clipboardType === 'selection' ? 'selection text' : 'standard text'
)
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(handlers.get('clipboard:readText')?.(makeClipboardEvent())).resolves.toBe(
@@ -163,7 +205,7 @@ describe('registerClipboardHandlers', () => {
it('rejects clipboard IPC from senders outside the current main renderer', async () => {
setTrustedClipboardRendererWebContentsId(17)
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
const untrustedEvent = makeClipboardEvent({ id: 42 })
@@ -178,6 +220,9 @@ describe('registerClipboardHandlers', () => {
connectionId: 'ssh-secret'
})
).rejects.toThrow('Unauthorized clipboard IPC sender')
expect(() =>
handlers.get('clipboard:writeFile')?.(untrustedEvent, '/tmp/copied-file.txt')
).toThrow('Unauthorized clipboard IPC sender')
expect(() =>
handlers.get('clipboard:writeImage')?.(untrustedEvent, 'data:image/png;base64,AAAA')
).toThrow('Unauthorized clipboard IPC sender')
@@ -187,11 +232,50 @@ describe('registerClipboardHandlers', () => {
expect(clipboardReadImageMock).not.toHaveBeenCalled()
expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled()
expect(clipboardWriteImageMock).not.toHaveBeenCalled()
expect(clipboardWriteBufferMock).not.toHaveBeenCalled()
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
})
it('writes local files through the trusted clipboard IPC handler', async () => {
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/tmp/copied-file.txt')
).resolves.toEqual({ ok: true })
expect(fsStatMock).toHaveBeenCalledWith('/tmp/copied-file.txt')
expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/tmp/copied-file.txt', {})
if (process.platform === 'darwin') {
expect(clipboardWriteBufferMock).toHaveBeenCalledWith(
'public.file-url',
Buffer.from('file:///tmp/copied-file.txt', 'utf8')
)
} else {
expect(spawnMock).toHaveBeenCalled()
}
})
it('rejects unauthorized local files before touching the OS clipboard', async () => {
resolveAuthorizedPathMock.mockRejectedValue(
new Error(
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
)
)
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/etc/passwd')
).resolves.toEqual({ ok: false, reason: 'access-denied' })
expect(fsStatMock).not.toHaveBeenCalled()
expect(clipboardWriteBufferMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects clipboard IPC from destroyed, browser, and mismatched dev-origin senders', async () => {
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -220,7 +304,7 @@ describe('registerClipboardHandlers', () => {
clipboardType === 'selection' ? 'selection secret' : 'standard secret'
)
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -236,7 +320,7 @@ describe('registerClipboardHandlers', () => {
const text = 'é'.repeat(300_000)
clipboardReadTextMock.mockReturnValue(text)
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
const result = handlers.get('clipboard:readText')?.(makeClipboardEvent(), {
@@ -258,7 +342,7 @@ describe('registerClipboardHandlers', () => {
vi.useFakeTimers()
const text = 'é'.repeat(300_000)
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
const result = handlers.get('clipboard:writeText')?.(makeClipboardEvent(), text)
@@ -277,7 +361,7 @@ describe('registerClipboardHandlers', () => {
})
it('rejects oversized text clipboard IPC writes before calling Electron clipboard', async () => {
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -296,13 +380,14 @@ describe('registerClipboardHandlers', () => {
})
it('removes stale clipboard IPC handlers before registering replacements', () => {
registerClipboardHandlers()
registerClipboardHandlers({} as never)
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readText')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readSelectionText')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeText')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeSelectionText')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeImage')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile')
})
@@ -318,7 +403,7 @@ describe('registerClipboardHandlers', () => {
toPNG: () => png
})
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -339,7 +424,7 @@ describe('registerClipboardHandlers', () => {
})
getSshFilesystemProviderMock.mockReturnValue({ getTempDir, writeFileBase64 })
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -369,7 +454,7 @@ describe('registerClipboardHandlers', () => {
writeFileBase64
})
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -393,7 +478,7 @@ describe('registerClipboardHandlers', () => {
toPNG
})
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -411,7 +496,7 @@ describe('registerClipboardHandlers', () => {
toPNG: () => Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)
})
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
@@ -424,7 +509,7 @@ describe('registerClipboardHandlers', () => {
})
it('ignores oversized clipboard write-image data before decoding base64', () => {
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
const dataUrl = [
@@ -443,7 +528,7 @@ describe('registerClipboardHandlers', () => {
isEmpty: () => false
})
registerClipboardHandlers()
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA')
+43 -1
View File
@@ -5,6 +5,10 @@ import {
type IpcMainInvokeEvent,
type WebContents
} from 'electron'
import { spawn } from 'node:child_process'
import { stat } from 'node:fs/promises'
import type { Store } from '../persistence'
import { isENOENT, PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth'
import {
assertClipboardTextWriteWithinLimitWithYield,
assertClipboardTextWithinLimitWithYield,
@@ -19,6 +23,7 @@ import {
assertClipboardImageByteLengthWithinLimit,
assertClipboardImageDimensionsWithinLimit
} from '../../shared/clipboard-image'
import { writeFileToClipboard } from './clipboard-file-copy'
let trustedClipboardRendererWebContentsId: number | null = null
@@ -26,12 +31,26 @@ export function setTrustedClipboardRendererWebContentsId(webContentsId: number |
trustedClipboardRendererWebContentsId = webContentsId
}
export function registerClipboardHandlers(): void {
// Run a short-lived OS clipboard helper (PowerShell / wl-copy / xclip), feeding
// it stdin when provided; resolves only on a clean exit.
function runCommand(command: string, args: string[], stdin?: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] })
child.on('error', reject)
child.on('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`))
)
child.stdin?.end(stdin ?? '')
})
}
export function registerClipboardHandlers(store: Store): void {
ipcMain.removeHandler('clipboard:readText')
ipcMain.removeHandler('clipboard:readSelectionText')
ipcMain.removeHandler('clipboard:writeText')
ipcMain.removeHandler('clipboard:writeSelectionText')
ipcMain.removeHandler('clipboard:writeImage')
ipcMain.removeHandler('clipboard:writeFile')
ipcMain.removeHandler('clipboard:saveImageAsTempFile')
ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => {
@@ -60,6 +79,29 @@ export function registerClipboardHandlers(): void {
return saveClipboardImageBufferAsTempFile(image.toPNG(), args)
}
)
// Why: copy the actual file to the OS clipboard so pasting in Finder/Explorer
// drops the file itself, not its path as text. Local files only.
ipcMain.handle('clipboard:writeFile', (event, filePath: string) => {
assertTrustedClipboardSender(event)
return writeFileToClipboard(filePath, {
platform: process.platform,
desktop: process.env.XDG_CURRENT_DESKTOP,
resolveFilePath: async (path) => {
try {
const authorizedPath = await resolveAuthorizedPath(path, store)
await stat(authorizedPath)
return { ok: true, path: authorizedPath }
} catch (error) {
if (error instanceof Error && error.message === PATH_ACCESS_DENIED_MESSAGE) {
return { ok: false, reason: 'access-denied' }
}
return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' }
}
},
writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer),
runCommand
})
})
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
assertTrustedClipboardSender(event)
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
+1
View File
@@ -2479,6 +2479,7 @@ export type PreloadApi = {
writeSelectionClipboardText: (text: string) => Promise<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void
writeClipboardFile: (filePath: string) => Promise<{ ok: boolean; reason?: string }>
onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void
+2
View File
@@ -3270,6 +3270,8 @@ const api = {
mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste'
})
},
writeClipboardFile: (filePath: string): Promise<{ ok: boolean; reason?: string }> =>
ipcRenderer.invoke('clipboard:writeFile', filePath),
onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) =>
subscribeNativeFileDrop(callback),
getZoomLevel: (): number => webFrame.getZoomLevel(),
@@ -16,6 +16,7 @@ import {
FileExplorerRow,
shouldShowCollapseFolderAction,
shouldShowFindInFolderAction,
shouldShowCopyFileAction,
shouldShowRemoteDownloadAction
} from './FileExplorerRow'
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
@@ -574,6 +575,16 @@ describe('FileExplorerRow collapse folder action', () => {
expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false)
})
it('shows OS file copy only for single local desktop selections', () => {
expect(shouldShowCopyFileAction(null, 1)).toBe(true)
expect(shouldShowCopyFileAction(undefined, 2)).toBe(false)
expect(shouldShowCopyFileAction('ssh-1', 1)).toBe(false)
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
expect(shouldShowCopyFileAction(null, 1)).toBe(false)
})
it('calls the preload download API and shows success only when not canceled', async () => {
const downloadFile = vi
.fn()
@@ -314,6 +314,16 @@ export function shouldShowRemoteDownloadAction(
)
}
export function shouldShowCopyFileAction(connectionId?: string | null, selectionSize = 1): boolean {
// Why: the OS file clipboard only holds local files — remote (SSH) files
// don't exist on this machine, and the web client has no native clipboard.
return (
!connectionId &&
selectionSize === 1 &&
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
)
}
export async function downloadRemoteFile(node: TreeNode, connectionId: string): Promise<void> {
try {
const result = await window.api.fs.downloadFile({ filePath: node.path, connectionId })
@@ -393,6 +403,7 @@ export function FileExplorerRow({
const FileIcon = getFileTypeIcon(node.relativePath || node.name)
const rowDropDir = node.isDirectory ? node.path : targetDir
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId)
const showCopyFileAction = shouldShowCopyFileAction(connectionId, selectionSize)
const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } =
useFileExplorerRowDrag({
rowDropDir,
@@ -420,6 +431,24 @@ export function FileExplorerRow({
}
void downloadRemoteFile(node, connectionId)
}, [connectionId, node])
const handleCopyFile = useCallback(() => {
const failureMessage = translate(
'auto.components.right.sidebar.FileExplorerRow.b234ab25b4',
'Could not copy the file to the clipboard'
)
void window.api.ui
.writeClipboardFile(node.path)
.then((result) => {
if (!result.ok) {
toast.error(failureMessage)
}
})
// A failure in the main process rejects the IPC promise; surface the same
// toast instead of leaving an unhandled rejection with no feedback.
.catch(() => {
toast.error(failureMessage)
})
}, [node.path])
return (
<ContextMenu
@@ -592,6 +621,12 @@ export function FileExplorerRow({
{translate('auto.components.right.sidebar.FileExplorerRow.f61af83316', 'New Folder')}
</ContextMenuItem>
<ContextMenuSeparator />
{showCopyFileAction && (
<ContextMenuItem onSelect={handleCopyFile}>
<Copy />
{translate('auto.components.right.sidebar.FileExplorerRow.98a79948b3', 'Copy')}
</ContextMenuItem>
)}
<ContextMenuItem onSelect={() => onCopyPaths('absolute')}>
<Copy />
{selectionSize > 1
+2
View File
@@ -8349,6 +8349,8 @@
"66a29dde82": "Copy Relative Path",
"42e10cbf57": "Copy Relative Paths",
"b5d436aa30": "Copy Path",
"98a79948b3": "Copy",
"b234ab25b4": "Could not copy the file to the clipboard",
"f9d7ca753d": "Copy Paths",
"3161c4e425": "folder"
},
+2
View File
@@ -8349,6 +8349,8 @@
"66a29dde82": "Copiar ruta relativa",
"42e10cbf57": "Copiar rutas relativas",
"b5d436aa30": "Copiar ruta",
"98a79948b3": "Copiar",
"b234ab25b4": "No se pudo copiar el archivo al portapapeles",
"f9d7ca753d": "Copiar rutas",
"3161c4e425": "carpeta"
},
+2
View File
@@ -8349,6 +8349,8 @@
"66a29dde82": "相対パスをコピー",
"42e10cbf57": "相対パスのコピー",
"b5d436aa30": "パスのコピー",
"98a79948b3": "コピー",
"b234ab25b4": "ファイルをクリップボードにコピーできませんでした",
"f9d7ca753d": "パスのコピー",
"3161c4e425": "フォルダ"
},
+2
View File
@@ -8349,6 +8349,8 @@
"66a29dde82": "상대 경로 복사",
"42e10cbf57": "상대 경로 복사",
"b5d436aa30": "경로 복사",
"98a79948b3": "복사",
"b234ab25b4": "파일을 클립보드에 복사하지 못했습니다",
"f9d7ca753d": "경로 복사",
"3161c4e425": "폴더"
},
+2
View File
@@ -8349,6 +8349,8 @@
"66a29dde82": "复制相对路径",
"42e10cbf57": "复制相对路径",
"b5d436aa30": "复制路径",
"98a79948b3": "复制",
"b234ab25b4": "无法将文件复制到剪贴板",
"f9d7ca753d": "复制路径",
"3161c4e425": "文件夹"
},
+1
View File
@@ -2041,6 +2041,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
writeSelectionClipboardText: () =>
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
writeClipboardImage: () => Promise.resolve(),
writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }),
performNativePaste: () => {
document.execCommand?.('paste')
},