20 KiB
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). Useargs.connectionId == null(not!args.connectionId) so an empty string cannot silently pick the local branch. - SSH (
connectionIdis 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) inskipped, and hard upload errors infailed. The split mirrorsImportItemResult'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/.gitignoreas*\n!.gitignore\nonly 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. UsesftpPathExistsbefore writing. (Two concurrent first-drops racing through thesftpPathExistscheck 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:
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:
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 theuserAgentdefault.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 passisWindowsUserAgent() ? '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; theshellEscapePathcall moves inside it with an explicittargetShell.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:
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
- Local worktree, drop a single file onto the terminal → original absolute path pasted. No copy. No change from today.
- Local worktree, drop multiple files → each path pasted separated by a space. No change from today.
- SSH worktree, drop a single file → file uploads to
${worktreePath}/.orca/drops/<file>on remote; remote path pasted into PTY; agent can read it. - SSH worktree, drop a folder → folder uploads recursively; remote dir path pasted.
- SSH worktree, drop a symlink → no injection; toast reads "Skipped 1 symlink." (not "Failed to upload") because symlink rejection is policy, not error.
- 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, notfailed, byimportExternalPathsSsh). Additionally, simulate an SFTP write error mid-upload to cover thefailedbranch → "Failed to upload N files" toast. - SSH worktree disconnected mid-drag → user-visible error toast, no partial injection.
- Name collision: drop the same file twice in quick succession → second
upload lands as
<name> copy.<ext>(deconfliction inherited fromimportExternalPathsSsh). - Fresh SSH worktree with no
.orcadirectory → first drop creates.orca/,.orca/.gitignore, and.orca/drops/; upload succeeds. - After an SSH drop,
git status --shortin the remote worktree does not show.orca/. - Windows client dropping
my file's $draft.txtinto 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$draftinside single quotes — no expansion, no backslash-escaping). - 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.tsexpectations through the new'posix' | 'windows'API and confirm outputs match the legacy userAgent-based outputs. - File-explorer → terminal drop (
TerminalPane.tsxonDrop) 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. - 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.) - Store unhydrated (
getConnectionIdreturnsundefined) → user-visible "Worktree not ready" toast, no injection, no IPC call. Not a silent local fallback. - Existing user-authored
.orca/.gitignorewith extra patterns → after first SSH drop the file is unchanged (bootstrap writes only when missing). - Unit coverage:
- preload/API types expose
resolveDroppedPathsForAgentand the channel is registered in the preloadcontextBridge/ 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
shellEscapePathis called with'posix'for SSH drops regardless of client userAgent.
- preload/API types expose
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. AbortControllerplumbing 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.