orca/docs/terminal-drop-ssh.md

453 lines
20 KiB
Markdown

# Terminal drag-and-drop over SSH
## Problem
Dragging a local file onto a terminal pane inserts the file's absolute path
into the PTY, so the user can reference it in a CLI or TUI-agent prompt. On
SSH worktrees the terminal runs remotely, so injecting a **local** path
(`/Users/alice/Desktop/log.txt`) is useless — the remote agent has no access
to it.
The file-explorer drop path was fixed in PR #1279 by routing through SFTP
upload (`importExternalPathsSsh`). The terminal drop path was not touched
and still breaks for SSH worktrees.
Reported: https://stablygroup.slack.com/archives/C0ASMDT6LQZ/p1777530155421009
## Goals
- Dropping a local file onto a terminal connected to an SSH worktree makes
that file available to the remote shell/agent, and injects a path the
remote process can read.
- Local terminal drops keep their current behavior: reference-in-place, no
copy, no authorization, no repo pollution.
- Local and SSH paths share one main-side resolver. The renderer may pass
connection context and show progress UI, but copy/upload/deconfliction
policy stays out of the renderer so the two modes do not drift over time.
## Non-goals
- Unifying terminal drop with file-explorer drop. They have different
semantics (explorer always copies into a user-picked `destDir`; terminal
references a path). They share the SSH upload primitive internally but
remain separate IPCs.
- Cleanup / garbage collection of staged remote files. Tracked as follow-up
(see "Follow-ups" below — file **before merging** and replace this
parenthetical with the issue number so the reference is locatable). Until
GC lands, files uploaded by a drop whose pane is unmounted before the
upload resolves are orphaned in `.orca/drops/` with no injected path.
Users should know uploads are not cancellable.
- Abortable uploads. SFTP transfers run to completion even if the terminal
pane is unmounted mid-flight.
## Considered: Option A (full unification)
Terminal drop calls `fs:importExternalPaths` with
`destDir = worktreePath`, then injects `result.destPath` into the PTY.
Shares exactly the file-explorer code path.
Rejected because it changes **local** terminal-drop UX: today dropping
`~/Desktop/log.txt` into the terminal pastes `/Users/…/Desktop/log.txt`
so the agent reads the file in place; under Option A the file would be
copied into the repo. Users rely on the reference-in-place behavior to
point agents at files without polluting the worktree.
## Design (Option B)
### New IPC
```
fs:resolveDroppedPathsForAgent({
paths: string[],
worktreePath: string,
connectionId?: string,
}) → {
resolvedPaths: string[],
skipped: { sourcePath: string; reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' }[],
failed: { sourcePath: string; reason: string }[],
}
```
Contract:
- **Local (`connectionId == null`):** returns
`{ resolvedPaths: paths, skipped: [], failed: [] }` unchanged. No copy. No
authorization (matches today's behavior — the agent's own read is what gets
authorized, not the drop). Use `args.connectionId == null` (not `!args.connectionId`)
so an empty string cannot silently pick the local branch.
- **SSH (`connectionId` is a non-empty string):** uploads each path via SFTP
into a staging dir under the worktree. Returns remote absolute paths for
items that uploaded successfully, items rejected by policy (symlinks,
missing sources, permission-denied, unsupported file types) in `skipped`,
and hard upload errors in `failed`. The split mirrors `ImportItemResult`'s
existing `'imported' | 'skipped' | 'failed'` and lets the renderer toast
"Skipped N symlinks" distinctly from "Failed to upload N files." Collapsing
skipped into failed would mislabel routine policy rejections as errors.
### SSH staging dir
`${worktreePath}/.orca/drops/` on the remote. `.orca/` is reserved as an
Orca-owned directory for future remote state (GC metadata, cached remote
capability probes, etc.); this is its first use. Future features adding
subpaths under `.orca/` should namespace themselves (`.orca/drops/`,
`.orca/<feature>/`) rather than placing files at the root.
Rationale:
- No need to resolve remote `$HOME` (which would require a round-trip and
caching layer).
- Lives inside the worktree, so cleaned up naturally when the worktree is
deleted.
- The agent has read access by construction (it runs with the worktree's
cwd).
The main process must bootstrap `.orca/.gitignore` with `*\n!.gitignore\n`
before the first successful upload. Otherwise every SSH terminal drop dirties
source control with an untracked `.orca/` directory, which recreates the
repo-pollution problem that ruled out Option A for local terminal drops. The
`!.gitignore` negation keeps the marker file itself trackable if we ever want
to (and costs nothing today — `git status` stays clean either way because
nothing tries to add it).
The staging directory must be created recursively over SFTP before upload:
- create `${worktreePath}/.orca` (ignore "already exists")
- write `${worktreePath}/.orca/.gitignore` as `*\n!.gitignore\n` **only if it
does not already exist** — never overwrite. A user may have added patterns
there, and silently clobbering user-authored content violates least
surprise even inside an Orca-owned directory. Use `sftpPathExists` before
writing. (Two concurrent first-drops racing through the `sftpPathExists`
check will both write the same bytes — last writer wins, idempotent, so
the race is benign and not worth locking.)
- create `${worktreePath}/.orca/drops` (ignore "already exists")
Do not rely on `uploadFile`, `uploadDirectory`, or the existing
`mkdirSftp(destPath)` calls to create missing parents. `uploadFile` writes
directly to the final remote file path, and `mkdirSftp` is not recursive, so
the first terminal drop into a fresh SSH worktree would fail if the parents do
not already exist.
### Main-side implementation
`src/main/ipc/filesystem-mutations.ts`:
```ts
ipcMain.handle('fs:resolveDroppedPathsForAgent', async (_e, args) => {
// Why: `== null` (not `!args.connectionId`) so an empty string is treated
// as an error from the renderer, not silently routed to the local branch.
if (args.connectionId == null) {
return { resolvedPaths: args.paths, skipped: [], failed: [] }
}
const worktreePath = args.worktreePath.replace(/\/+$/, '')
const destDir = `${worktreePath}/.orca/drops`
const { results } = await importExternalPathsSsh(
args.paths,
destDir,
args.connectionId,
{ ensureDir: true },
)
const resolvedPaths: string[] = []
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []
const failed: { sourcePath: string; reason: string }[] = []
// Iterate in input order so injected paths align with the user's drop order.
for (const r of results) {
if (r.status === 'imported') {
resolvedPaths.push(r.destPath)
} else if (r.status === 'skipped') {
skipped.push({ sourcePath: r.sourcePath, reason: r.reason })
} else {
failed.push({ sourcePath: r.sourcePath, reason: r.reason })
}
}
return { resolvedPaths, skipped, failed }
})
```
Reuses `importExternalPathsSsh` — SFTP upload, symlink pre-scan, name
deconfliction, per-item error reporting are all already there.
**Staging bootstrap lives inside `importExternalPathsSsh`** behind a new
optional `{ ensureDir?: boolean }` parameter. When set, before the first
upload the function creates `${destDir}`'s parent chain (`.orca`, then
`drops`) and writes `.orca/.gitignore` (`*\n`) only if missing, all on the
**same SFTP session** already opened for the upload. Do not add a separate
`ensureSshDropStagingDir` helper that opens its own channel — that would
double the SFTP handshake cost on every drop.
### Renderer-side implementation
**API change to `shellEscapePath`.** Today the second arg is a *userAgent
string* (substring-matched for `"Windows"`), which couples escape rules to
the client OS. For SSH drops we need to escape for the *target shell*,
which is always POSIX on the remote regardless of client OS. Change the
signature to take an explicit target:
```ts
shellEscapePath(path: string, targetShell: 'posix' | 'windows')
```
Callers derive `targetShell` from context: local drops pass
`isWindowsUserAgent() ? 'windows' : 'posix'`; SSH drops always pass
`'posix'`. This makes test #11 (Windows client → Linux SSH worktree)
correct by construction instead of by coincidence.
**Migration — all three call sites + tests must change together:**
- `src/renderer/src/components/terminal-pane/pane-helpers.ts:53` — update
signature; drop the `userAgent` default.
- `src/renderer/src/components/terminal-pane/TerminalPane.tsx:937`
(file-explorer → terminal drop). This is a **local-only** code path
(explorer drag uses a DOM MIME type that the preload SSH bridge does not
forward), so pass `isWindowsUserAgent() ? 'windows' : 'posix'` to preserve
today's behavior exactly.
- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts:344`
— replaced by the new SSH-aware handler below; the `shellEscapePath` call
moves inside it with an explicit `targetShell`.
- `src/renderer/src/components/terminal-pane/pane-helpers.test.ts` — the
existing cases pass `'Macintosh'`, `'Linux'`, `'Windows'` as the userAgent
arg. Rewrite to pass `'posix'` (for Mac/Linux) and `'windows'`, so tests
exercise the new contract rather than the legacy substring match.
No local behavior changes if this migration is done in one commit: Mac/Linux
already took the POSIX branch via the userAgent string match; Windows
already took the Windows branch. The new signature just names that
explicitly.
`src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts`:
```ts
return window.api.ui.onFileDrop(async (data) => {
if (data.target !== 'terminal') return
if (data.paths.length === 0) return
const manager = managerRef.current
if (!manager) return
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) return
const paneId = pane.id
const transport = paneTransportsRef.current.get(paneId)
if (!transport) return
const wtId = worktreeIdRef.current
const worktreePath = worktreePathRef.current
if (!wtId || !worktreePath) return
// Why: getConnectionId (selector on the terminals/repos slice:
// `state.repos.find(r => r.id === <worktree's repoId>)?.connectionId`,
// exposed via the store) returns `string` (SSH), `null` (local repo
// found), or `undefined` (store not hydrated / worktree not found).
// Treat `undefined` as an error, not as "local" — otherwise a drop
// during hydration would silently paste local paths into a remote
// shell.
const connectionId = getConnectionId(wtId)
if (connectionId === undefined) {
toast.error('Worktree not ready — try again in a moment.')
return
}
const isRemote = connectionId !== null
const targetShell: 'posix' | 'windows' = isRemote
? 'posix'
: isWindowsUserAgent()
? 'windows'
: 'posix'
// Local fast path: no IPC round-trip, no toast. Preserves today's
// zero-latency behavior exactly — same code shape as before, only the
// shellEscapePath signature is new (and resolves to the same branch).
if (!isRemote) {
for (const p of data.paths) {
transport.sendInput(`${shellEscapePath(p, targetShell)} `)
}
pane.terminal.focus()
return
}
const pending = toast.loading(
`Uploading ${data.paths.length} file(s) to remote…`,
)
try {
const { resolvedPaths, skipped, failed } =
await window.api.fs.resolveDroppedPathsForAgent({
paths: data.paths,
worktreePath,
connectionId,
})
// Why: pane may have unmounted during the SFTP upload (tab closed,
// worktree switched). Re-check the transport map before writing so
// we don't call sendInput on a torn-down PTY. Orphaned uploads are
// acknowledged in Non-goals.
const liveTransport = paneTransportsRef.current.get(paneId)
if (liveTransport) {
// resolvedPaths preserves input order (main-side iterates results in
// order); injected paths line up with the user's drop gesture.
for (const p of resolvedPaths) {
liveTransport.sendInput(`${shellEscapePath(p, targetShell)} `)
}
pane.terminal.focus()
}
if (skipped.length > 0) {
const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length
const noun = skipped.length === 1 ? 'item' : 'items'
toast.message(
symlinkCount === skipped.length
? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.`
: `Skipped ${skipped.length} ${noun}.`,
)
}
if (failed.length > 0) {
const noun = failed.length === 1 ? 'file' : 'files'
toast.error(`Failed to upload ${failed.length} ${noun}.`)
}
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'))
} finally {
toast.dismiss(pending)
}
})
```
`extractIpcErrorMessage` is the existing helper at
`src/renderer/src/lib/ipc-error.ts:6` (already used by
`useFileExplorerImport.ts`, `Terminal.tsx`, etc.). Reuse it — do not copy
the body locally.
New dependencies on the hook: `worktreeId` and `worktreePath` refs. Use the
`TerminalPane`'s own `worktreeId` prop, not global `activeWorktreeId`. The
drop listener is already gated by `isActive`, and the pane's own
`worktreeId` is the authoritative identity of the terminal being written
to; reading from global state would race during worktree switches. Promote
this reasoning into a `// Why:` comment at the call site per CLAUDE.md.
Derive `worktreePath` the same way `use-terminal-pane-lifecycle.ts` does
today: find the worktree by `worktreeId` in the store, then fall back to
`cwd`.
### Why not branch in the renderer
- Keeps upload semantics in one place. Adding a second terminal consumer
(e.g. a standalone TUI pane) should call the same resolver instead of
deciding where to copy, how to deconflict names, or how to report per-item
failures.
- The renderer can know that the worktree is SSH for progress UI and shell
escaping, but it should not know SFTP details or construct uploaded
destination filenames itself.
### UX details
- **SSH "Uploading…" toast:** SFTP of a few MB can take seconds. Without
feedback the user thinks the drop failed. Dismiss on success, replace
with an error toast on failure.
- **Don't inject until upload resolves.** Injecting the remote path before
the file lands means the agent may try to read a file that doesn't yet
exist and error. Worth the extra perceived latency.
- **Failure policy:** partial failures still inject the succeeded paths
and toast the count of failures (same pattern as the explorer).
- **Escape for the terminal that receives the path.** Local drops keep the
existing platform-specific quoting. SSH drops must use POSIX shell quoting
for the returned remote paths; do not let a Windows client choose Windows
quoting for a Linux/macOS SSH shell.
## System fit
```
[Electron preload native drop]
|
v
[terminal:file-drop IPC relay]
|
v
[active TerminalPane drop handler]
|
v
[fs:resolveDroppedPathsForAgent]
| local | SSH
v v
[return original paths] [SFTP stage into ${worktreePath}/.orca/drops]
| |
| v
| [return remote readable paths]
| |
+---------------+-----------------+
|
v
[PTY sendInput escaped paths]
```
## Testing
1. Local worktree, drop a single file onto the terminal → original
absolute path pasted. No copy. No change from today.
2. Local worktree, drop multiple files → each path pasted separated by a
space. No change from today.
3. SSH worktree, drop a single file → file uploads to
`${worktreePath}/.orca/drops/<file>` on remote; remote path pasted
into PTY; agent can read it.
4. SSH worktree, drop a folder → folder uploads recursively; remote dir
path pasted.
5. SSH worktree, drop a symlink → no injection; toast reads
"Skipped 1 symlink." (not "Failed to upload") because symlink rejection
is policy, not error.
6. SSH worktree, drop 5 files where items 2 and 4 are permission-denied at
the local source → paths 1, 3, 5 are injected **in that order**
(matching input order), toast says "Skipped 2 items." (permission-denied
is classified `skipped`, not `failed`, by `importExternalPathsSsh`).
Additionally, simulate an SFTP write error mid-upload to cover the
`failed` branch → "Failed to upload N files" toast.
7. SSH worktree disconnected mid-drag → user-visible error toast, no
partial injection.
8. Name collision: drop the same file twice in quick succession → second
upload lands as `<name> copy.<ext>` (deconfliction inherited from
`importExternalPathsSsh`).
9. Fresh SSH worktree with no `.orca` directory → first drop creates
`.orca/`, `.orca/.gitignore`, and `.orca/drops/`; upload succeeds.
10. After an SSH drop, `git status --short` in the remote worktree does not
show `.orca/`.
11. Windows client dropping `my file's $draft.txt` into a Linux SSH worktree
pastes a POSIX-escaped remote path, not Windows double-quoted syntax.
Expected output for remote path `/home/u/wt/.orca/drops/my file's $draft.txt`:
`'/home/u/wt/.orca/drops/my file'\''s $draft.txt'` (literal `$draft`
inside single quotes — no expansion, no backslash-escaping).
16. Local terminal drop on macOS / Linux / Windows clients behaves
byte-identically to pre-change: same injected string, no toast, no IPC
call. Run the existing `pane-helpers.test.ts` expectations through the
new `'posix' | 'windows'` API and confirm outputs match the legacy
userAgent-based outputs.
17. File-explorer → terminal drop (`TerminalPane.tsx` onDrop) continues to
work on local and SSH worktrees exactly as today — this path is not
touched by the new IPC and must still use the explorer's own handling.
12. Empty drop (`data.paths.length === 0`) → no IPC call, no toast, no
injection. (Possible on some OSes when a drag contains only non-file
items.)
13. Store unhydrated (`getConnectionId` returns `undefined`) → user-visible
"Worktree not ready" toast, no injection, no IPC call. Not a silent
local fallback.
14. Existing user-authored `.orca/.gitignore` with extra patterns → after
first SSH drop the file is unchanged (bootstrap writes only when
missing).
15. Unit coverage:
- preload/API types expose `resolveDroppedPathsForAgent` and the
channel is registered in the preload `contextBridge` / IPC
allowlist (regression guard — easy to forget).
- main IPC covers: local passthrough, SSH success, partial failure
(order preserved), fresh-worktree staging bootstrap, bootstrap
preserves existing `.gitignore`, and disconnected SSH.
- terminal-pane coverage verifies the resolver is called once per
gesture, no path is injected until the promise resolves, and
`shellEscapePath` is called with `'posix'` for SSH drops regardless
of client userAgent.
## Follow-ups
File each of these as a GitHub issue before merging the implementation PR
so "tracked as follow-up" is actually locatable. Inline the issue number
next to each item once filed (e.g. `- GC drops dir (#1301)`), and update
the GC paragraph in Non-goals to point to that issue directly.
- GC `${worktreePath}/.orca/drops/` on worktree delete / disconnect.
- `AbortController` plumbing so unmounting the terminal pane cancels the
in-flight SFTP upload (related to the pane-unmount guard added in the
renderer handler: today we no-op the injection, but the bytes still
transfer).
- Drag-over affordance on the terminal pane (it has the
`data-native-file-drop-target="terminal"` marker but no hover style),
so users get feedback that dropping into the terminal is supported.