Support SSH file copy to OS clipboard (#6110)
* Support SSH file copy to OS clipboard * Address SSH clipboard staging feedback
This commit is contained in:
parent
d992062cf9
commit
4df2b800b6
|
|
@ -12,6 +12,9 @@ const {
|
|||
spawnMock,
|
||||
childStdinEndMock,
|
||||
resolveAuthorizedPathMock,
|
||||
fsMkdirMock,
|
||||
fsReaddirMock,
|
||||
fsRmMock,
|
||||
fsWriteFileMock,
|
||||
fsStatMock,
|
||||
clipboardReadTextMock,
|
||||
|
|
@ -39,6 +42,9 @@ const {
|
|||
return child
|
||||
}),
|
||||
resolveAuthorizedPathMock: vi.fn(),
|
||||
fsMkdirMock: vi.fn(),
|
||||
fsReaddirMock: vi.fn(),
|
||||
fsRmMock: vi.fn(),
|
||||
fsWriteFileMock: vi.fn(),
|
||||
fsStatMock: vi.fn(),
|
||||
clipboardReadTextMock: vi.fn(),
|
||||
|
|
@ -56,6 +62,9 @@ vi.mock('node:child_process', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: fsMkdirMock,
|
||||
readdir: fsReaddirMock,
|
||||
rm: fsRmMock,
|
||||
stat: fsStatMock,
|
||||
default: {
|
||||
writeFile: fsWriteFileMock
|
||||
|
|
@ -111,6 +120,7 @@ import {
|
|||
registerClipboardHandlers,
|
||||
setTrustedClipboardRendererWebContentsId
|
||||
} from './clipboard-ipc-handlers'
|
||||
import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy'
|
||||
|
||||
function getRegisteredHandlers(): Map<string, (...args: unknown[]) => unknown> {
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
||||
|
|
@ -150,6 +160,10 @@ function trackPromiseSettled(promise: Promise<unknown>): () => boolean {
|
|||
return () => settled
|
||||
}
|
||||
|
||||
function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } {
|
||||
return { name, isDirectory: () => directory }
|
||||
}
|
||||
|
||||
describe('registerClipboardHandlers', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1760000000000)
|
||||
|
|
@ -159,6 +173,12 @@ describe('registerClipboardHandlers', () => {
|
|||
childStdinEndMock.mockClear()
|
||||
resolveAuthorizedPathMock.mockReset()
|
||||
resolveAuthorizedPathMock.mockImplementation(async (path: string) => path)
|
||||
fsMkdirMock.mockReset()
|
||||
fsMkdirMock.mockResolvedValue(undefined)
|
||||
fsReaddirMock.mockReset()
|
||||
fsReaddirMock.mockResolvedValue([])
|
||||
fsRmMock.mockReset()
|
||||
fsRmMock.mockResolvedValue(undefined)
|
||||
fsWriteFileMock.mockReset()
|
||||
fsStatMock.mockReset()
|
||||
fsStatMock.mockResolvedValue({})
|
||||
|
|
@ -256,6 +276,111 @@ describe('registerClipboardHandlers', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('sweeps expired remote clipboard staging directories', async () => {
|
||||
const nowMs = 1760000000000
|
||||
fsReaddirMock.mockResolvedValue([
|
||||
dirent('orca-clipboard-file-expired'),
|
||||
dirent('orca-clipboard-file-fresh'),
|
||||
dirent('orca-clipboard-file-plain-file', false),
|
||||
dirent('unrelated-temp')
|
||||
])
|
||||
fsStatMock.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath.endsWith('expired')) {
|
||||
return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 }
|
||||
}
|
||||
if (targetPath.endsWith('fresh')) {
|
||||
return { mtimeMs: nowMs - 1000 }
|
||||
}
|
||||
throw new Error(`unexpected stat: ${targetPath}`)
|
||||
})
|
||||
|
||||
await cleanupExpiredRemoteClipboardFiles(nowMs)
|
||||
|
||||
expect(fsRmMock).toHaveBeenCalledTimes(1)
|
||||
expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('materializes remote files before writing them to the OS clipboard', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }),
|
||||
downloadFile: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
const handlers = getRegisteredHandlers()
|
||||
const tempDir = join(
|
||||
'/tmp',
|
||||
'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000'
|
||||
)
|
||||
const tempPath = join(tempDir, 'report.pdf')
|
||||
|
||||
await expect(
|
||||
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), {
|
||||
filePath: '/remote/report.pdf',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf')
|
||||
expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 })
|
||||
expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath)
|
||||
expect(fsStatMock).toHaveBeenCalledWith(tempPath)
|
||||
expect(resolveAuthorizedPathMock).not.toHaveBeenCalled()
|
||||
expect(fsRmMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not materialize remote directories for OS clipboard copy', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
|
||||
downloadFile: vi.fn()
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
const handlers = getRegisteredHandlers()
|
||||
await expect(
|
||||
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), {
|
||||
filePath: '/remote/src',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: 'is-directory' })
|
||||
|
||||
expect(provider.downloadFile).not.toHaveBeenCalled()
|
||||
expect(fsMkdirMock).not.toHaveBeenCalled()
|
||||
expect(clipboardWriteBufferMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up remote clipboard temp files when transfer fails', async () => {
|
||||
const provider = {
|
||||
stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }),
|
||||
downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed'))
|
||||
}
|
||||
getSshFilesystemProviderMock.mockReturnValue(provider)
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
const handlers = getRegisteredHandlers()
|
||||
const tempDir = join(
|
||||
'/tmp',
|
||||
'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000'
|
||||
)
|
||||
const tempPath = join(tempDir, 'report.pdf')
|
||||
|
||||
await expect(
|
||||
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), {
|
||||
filePath: '/remote/report.pdf',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).rejects.toThrow('transfer failed')
|
||||
|
||||
expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath)
|
||||
expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true })
|
||||
expect(clipboardWriteBufferMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unauthorized local files before touching the OS clipboard', async () => {
|
||||
resolveAuthorizedPathMock.mockRejectedValue(
|
||||
new Error(
|
||||
|
|
|
|||
|
|
@ -23,10 +23,23 @@ import {
|
|||
assertClipboardImageByteLengthWithinLimit,
|
||||
assertClipboardImageDimensionsWithinLimit
|
||||
} from '../../shared/clipboard-image'
|
||||
import { writeFileToClipboard } from './clipboard-file-copy'
|
||||
import {
|
||||
writeFileToClipboard,
|
||||
type ClipboardFileDeps,
|
||||
type ClipboardFileResult
|
||||
} from './clipboard-file-copy'
|
||||
import {
|
||||
cleanupExpiredRemoteClipboardFiles,
|
||||
writeRemoteFileToClipboard
|
||||
} from './clipboard-remote-file-copy'
|
||||
|
||||
let trustedClipboardRendererWebContentsId: number | null = null
|
||||
|
||||
type ClipboardWriteFileRequest = {
|
||||
filePath: string
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
export function setTrustedClipboardRendererWebContentsId(webContentsId: number | null): void {
|
||||
trustedClipboardRendererWebContentsId = webContentsId
|
||||
}
|
||||
|
|
@ -53,6 +66,8 @@ export function registerClipboardHandlers(store: Store): void {
|
|||
ipcMain.removeHandler('clipboard:writeFile')
|
||||
ipcMain.removeHandler('clipboard:saveImageAsTempFile')
|
||||
|
||||
void cleanupExpiredRemoteClipboardFiles()
|
||||
|
||||
ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options)
|
||||
|
|
@ -80,13 +95,16 @@ export function registerClipboardHandlers(store: Store): void {
|
|||
}
|
||||
)
|
||||
// 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) => {
|
||||
// drops the file itself, not its path as text.
|
||||
ipcMain.handle(
|
||||
'clipboard:writeFile',
|
||||
(event, args: unknown): ClipboardFileResult | Promise<ClipboardFileResult> => {
|
||||
assertTrustedClipboardSender(event)
|
||||
const request = normalizeClipboardWriteFileRequest(args)
|
||||
if (!request) {
|
||||
return { ok: false, reason: 'invalid-path' }
|
||||
}
|
||||
const deps = makeClipboardFileDeps(async (path) => {
|
||||
try {
|
||||
const authorizedPath = await resolveAuthorizedPath(path, store)
|
||||
await stat(authorizedPath)
|
||||
|
|
@ -97,11 +115,17 @@ export function registerClipboardHandlers(store: Store): void {
|
|||
}
|
||||
return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' }
|
||||
}
|
||||
},
|
||||
writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer),
|
||||
runCommand
|
||||
})
|
||||
})
|
||||
})
|
||||
if (request.connectionId) {
|
||||
return writeRemoteFileToClipboard({
|
||||
remotePath: request.filePath,
|
||||
connectionId: request.connectionId,
|
||||
deps
|
||||
})
|
||||
}
|
||||
return writeFileToClipboard(request.filePath, deps)
|
||||
}
|
||||
)
|
||||
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
|
||||
|
|
@ -151,6 +175,36 @@ export function registerClipboardHandlers(store: Store): void {
|
|||
})
|
||||
}
|
||||
|
||||
function normalizeClipboardWriteFileRequest(args: unknown): ClipboardWriteFileRequest | null {
|
||||
if (typeof args === 'string') {
|
||||
return { filePath: args }
|
||||
}
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
||||
return null
|
||||
}
|
||||
const filePath = (args as { filePath?: unknown }).filePath
|
||||
if (typeof filePath !== 'string') {
|
||||
return null
|
||||
}
|
||||
const connectionId = (args as { connectionId?: unknown }).connectionId
|
||||
if (typeof connectionId === 'string' && connectionId.trim() !== '') {
|
||||
return { filePath, connectionId }
|
||||
}
|
||||
return { filePath }
|
||||
}
|
||||
|
||||
function makeClipboardFileDeps(
|
||||
resolveFilePath: ClipboardFileDeps['resolveFilePath']
|
||||
): ClipboardFileDeps {
|
||||
return {
|
||||
platform: process.platform,
|
||||
desktop: process.env.XDG_CURRENT_DESKTOP,
|
||||
resolveFilePath,
|
||||
writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer),
|
||||
runCommand
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void {
|
||||
if (!isTrustedClipboardRenderer(event.sender)) {
|
||||
throw new Error('Unauthorized clipboard IPC sender')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getRuntimePathBasename } from '../../shared/cross-platform-path'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
writeFileToClipboard,
|
||||
type ClipboardFileDeps,
|
||||
type ClipboardFileResult
|
||||
} from './clipboard-file-copy'
|
||||
|
||||
type RemoteClipboardFileDeps = Omit<ClipboardFileDeps, 'resolveFilePath'>
|
||||
|
||||
const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000
|
||||
const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-'
|
||||
const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
|
||||
const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
|
||||
|
||||
export async function writeRemoteFileToClipboard({
|
||||
remotePath,
|
||||
connectionId,
|
||||
deps
|
||||
}: {
|
||||
remotePath: string
|
||||
connectionId: string
|
||||
deps: RemoteClipboardFileDeps
|
||||
}): Promise<ClipboardFileResult> {
|
||||
const provider = requireSshFilesystemProvider(connectionId)
|
||||
const remoteStat = await provider.stat(remotePath)
|
||||
if (remoteStat.type === 'directory') {
|
||||
return { ok: false, reason: 'is-directory' }
|
||||
}
|
||||
if (!provider.downloadFile) {
|
||||
throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
|
||||
const tempDir = join(
|
||||
app.getPath('temp'),
|
||||
`${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}`
|
||||
)
|
||||
await mkdir(tempDir, { mode: 0o700 })
|
||||
const localPath = join(
|
||||
tempDir,
|
||||
sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath))
|
||||
)
|
||||
let keepTempFile = false
|
||||
|
||||
try {
|
||||
await provider.downloadFile(remotePath, localPath)
|
||||
const result = await writeFileToClipboard(localPath, {
|
||||
...deps,
|
||||
resolveFilePath: async (path) => {
|
||||
if (path !== localPath) {
|
||||
return { ok: false, reason: 'invalid-path' }
|
||||
}
|
||||
try {
|
||||
await stat(path)
|
||||
return { ok: true, path }
|
||||
} catch {
|
||||
return { ok: false, reason: 'not-found' }
|
||||
}
|
||||
}
|
||||
})
|
||||
if (result.ok) {
|
||||
// Why: OS file clipboards keep a path reference, so the staged copy must
|
||||
// survive after this IPC call long enough for the user to paste it.
|
||||
keepTempFile = true
|
||||
scheduleRemoteClipboardFileCleanup(tempDir)
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (!keepTempFile) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise<void> {
|
||||
const tempRoot = app.getPath('temp')
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await readdir(tempRoot, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) {
|
||||
return
|
||||
}
|
||||
const tempDir = join(tempRoot, entry.name)
|
||||
try {
|
||||
const tempStats = await stat(tempDir)
|
||||
if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) {
|
||||
return
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Why: stale staged SSH files should not make startup cleanup noisy.
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeLocalClipboardFilename(remoteBasename: string): string {
|
||||
const sanitized = Array.from(remoteBasename, (char) =>
|
||||
char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char
|
||||
)
|
||||
.join('')
|
||||
.replace(/[. ]+$/g, '')
|
||||
if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) {
|
||||
return 'download'
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function scheduleRemoteClipboardFileCleanup(tempDir: string): void {
|
||||
const timer = setTimeout(() => {
|
||||
void rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}, REMOTE_CLIPBOARD_FILE_TTL_MS)
|
||||
if (typeof timer === 'object' && 'unref' in timer) {
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
|
|
@ -2479,7 +2479,14 @@ 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 }>
|
||||
writeClipboardFile: (
|
||||
args:
|
||||
| {
|
||||
filePath: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
| string
|
||||
) => Promise<{ ok: boolean; reason?: string }>
|
||||
onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void
|
||||
getZoomLevel: () => number
|
||||
setZoomLevel: (level: number) => void
|
||||
|
|
|
|||
|
|
@ -3270,8 +3270,14 @@ 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),
|
||||
writeClipboardFile: (
|
||||
args:
|
||||
| {
|
||||
filePath: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
| string
|
||||
): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args),
|
||||
onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) =>
|
||||
subscribeNativeFileDrop(callback),
|
||||
getZoomLevel: (): number => webFrame.getZoomLevel(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getNextNameFilterCollapsedPaths
|
||||
} from './file-explorer-name-filter-projection'
|
||||
import {
|
||||
copyFileToOsClipboard,
|
||||
downloadRemoteFile,
|
||||
FileExplorerRow,
|
||||
shouldShowCollapseFolderAction,
|
||||
|
|
@ -575,14 +576,66 @@ 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)
|
||||
it('shows OS file copy for single local rows and SSH file rows on desktop', () => {
|
||||
const previous = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
try {
|
||||
expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(true)
|
||||
expect(shouldShowCopyFileAction(directoryNode, null, 1)).toBe(true)
|
||||
expect(shouldShowCopyFileAction(fileNode, undefined, 2)).toBe(false)
|
||||
expect(shouldShowCopyFileAction(fileNode, 'ssh-1', 1)).toBe(true)
|
||||
expect(shouldShowCopyFileAction(directoryNode, 'ssh-1', 1)).toBe(false)
|
||||
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
|
||||
expect(shouldShowCopyFileAction(null, 1)).toBe(false)
|
||||
expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(false)
|
||||
} finally {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('copies local and SSH file rows through the clipboard file API', async () => {
|
||||
const writeClipboardFile = vi.fn().mockResolvedValue({ ok: true })
|
||||
;(
|
||||
globalThis as unknown as {
|
||||
window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } }
|
||||
}
|
||||
).window = { api: { ui: { writeClipboardFile } } }
|
||||
|
||||
await copyFileToOsClipboard(fileNode)
|
||||
await copyFileToOsClipboard(fileNode, 'ssh-1')
|
||||
|
||||
expect(writeClipboardFile).toHaveBeenNthCalledWith(1, '/repo/src/index.ts')
|
||||
expect(writeClipboardFile).toHaveBeenNthCalledWith(2, {
|
||||
filePath: '/repo/src/index.ts',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a failure toast when OS file copy fails', async () => {
|
||||
const writeClipboardFile = vi.fn().mockResolvedValue({ ok: false, reason: 'invalid-path' })
|
||||
;(
|
||||
globalThis as unknown as {
|
||||
window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } }
|
||||
}
|
||||
).window = { api: { ui: { writeClipboardFile } } }
|
||||
|
||||
await copyFileToOsClipboard(fileNode)
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('Could not copy the file to the clipboard')
|
||||
})
|
||||
|
||||
it('shows the remote copy rejection message when SSH materialization fails', async () => {
|
||||
const writeClipboardFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped'))
|
||||
;(
|
||||
globalThis as unknown as {
|
||||
window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } }
|
||||
}
|
||||
).window = { api: { ui: { writeClipboardFile } } }
|
||||
|
||||
await copyFileToOsClipboard(fileNode, 'ssh-1')
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('Remote connection dropped')
|
||||
})
|
||||
|
||||
it('calls the preload download API and shows success only when not canceled', async () => {
|
||||
|
|
|
|||
|
|
@ -314,11 +314,15 @@ 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.
|
||||
export function shouldShowCopyFileAction(
|
||||
node: TreeNode,
|
||||
connectionId?: string | null,
|
||||
selectionSize = 1
|
||||
): boolean {
|
||||
// Why: remote directories would require recursive materialization semantics;
|
||||
// keep this to a single concrete file reference until multi-file copy exists.
|
||||
return (
|
||||
!connectionId &&
|
||||
(!connectionId || !node.isDirectory) &&
|
||||
selectionSize === 1 &&
|
||||
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
|
||||
)
|
||||
|
|
@ -360,6 +364,26 @@ export async function downloadRemoteFile(node: TreeNode, connectionId: string):
|
|||
}
|
||||
}
|
||||
|
||||
export async function copyFileToOsClipboard(
|
||||
node: TreeNode,
|
||||
connectionId?: string | null
|
||||
): Promise<void> {
|
||||
const failureMessage = translate(
|
||||
'auto.components.right.sidebar.FileExplorerRow.b234ab25b4',
|
||||
'Could not copy the file to the clipboard'
|
||||
)
|
||||
try {
|
||||
const result = await window.api.ui.writeClipboardFile(
|
||||
connectionId ? { filePath: node.path, connectionId } : node.path
|
||||
)
|
||||
if (!result.ok) {
|
||||
toast.error(failureMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(extractIpcErrorMessage(error, failureMessage))
|
||||
}
|
||||
}
|
||||
|
||||
export function FileExplorerRow({
|
||||
node,
|
||||
isExpanded,
|
||||
|
|
@ -403,7 +427,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 showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
|
||||
const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } =
|
||||
useFileExplorerRowDrag({
|
||||
rowDropDir,
|
||||
|
|
@ -432,23 +456,8 @@ 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])
|
||||
void copyFileToOsClipboard(node, connectionId)
|
||||
}, [connectionId, node])
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
|
|
|
|||
Loading…
Reference in New Issue