feat: allow dropping external files into the file explorer (#618)

Add support for dragging files from the OS into the file explorer sidebar.
Files are copied (not moved) into the target directory within the worktree.

- Preload detects native file drops via capture-phase listener and resolves
  the drop target (editor, terminal, or file-explorer) from DOM attributes
- New IPC handler `fs:importExternalPaths` copies files/directories with
  symlink pre-scanning, path-traversal protection, and dedup on conflict
- FileExplorer tracks native drag state separately from internal drag state
  to show correct drop highlights without interfering with tree reordering
- FileExplorerRow expands directories on hover during native drags
- useFileExplorerImport hook subscribes to preload IPC events and drives
  the import → refresh → reveal pipeline

Includes review fixes:
- Clear nativeDropTargetDir on row drag-leave to prevent stale highlights
- Clear native drag state on early return in useFileExplorerImport
- Extract row drag logic to useFileExplorerRowDrag hook
- Split import tests into dedicated filesystem-import.test.ts
- Use T[] syntax instead of Array<T> per lint rules
- Use ternary expressions for simple if/else per lint rules
This commit is contained in:
Jinjing 2026-04-13 22:04:49 -07:00 committed by GitHub
parent 975328e258
commit 28a1c93c8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1587 additions and 243 deletions

View file

@ -0,0 +1,374 @@
# Design Document: External File Drop Import in File Explorer
## 1. Overview
Orca's file explorer already supports drag-and-drop for moving items that originate inside the explorer, but it does not support dropping files or folders from the OS into the explorer to add them to the active worktree.
This document proposes a native file-drop import flow that works in both places users expect:
- Dropping onto the explorer background imports into the worktree root.
- Dropping onto a directory row imports into that directory.
- Dropping onto a file row imports into that file's parent directory.
The design follows the same high-level split used by VS Code: external/native drops are handled as imports, while in-explorer drags remain move operations. Superset is a useful reference for renderer-side drag-state handling: it treats `Files` drags as a distinct UX path with explicit hover state instead of trying to force them through the in-app DnD codepath.
## 2. Goals
- Let users drop external files and folders from Finder/Explorer/Linux file managers into Orca's file explorer.
- Support root-level drops and nested directory drops.
- Preserve the current in-explorer move behavior for `text/x-orca-file-path`.
- Keep the implementation cross-platform across macOS, Linux, and Windows.
- Avoid destructive overwrites by default.
- Keep the import path performant for large multi-file and directory drops.
## 3. Non-Goals
- No drag-out export from Orca to the OS.
- No cross-worktree move semantics for external drops. External drops always copy/import.
- No full upload/progress manager in v1.
- No overwrite prompt flow in v1.
## 4. Current State
Today the relevant pieces are split across three layers:
- `src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts` and `FileExplorerRow.tsx` handle only internal explorer drags via `text/x-orca-file-path`, and complete the action with `window.api.fs.rename(...)`.
- `src/preload/index.ts` intercepts native OS drops before React sees them and classifies them only as `editor` or `terminal`.
- `src/renderer/src/hooks/useGlobalFileDrop.ts` opens dropped files in the editor, while terminal panes insert dropped paths into the active PTY.
That means the explorer never receives a native-drop route, and there is no filesystem API that copies a dropped file tree into the worktree.
## 5. Reference Behavior
### 5.1 VS Code
VS Code explicitly separates:
- native drag/drop import (`NativeDragAndDropData` -> `ExternalFileImport.import(...)`)
- in-explorer drag/drop move/copy (`handleExplorerDrop(...)`)
The important design takeaway is not the exact API shape. It is that external drops resolve a destination directory first and then run an import pipeline instead of trying to reuse the internal move path.
### 5.2 Superset
Superset's desktop app uses renderer-side `onDragOver` / `onDragLeave` / `onDrop` handling keyed off `e.dataTransfer.types.includes("Files")`, with explicit hover UI and defensive `getPathForFile(...)` handling.
The useful precedent for Orca is the UX structure:
- treat native file drags as their own interaction mode
- show a clear copy/import affordance
- clear drag state reliably on `drop` and `dragend`
## 6. Proposed UX
When the user drags external files over the file explorer:
- The explorer root shows a copy/import highlight when the drop target is the worktree root.
- Directory rows highlight as valid copy targets.
- Hovering a collapsed directory during a native drag auto-expands it after the same delay used for internal moves.
- The cursor uses `dropEffect = "copy"`.
The explorer root drop surface must remain available even when the tree is empty, still loading, or showing a read error. In v1, the right sidebar should keep rendering a root-level explorer container for those states so users can still drop into the worktree root instead of losing the target entirely.
This requires restructuring the current early-return branches in `FileExplorer.tsx`. Today the component returns dedicated loading / error / empty placeholders before it renders the shared `ScrollArea`, so adding dataset markers only to the existing populated-tree path would still leave root import unavailable in those states.
When the user drops:
- On explorer background: import into the worktree root.
- On directory row: import into that directory.
- On file row: import into the parent directory.
In v1, "inside the folder" means dropping on that folder's row. Orca's explorer is a virtualized flat list rather than nested DOM containers, so arbitrary whitespace under a folder's rendered children is not treated as a separate interior drop zone.
After completion:
- Refresh the destination directory.
- Reveal and flash the first imported path.
- Show a summary toast, for example `Imported 3 items to src/components`.
The explorer should not auto-open dropped files in v1. Dropping into the explorer is an "add here" action, not an "open in editor" action.
## 7. Architecture
### 7.1 Extend Native Drop Routing
Add a third native-drop target in preload:
- `editor`
- `terminal`
- `file-explorer`
The current `getNativeFileDropTarget(...)` helper in `src/preload/index.ts` should become a richer resolver that walks `event.composedPath()` and extracts:
- the high-level target kind
- the nearest explorer destination directory, if any
The explorer DOM should expose two dataset markers:
- `data-native-file-drop-target="file-explorer"` on the root scroll area
- `data-native-file-drop-dir="<absolute dir path>"` on the root container and on each row drop target
Routing must fail closed for explorer drops. If preload sees `data-native-file-drop-target="file-explorer"` but cannot resolve a `destinationDir`, it should reject the gesture and emit no fallback `editor` drop event.
Why this is necessary: the preload layer consumes native OS `drop` events before React can read filesystem paths. If preload does not capture the destination directory at drop time, the renderer can no longer tell whether the user meant "root" or "inside this folder".
The relayed payload should become:
```ts
type NativeFileDropEvent =
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
```
Preload/main must emit exactly one native-drop event per drop gesture.
Why: the preload layer already has the full `FileList`. Re-emitting one IPC message per path and asking the renderer to reconstruct the gesture via timing would be both fragile and slower under large drops.
**Impact on existing listeners:**
Because the relay payload changes from `{ path: string }` to `{ paths: string[] }`, existing `ui.onFileDrop` handlers in:
- `src/renderer/src/hooks/useGlobalFileDrop.ts` (editor target)
- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts` (terminal target)
must be updated to loop over the `paths` array.
### 7.2 Renderer Explorer Drag State
`useFileExplorerDragDrop(...)` should handle two drag families:
- internal Orca drags: `text/x-orca-file-path`
- external/native drags: `Files`
The existing move logic stays unchanged for internal drags.
For native drags, the hook should:
- accept `Files` in root and row `onDragOver`
- set copy affordance and hover state
- reuse the existing row auto-expand timer for directory targets
- clear root/row highlight on `dragleave`, `dragend`, and after the import event fires
The hook should use the same explicit destination model as the drop router:
- root background -> worktree root
- directory row -> that directory
- file row -> file's parent directory
This follows Superset's approach of treating external drags as a distinct renderer interaction, even though the final import action is triggered from the preload-delivered event instead of the React `drop` handler.
### 7.3 New Filesystem Import IPC
Add a dedicated filesystem mutation:
```ts
window.api.fs.importExternalPaths({
sourcePaths: string[],
destDir: string
}): Promise<{
results: Array<
| {
sourcePath: string
status: 'imported'
destPath: string
kind: 'file' | 'directory'
renamed: boolean
}
| {
sourcePath: string
status: 'skipped'
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}
| {
sourcePath: string
status: 'failed'
reason: string
}
>
}>
```
Implementation lives alongside the existing filesystem mutations in `src/main/ipc/filesystem-mutations.ts`.
Behavior:
1. Authorize every source path with the existing external-path mechanism.
2. Validate every source path from its unresolved path using `lstat(...)` before any canonicalization so top-level symlinks are rejected instead of being silently dereferenced by `realpath(...)`.
3. Resolve `destDir` through `resolveAuthorizedPath(...)`.
4. Copy, never rename.
5. Support both files and directories.
6. Return per-top-level-item results so the renderer can produce correct summary UX for success, partial success, and renames.
### 7.4 Copy Semantics
Use recursive copy semantics in the main process:
- File source: copy file bytes.
- Directory source: create the top-level directory, then recursively copy descendants.
This should be implemented in Node-side filesystem code, not in the renderer, so path authorization and cross-platform behavior stay centralized.
### 7.5 Atomic Import Rules
Directory imports must be atomic at the top-level item boundary.
Required behavior:
- Before importing a dropped directory, pre-scan that directory tree for disallowed entries such as symlinks.
- Source validation must inspect the dropped path itself with `lstat(...)` before calling helpers like `resolveAuthorizedPath(...)` that canonicalize existing paths.
- If the pre-scan finds a disallowed entry, skip that top-level source entirely.
- Do not create any destination files or directories for a top-level source that fails pre-scan.
Why: if recursive copy discovers a symlink halfway through, Orca would otherwise leave a partially imported tree behind. Pre-scan is the preferred v1 design because it is simpler and more performant than temp-directory staging while still avoiding partial output.
## 8. Conflict Policy
v1 should be non-destructive and prompt-free:
- Never overwrite an existing file or folder.
- If a top-level dropped item collides with an existing name in `destDir`, generate a unique sibling name before copying.
Examples:
- `logo.png` -> `logo copy.png`
- `logo.png` -> `logo copy 2.png`
- `assets/` -> `assets copy/`
This matches Orca's current bias toward safe filesystem mutations and avoids blocking the drop on a modal confirmation flow.
Top-level deconfliction is sufficient for dropped directories because the copy target becomes a newly created directory. Once that top-level directory name is unique, nested collisions disappear inside that subtree.
If multiple dropped items collide with each other, the same deconfliction pass should run against the union of:
- already existing destination entries
- names reserved earlier in the same import batch
The result payload must preserve whether each successful import was renamed by deconfliction.
## 9. Symlink Policy
Reject symlinks in v1.
Rationale:
- Symlink copy semantics differ across platforms.
- Copying a symlink literal can produce confusing repository state.
- Following symlinks can escape the dropped subtree and import unintended content.
If a dropped source or descendant is a symlink, fail that top-level item and surface a toast summary such as `Skipped 1 item containing symlinks`.
## 10. Renderer Flow
The renderer-side import path should be:
1. `window.api.ui.onFileDrop(...)` receives one gesture-scoped event: `{ target: 'file-explorer', paths, destinationDir }`.
2. The explorer calls `window.api.fs.importExternalPaths({ sourcePaths: paths, destDir: destinationDir })`.
3. On success or partial success, it calls `refreshDir(destinationDir)` once.
4. It reveals and flashes the first successfully imported destination path, if any, by routing through Orca's existing expansion-aware reveal pipeline (`pendingExplorerReveal` / `useFileExplorerReveal`) or an equivalent mechanism that can expand collapsed ancestors before selecting the imported path.
5. It clears drag state and shows one summary toast derived from the returned per-item results.
## 11. Error Handling
Failure modes should be explicit but non-destructive:
- Source path no longer exists: skip and report.
- Permission denied: fail the affected item and report.
- Unsupported symlink: skip and report.
- Destination path unauthorized: fail the whole import.
Toast copy should summarize, not spam:
- Success: `Imported 5 items to src`
- Partial: `Imported 4 items to src. 1 item was skipped.`
- Failure: `Could not import dropped items`
The renderer should derive these counts from the returned result payload rather than inferring them from thrown exceptions.
## 12. Watcher and Refresh Strategy
The filesystem watcher in `useFileExplorerWatch.ts` is a useful backstop, but the import flow should still refresh explicitly after completion.
Why: native drops can create many files quickly, and the UX should not depend on watcher timing to make the destination directory show the new content.
The minimal explicit refresh is:
- `refreshDir(destinationDir)` after import
If imported content lands under directories that were already expanded, the watcher can reconcile the rest.
## 13. Performance
Performance is a core requirement for this feature.
### 13.1 Event Routing
- Preload should extract the native `FileList` once and relay one IPC event per drop gesture.
- The renderer should not do timer-based gesture reconstruction or emit one IPC call per dropped path.
### 13.2 Main-Process Import
- Import work must stay in the main process so the renderer remains responsive.
- The copy path should use native filesystem copy primitives or streaming I/O, not `readFile()` buffering whole files into memory.
- Pre-scan should walk dropped directories once per top-level source to detect symlinks before copy starts.
- Top-level deconfliction should happen once per dropped source before the copy loop, avoiding repeated deep-path collision checks.
### 13.3 UI Updates
- The renderer should call `refreshDir(destinationDir)` once per completed gesture.
- The renderer should emit one summary toast per gesture.
- The renderer should reveal only the first successful import rather than forcing multiple scroll/reveal passes.
### 13.4 Scope Control
To keep v1 fast and predictable, it should not include per-file progress rows, per-item toasts, or overwrite prompts inside the copy loop.
## 14. Testing
### 14.1 Preload / Main
- target resolution picks `file-explorer` when the composed path contains explorer markers
- nearest `data-native-file-drop-dir` wins over outer containers
- relay payload includes `destinationDir`
- relay emits one event containing all dropped `paths`
- file-explorer routing fails closed when the target marker is present but `destinationDir` is missing
- editor-target drops still open every dropped file from the single gesture payload
- terminal-target drops still insert every dropped path from the single gesture payload
### 14.2 Filesystem Mutation Tests
- imports a single file
- imports multiple files in one batch
- imports a directory recursively
- deconflicts top-level filename collisions
- deconflicts top-level directory collisions
- rejects top-level symlink sources before canonicalization
- skips a dropped directory with nested symlinks without leaving partial output
- rejects unauthorized destinations
- returns per-item results including rename metadata
### 14.3 Renderer Tests
- root drop surface remains active while the explorer is empty, loading, or showing a root read error
- root highlight appears for `Files` drag
- directory rows highlight and auto-expand for `Files` drag
- file rows map to parent directory targets
- one drop gesture triggers one import IPC call
- one drop gesture produces one summary toast
- success reveals the first imported destination path
- success reveal still works when the imported path lives under ancestors that were collapsed before the drop
## 15. Implementation Plan
1. Extend native-drop typing and relay payload in preload/main to send one event per drop gesture with `paths[]`.
2. Update existing editor and terminal `ui.onFileDrop` handlers in the renderer to accept `paths[]`.
3. Refactor `FileExplorer.tsx` so the shared root explorer container stays mounted for loading, error, and empty states, then add explorer DOM markers for root and per-row destination directories.
4. Teach `useFileExplorerDragDrop(...)` to track native `Files` drag state separately from internal move state.
5. Add `fs.importExternalPaths(...)` to preload typings and main IPC with a per-item result schema.
6. Implement pre-scan + recursive copy + top-level deconfliction + symlink rejection in `filesystem-mutations.ts`.
7. Wire the explorer import success path through the existing expansion-aware reveal flow so drops into collapsed folders still reveal correctly.
8. Add renderer import handling, refresh, reveal, and one-toast-per-gesture summary handling.
9. Add tests across preload, IPC, and renderer.
## 16. Open Questions
- Whether v2 should support an overwrite confirmation flow like VS Code instead of prompt-free deconfliction.
- Whether v2 should show progress UI for large directory imports.
- Whether symlink rejection should later become a user-visible choice for trusted repos.

View file

@ -0,0 +1,331 @@
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 } = vi.hoisted(
() => ({
handleMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
readdirMock: 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
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
const REPO_PATH = path.resolve('/workspace/repo')
const WORKSPACE_DIR = path.resolve('/workspace')
const store = {
getRepos: () => [
{ id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
],
getSettings: () => ({ workspaceDir: WORKSPACE_DIR })
}
function enoent(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
describe('fs:importExternalPaths', () => {
const destDir = path.resolve('/workspace/repo/src')
function mockSourceFile(filePath: string): void {
const resolvedPath = path.resolve(filePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
}
function mockSourceDir(dirPath: string, entries: { name: string; isDir: boolean }[]): void {
const resolvedDir = path.resolve(dirPath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedDir) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
throw enoent()
})
readdirMock.mockImplementation(async () => {
return entries.map((e) => ({
name: e.name,
isDirectory: () => e.isDir,
isSymbolicLink: () => false,
isFile: () => !e.isDir
}))
})
}
function mockSymlinkSource(filePath: string): void {
const resolvedPath = path.resolve(filePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
}
throw enoent()
})
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
lstatMock.mockReset()
mkdirMock.mockReset()
realpathMock.mockReset()
copyFileMock.mockReset()
readdirMock.mockReset()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
})
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
mkdirMock.mockResolvedValue(undefined)
copyFileMock.mockResolvedValue(undefined)
readdirMock.mockResolvedValue([])
registerFilesystemMutationHandlers(store as never)
})
it('imports a single file', async () => {
const sourcePath = '/tmp/dropped/logo.png'
mockSourceFile(sourcePath)
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: unknown[] }
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({
status: 'imported',
kind: 'file',
renamed: false,
destPath: path.join(destDir, 'logo.png')
})
expect(copyFileMock).toHaveBeenCalledWith(
path.resolve(sourcePath),
path.join(destDir, 'logo.png')
)
})
it('imports multiple files in one batch', async () => {
const sources = ['/tmp/dropped/a.txt', '/tmp/dropped/b.txt']
lstatMock.mockImplementation(async (p: string) => {
const resolved = [path.resolve(sources[0]), path.resolve(sources[1])]
if (resolved.includes(p)) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: sources,
destDir
})) as { results: { status: string }[] }
expect(result.results).toHaveLength(2)
expect(result.results[0]).toMatchObject({ status: 'imported' })
expect(result.results[1]).toMatchObject({ status: 'imported' })
})
it('imports a directory recursively', async () => {
const sourcePath = '/tmp/dropped/assets'
mockSourceDir(sourcePath, [
{ name: 'icon.png', isDir: false },
{ name: 'fonts', isDir: true }
])
readdirMock
.mockResolvedValueOnce([
{
name: 'icon.png',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
},
{ name: 'fonts', isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false }
])
.mockResolvedValue([])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; kind?: string }[] }
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({
status: 'imported',
kind: 'directory',
renamed: false
})
expect(mkdirMock).toHaveBeenCalled()
})
it('deconflicts top-level filename collisions', async () => {
const sourcePath = '/tmp/dropped/logo.png'
const existingDest = path.join(destDir, 'logo.png')
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sourcePath)) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
if (p === existingDest) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; destPath?: string; renamed?: boolean }[] }
expect(result.results[0]).toMatchObject({
status: 'imported',
destPath: path.join(destDir, 'logo copy.png'),
renamed: true
})
})
it('deconflicts top-level directory collisions', async () => {
const sourcePath = '/tmp/dropped/assets'
const existingDest = path.join(destDir, 'assets')
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sourcePath)) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
if (p === existingDest) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
throw enoent()
})
readdirMock.mockResolvedValue([])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; destPath?: string; renamed?: boolean }[] }
expect(result.results[0]).toMatchObject({
status: 'imported',
destPath: path.join(destDir, 'assets copy'),
renamed: true
})
})
it('rejects top-level symlink sources before canonicalization', async () => {
const sourcePath = '/tmp/dropped/link.txt'
mockSymlinkSource(sourcePath)
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
expect(copyFileMock).not.toHaveBeenCalled()
})
it('skips a dropped directory with nested symlinks without leaving partial output', async () => {
const sourcePath = '/tmp/dropped/mixeddir'
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sourcePath)) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
throw enoent()
})
readdirMock.mockResolvedValue([
{
name: 'normal.txt',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
},
{
name: 'bad-link',
isDirectory: () => false,
isSymbolicLink: () => true,
isFile: () => false
}
])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
expect(copyFileMock).not.toHaveBeenCalled()
})
it('rejects unauthorized destinations', async () => {
const sourcePath = '/tmp/dropped/file.txt'
mockSourceFile(sourcePath)
realpathMock.mockImplementation(async (p: string) => {
if (p === path.resolve('/outside/evil')) {
return path.resolve('/outside/evil')
}
return p
})
await expect(
handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir: '/outside/evil'
})
).rejects.toThrow('Access denied')
})
it('returns per-item results including rename metadata', async () => {
const sources = ['/tmp/dropped/a.txt', '/tmp/dropped/missing.txt']
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sources[0])) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: sources,
destDir
})) as {
results: {
sourcePath: string
status: string
reason?: string
renamed?: boolean
}[]
}
expect(result.results).toHaveLength(2)
expect(result.results[0]).toMatchObject({
sourcePath: sources[0],
status: 'imported',
renamed: false
})
expect(result.results[1]).toMatchObject({
sourcePath: sources[1],
status: 'skipped',
reason: 'missing'
})
})
})

View file

@ -22,7 +22,9 @@ vi.mock('fs/promises', () => ({
mkdir: mkdirMock,
rename: renameMock,
writeFile: writeFileMock,
realpath: realpathMock
realpath: realpathMock,
copyFile: vi.fn(),
readdir: vi.fn()
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
@ -95,7 +97,9 @@ describe('registerFilesystemMutationHandlers', () => {
writeFileMock.mockRejectedValue(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }))
await expect(
handlers.get('fs:createFile')!(null, { filePath: path.resolve('/workspace/repo/existing.ts') })
handlers.get('fs:createFile')!(null, {
filePath: path.resolve('/workspace/repo/existing.ts')
})
).rejects.toThrow("A file or folder named 'existing.ts' already exists in this location")
})

View file

@ -1,8 +1,8 @@
import { ipcMain } from 'electron'
import { lstat, mkdir, rename, writeFile } from 'fs/promises'
import { basename, dirname } from 'path'
import { copyFile, lstat, mkdir, readdir, rename, writeFile } from 'fs/promises'
import { basename, dirname, join, resolve } from 'path'
import type { Store } from '../persistence'
import { resolveAuthorizedPath, isENOENT } from './filesystem-auth'
import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
/**
@ -109,4 +109,230 @@ export function registerFilesystemMutationHandlers(store: Store): void {
await rename(oldPath, newPath)
}
)
ipcMain.handle(
'fs:importExternalPaths',
async (
_event,
args: { sourcePaths: string[]; destDir: string }
): Promise<{ results: ImportItemResult[] }> => {
// Why: destDir must be authorized before any copy work begins. If the
// destination is outside allowed roots, the entire import fails.
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) {
const result = await importOneSource(sourcePath, resolvedDest, reservedNames)
results.push(result)
if (result.status === 'imported') {
reservedNames.add(basename(result.destPath))
}
}
return { results }
}
)
}
// ─── External Import Types ──────────────────────────────────────────
export type ImportItemResult =
| {
sourcePath: string
status: 'imported'
destPath: string
kind: 'file' | 'directory'
renamed: boolean
}
| {
sourcePath: string
status: 'skipped'
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}
| {
sourcePath: string
status: 'failed'
reason: string
}
// ─── External Import Implementation ─────────────────────────────────
/**
* Import a single top-level source into destDir, handling authorization,
* validation, pre-scan, deconfliction, and copy.
*/
async function importOneSource(
sourcePath: string,
destDir: string,
reservedNames: Set<string>
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
// Why: authorize the external source path so downstream filesystem
// operations (lstat, readdir, copyFile) are permitted by Electron.
authorizeExternalPath(resolvedSource)
// Why: validate source using lstat on the unresolved path *before*
// canonicalization so top-level symlinks are rejected instead of being
// silently dereferenced by realpath.
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)
}
}
// Why: reject symlinks in v1 — symlink copy semantics differ across
// platforms, and following them can escape the dropped subtree.
if (sourceStat.isSymbolicLink()) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
return { sourcePath, status: 'skipped', reason: 'unsupported' }
}
const isDir = sourceStat.isDirectory()
// Why: for directories, pre-scan the entire tree for symlinks before
// creating any destination files. This prevents partially imported
// trees when a symlink is discovered halfway through recursive copy.
if (isDir) {
const hasSymlink = await preScanForSymlinks(resolvedSource)
if (hasSymlink) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
}
// Top-level deconfliction: generate a unique name if collision exists
const originalName = basename(resolvedSource)
const finalName = await deconflictName(destDir, originalName, reservedNames)
const destPath = join(destDir, finalName)
const renamed = finalName !== originalName
try {
await (isDir ? recursiveCopyDir(resolvedSource, destPath) : copyFile(resolvedSource, destPath))
} catch (error) {
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
return {
sourcePath,
status: 'imported',
destPath,
kind: isDir ? 'directory' : 'file',
renamed
}
}
/**
* Pre-scan a directory tree for symlinks. Returns true if any symlink
* is found anywhere in the subtree.
*/
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
}
/**
* Recursively copy a directory and all its contents. Uses copyFile for
* individual files to leverage native OS copy primitives instead of
* buffering entire files into memory.
*/
async function recursiveCopyDir(srcDir: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true })
const entries = await readdir(srcDir, { withFileTypes: true })
for (const entry of entries) {
const srcPath = join(srcDir, entry.name)
const dstPath = join(destDir, entry.name)
await (entry.isDirectory() ? recursiveCopyDir(srcPath, dstPath) : copyFile(srcPath, dstPath))
}
}
/**
* Generate a unique sibling name in destDir to avoid overwriting existing
* files or colliding with other items in the same import batch.
*
* Pattern: "name copy.ext", "name copy 2.ext", "name copy 3.ext", etc.
* For directories: "name copy", "name copy 2", "name copy 3", etc.
*/
async function deconflictName(
destDir: string,
originalName: string,
reservedNames: Set<string>
): Promise<string> {
if (!(await nameExists(destDir, originalName)) && !reservedNames.has(originalName)) {
return originalName
}
const dotIndex = originalName.lastIndexOf('.')
// Treat the entire name as stem for dotfiles or names without extensions
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 nameExists(destDir, candidate)) && !reservedNames.has(candidate)) {
return candidate
}
let counter = 2
while (counter < 10000) {
candidate = `${stem} copy ${counter}${ext}`
if (!(await nameExists(destDir, candidate)) && !reservedNames.has(candidate)) {
return candidate
}
counter += 1
}
// Extremely unlikely fallback
throw new Error(
`Could not generate a unique name for '${originalName}' after ${counter} attempts`
)
}
async function nameExists(dir: string, name: string): Promise<boolean> {
try {
await lstat(join(dir, name))
return true
} catch (error) {
if (isENOENT(error)) {
return false
}
throw error
}
}

View file

@ -145,14 +145,20 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void {
ipcMain.removeAllListeners('terminal:file-dropped-from-preload')
ipcMain.on(
'terminal:file-dropped-from-preload',
(_event, args: { paths: string[]; target: 'editor' | 'terminal' }) => {
(
_event,
args:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => {
if (mainWindow.isDestroyed()) {
return
}
for (const path of args.paths) {
mainWindow.webContents.send('terminal:file-drop', { path, target: args.target })
}
// Why: relay exactly one IPC event per drop gesture so the renderer
// receives the full batch of paths without timer-based reconstruction.
mainWindow.webContents.send('terminal:file-drop', args)
}
)
}

View file

@ -389,6 +389,27 @@ export type PreloadApi = {
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
listFiles: (args: { rootPath: string; connectionId?: string }) => Promise<string[]>
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
importExternalPaths: (args: { sourcePaths: string[]; destDir: string }) => Promise<{
results: (
| {
sourcePath: string
status: 'imported'
destPath: string
kind: 'file' | 'directory'
renamed: boolean
}
| {
sourcePath: string
status: 'skipped'
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}
| {
sourcePath: string
status: 'failed'
reason: string
}
)[]
}>
watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
@ -481,7 +502,12 @@ export type PreloadApi = {
writeClipboardText: (text: string) => Promise<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
onFileDrop: (
callback: (data: { path: string; target: 'editor' | 'terminal' }) => void
callback: (
data:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => void
) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void

View file

@ -20,19 +20,59 @@ import {
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
type NativeFileDropTarget = 'editor' | 'terminal'
type NativeDropResolution =
| { target: 'editor' }
| { target: 'terminal' }
| { target: 'file-explorer'; destinationDir: string }
// Why: returned when the explorer marker was found but no destinationDir
// could be resolved. The caller must suppress the drop entirely instead of
// falling back to 'editor' — fail-closed behavior per design §7.1.
| { target: 'rejected' }
function getNativeFileDropTarget(event: DragEvent): NativeFileDropTarget | null {
/**
* Walk the composed event path to classify which UI surface the native OS drop
* landed on, and for file-explorer drops extract the nearest destination
* directory from `data-native-file-drop-dir`.
*
* Why: the preload layer consumes native OS `drop` events before React can read
* filesystem paths. If preload does not capture the destination directory at
* drop time, the renderer can no longer tell whether the user meant "root" or
* "inside this folder".
*/
function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null {
const path = event.composedPath()
let foundExplorer = false
let destinationDir: string | undefined
for (const entry of path) {
if (!(entry instanceof HTMLElement)) {
continue
}
const target = entry.dataset.nativeFileDropTarget
if (target === 'editor' || target === 'terminal') {
return target
return { target }
}
if (target === 'file-explorer') {
foundExplorer = true
}
// Pick the nearest (innermost) destination directory marker
if (destinationDir === undefined && entry.dataset.nativeFileDropDir) {
destinationDir = entry.dataset.nativeFileDropDir
}
}
if (foundExplorer) {
// Why: routing must fail closed for explorer drops. If preload sees the
// explorer target marker but cannot resolve a destinationDir, it rejects
// the gesture and emits no fallback editor drop event.
if (!destinationDir) {
return { target: 'rejected' }
}
return { target: 'file-explorer', destinationDir }
}
return null
}
@ -71,7 +111,7 @@ document.addEventListener(
if (!files || files.length === 0) {
return
}
const target = getNativeFileDropTarget(e)
const resolution = resolveNativeFileDrop(e)
const paths: string[] = []
for (let i = 0; i < files.length; i++) {
@ -82,16 +122,34 @@ document.addEventListener(
}
}
if (paths.length > 0) {
// Why: native OS file drops must be classified before the event crosses
// into the isolated renderer; otherwise every drop looks identical and we
// cannot distinguish "open this in Orca's editor" from "send this path to
// the active coding CLI". Falls back to 'editor' so drops on surfaces
// without an explicit marker (sidebar, editor body, etc.) preserve the
// prior open-in-editor behavior instead of being silently discarded.
if (paths.length === 0) {
return
}
// Why: when the explorer marker was present but no destination directory
// could be resolved, the gesture is rejected entirely — no fallback to
// editor, per the fail-closed requirement in design §7.1.
if (resolution?.target === 'rejected') {
return
}
// Why: preload must emit exactly one native-drop event per drop gesture.
// The preload layer already has the full FileList. Re-emitting one IPC
// message per path and asking the renderer to reconstruct the gesture via
// timing would be both fragile and slower under large drops.
if (resolution?.target === 'file-explorer') {
ipcRenderer.send('terminal:file-dropped-from-preload', {
paths,
target: target ?? 'editor'
target: 'file-explorer',
destinationDir: resolution.destinationDir
})
} else {
// Why: falls back to 'editor' so drops on surfaces without an explicit
// marker (sidebar, editor body, etc.) preserve the prior open-in-editor
// behavior instead of being silently discarded.
ipcRenderer.send('terminal:file-dropped-from-preload', {
paths,
target: resolution?.target ?? 'editor'
})
}
},
@ -763,6 +821,30 @@ const api = {
totalMatches: number
truncated: boolean
}> => ipcRenderer.invoke('fs:search', args),
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
}): Promise<{
results: (
| {
sourcePath: string
status: 'imported'
destPath: string
kind: 'file' | 'directory'
renamed: boolean
}
| {
sourcePath: string
status: 'skipped'
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}
| {
sourcePath: string
status: 'failed'
reason: string
}
)[]
}> => ipcRenderer.invoke('fs:importExternalPaths', args),
watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('fs:watchWorktree', args),
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
@ -926,11 +1008,19 @@ const api = {
writeClipboardImage: (dataUrl: string): Promise<void> =>
ipcRenderer.invoke('clipboard:writeImage', dataUrl),
onFileDrop: (
callback: (data: { path: string; target: 'editor' | 'terminal' }) => void
callback: (
data:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { path: string; target: 'editor' | 'terminal' }
data:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => callback(data)
ipcRenderer.on('terminal:file-drop', listener)
return () => ipcRenderer.removeListener('terminal:file-drop', listener)

View file

@ -19,6 +19,7 @@ import { useFileExplorerKeys } from './useFileExplorerKeys'
import { useActiveWorktreePath } from './useActiveWorktreePath'
import { useFileDuplicate } from './useFileDuplicate'
import { useFileExplorerDragDrop } from './useFileExplorerDragDrop'
import { useFileExplorerImport } from './useFileExplorerImport'
import { useFileExplorerTree } from './useFileExplorerTree'
import { useFileExplorerWatch } from './useFileExplorerWatch'
@ -108,8 +109,13 @@ export default function FileExplorer(): React.JSX.Element {
dragSourcePath,
setDragSourcePath,
isRootDragOver,
isNativeDragOver,
nativeDropTargetDir,
setNativeDropTargetDir,
handleNativeDragExpandDir,
stopDragEdgeScroll,
rootDragHandlers
rootDragHandlers,
clearNativeDragState
} = useFileExplorerDragDrop({
worktreePath,
activeWorktreeId,
@ -169,6 +175,13 @@ export default function FileExplorer(): React.JSX.Element {
dragSourcePath
})
useFileExplorerImport({
worktreePath,
activeWorktreeId,
refreshDir,
clearNativeDragState
})
const totalCount = flatRows.length + (inlineInputIndex >= 0 ? 1 : 0)
const virtualizer = useVirtualizer({
@ -252,27 +265,15 @@ export default function FileExplorer(): React.JSX.Element {
)
}
if (flatRows.length === 0 && !inlineInput) {
if (rootCache?.loading ?? true) {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)
}
if (rootError) {
return (
<div className="flex h-full items-center justify-center px-4 text-center text-[11px] text-muted-foreground">
Could not load files for this worktree: {rootError}
</div>
)
}
return (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground px-4 text-center">
No files in this worktree
</div>
)
}
// Why: the root explorer container must stay mounted for loading, error,
// and empty states so the data-native-file-drop-target marker is always
// present. Without this, external file drops would have no target surface
// when the tree is empty, still loading, or showing a read error.
const isEmptyState = flatRows.length === 0 && !inlineInput
const isLoading = isEmptyState && (rootCache?.loading ?? true)
const hasError = isEmptyState && !isLoading && !!rootError
const isEmpty = isEmptyState && !isLoading && !hasError
const showTree = !isEmptyState
return (
<>
@ -281,10 +282,13 @@ export default function FileExplorer(): React.JSX.Element {
'h-full min-h-0',
isRootDragOver &&
!(dragSourcePath && dirname(dragSourcePath) === worktreePath) &&
'bg-border'
'bg-border',
isNativeDragOver && !nativeDropTargetDir && 'bg-border'
)}
viewportRef={scrollRef}
viewportClassName="h-full min-h-0 py-2"
data-native-file-drop-target="file-explorer"
data-native-file-drop-dir={worktreePath}
onWheelCapture={handleWheelCapture}
onDragOver={rootDragHandlers.onDragOver}
onDragEnter={rootDragHandlers.onDragEnter}
@ -304,90 +308,110 @@ export default function FileExplorer(): React.JSX.Element {
setBgMenuOpen(true)
}}
>
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((vItem) => {
const isInlineRow = inlineInputIndex >= 0 && vItem.index === inlineInputIndex
const rowIndex =
!isInlineRow && inlineInputIndex >= 0 && vItem.index > inlineInputIndex
? vItem.index - 1
: vItem.index
const node = isInlineRow ? null : flatRows[rowIndex]
if (!isInlineRow && !node) {
return null
}
{isLoading && (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)}
{hasError && (
<div className="flex h-full items-center justify-center px-4 text-center text-[11px] text-muted-foreground">
Could not load files for this worktree: {rootError}
</div>
)}
{isEmpty && (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground px-4 text-center">
No files in this worktree
</div>
)}
{showTree && (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((vItem) => {
const isInlineRow = inlineInputIndex >= 0 && vItem.index === inlineInputIndex
const rowIndex =
!isInlineRow && inlineInputIndex >= 0 && vItem.index > inlineInputIndex
? vItem.index - 1
: vItem.index
const node = isInlineRow ? null : flatRows[rowIndex]
if (!isInlineRow && !node) {
return null
}
const showInline =
isInlineRow ||
(inlineInput?.type === 'rename' && node && inlineInput.existingPath === node.path)
const inlineDepth = isInlineRow ? inlineInput!.depth : (node?.depth ?? 0)
const showInline =
isInlineRow ||
(inlineInput?.type === 'rename' && node && inlineInput.existingPath === node.path)
const inlineDepth = isInlineRow ? inlineInput!.depth : (node?.depth ?? 0)
if (showInline) {
if (showInline) {
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<InlineInputRow
depth={inlineDepth}
inlineInput={inlineInput!}
onSubmit={handleInlineSubmit}
onCancel={dismissInlineInput}
/>
</div>
)
}
// Safe: the isInlineRow/showInline guards above ensure node is non-null here
const n = node!
const normalizedRelativePath = normalizeRelativePath(n.relativePath)
const nodeStatus = n.isDirectory
? (folderStatusByRelativePath.get(normalizedRelativePath) ?? null)
: (statusByRelativePath.get(normalizedRelativePath) ?? null)
const rowParentDir = n.isDirectory ? n.path : dirname(n.path)
const sourceParentDir = dragSourcePath ? dirname(dragSourcePath) : null
const isInDropTarget =
(dropTargetDir != null &&
dropTargetDir === rowParentDir &&
dropTargetDir !== sourceParentDir) ||
(nativeDropTargetDir != null && nativeDropTargetDir === rowParentDir)
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
className={cn('absolute left-0 right-0', isInDropTarget && 'bg-border')}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<InlineInputRow
depth={inlineDepth}
inlineInput={inlineInput!}
onSubmit={handleInlineSubmit}
onCancel={dismissInlineInput}
<FileExplorerRow
node={n}
isExpanded={expanded.has(n.path)}
isLoading={n.isDirectory && Boolean(dirCache[n.path]?.loading)}
isSelected={selectedPath === n.path || activeFileId === n.path}
isFlashing={flashingPath === n.path}
nodeStatus={nodeStatus}
statusColor={nodeStatus ? STATUS_COLORS[nodeStatus] : null}
deleteShortcutLabel={deleteShortcutLabel}
targetDir={n.isDirectory ? n.path : dirname(n.path)}
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}
onClick={() => handleClick(n)}
onDoubleClick={() => handleDoubleClick(n)}
onSelect={() => setSelectedPath(n.path)}
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}
onRequestDelete={() => requestDelete(n)}
onMoveDrop={handleMoveDrop}
onDragTargetChange={setDropTargetDir}
onDragSourceChange={setDragSourcePath}
onDragExpandDir={handleDragExpandDir}
onNativeDragTargetChange={setNativeDropTargetDir}
onNativeDragExpandDir={handleNativeDragExpandDir}
/>
</div>
)
}
// Safe: the isInlineRow/showInline guards above ensure node is non-null here
const n = node!
const normalizedRelativePath = normalizeRelativePath(n.relativePath)
const nodeStatus = n.isDirectory
? (folderStatusByRelativePath.get(normalizedRelativePath) ?? null)
: (statusByRelativePath.get(normalizedRelativePath) ?? null)
const rowParentDir = n.isDirectory ? n.path : dirname(n.path)
const sourceParentDir = dragSourcePath ? dirname(dragSourcePath) : null
const isInDropTarget =
dropTargetDir != null &&
dropTargetDir === rowParentDir &&
dropTargetDir !== sourceParentDir
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className={cn('absolute left-0 right-0', isInDropTarget && 'bg-border')}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<FileExplorerRow
node={n}
isExpanded={expanded.has(n.path)}
isLoading={n.isDirectory && Boolean(dirCache[n.path]?.loading)}
isSelected={selectedPath === n.path || activeFileId === n.path}
isFlashing={flashingPath === n.path}
nodeStatus={nodeStatus}
statusColor={nodeStatus ? STATUS_COLORS[nodeStatus] : null}
deleteShortcutLabel={deleteShortcutLabel}
targetDir={n.isDirectory ? n.path : dirname(n.path)}
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}
onClick={() => handleClick(n)}
onDoubleClick={() => handleDoubleClick(n)}
onSelect={() => setSelectedPath(n.path)}
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}
onRequestDelete={() => requestDelete(n)}
onMoveDrop={handleMoveDrop}
onDragTargetChange={setDropTargetDir}
onDragSourceChange={setDragSourcePath}
onDragExpandDir={handleDragExpandDir}
/>
</div>
)
})}
</div>
})}
</div>
)}
</ScrollArea>
<FileExplorerBackgroundMenu

View file

@ -25,6 +25,7 @@ import { cn } from '@/lib/utils'
import type { GitFileStatus } from '../../../../shared/types'
import { STATUS_LABELS } from './status-display'
import type { TreeNode } from './file-explorer-types'
import { useFileExplorerRowDrag } from './useFileExplorerRowDrag'
const ORCA_PATH_MIME = 'text/x-orca-file-path'
@ -206,10 +207,10 @@ type FileExplorerRowProps = {
onDragTargetChange: (dir: string | null) => void
onDragSourceChange: (path: string | null) => void
onDragExpandDir: (dirPath: string) => void
onNativeDragTargetChange: (dir: string | null) => void
onNativeDragExpandDir: (dirPath: string) => void
}
const DRAG_EXPAND_DELAY_MS = 500
export function FileExplorerRow({
node,
isExpanded,
@ -231,82 +232,22 @@ export function FileExplorerRow({
onMoveDrop,
onDragTargetChange,
onDragSourceChange,
onDragExpandDir
onDragExpandDir,
onNativeDragTargetChange,
onNativeDragExpandDir
}: FileExplorerRowProps): React.JSX.Element {
// Drag and drop into directories. Directories expand on timer
const rowDropDir = node.isDirectory ? node.path : targetDir
const expandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dragCounterRef = useRef(0)
const clearExpandTimer = useCallback(() => {
if (expandTimerRef.current !== null) {
clearTimeout(expandTimerRef.current)
expandTimerRef.current = null
}
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(ORCA_PATH_MIME)) {
return
}
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}, [])
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(ORCA_PATH_MIME)) {
return
}
e.preventDefault()
e.stopPropagation()
dragCounterRef.current += 1
onDragTargetChange(rowDropDir)
if (dragCounterRef.current === 1 && node.isDirectory && !isExpanded) {
clearExpandTimer()
expandTimerRef.current = setTimeout(() => {
expandTimerRef.current = null
onDragExpandDir(node.path)
}, DRAG_EXPAND_DELAY_MS)
}
},
[
rowDropDir,
onDragTargetChange,
clearExpandTimer,
node.isDirectory,
node.path,
isExpanded,
onDragExpandDir
]
)
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.stopPropagation()
dragCounterRef.current -= 1
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0
clearExpandTimer()
}
},
[clearExpandTimer]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounterRef.current = 0
clearExpandTimer()
onDragTargetChange(null)
const sourcePath = e.dataTransfer.getData(ORCA_PATH_MIME)
if (sourcePath) {
onMoveDrop(sourcePath, rowDropDir)
}
},
[rowDropDir, onMoveDrop, onDragTargetChange, clearExpandTimer]
)
const { handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileExplorerRowDrag({
rowDropDir,
isDirectory: node.isDirectory,
nodePath: node.path,
isExpanded,
onDragTargetChange,
onDragExpandDir,
onNativeDragTargetChange,
onNativeDragExpandDir,
onMoveDrop
})
return (
<ContextMenu>
@ -318,6 +259,7 @@ export function FileExplorerRow({
isFlashing && 'bg-amber-400/20 ring-1 ring-inset ring-amber-400/70'
)}
style={{ paddingLeft: `${node.depth * 16 + 8}px` }}
data-native-file-drop-dir={rowDropDir}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(ORCA_PATH_MIME, node.path)

View file

@ -33,6 +33,12 @@ type UseFileExplorerDragDropResult = {
dragSourcePath: string | null
setDragSourcePath: (path: string | null) => void
isRootDragOver: boolean
/** True when a native OS file drag (Files) is hovering over the explorer */
isNativeDragOver: boolean
/** Directory path highlighted during a native Files drag, or null */
nativeDropTargetDir: string | null
setNativeDropTargetDir: (dir: string | null) => void
handleNativeDragExpandDir: (dirPath: string) => void
// Stops the drag edge auto-scroll loop (call on drag end / unmount)
stopDragEdgeScroll: () => void
rootDragHandlers: {
@ -41,6 +47,8 @@ type UseFileExplorerDragDropResult = {
onDragLeave: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
}
/** Clears all native drag visual state (call after import completes) */
clearNativeDragState: () => void
}
const ORCA_PATH_MIME = 'text/x-orca-file-path'
@ -69,6 +77,11 @@ export function useFileExplorerDragDrop({
const [dropTargetDir, setDropTargetDir] = useState<string | null>(null)
const [dragSourcePath, setDragSourcePath] = useState<string | null>(null)
// Native Files drag state — tracked separately from internal move state
const [isNativeDragOver, setIsNativeDragOver] = useState(false)
const nativeRootDragCounterRef = useRef(0)
const [nativeDropTargetDir, setNativeDropTargetDir] = useState<string | null>(null)
const lastDragClientYRef = useRef<number | null>(null)
const edgeScrollRafRef = useRef<number | null>(null)
@ -223,14 +236,22 @@ export function useFileExplorerDragDrop({
]
)
const clearNativeDragState = useCallback(() => {
nativeRootDragCounterRef.current = 0
setIsNativeDragOver(false)
setNativeDropTargetDir(null)
}, [])
const rootDragHandlers = {
onDragOver: useCallback(
(e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(ORCA_PATH_MIME)) {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isNative = e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
}
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy'
lastDragClientYRef.current = e.clientY
if (edgeScrollRafRef.current === null) {
edgeScrollRafRef.current = requestAnimationFrame(tickDragEdgeScroll)
@ -239,19 +260,32 @@ export function useFileExplorerDragDrop({
[tickDragEdgeScroll]
),
onDragEnter: useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(ORCA_PATH_MIME)) {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isNative = !isInternal && e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
}
e.preventDefault()
rootDragCounterRef.current += 1
setIsRootDragOver(true)
if (isInternal) {
rootDragCounterRef.current += 1
setIsRootDragOver(true)
} else {
nativeRootDragCounterRef.current += 1
setIsNativeDragOver(true)
}
}, []),
onDragLeave: useCallback((_e: React.DragEvent) => {
// Decrement both counters since we cannot inspect types on dragleave
rootDragCounterRef.current -= 1
if (rootDragCounterRef.current <= 0) {
rootDragCounterRef.current = 0
setIsRootDragOver(false)
}
nativeRootDragCounterRef.current -= 1
if (nativeRootDragCounterRef.current <= 0) {
nativeRootDragCounterRef.current = 0
setIsNativeDragOver(false)
}
}, []),
onDrop: useCallback(
(e: React.DragEvent) => {
@ -260,12 +294,16 @@ export function useFileExplorerDragDrop({
rootDragCounterRef.current = 0
setIsRootDragOver(false)
setDropTargetDir(null)
// Why: native Files drops are handled by the preload-relayed IPC event,
// not the React drop handler. We only clear native drag visual state
// here; the actual import is triggered from onFileDrop.
clearNativeDragState()
const sourcePath = e.dataTransfer.getData(ORCA_PATH_MIME)
if (sourcePath && worktreePath) {
handleMoveDrop(sourcePath, worktreePath)
}
},
[worktreePath, handleMoveDrop, stopDragEdgeScroll]
[worktreePath, handleMoveDrop, stopDragEdgeScroll, clearNativeDragState]
)
}
@ -279,6 +317,30 @@ export function useFileExplorerDragDrop({
[activeWorktreeId, expanded, toggleDir]
)
// Why: native drag expand must be expand-only (never collapse). The preload
// captures native drop events in the capture phase and stops propagation,
// so React's handleDrop never fires and the expand timer is never cleared.
// If revealInExplorer already expanded the folder before the timer fires,
// a toggleDir call would collapse it. Reading current state at call time
// also avoids stale-closure issues with the 500ms timer callback.
const handleNativeDragExpandDir = useCallback(
(dirPath: string) => {
if (!activeWorktreeId) {
return
}
useAppStore.setState((state) => {
const current = state.expandedDirs[activeWorktreeId] ?? new Set<string>()
if (current.has(dirPath)) {
return state
}
const next = new Set(current)
next.add(dirPath)
return { expandedDirs: { ...state.expandedDirs, [activeWorktreeId]: next } }
})
},
[activeWorktreeId]
)
return {
handleMoveDrop,
handleDragExpandDir,
@ -287,7 +349,12 @@ export function useFileExplorerDragDrop({
dragSourcePath,
setDragSourcePath,
isRootDragOver,
isNativeDragOver,
nativeDropTargetDir,
setNativeDropTargetDir,
handleNativeDragExpandDir,
stopDragEdgeScroll,
rootDragHandlers
rootDragHandlers,
clearNativeDragState
}
}

View file

@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
type UseFileExplorerImportParams = {
worktreePath: string | null
activeWorktreeId: string | null
refreshDir: (dirPath: string) => Promise<void>
clearNativeDragState: () => void
}
/**
* Subscribes to native file-drop events targeted at the file explorer and
* runs the import pipeline: copy into worktree, refresh, reveal.
*
* Why this is a separate hook: the actual filesystem paths from native OS
* drops are only available through the preload-relayed IPC event, not the
* React drop handler. The drop handler manages visual state; this hook
* manages the import action.
*/
export function useFileExplorerImport({
worktreePath,
activeWorktreeId,
refreshDir,
clearNativeDragState
}: UseFileExplorerImportParams): void {
const revealInExplorer = useAppStore((s) => s.revealInExplorer)
// Refs to avoid re-subscribing IPC listener on every render
const worktreePathRef = useRef(worktreePath)
worktreePathRef.current = worktreePath
const activeWorktreeIdRef = useRef(activeWorktreeId)
activeWorktreeIdRef.current = activeWorktreeId
const refreshDirRef = useRef(refreshDir)
refreshDirRef.current = refreshDir
const clearNativeDragStateRef = useRef(clearNativeDragState)
clearNativeDragStateRef.current = clearNativeDragState
const revealInExplorerRef = useRef(revealInExplorer)
revealInExplorerRef.current = revealInExplorer
useEffect(() => {
return window.api.ui.onFileDrop((data) => {
if (data.target !== 'file-explorer') {
return
}
const wtId = activeWorktreeIdRef.current
if (!wtId || !worktreePathRef.current) {
// Why: the preload stops propagation of the native drop event, so
// React onDrop handlers never fire. We must clear the drag highlight
// ourselves even when we bail out, otherwise the explorer stays stuck
// in its drag-over visual state.
clearNativeDragStateRef.current()
return
}
const { paths, destinationDir } = data
void (async () => {
try {
const { results } = await window.api.fs.importExternalPaths({
sourcePaths: paths,
destDir: destinationDir
})
// Refresh the destination directory once per gesture
await refreshDirRef.current(destinationDir)
// Reveal and flash the first successfully imported path
const imported = results.filter((r) => r.status === 'imported')
if (imported.length > 0) {
revealInExplorerRef.current(wtId, imported[0].destPath)
}
} finally {
clearNativeDragStateRef.current()
}
})()
})
}, [])
}

View file

@ -0,0 +1,169 @@
import React, { useCallback, useRef } from 'react'
const ORCA_PATH_MIME = 'text/x-orca-file-path'
const DRAG_EXPAND_DELAY_MS = 500
type UseFileExplorerRowDragParams = {
rowDropDir: string
isDirectory: boolean
nodePath: string
isExpanded: boolean
onDragTargetChange: (dir: string | null) => void
onDragExpandDir: (dirPath: string) => void
onNativeDragTargetChange: (dir: string | null) => void
onNativeDragExpandDir: (dirPath: string) => void
onMoveDrop: (sourcePath: string, destDir: string) => void
}
type RowDragHandlers = {
handleDragOver: (e: React.DragEvent) => void
handleDragEnter: (e: React.DragEvent) => void
handleDragLeave: (e: React.DragEvent) => void
handleDrop: (e: React.DragEvent) => void
}
export function useFileExplorerRowDrag({
rowDropDir,
isDirectory,
nodePath,
isExpanded,
onDragTargetChange,
onDragExpandDir,
onNativeDragTargetChange,
onNativeDragExpandDir,
onMoveDrop
}: UseFileExplorerRowDragParams): RowDragHandlers {
const expandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dragCounterRef = useRef(0)
const nativeDragCounterRef = useRef(0)
const nativeExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearExpandTimer = useCallback(() => {
if (expandTimerRef.current !== null) {
clearTimeout(expandTimerRef.current)
expandTimerRef.current = null
}
}, [])
const clearNativeExpandTimer = useCallback(() => {
if (nativeExpandTimerRef.current !== null) {
clearTimeout(nativeExpandTimerRef.current)
nativeExpandTimerRef.current = null
}
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isNative = e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
}
e.preventDefault()
e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy'
}, [])
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isNative = !isInternal && e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
}
e.preventDefault()
e.stopPropagation()
if (isInternal) {
dragCounterRef.current += 1
onDragTargetChange(rowDropDir)
if (dragCounterRef.current === 1 && isDirectory && !isExpanded) {
clearExpandTimer()
expandTimerRef.current = setTimeout(() => {
expandTimerRef.current = null
onDragExpandDir(nodePath)
}, DRAG_EXPAND_DELAY_MS)
}
} else {
nativeDragCounterRef.current += 1
// Why: only directories should claim themselves as native drop targets.
// A file row's parent dir (rowDropDir) would highlight every sibling,
// which is misleading when the user is aiming for a specific folder.
// Clearing the target for files lets the root container's subtle
// bg-border indicate the fallback drop zone instead.
onNativeDragTargetChange(isDirectory ? rowDropDir : null)
// Reuse the same auto-expand delay for native drags over directories
if (nativeDragCounterRef.current === 1 && isDirectory && !isExpanded) {
clearNativeExpandTimer()
nativeExpandTimerRef.current = setTimeout(() => {
nativeExpandTimerRef.current = null
onNativeDragExpandDir(nodePath)
}, DRAG_EXPAND_DELAY_MS)
}
}
},
[
rowDropDir,
onDragTargetChange,
onNativeDragTargetChange,
clearExpandTimer,
clearNativeExpandTimer,
isDirectory,
nodePath,
isExpanded,
onDragExpandDir,
onNativeDragExpandDir
]
)
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.stopPropagation()
dragCounterRef.current -= 1
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0
clearExpandTimer()
}
// Decrement both counters since we cannot inspect types on dragleave
// (dataTransfer.types is empty in some browsers during dragleave).
// The clamp-to-zero prevents negative drift.
nativeDragCounterRef.current -= 1
if (nativeDragCounterRef.current <= 0) {
nativeDragCounterRef.current = 0
clearNativeExpandTimer()
// Why: clear stale row highlight so moving from a row to the root
// background (which has no row-level nativeDragTargetChange) does not
// leave the previous row visually highlighted.
onNativeDragTargetChange(null)
}
},
[clearExpandTimer, clearNativeExpandTimer, onNativeDragTargetChange]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounterRef.current = 0
nativeDragCounterRef.current = 0
clearExpandTimer()
clearNativeExpandTimer()
onDragTargetChange(null)
onNativeDragTargetChange(null)
const sourcePath = e.dataTransfer.getData(ORCA_PATH_MIME)
if (sourcePath) {
onMoveDrop(sourcePath, rowDropDir)
}
// Why: native Files drops are handled by the preload-relayed IPC event,
// not the React drop handler. We only clear visual state here.
},
[
rowDropDir,
onMoveDrop,
onDragTargetChange,
onNativeDragTargetChange,
clearExpandTimer,
clearNativeExpandTimer
]
)
return { handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
}

View file

@ -182,8 +182,8 @@ export function useTerminalPaneGlobalEffects({
}, [isActive])
useEffect(() => {
return window.api.ui.onFileDrop(({ path, target }) => {
if (!isActiveRef.current || target !== 'terminal') {
return window.api.ui.onFileDrop((data) => {
if (!isActiveRef.current || data.target !== 'terminal') {
return
}
const manager = managerRef.current
@ -202,10 +202,11 @@ export function useTerminalPaneGlobalEffects({
// 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: the main process sends one IPC event per dropped file, so
// appending a trailing space keeps multiple paths separated in the
// Why: appending a trailing space keeps multiple paths separated in the
// terminal input, matching standard drag-and-drop UX conventions.
transport.sendInput(`${shellEscapePath(path)} `)
for (const path of data.paths) {
transport.sendInput(`${shellEscapePath(path)} `)
}
})
}, [isActiveRef, managerRef, paneTransportsRef])
}

View file

@ -6,8 +6,8 @@ import { getConnectionId } from '@/lib/connection-context'
export function useGlobalFileDrop(): void {
useEffect(() => {
return window.api.ui.onFileDrop(({ path: filePath, target }) => {
if (target !== 'editor') {
return window.api.ui.onFileDrop((data) => {
if (data.target !== 'editor') {
return
}
@ -20,42 +20,47 @@ export function useGlobalFileDrop(): void {
const activeWorktree = store.allWorktrees().find((w) => w.id === activeWorktreeId)
const worktreePath = activeWorktree?.path
void (async () => {
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
// Why: remote paths don't need local auth — the relay is the security boundary.
if (!connectionId) {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
const stat = await window.api.fs.stat({ filePath, connectionId })
if (stat.isDirectory) {
return
}
let relativePath = filePath
if (worktreePath && isPathInsideWorktree(filePath, worktreePath)) {
const maybeRelative = toWorktreeRelativePath(filePath, worktreePath)
if (maybeRelative !== null && maybeRelative.length > 0) {
relativePath = maybeRelative
// Why: the relay payload now sends all paths in one gesture-scoped event.
// Loop over every dropped file so multi-file editor drops still open
// each file, matching the prior per-path behavior.
for (const filePath of data.paths) {
void (async () => {
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
// Why: remote paths don't need local auth — the relay is the security boundary.
if (!connectionId) {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
const stat = await window.api.fs.stat({ filePath, connectionId })
if (stat.isDirectory) {
return
}
}
// Why: the preload bridge already proved this OS drop landed on the
// tab-strip editor target. Keeping the editor-open path centralized
// here avoids the regression where CLI drops were all coerced into
// editor tabs once the renderer lost the original drop surface.
store.setActiveTabType('editor')
store.openFile({
filePath,
relativePath,
worktreeId: activeWorktreeId,
language: detectLanguage(filePath),
mode: 'edit'
})
} catch {
// Ignore files that cannot be authorized or stat'd.
}
})()
let relativePath = filePath
if (worktreePath && isPathInsideWorktree(filePath, worktreePath)) {
const maybeRelative = toWorktreeRelativePath(filePath, worktreePath)
if (maybeRelative !== null && maybeRelative.length > 0) {
relativePath = maybeRelative
}
}
// Why: the preload bridge already proved this OS drop landed on the
// tab-strip editor target. Keeping the editor-open path centralized
// here avoids the regression where CLI drops were all coerced into
// editor tabs once the renderer lost the original drop surface.
store.setActiveTabType('editor')
store.openFile({
filePath,
relativePath,
worktreeId: activeWorktreeId,
language: detectLanguage(filePath),
mode: 'edit'
})
} catch {
// Ignore files that cannot be authorized or stat'd.
}
})()
}
})
}, [])
}