feat(file-explorer): support drag-and-drop file import over SSH (#1279)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-04-30 01:28:43 -07:00 committed by GitHub
parent 79faafcc98
commit cb73b372ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1098 additions and 61 deletions

317
docs/drag-drop-files-ssh.md Normal file
View File

@ -0,0 +1,317 @@
# Design Document: Drag-and-Drop File Import over SSH
## 1. Overview
Orca's file explorer supports dragging external files from the OS into the explorer when working with a local worktree (see `docs/file-explorer-external-drop.md`). However, this feature does not work when connected to an SSH remote. The `fs:importExternalPaths` IPC handler uses Node's local filesystem APIs (`copyFile`, `lstat`, `readdir`, `mkdir`) and has no `connectionId` parameter — the renderer never passes one, and the main process has no code path to route import operations through the SSH filesystem provider.
This document proposes extending the import flow to support SSH connections, enabling users to drop local files onto the explorer and have them uploaded to the remote server.
**Origin:** User feedback — [Slack thread](https://stablygroup.slack.com/archives/C0ASMDT6LQZ/p1777530155421009), [GitHub #200](https://github.com/stablyai/orca-internal/issues/200).
## 2. Current Architecture
### 2.1 Local Import Path
The existing local import flow:
1. **Preload** intercepts native OS `drop` events, extracts `FileList` paths, resolves the destination directory from `data-native-file-drop-dir` DOM markers, and emits one IPC event: `{ target: 'file-explorer', paths, destinationDir }`.
2. **Renderer** (`useFileExplorerImport.ts`) receives the event and calls `window.api.fs.importExternalPaths({ sourcePaths, destDir })`.
3. **Main** (`filesystem-mutations.ts`) runs the import: authorize paths → `lstat` validation → symlink pre-scan → deconflict names → `copyFile`/`recursiveCopyDir`.
All of this is local filesystem only. No `connectionId` is threaded anywhere.
### 2.2 SSH Filesystem Provider
The `SshFilesystemProvider` communicates with a relay binary on the remote host via a JSON-RPC multiplexer (`SshChannelMultiplexer`). It supports:
- `readDir`, `readFile`, `writeFile`, `stat`, `deletePath`, `createFile`, `createDir`, `rename`, `copy`, `realpath`, `search`, `listFiles`, `watch`
`writeFile` accepts a `string` content parameter — it is text-only and unsuitable for binary files (images, compiled assets, etc.).
`copy` is remote-to-remote — it tells the relay to copy a file on the remote side.
### 2.3 Direct SFTP
The codebase already uses direct SFTP for relay deployment (`ssh-relay-deploy-helpers.ts`):
- `uploadFile(sftp, localPath, remotePath)` — streams a local file to the remote via `createReadStream``sftp.createWriteStream`.
- `uploadDirectory(sftp, localDir, remoteDir)` — recursively creates directories and uploads files.
- `mkdirSftp(sftp, remotePath)` — creates remote directories.
These helpers use `ssh2`'s `SFTPWrapper` obtained from `SshConnection.sftp()`.
### 2.4 Other SSH-Aware Mutations
Other filesystem mutations (`createFile`, `createDir`, `rename`) already accept `connectionId` and route through `getSshFilesystemProvider()`. The import handler is the exception.
### 2.5 System Context
```
┌──────────────────────────────────────────────────────────┐
│ Renderer (file-explorer) │
│ useFileExplorerImport ──► IPC: fs:importExternalPaths │
│ { sourcePaths, destDir, connectionId? } │
└──────────────────────┬───────────────────────────────────┘
┌────────────▼────────────┐
│ Main Process │
│ filesystem-mutations │
│ │
│ connectionId present? │
│ ├─ NO → local fs │
│ │ copyFile / mkdir │
│ └─ YES → SFTP │
│ SshConnection │
│ .sftp() │
└──────┬──────────┬───────┘
│ │
┌────────▼──┐ ┌───▼────────────┐
│ Local FS │ │ Remote Host │
│ (source │ │ (destination │
│ always │ │ via SFTP) │
│ local) │ │ │
└───────────┘ └────────────────┘
```
> **Architecture note:** The import handler bypasses `SshFilesystemProvider` and uses `SshConnection.sftp()` directly. This is intentional — the relay's JSON-RPC `fs.writeFile` is text-only and cannot carry binary data without base64 encoding overhead. Future maintainers should not "fix" this to route through the provider.
## 3. Gap Analysis
| Requirement | Local | SSH |
|---|---|---|
| Source path validation (`lstat`) | Local `fs.lstat` | Local `fs.lstat` (source is always local) |
| Symlink pre-scan | Local `fs.readdir` | Local `fs.readdir` (source is always local) |
| Name deconfliction | Local `fs.lstat` on dest | Remote `stat` via relay/SFTP |
| File copy | `fs.copyFile` | SFTP stream upload |
| Directory creation | `fs.mkdir` | SFTP `mkdir` or relay `fs.createDir` |
| Recursive directory copy | Local `readdir` + `copyFile` | Local `readdir` + SFTP upload per file |
Key insight: **source paths are always local** (they come from the user's OS file manager). Only the destination is remote. This means source validation (lstat, symlink pre-scan) stays unchanged — only the copy-to-destination step needs an SSH path.
## 4. Proposed Design
### 4.1 Strategy: Direct SFTP Upload
Use `ssh2`'s SFTP channel directly from the main process, reusing the existing `uploadFile`/`uploadDirectory`/`mkdirSftp` helpers from `ssh-relay-deploy-helpers.ts`. Do NOT route through the relay's JSON-RPC `fs.writeFile` because:
- `fs.writeFile` is text-only (string content over JSON-RPC).
- Binary files (images, PDFs, compiled assets) would require base64 encoding + relay-side decode, adding complexity and ~33% bandwidth overhead.
- The SFTP helpers already exist, are tested, and handle streaming correctly.
### 4.2 IPC Changes
**`api-types.ts`** — Add `connectionId` to the import args:
```ts
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
connectionId?: string
}) => Promise<{ results: ImportItemResult[] }>
```
**`preload/index.ts`** — Thread `connectionId` through the IPC invoke.
### 4.3 Main-Process Import Handler
Extend the `fs:importExternalPaths` handler in `filesystem-mutations.ts`:
```
if (connectionId) {
→ check SSH connection state; if reconnecting, return user-friendly error
→ guard: if sourcePaths is empty, return { results: [] } immediately
→ get SshConnection from session registry
→ open SFTP channel
→ show indeterminate "Importing files…" toast
→ try:
run SSH import path (4.4)
finally:
close SFTP channel (guaranteed cleanup)
dismiss toast
else
→ resolveAuthorizedPath(destDir)
→ existing local import path (unchanged)
```
**Implementation constraint — `resolveAuthorizedPath` placement:** The current handler calls `resolveAuthorizedPath(destDir)` unconditionally before any copy work. This must be restructured: move `resolveAuthorizedPath(destDir)` inside the `else` (local) branch. For SSH imports, `destDir` is a remote path that does not exist on the local filesystem, so `resolveAuthorizedPath` will throw. The SSH branch skips local path authorization because remote paths are authorized by the SSH connection boundary itself (see Section 9).
**Connection-state check:** Before attempting `connection.sftp()`, the handler must inspect the connection state. If the connection is in `reconnecting` state, fail early with a toast: _"SSH connection is reconnecting — please try again in a moment."_ This avoids an unhelpful generic "Not connected" SFTP error that gives the user no guidance.
**Empty source paths:** If `sourcePaths` is an empty array, return `{ results: [] }` immediately without opening an SFTP channel. This avoids unnecessary channel overhead for a no-op.
**SFTP channel cleanup:** The SFTP channel opened for the import must be closed on all code paths — success, partial failure, or exception. The handler must use `try/finally` semantics around the upload loop. Note that individual `uploadFile` calls receive the `sftp` handle as a parameter and do not manage its lifecycle; the caller (the import handler) is solely responsible for closing the channel.
**In-progress feedback:** Show an indeterminate "Importing files…" toast when the IPC call begins and dismiss it when the import completes (success or failure). This costs almost nothing to implement and significantly improves the experience on slow connections where network latency makes the drop-to-toast gap noticeable.
### 4.4 SSH Import Pipeline
For each source path in the batch:
1. **Source validation** — unchanged. `lstat` the local source path. Reject symlinks, missing, permission-denied. Pre-scan directories for nested symlinks.
2. **Name deconfliction** — use SFTP `lstat` (not `stat`) on the remote destination to check for collisions, matching the local import's use of `lstat`. This ensures consistent collision semantics: a dangling symlink at the destination is still treated as "name taken." SFTP lstat throws `SSH_FX_NO_SUCH_FILE` (code 2) when the path doesn't exist — use this as the "no collision" signal.
3. **Upload** — for files, use `uploadFile(sftp, localPath, remotePath)`. For directories, use recursive SFTP mkdir + uploadFile. Reuse the existing helpers from `ssh-relay-deploy-helpers.ts` after extracting them to a shared location.
4. **Result reporting** — same per-item `ImportItemResult` schema. The renderer doesn't need to know whether the import went local or SSH.
### 4.5 Accessing the SFTP Channel
The `SshConnection` class already exposes `async sftp(): Promise<SFTPWrapper>`. The import handler needs access to the connection for a given `connectionId`.
Current architecture: `SshRelaySession` owns the connection lifecycle but doesn't directly expose the `SshConnection`. The `getSshFilesystemProvider()` dispatch only returns the `IFilesystemProvider` interface.
Options:
**Option A: Expose `SshConnection` via a session registry.**
Add a `getSshConnection(connectionId)` function that returns the `SshConnection` from the `SshRelaySession` map. The import handler calls `connection.sftp()` directly.
**Option B: Add an `uploadFile` method to `IFilesystemProvider`.**
Extend the provider interface with `uploadFile(localPath: string, remotePath: string): Promise<void>` and `uploadDirectory(localDir: string, remoteDir: string): Promise<void>`. The SSH provider implements them via SFTP; the local provider implements them as `copyFile`/`recursiveCopyDir`.
**Recommendation: Option A.** Option B pollutes the provider interface with a local↔remote transfer concern that only applies to import. The relay-based provider should stay focused on remote-side operations. A direct SFTP path from the import handler is simpler and keeps the provider interface clean.
### 4.6 Renderer Changes
**`useFileExplorerImport.ts`** — Pass `connectionId` from the active worktree:
```ts
const connectionId = getConnectionId(activeWorktreeIdRef.current) ?? undefined
const { results } = await window.api.fs.importExternalPaths({
sourcePaths: paths,
destDir: destinationDir,
connectionId
})
```
This is the only renderer change needed. The rest of the import UX (drag state, highlight, toast, reveal) works identically for local and SSH.
### 4.7 Helper Extraction
Move `uploadFile`, `uploadDirectory`, and `mkdirSftp` from `ssh-relay-deploy-helpers.ts` to a shared module (e.g., `src/main/ssh/sftp-upload.ts`). The relay deploy code imports from the new location. This avoids coupling the import feature to relay deployment internals.
**Async filesystem calls:** The existing `uploadDirectory` uses `readdirSync` and `statSync`, which block the event loop. During extraction, replace these with their async counterparts (`readdir` with `{ withFileTypes: true }` from `fs/promises`). The local import's `recursiveCopyDir` already uses async fs calls and serves as the template for this conversion.
## 5. Symlink Policy
Unchanged from the local import design. Source-side symlinks are rejected before upload begins. The pre-scan uses local `readdir` + `lstat`, which works identically regardless of the destination being local or remote.
## 6. Conflict Policy
Same as local: non-destructive, prompt-free deconfliction. The difference is that collision checks use SFTP `lstat` instead of local `lstat`. Using `lstat` (rather than `stat`) matches the local path's semantics: a symlink at the destination is treated as a collision even if its target doesn't exist.
SFTP lstat error handling:
- `SSH_FX_NO_SUCH_FILE` (status code 2) → no collision, name is available.
- `SSH_FX_PERMISSION_DENIED` (status code 3) → fail the item.
- Any other error → fail the item with the error message.
## 7. Performance Considerations
### 7.1 SFTP Channel Lifecycle
Open one SFTP channel per import gesture, not per file. Close it after all items are uploaded using `try/finally` to guarantee cleanup even on partial failure. Opening an SFTP subsystem has ~100ms overhead per channel due to the SSH handshake.
### 7.2 Sequential vs. Parallel Upload
v1: sequential upload (one file at a time). This matches the existing relay deploy behavior and avoids SFTP channel contention. SFTP supports multiple concurrent operations, but managing parallel uploads with error handling and progress adds complexity that isn't needed for v1.
### 7.3 Large File Handling
`uploadFile` uses `createReadStream``sftp.createWriteStream`, which streams data rather than buffering entire files into memory. This handles large files without OOM risk.
### 7.4 Network Latency
Unlike local imports, SSH imports are bounded by network throughput. For large drops, the user may see a noticeable delay between the drop gesture and the toast/reveal. v1 includes an indeterminate "Importing files…" toast during the upload to bridge this gap. Granular per-file progress UI is deferred to v2.
## 8. Error Handling
Same per-item error reporting as local imports, plus SSH-specific failures:
- **SSH connection in `reconnecting` state at drop time:** Fail immediately with toast: _"SSH connection is reconnecting — please try again in a moment."_ Do not attempt to open an SFTP channel.
- **SSH connection lost during upload:** Fail remaining items. Partially uploaded files may be left on the remote — acceptable for v1 since partial files are visible in the explorer and can be deleted manually.
- **SFTP channel failure:** Fail the entire import. The `finally` block still runs to release the channel handle.
- **Remote disk full:** SFTP write stream error — fail the affected item.
- **Permission denied on remote directory:** Fail the affected item.
Toast messages remain the same format:
- `Imported 5 items to ~/project/src`
- `Imported 4 items to ~/project/src. 1 item was skipped.`
- `Could not import dropped items`
- `SSH connection is reconnecting — please try again in a moment`
### 8.1 Data Flow Paths
**Happy path:** Renderer sends `{ sourcePaths: ["/a.txt"], destDir: "/remote/dir", connectionId: "abc" }` → main checks connection state (connected) → opens SFTP → shows "Importing files…" toast → validates source locally → deconflicts name via SFTP stat → uploads via SFTP stream → closes SFTP → dismisses toast → returns `{ results: [{ path: "/remote/dir/a.txt", status: "ok" }] }` → renderer shows success toast and reveals file.
**Empty sourcePaths:** Renderer sends `{ sourcePaths: [], destDir: "/remote/dir", connectionId: "abc" }` → main returns `{ results: [] }` immediately, no SFTP channel opened, no toast shown.
**Nil connectionId (local fallback):** Renderer sends `{ sourcePaths: [...], destDir: "/local/dir" }` → main takes existing local import path unchanged.
**Error — reconnecting:** Renderer sends `{ sourcePaths: [...], destDir: "/remote/dir", connectionId: "abc" }` → main checks connection state → state is `reconnecting` → returns error → renderer shows "SSH connection is reconnecting — please try again in a moment" toast.
**Error — mid-upload failure:** Main opens SFTP → uploads file 1 OK → file 2 throws (e.g., permission denied) → file 2 marked as failed → file 3 continues → SFTP closed in `finally` → toast dismissed → returns mixed results → renderer shows "Imported 2 items … 1 item was skipped."
## 9. Security
- Source paths are still authorized via `authorizeExternalPath()` — unchanged.
- Destination paths on the remote are not subject to local path authorization (they're on the remote host). The SSH connection itself is the authorization boundary.
- SFTP operations run under the SSH user's permissions on the remote host.
## 10. Testing
### 10.1 Unit Tests (Main Process)
- SSH import handler routes to SFTP when `connectionId` is present.
- SSH import handler falls back to local import when `connectionId` is absent.
- Empty `sourcePaths` array returns `{ results: [] }` without opening an SFTP channel.
- Reconnecting connection state returns a user-friendly error without attempting SFTP.
- Name deconfliction works with SFTP stat (mock SFTP stat to simulate collisions).
- Source-side symlink rejection works identically for SSH imports.
- SFTP channel is opened once per gesture, not per file.
- SFTP channel is closed after import completes (success or failure) — verify `finally` cleanup runs even when upload throws.
- Partial failure (some files succeed, some fail) returns correct per-item results.
- Indeterminate "Importing files…" toast is shown during upload and dismissed on completion.
### 10.2 Integration Tests
- Drop a single file into an SSH-connected explorer root → file appears on remote.
- Drop a directory into an SSH-connected explorer → directory tree appears on remote.
- Drop a file that collides with an existing remote file → deconflicted name used.
- Drop onto a subdirectory row → file lands in that directory on remote.
### 10.3 Renderer Tests
- `useFileExplorerImport` passes `connectionId` when active worktree has an SSH connection.
- `useFileExplorerImport` passes `undefined` for `connectionId` when local.
## 11. Implementation Plan
1. **Extract SFTP helpers** — Move `uploadFile`, `uploadDirectory`, `mkdirSftp` from `ssh-relay-deploy-helpers.ts` to `src/main/ssh/sftp-upload.ts`. Update relay deploy imports.
2. **Expose SSH connection accessor** — Add `getSshConnection(connectionId)` to the session registry so the import handler can obtain an SFTP channel.
3. **Add `connectionId` to import IPC** — Update `api-types.ts`, `preload/index.ts`, and the `fs:importExternalPaths` handler signature.
4. **Implement SSH import path** — In `filesystem-mutations.ts` (or a new `filesystem-import-ssh.ts`), add the SSH branch: open SFTP → validate sources locally → deconflict names via SFTP stat → upload via SFTP → close SFTP → return results.
5. **Thread `connectionId` in renderer** — Update `useFileExplorerImport.ts` to pass `connectionId` from the active worktree.
6. **Tests** — Unit tests for the SSH import path, integration tests for end-to-end flow.
## 12. Complexity Assessment
**Estimated difficulty: Medium.**
- The hardest part is already done — the local drag-drop UX, preload routing, and renderer import hook all exist and work.
- SFTP upload helpers exist and are proven in relay deployment.
- The main new work is: (a) wiring `connectionId` through the import IPC, (b) implementing SFTP-based name deconfliction, (c) connecting the import handler to the SSH connection's SFTP channel.
- No relay protocol changes needed. No new renderer UI. No new preload routing.
- Risk areas: SFTP error handling edge cases, ensuring the SFTP channel is cleaned up on all code paths, and testing against real SSH servers.
## 13. Open Questions
- Whether v2 should show granular per-file upload progress for SSH imports (v1 includes an indeterminate toast; per-file progress would require tracking bytes transferred).
- Whether partial uploads should be cleaned up on failure (currently left on remote).
- Whether to support drag-and-drop *from* the SSH explorer to the local OS (reverse direction).

View File

@ -0,0 +1,270 @@
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
const {
handleMock,
lstatMock,
mkdirMock,
realpathMock,
copyFileMock,
readdirMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
mkdirSftpMock,
getConnMgrMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
readdirMock: vi.fn(),
sftpExistsMock: vi.fn(),
uploadFileMock: vi.fn(),
uploadDirMock: vi.fn(),
mkdirSftpMock: vi.fn(),
getConnMgrMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
rename: vi.fn(),
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
readdir: readdirMock
}))
vi.mock('../ssh/sftp-upload', () => ({
sftpPathExists: sftpExistsMock,
uploadFile: uploadFileMock,
uploadDirectory: uploadDirMock,
mkdirSftp: mkdirSftpMock
}))
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
const store = {
getRepos: () => [
{
id: 'r1',
path: path.resolve('/workspace/repo'),
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
],
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
}
const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
describe('fs:importExternalPaths — SSH operations', () => {
const destDir = '/home/user/project/src'
const connId = 'ssh-conn-1'
const mockSftp = { end: vi.fn() }
const makeConn = () => ({
getState: () => ({ status: 'connected' }),
sftp: vi.fn().mockResolvedValue(mockSftp)
})
const mockDir = (p: string): void => {
const rp = path.resolve(p)
lstatMock.mockImplementation(async (x: string) => {
if (x === rp) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
throw enoent()
})
}
const invoke = (args: Record<string, unknown>) =>
handlers.get('fs:importExternalPaths')!(null, args) as Promise<{
results: Record<string, unknown>[]
}>
beforeEach(() => {
handlers.clear()
;[
handleMock,
lstatMock,
mkdirMock,
realpathMock,
copyFileMock,
readdirMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
mkdirSftpMock,
getConnMgrMock
].forEach((m) => m.mockReset())
mockSftp.end.mockReset()
handleMock.mockImplementation((ch: string, h: never) => {
handlers.set(ch, h)
})
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
sftpExistsMock.mockResolvedValue(false)
uploadFileMock.mockResolvedValue(undefined)
uploadDirMock.mockResolvedValue(undefined)
mkdirSftpMock.mockResolvedValue(undefined)
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
registerFilesystemMutationHandlers(store as never)
})
it('deconflicts file names via SFTP lstat', async () => {
const rp = path.resolve('/tmp/dropped/logo.png')
lstatMock.mockImplementation(async (x: string) => {
if (x === rp) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/logo.png`)
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/logo.png'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({
status: 'imported',
destPath: `${destDir}/logo copy.png`,
renamed: true
})
})
it('rejects symlink sources', async () => {
const rp = path.resolve('/tmp/dropped/link.txt')
lstatMock.mockImplementation(async (p: string) => {
if (p === rp) {
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
}
throw enoent()
})
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/link.txt'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
})
it('handles partial failure with correct per-item results', async () => {
const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt']
lstatMock.mockImplementation(async (p: string) => {
if (sources.map((s) => path.resolve(s)).includes(p)) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
uploadFileMock.mockImplementation(async (_s: unknown, lp: string) => {
if (lp === path.resolve('/tmp/dropped/bad.txt')) {
throw new Error('permission denied')
}
})
const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId })
expect(results).toHaveLength(3)
expect(results[0]).toMatchObject({ status: 'imported' })
expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' })
expect(results[2]).toMatchObject({ status: 'imported' })
})
it('uploads directories via mkdirSftp + uploadDirectory', async () => {
mockDir('/tmp/dropped/assets')
readdirMock.mockResolvedValue([])
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/assets'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`)
})
it('reports per-item failure when deconfliction throws', async () => {
const rp = path.resolve('/tmp/dropped/file.txt')
lstatMock.mockImplementation(async (x: string) => {
if (x === rp) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
sftpExistsMock.mockRejectedValue(new Error('SFTP channel closed'))
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/file.txt'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'failed', reason: 'SFTP channel closed' })
})
it('reports failure when mkdirSftp rejects', async () => {
mockDir('/tmp/dropped/mydir')
readdirMock.mockResolvedValue([])
mkdirSftpMock.mockRejectedValue(new Error('permission denied'))
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/mydir'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' })
})
it('deconflicts directory names via SFTP lstat', async () => {
mockDir('/tmp/dropped/assets')
readdirMock.mockResolvedValue([])
sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/assets`)
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/assets'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({
status: 'imported',
destPath: `${destDir}/assets copy`,
renamed: true
})
})
it('rejects directory containing nested symlinks', async () => {
mockDir('/tmp/dropped/project')
const rd = path.resolve('/tmp/dropped/project')
readdirMock.mockImplementation(async (p: string) => {
if (p === rd) {
return [
{
name: 'l.txt',
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true
}
]
}
return []
})
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/project'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
expect(uploadDirMock).not.toHaveBeenCalled()
})
it('reports skipped when source lstat returns EACCES', async () => {
const rp = path.resolve('/tmp/dropped/secret.txt')
lstatMock.mockImplementation(async (p: string) => {
if (p === rp) {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
}
throw enoent()
})
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/secret.txt'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'permission-denied' })
})
})

View File

@ -0,0 +1,195 @@
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
const {
handleMock,
lstatMock,
mkdirMock,
realpathMock,
copyFileMock,
readdirMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
mkdirSftpMock,
getConnMgrMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
readdirMock: vi.fn(),
sftpExistsMock: vi.fn(),
uploadFileMock: vi.fn(),
uploadDirMock: vi.fn(),
mkdirSftpMock: vi.fn(),
getConnMgrMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
rename: vi.fn(),
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
readdir: readdirMock
}))
vi.mock('../ssh/sftp-upload', () => ({
sftpPathExists: sftpExistsMock,
uploadFile: uploadFileMock,
uploadDirectory: uploadDirMock,
mkdirSftp: mkdirSftpMock
}))
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
const store = {
getRepos: () => [
{
id: 'r1',
path: path.resolve('/workspace/repo'),
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
],
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
}
const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
describe('fs:importExternalPaths — SSH routing & connection', () => {
const destDir = '/home/user/project/src'
const connId = 'ssh-conn-1'
const mockSftp = { end: vi.fn() }
const makeConn = (status = 'connected') => ({
getState: () => ({ status }),
sftp: vi.fn().mockResolvedValue(mockSftp)
})
const mockFile = (p: string): void => {
const rp = path.resolve(p)
lstatMock.mockImplementation(async (x: string) => {
if (x === rp) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
}
const invoke = (args: Record<string, unknown>) =>
handlers.get('fs:importExternalPaths')!(null, args) as Promise<{
results: Record<string, unknown>[]
}>
beforeEach(() => {
handlers.clear()
;[
handleMock,
lstatMock,
mkdirMock,
realpathMock,
copyFileMock,
readdirMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
mkdirSftpMock,
getConnMgrMock
].forEach((m) => m.mockReset())
mockSftp.end.mockReset()
handleMock.mockImplementation((ch: string, h: never) => {
handlers.set(ch, h)
})
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
sftpExistsMock.mockResolvedValue(false)
uploadFileMock.mockResolvedValue(undefined)
uploadDirMock.mockResolvedValue(undefined)
mkdirSftpMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
it('routes to SFTP when connectionId is present', async () => {
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
mockFile('/tmp/dropped/file.txt')
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/file.txt'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' })
expect(uploadFileMock).toHaveBeenCalled()
expect(copyFileMock).not.toHaveBeenCalled()
})
it('falls back to local import when connectionId is absent', async () => {
mockFile('/tmp/dropped/file.txt')
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/file.txt'],
destDir: path.resolve('/workspace/repo/src')
})
expect(results[0]).toMatchObject({ status: 'imported' })
expect(copyFileMock).toHaveBeenCalled()
})
it('returns empty results without opening SFTP', async () => {
const conn = makeConn()
getConnMgrMock.mockReturnValue({ getConnection: () => conn })
const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId })
expect(results).toHaveLength(0)
expect(conn.sftp).not.toHaveBeenCalled()
})
it('throws when connectionId has no matching connection', async () => {
getConnMgrMock.mockReturnValue({ getConnection: () => null })
await expect(
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
).rejects.toThrow('No SSH connection')
})
it('throws when connection is reconnecting', async () => {
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('reconnecting') })
await expect(
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
).rejects.toThrow('reconnecting')
})
it('throws when connection is not active', async () => {
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('disconnected') })
await expect(
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
).rejects.toThrow('not active')
})
it('throws when conn.sftp() rejects', async () => {
const conn = makeConn()
conn.sftp.mockRejectedValue(new Error('SFTP subsystem not available'))
getConnMgrMock.mockReturnValue({ getConnection: () => conn })
await expect(
invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId })
).rejects.toThrow('SFTP subsystem')
})
it('closes SFTP channel after success', async () => {
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
mockFile('/tmp/dropped/file.txt')
await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId })
expect(mockSftp.end).toHaveBeenCalledOnce()
})
it('closes SFTP channel after upload error', async () => {
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
mockFile('/tmp/dropped/file.txt')
uploadFileMock.mockRejectedValue(new Error('disk full'))
const { results } = await invoke({
sourcePaths: ['/tmp/dropped/file.txt'],
destDir,
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' })
expect(mockSftp.end).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,188 @@
import { lstat, readdir } from 'fs/promises'
import { basename, join, posix, resolve } from 'path'
import type { SFTPWrapper } from 'ssh2'
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
import { getSshConnectionManager } from './ssh'
import { uploadFile, uploadDirectory, mkdirSftp, sftpPathExists } from '../ssh/sftp-upload'
import type { ImportItemResult } from './filesystem-mutations'
// Why: the SSH import path bypasses SshFilesystemProvider and uses
// SshConnection.sftp() directly because the relay's JSON-RPC fs.writeFile
// is text-only and cannot carry binary data without base64 overhead.
export async function importExternalPathsSsh(
sourcePaths: string[],
destDir: string,
connectionId: string
): Promise<{ results: ImportItemResult[] }> {
if (sourcePaths.length === 0) {
return { results: [] }
}
const connManager = getSshConnectionManager()
const conn = connManager?.getConnection(connectionId)
if (!conn) {
throw new Error(`No SSH connection for "${connectionId}"`)
}
const state = conn.getState()
if (state.status !== 'connected') {
if (state.status === 'reconnecting') {
throw new Error('SSH connection is reconnecting — please try again in a moment')
}
throw new Error('SSH connection is not active — please reconnect and try again')
}
const sftp = await conn.sftp()
try {
const results: ImportItemResult[] = []
const reservedNames = new Set<string>()
for (const sourcePath of sourcePaths) {
const result = await importOneSourceSsh(sftp, sourcePath, destDir, reservedNames)
results.push(result)
if (result.status === 'imported') {
// Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt).
// Node's basename() uses the OS separator, which on Windows would
// return the entire string instead of just the filename.
reservedNames.add(posix.basename(result.destPath))
}
}
return { results }
} finally {
sftp.end()
}
}
async function importOneSourceSsh(
sftp: SFTPWrapper,
sourcePath: string,
destDir: string,
reservedNames: Set<string>
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
authorizeExternalPath(resolvedSource)
let sourceStat: Awaited<ReturnType<typeof lstat>>
try {
sourceStat = await lstat(resolvedSource)
} catch (error) {
if (isENOENT(error)) {
return { sourcePath, status: 'skipped', reason: 'missing' }
}
if (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
) {
return { sourcePath, status: 'skipped', reason: 'permission-denied' }
}
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
if (sourceStat.isSymbolicLink()) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
return { sourcePath, status: 'skipped', reason: 'unsupported' }
}
const isDir = sourceStat.isDirectory()
if (isDir) {
const hasSymlink = await preScanForSymlinks(resolvedSource)
if (hasSymlink) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
}
const originalName = basename(resolvedSource)
try {
const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames)
const destPath = `${destDir}/${finalName}`
const renamed = finalName !== originalName
if (isDir) {
await mkdirSftp(sftp, destPath)
await uploadDirectory(sftp, resolvedSource, destPath)
} else {
await uploadFile(sftp, resolvedSource, destPath)
}
return {
sourcePath,
status: 'imported',
destPath,
kind: isDir ? 'directory' : 'file',
renamed
}
} catch (error) {
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
}
async function deconflictNameSftp(
sftp: SFTPWrapper,
destDir: string,
originalName: string,
reservedNames: Set<string>
): Promise<string> {
if (
!(await sftpPathExists(sftp, `${destDir}/${originalName}`)) &&
!reservedNames.has(originalName)
) {
return originalName
}
const dotIndex = originalName.lastIndexOf('.')
const hasMeaningfulExt = dotIndex > 0
const stem = hasMeaningfulExt ? originalName.slice(0, dotIndex) : originalName
const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : ''
let candidate = `${stem} copy${ext}`
if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) {
return candidate
}
let counter = 2
while (counter < 10000) {
candidate = `${stem} copy ${counter}${ext}`
if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) {
return candidate
}
counter += 1
}
throw new Error(
`Could not generate a unique name for '${originalName}' after ${counter} attempts`
)
}
async function preScanForSymlinks(dirPath: string): Promise<boolean> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isSymbolicLink()) {
return true
}
if (entry.isDirectory()) {
const childPath = join(dirPath, entry.name)
if (await preScanForSymlinks(childPath)) {
return true
}
}
}
return false
}

View File

@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from 'path'
import type { Store } from '../persistence'
import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { importExternalPathsSsh } from './filesystem-import-ssh'
/**
* Re-throw filesystem errors with user-friendly messages.
@ -120,15 +121,19 @@ export function registerFilesystemMutationHandlers(store: Store): void {
'fs:importExternalPaths',
async (
_event,
args: { sourcePaths: string[]; destDir: string }
args: { sourcePaths: string[]; destDir: string; connectionId?: string }
): Promise<{ results: ImportItemResult[] }> => {
if (args.connectionId) {
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId)
}
// Why: destDir must be authorized before any copy work begins. If the
// destination is outside allowed roots, the entire import fails.
// This only applies to local imports — remote paths are authorized by
// the SSH connection boundary (see importExternalPathsSsh).
const resolvedDest = await resolveAuthorizedPath(args.destDir, store)
const results: ImportItemResult[] = []
// Track names reserved during this import batch to avoid collisions
// between multiple dropped items that share the same basename.
const reservedNames = new Set<string>()
for (const sourcePath of args.sourcePaths) {

View File

@ -0,0 +1,97 @@
import { createReadStream } from 'fs'
import { readdir } from 'fs/promises'
import { join as pathJoin } from 'path'
import type { SFTPWrapper } from 'ssh2'
export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, (err) => {
// Why: SFTP status code 4 (SSH_FX_FAILURE) is a generic code that
// OpenSSH returns for "already exists," but could also cover other
// failures (e.g. permission denied on parent). We accept this ambiguity
// because the next operation (write/recurse) will surface the real error.
if (err && (err as { code?: number }).code !== 4) {
reject(err)
} else {
resolve()
}
})
})
}
export function uploadFile(
sftp: SFTPWrapper,
localPath: string,
remotePath: string
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const readStream = createReadStream(localPath)
const writeStream = sftp.createWriteStream(remotePath)
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
if (settled) {
return
}
settled = true
readStream.destroy()
writeStream.destroy()
fn(val as never)
}
writeStream.on('close', () => settle(resolve))
writeStream.on('error', (err) => settle(reject, err))
readStream.on('error', (err) => settle(reject, err))
readStream.pipe(writeStream)
})
}
export async function uploadDirectory(
sftp: SFTPWrapper,
localDir: string,
remoteDir: string
): Promise<void> {
const entries = await readdir(localDir, { withFileTypes: true })
for (const entry of entries) {
const localPath = pathJoin(localDir, entry.name)
const remotePath = `${remoteDir}/${entry.name}`
// Why: skip symlinks and special files (sockets, FIFOs, devices) to
// prevent following symlinks that could exfiltrate local files to the
// remote. The caller's pre-scan catches symlinks up-front, but this
// guard closes the TOCTOU gap if one is created between scan and upload.
if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) {
continue
}
if (entry.isDirectory()) {
await mkdirSftp(sftp, remotePath)
await uploadDirectory(sftp, localPath, remotePath)
} else {
await uploadFile(sftp, localPath, remotePath)
}
}
}
/**
* Check whether a path exists on the remote via SFTP lstat.
* Returns true if the path exists (file, directory, or symlink).
*/
export function sftpPathExists(sftp: SFTPWrapper, remotePath: string): Promise<boolean> {
return new Promise((resolve, reject) => {
sftp.lstat(remotePath, (err) => {
if (!err) {
resolve(true)
return
}
// Why: SFTP status code 2 = SSH_FX_NO_SUCH_FILE — the path does not
// exist, which is the expected "no collision" signal for deconfliction.
if ((err as { code?: number }).code === 2) {
resolve(false)
return
}
reject(err)
})
})
}

View File

@ -1,63 +1,9 @@
import { createReadStream } from 'fs'
import type { SFTPWrapper, ClientChannel } from 'ssh2'
import type { ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
// ── SFTP upload helpers ───────────────────────────────────────────────
export async function uploadDirectory(
sftp: SFTPWrapper,
localDir: string,
remoteDir: string
): Promise<void> {
const { readdirSync, statSync } = await import('fs')
const { join: pathJoin } = await import('path')
const entries = readdirSync(localDir)
for (const entry of entries) {
const localPath = pathJoin(localDir, entry)
const remotePath = `${remoteDir}/${entry}`
const stat = statSync(localPath)
if (stat.isDirectory()) {
await mkdirSftp(sftp, remotePath)
await uploadDirectory(sftp, localPath, remotePath)
} else {
await uploadFile(sftp, localPath, remotePath)
}
}
}
export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, (err) => {
// Ignore "already exists" errors (SFTP status code 4 = SSH_FX_FAILURE)
if (err && (err as { code?: number }).code !== 4) {
reject(err)
} else {
resolve()
}
})
})
}
export function uploadFile(
sftp: SFTPWrapper,
localPath: string,
remotePath: string
): Promise<void> {
return new Promise((resolve, reject) => {
const readStream = createReadStream(localPath)
const writeStream = sftp.createWriteStream(remotePath)
writeStream.on('close', resolve)
writeStream.on('error', reject)
readStream.on('error', reject)
readStream.pipe(writeStream)
})
}
export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload'
// ── Sentinel detection ────────────────────────────────────────────────

View File

@ -657,7 +657,11 @@ export type PreloadApi = {
excludePaths?: string[]
}) => Promise<string[]>
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
importExternalPaths: (args: { sourcePaths: string[]; destDir: string }) => Promise<{
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
connectionId?: string
}) => Promise<{
results: (
| {
sourcePath: string

View File

@ -1158,6 +1158,7 @@ const api = {
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
connectionId?: string
}): Promise<{
results: (
| {

View File

@ -1,5 +1,8 @@
import { useEffect, useRef } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
type UseFileExplorerImportParams = {
worktreePath: string | null
@ -54,12 +57,14 @@ export function useFileExplorerImport({
}
const { paths, destinationDir } = data
const connectionId = getConnectionId(wtId) ?? undefined
void (async () => {
try {
const { results } = await window.api.fs.importExternalPaths({
sourcePaths: paths,
destDir: destinationDir
destDir: destinationDir,
connectionId
})
// Refresh the destination directory once per gesture
@ -71,9 +76,18 @@ export function useFileExplorerImport({
// scroll-to-center races with FS watcher refreshes and can snap the
// viewport back to the top of the tree.
const imported = results.filter((r) => r.status === 'imported')
const failed = results.filter((r) => r.status === 'failed')
if (imported.length > 0) {
setSelectedPathRef.current(imported[0].destPath)
}
if (failed.length > 0) {
const noun = failed.length === 1 ? 'file' : 'files'
toast.error(`Failed to import ${failed.length} ${noun}.`)
}
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to import files.'))
} finally {
clearNativeDragStateRef.current()
}