fix: handle unhydrated store in file-explorer→terminal drop (#1296)

getConnectionId returns undefined during store hydration, which was
misclassified as remote by `!== null` check. Changed to `typeof === 'string'`
so unhydrated state falls through to client-OS quoting, consistent with
terminal-drop-handler.ts behavior.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-04-30 20:39:04 -07:00
committed by GitHub
co-authored by Orca
parent 3ef62afcdb
commit be0e3c2fa7
10 changed files with 750 additions and 29 deletions
+452
View File
@@ -0,0 +1,452 @@
# 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.
+45 -1
View File
@@ -12,7 +12,8 @@ import type { ImportItemResult } from './filesystem-mutations'
export async function importExternalPathsSsh(
sourcePaths: string[],
destDir: string,
connectionId: string
connectionId: string,
options?: { ensureDir?: boolean }
): Promise<{ results: ImportItemResult[] }> {
if (sourcePaths.length === 0) {
return { results: [] }
@@ -35,6 +36,17 @@ export async function importExternalPathsSsh(
const sftp = await conn.sftp()
try {
if (options?.ensureDir) {
// Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist
// before the first upload. Upload primitives do not create parent dirs,
// and mkdirSftp is not recursive — so walk the parent chain here on the
// same SFTP session to avoid doubling the handshake cost. Writing the
// .orca/.gitignore marker only when absent prevents clobbering user-
// authored patterns. .orca/ is reserved as Orca-owned remote state;
// see docs/terminal-drop-ssh.md.
await ensureDropStagingDir(sftp, destDir)
}
const results: ImportItemResult[] = []
const reservedNames = new Set<string>()
@@ -171,6 +183,38 @@ async function deconflictNameSftp(
)
}
async function ensureDropStagingDir(sftp: SFTPWrapper, destDir: string): Promise<void> {
// destDir is a posix remote path, expected to be `${worktreePath}/.orca/drops`.
const parent = posix.dirname(destDir)
await mkdirSftp(sftp, parent)
const gitignorePath = `${parent}/.gitignore`
if (!(await sftpPathExists(sftp, gitignorePath))) {
// Why: negate the marker so .orca/.gitignore itself is trackable if we
// ever want to, without dirtying `git status` today. Only write when
// absent to avoid clobbering user-authored patterns.
await writeSftpFile(sftp, gitignorePath, '*\n!.gitignore\n')
}
await mkdirSftp(sftp, destDir)
}
function writeSftpFile(sftp: SFTPWrapper, remotePath: string, contents: string): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const writeStream = sftp.createWriteStream(remotePath)
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
if (settled) {
return
}
settled = true
writeStream.destroy()
fn(val as never)
}
writeStream.on('close', () => settle(resolve))
writeStream.on('error', (err) => settle(reject, err))
writeStream.end(contents)
})
}
async function preScanForSymlinks(dirPath: string): Promise<boolean> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
+48 -1
View File
@@ -147,6 +147,53 @@ export function registerFilesystemMutationHandlers(store: Store): void {
return { results }
}
)
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
// upload each path into `${worktreePath}/.orca/drops/` and return remote
// paths the remote agent can read. Kept as a separate IPC from
// fs:importExternalPaths because terminal semantics differ from the
// explorer's "copy into user-picked destDir". See docs/terminal-drop-ssh.md.
ipcMain.handle(
'fs:resolveDroppedPathsForAgent',
async (
_event,
args: { paths: string[]; worktreePath: string; connectionId?: string }
): Promise<ResolveDroppedPathsResult> => {
// Why: `== null` (not `!args.connectionId`) so an empty string is
// treated as a renderer error, 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 }
}
)
}
export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
export type ResolveDroppedPathsResult = {
resolvedPaths: string[]
skipped: { sourcePath: string; reason: ImportSkipReason }[]
failed: { sourcePath: string; reason: string }[]
}
// ─── External Import Types ──────────────────────────────────────────
@@ -162,7 +209,7 @@ export type ImportItemResult =
| {
sourcePath: string
status: 'skipped'
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
reason: ImportSkipReason
}
| {
sourcePath: string
+12
View File
@@ -674,6 +674,18 @@ export type PreloadApi = {
}
)[]
}>
resolveDroppedPathsForAgent: (args: {
paths: string[]
worktreePath: string
connectionId?: string
}) => Promise<{
resolvedPaths: string[]
skipped: {
sourcePath: string
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}[]
failed: { sourcePath: string; reason: string }[]
}>
watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
+12
View File
@@ -1161,6 +1161,18 @@ const api = {
}
)[]
}> => ipcRenderer.invoke('fs:importExternalPaths', args),
resolveDroppedPathsForAgent: (args: {
paths: string[]
worktreePath: string
connectionId?: string
}): Promise<{
resolvedPaths: string[]
skipped: {
sourcePath: string
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}[]
failed: { sourcePath: string; reason: string }[]
}> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args),
watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('fs:watchWorktree', args),
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
@@ -13,6 +13,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers'
import { getConnectionId } from '@/lib/connection-context'
import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization'
import { createExpandCollapseActions } from './expand-collapse'
import { useTerminalKeyboardShortcuts, type SearchState } from './keyboard-handlers'
@@ -536,6 +537,13 @@ export default function TerminalPane({
useTerminalPaneGlobalEffects({
tabId,
// Why: use the pane's own `worktreeId` prop (not global activeWorktreeId)
// so the terminal-drop resolver routes to the worktree that actually owns
// this PTY. Reading from global state would race during worktree switches
// — the drop listener is already gated by `isActive`, and the pane's own
// id is the authoritative identity of the terminal being written to.
worktreeId,
cwd,
isActive,
isVisible,
managerRef,
@@ -934,7 +942,21 @@ export default function TerminalPane({
if (!transport) {
return
}
transport.sendInput(shellEscapePath(filePath))
// Why: the explorer passes the worktree-absolute path via a DOM
// MIME, so for SSH worktrees this is a remote POSIX path destined
// for the remote shell. Quote for the target shell (remote = posix)
// rather than the client OS; otherwise a Windows client dropping
// onto an SSH-Linux worktree would emit Windows-style quoting.
// Why: `typeof === 'string'` (not `!== null`) so an unhydrated
// store (`undefined`) is treated as local and falls through to
// client-OS quoting, rather than being misclassified as remote.
const isRemote = typeof getConnectionId(worktreeId) === 'string'
const targetShell: 'posix' | 'windows' = isRemote
? 'posix'
: isWindowsUserAgent()
? 'windows'
: 'posix'
transport.sendInput(shellEscapePath(filePath, targetShell))
// Move focus to the terminal so the user can keep typing where the
// dropped path just landed. Without this, focus stays on the file
// tree row that originated the drag and subsequent keystrokes do
@@ -13,28 +13,36 @@ describe('isWindowsUserAgent', () => {
describe('shellEscapePath', () => {
it('keeps safe POSIX paths unquoted', () => {
expect(shellEscapePath('/tmp/file.txt', 'Macintosh')).toBe('/tmp/file.txt')
expect(shellEscapePath('/tmp/file.txt', 'posix')).toBe('/tmp/file.txt')
})
it('single-quotes POSIX paths with shell-special characters', () => {
expect(shellEscapePath("/tmp/it's here.txt", 'Linux')).toBe("'/tmp/it'\\''s here.txt'")
expect(shellEscapePath("/tmp/it's here.txt", 'posix')).toBe("'/tmp/it'\\''s here.txt'")
})
it('keeps safe Windows paths unquoted', () => {
expect(shellEscapePath('C:\\Users\\orca\\file.txt', 'Windows')).toBe(
expect(shellEscapePath('C:\\Users\\orca\\file.txt', 'windows')).toBe(
'C:\\Users\\orca\\file.txt'
)
})
it('double-quotes Windows paths with spaces', () => {
expect(shellEscapePath('C:\\Users\\orca\\my file.txt', 'Windows')).toBe(
expect(shellEscapePath('C:\\Users\\orca\\my file.txt', 'windows')).toBe(
'"C:\\Users\\orca\\my file.txt"'
)
})
it('double-quotes Windows paths with cmd separators', () => {
expect(shellEscapePath('C:\\Users\\orca\\a&b.txt', 'Windows')).toBe(
expect(shellEscapePath('C:\\Users\\orca\\a&b.txt', 'windows')).toBe(
'"C:\\Users\\orca\\a&b.txt"'
)
})
it('uses POSIX escaping for SSH drops regardless of client OS', () => {
// A Windows client dropping into a Linux SSH worktree must produce POSIX
// quoting, not Windows double-quotes (see docs/terminal-drop-ssh.md).
expect(shellEscapePath("/home/u/wt/.orca/drops/my file's $draft.txt", 'posix')).toBe(
"'/home/u/wt/.orca/drops/my file'\\''s $draft.txt'"
)
})
})
@@ -50,11 +50,12 @@ export function isMacUserAgent(
return userAgent.includes('Mac')
}
export function shellEscapePath(
path: string,
userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent
): string {
if (isWindowsUserAgent(userAgent)) {
// Why: escape rules are a property of the *target* shell receiving the path,
// not the client OS. A Windows client dropping onto a Linux SSH worktree must
// produce POSIX-quoted output; passing a userAgent string here coupled escape
// rules to the client and silently misquoted cross-platform SSH drops.
export function shellEscapePath(path: string, targetShell: 'posix' | 'windows'): string {
if (targetShell === 'windows') {
return /^[a-zA-Z0-9_./@:\\-]+$/.test(path) ? path : `"${path}"`
}
@@ -0,0 +1,121 @@
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { useAppStore } from '@/store'
import { isWindowsUserAgent, shellEscapePath } from './pane-helpers'
import type { PtyTransport } from './pty-transport'
type Args = {
manager: PaneManager
paneTransports: Map<number, PtyTransport>
worktreeId: string
cwd: string | undefined
data: { paths: string[]; target: string }
}
/**
* Handle a native file drop targeted at a terminal pane.
*
* Local worktrees: paste the local absolute path (reference-in-place; no copy
* or IPC). SSH worktrees: upload each file into `${worktreePath}/.orca/drops`
* and paste the remote path so the remote agent can read it. See
* docs/terminal-drop-ssh.md.
*/
export async function handleTerminalFileDrop(args: Args): Promise<void> {
const { manager, paneTransports, worktreeId, cwd, data } = args
if (data.paths.length === 0) {
return
}
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
const paneId = pane.id
const transport = paneTransports.get(paneId)
if (!transport) {
return
}
// Why: `getConnectionId` returns `string` (SSH), `null` (local repo found),
// or `undefined` (store not hydrated / worktree not found). Treat
// `undefined` as an error — otherwise a drop during hydration would
// silently paste local paths into a remote shell.
const connectionId = getConnectionId(worktreeId)
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'
// Why: local fast path — no IPC round-trip, no toast — preserves today's
// zero-latency drop behavior. Trailing space separates multiple paths in
// the terminal input, matching standard drag-and-drop UX conventions.
if (!isRemote) {
for (const p of data.paths) {
transport.sendInput(`${shellEscapePath(p, targetShell)} `)
}
pane.terminal.focus()
return
}
const worktreePath = resolveWorktreePath(worktreeId, cwd)
if (!worktreePath) {
toast.error('Worktree path not available.')
return
}
const pending = toast.loading(
`Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : '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 an
// acknowledged limitation — see docs/terminal-drop-ssh.md.
const liveTransport = paneTransports.get(paneId)
if (liveTransport) {
for (const p of resolvedPaths) {
liveTransport.sendInput(`${shellEscapePath(p, targetShell)} `)
}
pane.terminal.focus()
}
if (skipped.length > 0) {
// Why: symlink rejection is policy, not error — show as neutral
// message. Mixed skips collapse to a single "items" count to avoid
// enumerating every reason.
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)
}
}
function resolveWorktreePath(worktreeId: string, fallbackCwd: string | undefined): string | null {
const state = useAppStore.getState()
const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
return worktree?.path ?? fallbackCwd ?? null
}
@@ -6,12 +6,14 @@ import {
type FocusTerminalPaneDetail
} from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { shellEscapePath } from './pane-helpers'
import { fitAndFocusPanes, fitPanes, hasDimensionsChanged } from './pane-helpers'
import type { PtyTransport } from './pty-transport'
import { handleTerminalFileDrop } from './terminal-drop-handler'
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
worktreeId: string
cwd?: string
isActive: boolean
isVisible: boolean
managerRef: React.RefObject<PaneManager | null>
@@ -25,6 +27,8 @@ type UseTerminalPaneGlobalEffectsArgs = {
export function useTerminalPaneGlobalEffects({
tabId,
worktreeId,
cwd,
isActive,
isVisible,
managerRef,
@@ -35,6 +39,10 @@ export function useTerminalPaneGlobalEffects({
isVisibleRef,
toggleExpandPane
}: UseTerminalPaneGlobalEffectsArgs): void {
const worktreeIdRef = useRef(worktreeId)
worktreeIdRef.current = worktreeId
const cwdRef = useRef(cwd)
cwdRef.current = cwd
// Why: starts as `true` so the first render with isVisible=false triggers
// suspendRendering(). Without this, background worktrees that mount hidden
// (isVisible=false from the start) never suspend their WebGL contexts —
@@ -326,23 +334,17 @@ export function useTerminalPaneGlobalEffects({
if (!manager) {
return
}
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
const wtId = worktreeIdRef.current
if (!wtId) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
if (!transport) {
return
}
// Why: preload consumes native OS drops before React sees them, so the
// terminal cannot rely on DOM `drop` events for external files. Reusing
// the active PTY transport preserves the existing CLI behavior for drag-
// and-drop path insertion instead of opening those files in the editor.
// Why: appending a trailing space keeps multiple paths separated in the
// terminal input, matching standard drag-and-drop UX conventions.
for (const path of data.paths) {
transport.sendInput(`${shellEscapePath(path)} `)
}
void handleTerminalFileDrop({
manager,
paneTransports: paneTransportsRef.current,
worktreeId: wtId,
cwd: cwdRef.current,
data
})
})
}, [isActive, managerRef, paneTransportsRef])
}