Remove legacy OS file-drop routing (STA-6940 PR6/6) (#26385)

* feat(file-drop): add element owner preparation plumbing

* Fix terminal and chat file drop destination ownership

* fix runtime terminal drop ownership and queued chat retries

* fix(file-drop): preserve feedback acceptance and destination ordering

* fix(file-drop): attach chat and composer files at the drop surface

* fix(file-drop): preserve project destinations and live chat availability

* fix(file-drop): keep path resolution in filesystem namespace

* test(file-drop): use filesystem path bridge in PR3 fixtures

* fix(file-drop): bind terminal drops to pane elements

* fix(file-drop): compare terminal pane identity across public views

* test(file-drop): align composer lifecycle with element-owned drops

* Fix interrupted chat composition and refuse unused quick-create drops

* Type the quick-create drop regression transfer

* fix(file-drop): let the explorer, project sidebar, tab strip and editor own OS drops

STA-6940 PR5. The file explorer tree, the project sidebar, each editor
group's tab strip and its editor area now take OS file drops on their own
element through the shared owner hook, with identity from their own render
(the explorer's shown workspace and target row, the tab strip's and editor
group's worktree and group) instead of the active worktree. The explorer and
sidebar broadcast subscribers and their legacy drop markers are deleted;
editor-open moves out of useGlobalFileDrop into editor-dropped-file-open,
which the legacy unmarked-chrome route still uses until PR6.

* fix(file-drop): keep editor delivery tied to its destination group

* fix(file-drop): guard delayed opens and share floating ownership

* fix(file-drop): remove legacy OS drop routing (STA-6940 PR6/6)

* test(file-drop): finish owner test and comment cleanup

* Clarify dropped-file default editor destination

* Retain UI test namespace for terminal resume coverage
This commit is contained in:
Brennan Benson
2026-10-07 23:11:37 -07:00
committed by GitHub
parent 2caea6ce93
commit e4cd14d914
54 changed files with 553 additions and 2099 deletions
-2
View File
@@ -7407,7 +7407,6 @@
"src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 587,
"src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 45,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1301,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts": 1219,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1149,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1211,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1904,
@@ -7632,7 +7631,6 @@
"src/renderer/src/hooks/useEditorExternalWatch.test.ts": 492,
"src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 420,
"src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 556,
"src/renderer/src/hooks/useGlobalFileDrop.test.ts": 1582,
"src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 6914,
"src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1496,
"src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 50,
@@ -628,81 +628,6 @@ describe('attachMainWindowServices', () => {
expect(browserManagerUnregisterAllMock).toHaveBeenCalledTimes(1)
})
it('removes the native file-drop relay when the main window closes', () => {
const mainWindowOnMock = vi.fn()
const mainWindow = createMainWindow({ send: vi.fn() })
mainWindow.on = mainWindowOnMock
attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)
const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
expect(relayHandler).toBeTypeOf('function')
expect(removeAllListenersMock).toHaveBeenCalledWith(channel)
const closedHandlers = getClosedHandlers(mainWindowOnMock)
for (const handler of closedHandlers) {
handler()
}
expect(removeListenerMock).toHaveBeenCalledWith(channel, relayHandler)
})
it('relays native file drops only from the owning renderer webContents', () => {
const sendMock = vi.fn()
const mainWindow = createMainWindow({ send: sendMock })
attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)
const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
const payload = { paths: ['/tmp/a'], target: 'editor' }
relayHandler?.({ sender: { id: 999 } }, payload)
expect(sendMock).not.toHaveBeenCalled()
relayHandler?.({ sender: mainWindow.webContents }, payload)
expect(sendMock).toHaveBeenCalledWith('terminal:file-drop', payload)
})
it('ignores malformed native file-drop payloads from the owning renderer', () => {
const sendMock = vi.fn()
const mainWindow = createMainWindow({ send: sendMock })
attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)
const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
relayHandler?.(
{ sender: mainWindow.webContents },
{ paths: ['C:\\Users\\alice\\secret.txt'], target: 'browser' }
)
relayHandler?.(
{ sender: mainWindow.webContents },
{ paths: ['/tmp/a'], target: 'file-explorer' }
)
expect(sendMock).not.toHaveBeenCalled()
})
it('ignores native file drops after the owning webContents is destroyed', () => {
const sendMock = vi.fn()
const mainWindow = createMainWindow({ send: sendMock })
attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)
const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
mainWindow.webContents.isDestroyed?.mockReturnValue(true)
relayHandler?.({ sender: mainWindow.webContents }, { paths: ['/tmp/a'], target: 'editor' })
expect(sendMock).not.toHaveBeenCalled()
})
it('clears the runtime notifier when the owning window closes', () => {
const mainWindowOnMock = vi.fn()
const mainWindow = createMainWindow()
@@ -39,7 +39,7 @@ import {
import { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade'
import { scheduleMainWindowAutoUpdaterSetup } from './main-window-updater'
import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle'
import { registerFileDropRelay } from './native-file-drop-relay'
import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc'
export { ensureAutoUpdaterConfigured, registerUpdaterHandlers } from './main-window-updater'
@@ -126,7 +126,7 @@ export function attachMainWindowServices(
}
registerSshHandlers(store, () => mainWindow, runtime)
registerRemoteWorkspaceHandlers(store, () => mainWindow, runtime)
registerFileDropRelay(mainWindow)
registerDroppedPathPreparation(mainWindow)
registerTccPromptNoticeHandlers(mainWindow)
scheduleMainWindowAutoUpdaterSetup(mainWindow, store, options)
registerRuntimeWindowLifecycle(mainWindow, runtime)
@@ -0,0 +1,192 @@
import type { BrowserWindow } from 'electron'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DragTempFileCopy from './dragged-temp-file-copy'
import type { DragTempCopyItemResult } from './dragged-temp-file-copy'
type IpcListener = (event: { sender: unknown }, payload: unknown) => void
type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise<unknown>
const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({
materializeMock: vi.fn(),
sweepMock: vi.fn(),
ipcListeners: new Map<string, IpcListener>(),
ipcHandlers: new Map<string, InvokeHandler>()
}))
vi.mock('electron', () => ({
app: { getPath: () => '/app-temp' },
ipcMain: {
handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler),
removeHandler: (channel: string) => ipcHandlers.delete(channel),
on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener),
removeListener: (channel: string, listener: IpcListener) => {
if (ipcListeners.get(channel) === listener) {
ipcListeners.delete(channel)
}
},
removeAllListeners: (channel: string) => ipcListeners.delete(channel)
}
}))
vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({
...(await importOriginal<typeof DragTempFileCopy>()),
materializeDragTempPaths: materializeMock,
scheduleDragTempCopySweep: sweepMock
}))
vi.mock('./darwin-user-temp-dir', () => ({
getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T'
}))
import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc'
const DRAG_TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png')
const COPY = join('/', 'var', 'T', 'orca-drops-501', 'orca-drop-abc123', 'Shot.png')
const FINDER = join('/', 'Users', 'me', 'Desktop', 'notes.txt')
function copied(sourcePath: string, destPath = sourcePath): DragTempCopyItemResult {
return { sourcePath, status: 'imported', destPath }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
}
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
materializeMock.mockReset()
})
function createWindow() {
let destroyed = false
const windowListeners = new Map<string, () => void>()
const webContents = {
isDestroyed: () => destroyed,
send: vi.fn(),
once: vi.fn<(event: string, listener: () => void) => void>(),
removeListener: vi.fn()
}
const fake = {
isDestroyed: () => destroyed,
on: (event: string, listener: () => void) => windowListeners.set(event, listener),
webContents
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preparation handler only touches the members stubbed above.
const window = fake as unknown as BrowserWindow
return {
window,
webContents,
close: () => {
destroyed = true
windowListeners.get('closed')?.()
},
destroy: () => {
destroyed = true
webContents.once.mock.calls.find(([event]) => event === 'destroyed')?.[1]()
}
}
}
describe('registerDroppedPathPreparation', () => {
beforeEach(() => {
ipcListeners.clear()
ipcHandlers.clear()
sweepMock.mockReset()
})
it('registers only the preparation request and starts copy expiry', () => {
const { window } = createWindow()
registerDroppedPathPreparation(window)
expect([...ipcHandlers.keys()]).toEqual(['fs:prepareDroppedPaths'])
expect(ipcListeners.size).toBe(0)
expect(sweepMock).toHaveBeenCalledOnce()
})
it('prepares ordinary paths only for its own live renderer and validates the request', async () => {
const { window, webContents, close } = createWindow()
registerDroppedPathPreparation(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
const request = { paths: [FINDER], consumer: 'agent' }
expect(() => handler({ sender: {} }, request)).toThrow('owning window')
for (const invalid of [
null,
{ paths: [FINDER], consumer: 'other' },
{ paths: [42], consumer: 'agent' }
]) {
expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid')
}
await expect(handler({ sender: webContents }, request)).resolves.toEqual({
paths: [FINDER],
failures: []
})
expect(webContents.send).not.toHaveBeenCalled()
close()
expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false)
expect(() => handler({ sender: webContents }, request)).toThrow('owning window')
})
it('enforces both request caps before preparing any paths', async () => {
const { window, webContents } = createWindow()
registerDroppedPathPreparation(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
await expect(
handler(
{ sender: webContents },
{ paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' }
)
).resolves.toMatchObject({
paths: [],
failures: [{ reason: 'too-many-paths', pathCount: 257 }]
})
await expect(
handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' })
).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] })
expect(materializeMock).not.toHaveBeenCalled()
})
it('keeps the replacement window handler when an older window closes late', async () => {
const first = createWindow()
const second = createWindow()
registerDroppedPathPreparation(first.window)
registerDroppedPathPreparation(second.window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
first.close()
expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler)
await expect(
handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' })
).resolves.toEqual({ paths: [FINDER], failures: [] })
})
it.skipIf(process.platform !== 'darwin')(
'copies from the macOS user temp dir and aborts when the renderer is gone',
async () => {
const copy = deferred<DragTempCopyItemResult[]>()
materializeMock.mockReturnValueOnce(copy.promise)
const { window, webContents, destroy } = createWindow()
registerDroppedPathPreparation(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
const pending = handler({ sender: webContents }, { paths: [DRAG_TEMP], consumer: 'agent' })
await settle()
expect(materializeMock.mock.calls[0][1]).toMatchObject({
platform: 'darwin',
sourceTempRoot: '/private/var/folders/ab/xyz/T'
})
const signal = materializeMock.mock.calls[0][2]
expect(signal).toBeInstanceOf(AbortSignal)
const rejected = expect(pending).rejects.toThrow('went away')
destroy()
copy.resolve([copied(DRAG_TEMP, COPY)])
await rejected
expect(signal.aborted).toBe(true)
expect(webContents.send).not.toHaveBeenCalled()
}
)
})
@@ -0,0 +1,78 @@
import { app, ipcMain, type BrowserWindow } from 'electron'
import {
createRejectedNativeFileDropPayload,
validateNativeFileDropPaths
} from '../../shared/native-file-drop'
import type {
PrepareDroppedPathsRequest,
PreparedDroppedPaths
} from '../../shared/native-file-drop-preparation'
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy'
import {
createDroppedPathPreparationQueue,
windowDragTempCopyLane
} from './dropped-path-preparation'
import { getDarwinUserTempDir } from './darwin-user-temp-dir'
export function registerDroppedPathPreparation(mainWindow: BrowserWindow): void {
const prepareChannel = 'fs:prepareDroppedPaths'
const mainWebContents = mainWindow.webContents
const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed()
ipcMain.removeHandler(prepareChannel)
const prepare = createDroppedPathPreparationQueue(
{
platform: process.platform,
getCopyEnvironment: async () => ({
platform: process.platform,
sourceTempRoot: await getDarwinUserTempDir(),
copyRoot: getDragTempCopyRoot(app.getPath('temp'))
}),
watchRenderer: () => abortWhenRendererGone(mainWebContents)
},
windowDragTempCopyLane
)
const prepareHandler = (
event: Electron.IpcMainInvokeEvent,
args: unknown
): Promise<PreparedDroppedPaths> => {
if (isWindowGone() || event.sender !== mainWebContents) {
throw new Error('Dropped paths must come from the owning window')
}
if (!isPrepareDroppedPathsRequest(args)) {
throw new Error('Invalid dropped paths request')
}
const validation = validateNativeFileDropPaths(args.paths)
if (validation.status === 'rejected') {
return Promise.resolve({
paths: [],
failures: [createRejectedNativeFileDropPayload(validation)]
})
}
return Promise.resolve(prepare(args))
}
ipcMain.handle(prepareChannel, prepareHandler)
activePrepareHandler = prepareHandler
mainWindow.on('closed', () => {
// Why: an old window closing late must not remove the replacement window's handler.
if (activePrepareHandler === prepareHandler) {
ipcMain.removeHandler(prepareChannel)
activePrepareHandler = undefined
}
})
scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp')))
}
let activePrepareHandler: unknown
function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest {
return (
!!value &&
typeof value === 'object' &&
'consumer' in value &&
(value.consumer === 'agent' || value.consumer === 'main-reader') &&
'paths' in value &&
Array.isArray(value.paths) &&
value.paths.every((path: unknown) => typeof path === 'string')
)
}
@@ -2,9 +2,11 @@ import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DragTempCopyItemResult } from './dragged-temp-file-copy'
import type * as DragTempFileCopy from './dragged-temp-file-copy'
import type { NativeFileDropPayload } from '../../shared/native-file-drop'
import { createDragTempCopyLane, MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation'
import { createNativeFileDropQueue } from './native-file-drop-relay'
import {
createDragTempCopyLane,
createDroppedPathPreparationQueue,
MAX_PENDING_DRAG_TEMP_COPIES
} from './dropped-path-preparation'
const materialize = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ app: {}, ipcMain: {} }))
@@ -19,19 +21,21 @@ const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '
function queue(lane = createDragTempCopyLane(), copyTimeoutMs = 1000) {
const controller = new AbortController()
const forwarded: NativeFileDropPayload[] = []
const dispose = vi.fn()
const enqueue = createNativeFileDropQueue(
const enqueue = createDroppedPathPreparationQueue(
{
platform: 'darwin',
getCopyEnvironment: async () => env,
watchRenderer: () => ({ signal: controller.signal, dispose }),
forward: (payload) => forwarded.push(payload),
copyTimeoutMs
},
lane
)
return { enqueue, controller, forwarded, dispose }
return {
enqueue: async (request: Parameters<typeof enqueue>[0]) => enqueue(request),
controller,
dispose
}
}
function deferred() {
let resolve!: (value: DragTempCopyItemResult[]) => void
@@ -47,12 +51,12 @@ beforeEach(() => {
materialize.mockReset()
})
describe('dropped path preparation through the shared relay queue', () => {
describe('dropped path preparation through the shared copy queue', () => {
it.each(['agent', 'main-reader'] as const)(
'passes ordinary paths without copying for %s',
async (consumer) => {
const { enqueue } = queue()
await expect(enqueue.prepare({ paths: [ORDINARY], consumer })).resolves.toEqual({
await expect(enqueue({ paths: [ORDINARY], consumer })).resolves.toEqual({
paths: [ORDINARY],
failures: []
})
@@ -68,7 +72,7 @@ describe('dropped path preparation through the shared relay queue', () => {
{ sourcePath: TEMP, status: 'uncopied', reason: 'storage-full' }
])
const { enqueue } = queue()
const result = await enqueue.prepare({ paths: [ORDINARY, TEMP], consumer })
const result = await enqueue({ paths: [ORDINARY, TEMP], consumer })
expect(result.paths).toEqual(consumer === 'agent' ? [ORDINARY] : [ORDINARY, TEMP])
expect(result.failures).toEqual(
consumer === 'agent'
@@ -92,7 +96,7 @@ describe('dropped path preparation through the shared relay queue', () => {
{ sourcePath: TEMP, status: 'imported', destPath: COPY },
{ sourcePath: TEMP, status: 'failed', reason: 'missing' }
])
const result = await queue().enqueue.prepare({
const result = await queue().enqueue({
paths: [ORDINARY, TEMP, TEMP],
consumer: 'agent'
})
@@ -100,20 +104,19 @@ describe('dropped path preparation through the shared relay queue', () => {
expect(result.failures[0]).toMatchObject({ pathCount: 1, commonReason: 'missing' })
})
it('serializes requests and the legacy relay together', async () => {
it('serializes copy requests from different owners', async () => {
const first = deferred()
materialize
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
const { enqueue, forwarded } = queue()
enqueue({ paths: [TEMP], target: 'terminal' })
const requested = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
const { enqueue } = queue()
const chat = enqueue({ paths: [TEMP], consumer: 'agent' })
const editor = enqueue({ paths: [TEMP], consumer: 'main-reader' })
await settle()
expect(materialize).toHaveBeenCalledTimes(1)
first.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
await expect(requested).resolves.toEqual({ paths: [COPY], failures: [] })
await settle()
expect(forwarded).toEqual([{ paths: [COPY], target: 'terminal' }])
await expect(chat).resolves.toEqual({ paths: [COPY], failures: [] })
await expect(editor).resolves.toEqual({ paths: [COPY], failures: [] })
expect(materialize).toHaveBeenCalledTimes(2)
})
@@ -123,10 +126,10 @@ describe('dropped path preparation through the shared relay queue', () => {
const first = queue(lane)
const second = queue(lane)
const results = Array.from({ length: MAX_PENDING_DRAG_TEMP_COPIES }, () =>
first.enqueue.prepare({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined)
first.enqueue({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined)
)
await expect(
second.enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' })
second.enqueue({ paths: [ORDINARY, TEMP], consumer: 'agent' })
).resolves.toMatchObject({ paths: [ORDINARY], failures: [{ commonReason: 'busy' }] })
first.controller.abort(new Error('test ended'))
await Promise.all(results)
@@ -138,8 +141,8 @@ describe('dropped path preparation through the shared relay queue', () => {
const copy = deferred()
materialize.mockReturnValueOnce(copy.promise)
const { enqueue } = queue()
const pending = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
await expect(enqueue.prepare({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({
const pending = enqueue({ paths: [TEMP], consumer: 'agent' })
await expect(enqueue({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({
paths: [ORDINARY],
failures: []
})
@@ -150,8 +153,8 @@ describe('dropped path preparation through the shared relay queue', () => {
it('aborts running and queued requests on reload and releases their listeners', async () => {
materialize.mockReturnValue(new Promise(() => undefined))
const { enqueue, controller, dispose } = queue()
const running = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
const queued = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
const running = enqueue({ paths: [TEMP], consumer: 'agent' })
const queued = enqueue({ paths: [TEMP], consumer: 'agent' })
const assertions = [
expect(running).rejects.toThrow('reloaded'),
expect(queued).rejects.toThrow('reloaded')
@@ -169,12 +172,53 @@ describe('dropped path preparation through the shared relay queue', () => {
.mockReturnValueOnce(new Promise(() => undefined))
.mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
const { enqueue } = queue(undefined, 5)
const first = enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' })
const next = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
const first = enqueue({ paths: [ORDINARY, TEMP], consumer: 'agent' })
const next = enqueue({ paths: [TEMP], consumer: 'agent' })
await expect(first).resolves.toMatchObject({
paths: [ORDINARY],
failures: [{ commonReason: 'timed-out' }]
})
await expect(next).resolves.toEqual({ paths: [COPY], failures: [] })
})
it('passes drag-temp-looking paths unchanged on Linux and Windows', () => {
for (const platform of ['linux', 'win32'] as const) {
const enqueue = createDroppedPathPreparationQueue({
platform,
getCopyEnvironment: async () => env,
watchRenderer: () => ({ signal: new AbortController().signal, dispose: vi.fn() })
})
expect(enqueue({ paths: [TEMP], consumer: 'agent' })).toEqual({ paths: [TEMP], failures: [] })
}
expect(materialize).not.toHaveBeenCalled()
})
it('reports all lost paths without a shared reason when their failures differ', async () => {
materialize.mockResolvedValue([
{ sourcePath: TEMP, status: 'failed', reason: 'missing' },
{ sourcePath: TEMP, status: 'failed', reason: 'changed' }
])
await expect(queue().enqueue({ paths: [TEMP, TEMP], consumer: 'agent' })).resolves.toEqual({
paths: [],
failures: [{ byteLength: 0, pathCount: 2, reason: 'temp-copy-failed', target: 'rejected' }]
})
})
it('keeps ordinary paths and serves the next drop when the copy environment fails', async () => {
const getCopyEnvironment = vi.fn(async () => env)
getCopyEnvironment.mockRejectedValueOnce(new Error('no temp path'))
const enqueue = createDroppedPathPreparationQueue({
platform: 'darwin',
getCopyEnvironment,
watchRenderer: () => ({ signal: new AbortController().signal, dispose: vi.fn() })
})
await expect(enqueue({ paths: [ORDINARY, TEMP], consumer: 'agent' })).resolves.toEqual({
paths: [ORDINARY],
failures: [{ byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' }]
})
materialize.mockResolvedValue([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
await expect(enqueue({ paths: [TEMP], consumer: 'agent' })).resolves.toEqual({
paths: [COPY],
failures: []
})
})
})
@@ -1,536 +0,0 @@
import type { BrowserWindow } from 'electron'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeFileDropPayload } from '../../shared/native-file-drop'
import type * as DragTempFileCopy from './dragged-temp-file-copy'
import type { DragTempCopyItemResult } from './dragged-temp-file-copy'
type IpcListener = (event: { sender: unknown }, payload: unknown) => void
type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise<unknown>
const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({
materializeMock: vi.fn(),
sweepMock: vi.fn(),
ipcListeners: new Map<string, IpcListener>(),
ipcHandlers: new Map<string, InvokeHandler>()
}))
vi.mock('electron', () => ({
app: { getPath: () => '/app-temp' },
ipcMain: {
handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler),
removeHandler: (channel: string) => ipcHandlers.delete(channel),
on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener),
removeListener: (channel: string, listener: IpcListener) => {
if (ipcListeners.get(channel) === listener) {
ipcListeners.delete(channel)
}
},
removeAllListeners: (channel: string) => ipcListeners.delete(channel)
}
}))
vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({
...(await importOriginal<typeof DragTempFileCopy>()),
materializeDragTempPaths: materializeMock,
scheduleDragTempCopySweep: sweepMock
}))
vi.mock('./darwin-user-temp-dir', () => ({
getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T'
}))
import {
createNativeFileDropQueue,
MAX_PENDING_DRAG_TEMP_COPIES,
registerFileDropRelay
} from './native-file-drop-relay'
const DRAG_TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png')
const OTHER_DRAG_TEMP = join(
'/',
'var',
'T',
'TemporaryItems',
'NSIRD_screencaptureui_1',
'Other.png'
)
const COPY = join('/', 'var', 'T', 'orca-drops-501', 'orca-drop-abc123', 'Shot.png')
const FINDER = join('/', 'Users', 'me', 'Desktop', 'notes.txt')
const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '/var/T/drops' }
function copied(sourcePath: string, destPath = sourcePath): DragTempCopyItemResult {
return { sourcePath, status: 'imported', destPath }
}
function createQueue(
overrides: {
getCopyEnvironment?: () => Promise<typeof env>
forward?: (payload: NativeFileDropPayload) => void
copyTimeoutMs?: number
} = {}
) {
const forwarded: NativeFileDropPayload[] = []
const controller = new AbortController()
const enqueue = createNativeFileDropQueue({
forward: overrides.forward ?? ((payload) => forwarded.push(payload)),
platform: 'darwin',
getCopyEnvironment: overrides.getCopyEnvironment ?? (async () => env),
watchRenderer: () => ({ signal: controller.signal, dispose: () => undefined }),
copyTimeoutMs: overrides.copyTimeoutMs
})
return { enqueue, forwarded, controller }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
}
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
materializeMock.mockReset()
})
describe('createNativeFileDropQueue', () => {
it('forwards a drop with no drag-temp path synchronously without copying', () => {
const { enqueue, forwarded } = createQueue()
const payload: NativeFileDropPayload = { paths: [FINDER], target: 'editor' }
enqueue(payload)
expect(forwarded).toEqual([payload])
expect(materializeMock).not.toHaveBeenCalled()
})
it('forwards drag-temp-looking paths untouched off macOS', () => {
const forwarded: NativeFileDropPayload[] = []
const enqueue = createNativeFileDropQueue({
forward: (payload) => forwarded.push(payload),
platform: 'linux',
getCopyEnvironment: async () => env,
watchRenderer: () => ({ signal: new AbortController().signal, dispose: () => undefined })
})
enqueue({ paths: [DRAG_TEMP], target: 'composer' })
expect(forwarded).toEqual([{ paths: [DRAG_TEMP], target: 'composer' }])
expect(materializeMock).not.toHaveBeenCalled()
})
it('swaps in the copy and keeps the drop target fields', async () => {
materializeMock.mockResolvedValue([copied(FINDER), copied(DRAG_TEMP, COPY)])
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal', tabId: 't1', paneLeafId: 'p1' })
await settle()
expect(materializeMock).toHaveBeenCalledWith([FINDER, DRAG_TEMP], env, expect.any(AbortSignal))
expect(forwarded).toEqual([
{ paths: [FINDER, COPY], target: 'terminal', tabId: 't1', paneLeafId: 'p1' }
])
})
it('holds a later plain drop until an earlier copy is forwarded', async () => {
const copy = deferred<DragTempCopyItemResult[]>()
materializeMock.mockReturnValueOnce(copy.promise)
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [DRAG_TEMP], target: 'composer' })
enqueue({ paths: [FINDER], target: 'composer' })
await settle()
expect(forwarded).toEqual([])
copy.resolve([copied(DRAG_TEMP, COPY)])
await settle()
expect(forwarded).toEqual([
{ paths: [COPY], target: 'composer' },
{ paths: [FINDER], target: 'composer' }
])
enqueue({ paths: [FINDER], target: 'editor' })
expect(forwarded).toHaveLength(3)
})
it('copies drag-temp drops one at a time, in arrival order', async () => {
const copy = deferred<DragTempCopyItemResult[]>()
materializeMock
.mockReturnValueOnce(copy.promise)
.mockResolvedValueOnce([copied(OTHER_DRAG_TEMP, COPY)])
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [DRAG_TEMP], target: 'composer' })
enqueue({ paths: [OTHER_DRAG_TEMP], target: 'terminal' })
await settle()
expect(materializeMock).toHaveBeenCalledTimes(1)
copy.resolve([copied(DRAG_TEMP, COPY)])
await settle()
expect(forwarded.map((payload) => payload.target)).toEqual(['composer', 'terminal'])
})
it('hands an uncopied file to a target main reads, without reporting it', async () => {
materializeMock.mockResolvedValue([
{ sourcePath: DRAG_TEMP, status: 'uncopied', reason: 'too-large' }
])
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [DRAG_TEMP], target: 'editor' })
await settle()
expect(forwarded).toEqual([{ paths: [DRAG_TEMP], target: 'editor' }])
})
it('withholds an uncopied file from terminals and composers and reports it', async () => {
materializeMock.mockResolvedValue([
copied(FINDER),
{ sourcePath: DRAG_TEMP, status: 'uncopied', reason: 'storage-full' }
])
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal' })
await settle()
expect(forwarded).toEqual([
{ paths: [FINDER], target: 'terminal' },
{
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason: 'storage-full'
}
])
})
it('forwards what it could copy and reports the rest with their shared reason', async () => {
materializeMock.mockResolvedValue([
copied(DRAG_TEMP, COPY),
{ sourcePath: OTHER_DRAG_TEMP, status: 'failed', reason: 'permission-denied' }
])
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [DRAG_TEMP, OTHER_DRAG_TEMP], target: 'composer', scopeKey: 'pane-1' })
await settle()
expect(forwarded).toEqual([
{ paths: [COPY], target: 'composer', scopeKey: 'pane-1' },
{
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason: 'permission-denied'
}
])
})
it('reports a drop that lost every file, with no shared reason when they differ', async () => {
materializeMock.mockResolvedValue([
{ sourcePath: DRAG_TEMP, status: 'failed', reason: 'missing' },
{ sourcePath: DRAG_TEMP, status: 'failed', reason: 'changed' }
])
const { enqueue, forwarded } = createQueue()
enqueue({ paths: [DRAG_TEMP, DRAG_TEMP], target: 'terminal' })
await settle()
expect(forwarded).toEqual([
{ byteLength: 0, pathCount: 2, reason: 'temp-copy-failed', target: 'rejected' }
])
})
it('drops a copy whose renderer went away, and keeps serving later drops', async () => {
const { enqueue, forwarded, controller } = createQueue()
materializeMock.mockImplementationOnce(async () => {
controller.abort(new Error('renderer gone'))
throw new Error('renderer gone')
})
enqueue({ paths: [DRAG_TEMP], target: 'terminal' })
await settle()
enqueue({ paths: [FINDER], target: 'editor' })
expect(forwarded).toEqual([{ paths: [FINDER], target: 'editor' }])
})
it('drops queued drops from a document that reloaded, and serves the new document', async () => {
const copy = deferred<DragTempCopyItemResult[]>()
materializeMock
.mockReturnValueOnce(copy.promise)
.mockImplementation(async (paths: string[]) => paths.map((path) => copied(path, COPY)))
let document = new AbortController()
const forwarded: NativeFileDropPayload[] = []
const enqueue = createNativeFileDropQueue({
forward: (payload) => forwarded.push(payload),
platform: 'darwin',
getCopyEnvironment: async () => env,
watchRenderer: () => ({ signal: document.signal, dispose: () => undefined })
})
enqueue({ paths: [DRAG_TEMP], target: 'terminal' })
await settle()
enqueue({ paths: [OTHER_DRAG_TEMP], target: 'editor' })
document.abort(new Error('reloaded'))
document = new AbortController()
enqueue({ paths: [OTHER_DRAG_TEMP], target: 'composer' })
copy.resolve([copied(DRAG_TEMP, COPY)])
await settle()
expect(forwarded).toEqual([{ paths: [COPY], target: 'composer' }])
})
it('still delivers the ordinary paths of a drop whose copy stage failed outright', async () => {
const { enqueue, forwarded } = createQueue({
getCopyEnvironment: async () => {
throw new Error('no temp path')
}
})
enqueue({ paths: [FINDER, DRAG_TEMP], target: 'composer', scopeKey: 'pane-1' })
await settle()
expect(forwarded).toEqual([
{ paths: [FINDER], target: 'composer', scopeKey: 'pane-1' },
{ byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' }
])
})
it('forwards a rejected drop at once, without waiting on the copy environment', () => {
const getCopyEnvironment = vi.fn(() => new Promise<typeof env>(() => undefined))
const { enqueue, forwarded } = createQueue({ getCopyEnvironment })
enqueue({ paths: [DRAG_TEMP], target: 'terminal' })
const rejected: NativeFileDropPayload = {
byteLength: 0,
pathCount: 300,
reason: 'too-many-paths',
target: 'rejected'
}
enqueue(rejected)
expect(forwarded).toEqual([rejected])
})
it('gives up on a hung copy, says why, and serves the copies behind it', async () => {
let copySignal: AbortSignal | undefined
materializeMock.mockImplementationOnce((_paths, _env, signal: AbortSignal) => {
copySignal = signal
return new Promise(() => undefined)
})
materializeMock.mockResolvedValueOnce([copied(OTHER_DRAG_TEMP, COPY)])
const { enqueue, forwarded } = createQueue({ copyTimeoutMs: 5 })
enqueue({ paths: [FINDER, DRAG_TEMP], target: 'terminal' })
enqueue({ paths: [OTHER_DRAG_TEMP], target: 'editor' })
await new Promise((resolve) => setTimeout(resolve, 20))
expect(copySignal?.aborted).toBe(true)
expect(forwarded).toEqual([
{ paths: [FINDER], target: 'terminal' },
{
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason: 'timed-out'
},
{ paths: [COPY], target: 'editor' }
])
})
it('refuses drag-temp files past the pending-copy limit but keeps their ordinary paths', async () => {
materializeMock.mockReturnValue(new Promise(() => undefined))
const { enqueue, forwarded, controller } = createQueue()
for (let i = 0; i < MAX_PENDING_DRAG_TEMP_COPIES; i += 1) {
enqueue({ paths: [DRAG_TEMP], target: 'terminal' })
}
enqueue({ paths: [FINDER, DRAG_TEMP], target: 'composer' })
// The ordinary path waits behind the earlier drops; the refusal does not.
expect(forwarded).toEqual([
{
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason: 'busy'
}
])
controller.abort(new Error('test done'))
})
it('does not report a copied drop as a failed copy when forwarding it throws', async () => {
materializeMock.mockResolvedValueOnce([copied(DRAG_TEMP, COPY)])
const forward = vi.fn((_payload: NativeFileDropPayload) => {
throw new Error('send failed')
})
const { enqueue } = createQueue({ forward })
enqueue({ paths: [DRAG_TEMP], target: 'terminal' })
await settle()
expect(forward.mock.calls).toEqual([[{ paths: [COPY], target: 'terminal' }]])
})
})
const CHANNEL = 'terminal:file-dropped-from-preload'
function createWindow() {
let destroyed = false
const windowListeners = new Map<string, () => void>()
const webContents = {
isDestroyed: () => destroyed,
send: vi.fn(),
once: vi.fn(),
removeListener: vi.fn()
}
const fake = {
isDestroyed: () => destroyed,
on: (event: string, listener: () => void) => windowListeners.set(event, listener),
webContents
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay only touches the members stubbed above.
const window = fake as unknown as BrowserWindow
return {
window,
webContents,
close: () => {
destroyed = true
windowListeners.get('closed')?.()
},
destroy: () => {
destroyed = true
}
}
}
function relay(): IpcListener {
const listener = ipcListeners.get(CHANNEL)
expect(listener).toBeDefined()
return listener!
}
describe('registerFileDropRelay', () => {
beforeEach(() => {
ipcListeners.clear()
ipcHandlers.clear()
sweepMock.mockReset()
})
it('relays only well-formed drops from its own renderer, until the window closes', () => {
const { window, webContents, close } = createWindow()
registerFileDropRelay(window)
const drop: NativeFileDropPayload = { paths: [FINDER], target: 'editor' }
relay()({ sender: { id: 'other-window' } }, drop)
relay()({ sender: webContents }, { paths: 'not-a-list', target: 'editor' })
// Only main may report a failed copy; a renderer claiming one is ignored.
relay()(
{ sender: webContents },
{ byteLength: 0, pathCount: 1, reason: 'temp-copy-failed', target: 'rejected' }
)
relay()({ sender: webContents }, drop)
expect(webContents.send.mock.calls).toEqual([['terminal:file-drop', drop]])
expect(sweepMock).toHaveBeenCalledTimes(1)
close()
expect(ipcListeners.has(CHANNEL)).toBe(false)
})
it('replaces a listener left by an earlier window', () => {
const first = createWindow()
const second = createWindow()
registerFileDropRelay(first.window)
registerFileDropRelay(second.window)
relay()({ sender: first.webContents }, { paths: [FINDER], target: 'editor' })
expect(first.webContents.send).not.toHaveBeenCalled()
})
it('prepares ordinary paths only for its own live renderer and validates the request', async () => {
const { window, webContents, close } = createWindow()
registerFileDropRelay(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
const request = { paths: [FINDER], consumer: 'agent' }
expect(() => handler({ sender: {} }, request)).toThrow('owning window')
for (const invalid of [
null,
{ paths: [FINDER], consumer: 'other' },
{ paths: [42], consumer: 'agent' }
]) {
expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid')
}
await expect(handler({ sender: webContents }, request)).resolves.toEqual({
paths: [FINDER],
failures: []
})
expect(webContents.send).not.toHaveBeenCalled()
close()
expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false)
expect(() => handler({ sender: webContents }, request)).toThrow('owning window')
})
it('enforces both request caps before preparing any paths', async () => {
const { window, webContents } = createWindow()
registerFileDropRelay(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
await expect(
handler(
{ sender: webContents },
{ paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' }
)
).resolves.toMatchObject({
paths: [],
failures: [{ reason: 'too-many-paths', pathCount: 257 }]
})
await expect(
handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' })
).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] })
expect(materializeMock).not.toHaveBeenCalled()
})
it('keeps the replacement window handler when an older window closes late', async () => {
const first = createWindow()
const second = createWindow()
registerFileDropRelay(first.window)
registerFileDropRelay(second.window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
first.close()
expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler)
await expect(
handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' })
).resolves.toEqual({ paths: [FINDER], failures: [] })
})
it.skipIf(process.platform !== 'darwin')(
'copies from the macOS user temp dir and sends nothing once the window is gone',
async () => {
const copy = deferred<DragTempCopyItemResult[]>()
materializeMock.mockReturnValueOnce(copy.promise)
const { window, webContents, destroy } = createWindow()
registerFileDropRelay(window)
relay()({ sender: webContents }, { paths: [DRAG_TEMP], target: 'terminal' })
await settle()
destroy()
copy.resolve([copied(DRAG_TEMP, COPY)])
await settle()
expect(materializeMock.mock.calls[0][1]).toMatchObject({
platform: 'darwin',
sourceTempRoot: '/private/var/folders/ab/xyz/T'
})
expect(webContents.send).not.toHaveBeenCalled()
}
)
})
-219
View File
@@ -1,219 +0,0 @@
import { app, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import {
createRejectedNativeFileDropPayload,
isNativeFileDropPayload,
NATIVE_FILE_DROP_TARGET,
validateNativeFileDropPaths,
type NativeFileDropPayload,
type NativeFileDropRejectedPayload
} from '../../shared/native-file-drop'
import type {
PrepareDroppedPathsRequest,
PreparedDroppedPaths
} from '../../shared/native-file-drop-preparation'
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy'
import {
createDroppedPathPreparationQueue,
type createDragTempCopyLane,
prepareDroppedPaths,
windowDragTempCopyLane,
type DroppedPathPreparationDeps
} from './dropped-path-preparation'
import type { DragTempCopyEnvironment } from './dragged-temp-file-copy'
import { getDarwinUserTempDir } from './darwin-user-temp-dir'
export { MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation'
type AcceptedNativeFileDropPayload = Exclude<NativeFileDropPayload, NativeFileDropRejectedPayload>
type NativeFileDropQueueDeps = DroppedPathPreparationDeps & {
forward: (payload: NativeFileDropPayload) => void
}
type NativeFileDropQueue = ((payload: NativeFileDropPayload) => void) & {
prepare: (request: PrepareDroppedPathsRequest) => Promise<PreparedDroppedPaths>
}
export function registerFileDropRelay(mainWindow: BrowserWindow): void {
const channel = 'terminal:file-dropped-from-preload'
const prepareChannel = 'fs:prepareDroppedPaths'
const mainWebContents = mainWindow.webContents
const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed()
ipcMain.removeAllListeners(channel)
ipcMain.removeHandler(prepareChannel)
const enqueue = createNativeFileDropQueue(
{
forward: (payload) => {
if (!isWindowGone()) {
mainWebContents.send('terminal:file-drop', payload)
}
},
platform: process.platform,
getCopyEnvironment: async () => ({
platform: process.platform,
sourceTempRoot: await getDarwinUserTempDir(),
copyRoot: getDragTempCopyRoot(app.getPath('temp'))
}),
watchRenderer: () => abortWhenRendererGone(mainWebContents)
},
windowDragTempCopyLane
)
const relayFileDrop = (event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => {
if (isWindowGone() || event.sender !== mainWebContents) {
return
}
if (!isNativeFileDropPayload(args) || isTempCopyFailure(args)) {
return
}
enqueue(args)
}
ipcMain.on(channel, relayFileDrop)
const prepareHandler = (
event: Electron.IpcMainInvokeEvent,
args: unknown
): Promise<PreparedDroppedPaths> => {
if (isWindowGone() || event.sender !== mainWebContents) {
throw new Error('Dropped paths must come from the owning window')
}
if (!isPrepareDroppedPathsRequest(args)) {
throw new Error('Invalid dropped paths request')
}
const validation = validateNativeFileDropPaths(args.paths)
if (validation.status === 'rejected') {
return Promise.resolve({
paths: [],
failures: [createRejectedNativeFileDropPayload(validation)]
})
}
return enqueue.prepare(args)
}
ipcMain.handle(prepareChannel, prepareHandler)
activePrepareHandler = prepareHandler
mainWindow.on('closed', () => {
ipcMain.removeListener(channel, relayFileDrop)
// Why: an old window closing late must not remove the replacement window's handler.
if (activePrepareHandler === prepareHandler) {
ipcMain.removeHandler(prepareChannel)
activePrepareHandler = undefined
}
})
scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp')))
}
let activePrepareHandler: unknown
/** Legacy delivery stays ordered; new owners share only the serialized copy work. */
export function createNativeFileDropQueue(
deps: NativeFileDropQueueDeps,
lane?: ReturnType<typeof createDragTempCopyLane>
): NativeFileDropQueue {
const prepare = createDroppedPathPreparationQueue(deps, lane)
let deliveryTail = Promise.resolve()
let queuedDeliveries = 0
const deliver = (payloads: NativeFileDropPayload[]): void => {
for (const payload of payloads) {
deps.forward(payload)
}
}
const enqueueDelivery = (work: () => Promise<void> | void): void => {
queuedDeliveries += 1
deliveryTail = deliveryTail
.then(work)
.catch(() => undefined)
.finally(() => {
queuedDeliveries -= 1
})
}
const forwardInOrder = (payload: NativeFileDropPayload): void => {
if (payload.target === 'rejected' || queuedDeliveries === 0) {
deps.forward(payload)
return
}
const lifetime = deps.watchRenderer()
enqueueDelivery(() => {
try {
if (!lifetime.signal.aborted) {
deps.forward(payload)
}
} finally {
lifetime.dispose()
}
})
}
const enqueue = (payload: NativeFileDropPayload): void => {
if (payload.target === 'rejected') {
forwardInOrder(payload)
return
}
const prepared = prepare({ paths: payload.paths, consumer: legacyConsumer(payload) })
if (!(prepared instanceof Promise)) {
if (payload.paths.length === 0) {
forwardInOrder(payload)
return
}
for (const item of legacyPayloads(payload, prepared)) {
forwardInOrder(item)
}
return
}
// Why: observe rejection now, even while legacy delivery waits behind an earlier drop.
const result = prepared.then(
(value) => legacyPayloads(payload, value),
() => []
)
enqueueDelivery(async () => deliver(await result))
}
return Object.assign(enqueue, {
prepare: async (request: PrepareDroppedPathsRequest) => prepare(request)
})
}
export async function prepareNativeFileDrop(
payload: AcceptedNativeFileDropPayload,
env: DragTempCopyEnvironment,
signal?: AbortSignal
): Promise<NativeFileDropPayload[]> {
return legacyPayloads(
payload,
await prepareDroppedPaths(
{ paths: payload.paths, consumer: legacyConsumer(payload) },
env,
signal
)
)
}
function legacyConsumer(
payload: AcceptedNativeFileDropPayload
): PrepareDroppedPathsRequest['consumer'] {
return payload.target === NATIVE_FILE_DROP_TARGET.terminal ||
payload.target === NATIVE_FILE_DROP_TARGET.composer
? 'agent'
: 'main-reader'
}
function legacyPayloads(
payload: AcceptedNativeFileDropPayload,
prepared: PreparedDroppedPaths
): NativeFileDropPayload[] {
return [
...(prepared.paths.length > 0 ? [{ ...payload, paths: prepared.paths }] : []),
...prepared.failures
]
}
function isTempCopyFailure(payload: NativeFileDropPayload): boolean {
return payload.target === 'rejected' && payload.reason === 'temp-copy-failed'
}
function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest {
return (
!!value &&
typeof value === 'object' &&
'consumer' in value &&
(value.consumer === 'agent' || value.consumer === 'main-reader') &&
'paths' in value &&
Array.isArray(value.paths) &&
value.paths.every((path: unknown) => typeof path === 'string')
)
}
@@ -10,12 +10,10 @@ import {
type RichMarkdownContextMenuCommandPayload,
type RichMarkdownContextMenuTableTarget
} from '../../shared/rich-markdown-context-menu'
import type { NativeFileDropPayload } from '../../shared/native-file-drop'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
import type { ClipboardImageThumbnail } from '../../shared/clipboard-image'
import type { ReadClipboardTextOptions } from '../../shared/clipboard-text'
import { subscribeNativeFileDrop } from '../preload-runtime-support'
import type { PreloadApi } from '../api-types'
export const uiClipboardAndWindowControlsApi = {
@@ -130,8 +128,6 @@ export const uiClipboardAndWindowControlsApi = {
}
| string
): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args),
onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) =>
subscribeNativeFileDrop(callback),
getZoomLevel: (): number => webFrame.getZoomLevel(),
setZoomLevel: (level: number): void => webFrame.setZoomLevel(level),
syncTrafficLights: (zoomFactor: number): void =>
-2
View File
@@ -1,7 +1,6 @@
import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments'
import type { ClipboardImageThumbnail } from '../../shared/clipboard-image'
import type { ReadClipboardTextOptions } from '../../shared/clipboard-text'
import type { NativeFileDropPayload } from '../../shared/native-file-drop'
import type {
RichMarkdownContextMenuCommandPayload,
RichMarkdownContextMenuTableTarget
@@ -39,7 +38,6 @@ export type UiWindowApi = {
}
| string
) => Promise<{ ok: boolean; reason?: string }>
onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void
syncTrafficLights: (zoomFactor: number) => void
+1 -5
View File
@@ -1,9 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { PreloadApi } from './api-types'
import {
installBrowserFindListener,
installNativeFileDropHandlers
} from './preload-runtime-support'
import { installBrowserFindListener } from './preload-runtime-support'
import { appApi } from './api/app-bridge'
import { orcaProfilesApi } from './api/orca-profiles-bridge'
import { platformApi } from './api/platform-bridge'
@@ -87,7 +84,6 @@ import { mobileApi } from './api/mobile-bridge'
import { agentStatusApi } from './api/agent-status-bridge'
import { speechApi } from './api/speech-bridge'
installNativeFileDropHandlers()
installBrowserFindListener()
// Custom APIs for renderer. Each domain bridge owns its IPC contract.
@@ -0,0 +1,25 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
ipcRenderer: { on: vi.fn(), removeListener: vi.fn(), send: vi.fn() },
webUtils: { getPathForFile: vi.fn() }
}))
it('registers no document drop or dragover listeners in preload', async () => {
const addListener = vi.spyOn(document, 'addEventListener')
try {
const preload = await import('./preload-runtime-support')
if (
'installNativeFileDropHandlers' in preload &&
typeof preload.installNativeFileDropHandlers === 'function'
) {
preload.installNativeFileDropHandlers()
}
expect(
addListener.mock.calls.filter(([type]) => type === 'drop' || type === 'dragover')
).toEqual([])
} finally {
addListener.mockRestore()
}
})
@@ -1,122 +0,0 @@
// @vitest-environment happy-dom
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation'
import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../shared/native-file-drop'
const electron = vi.hoisted(() => ({
send: vi.fn(),
getPathForFile: vi.fn((file: File) => `/drop/${file.name}`),
on: vi.fn(),
removeListener: vi.fn()
}))
vi.mock('electron', () => ({
ipcRenderer: electron,
webUtils: { getPathForFile: electron.getPathForFile }
}))
import { installNativeFileDropHandlers } from './preload-runtime-support'
function dispatch(target: Element, type = 'drop', types = ['Files']) {
const event = new Event(type, { bubbles: true, cancelable: true, composed: true })
const transfer = { types, files: [new File(['x'], 'notes.txt')], dropEffect: 'none' }
Object.defineProperty(event, 'dataTransfer', { value: transfer })
const stop = vi.spyOn(event, 'stopPropagation')
target.dispatchEvent(event)
return { event, transfer, stop }
}
function root(owner: boolean, legacy = false) {
const element = document.createElement('div')
if (owner) {
element.setAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE, '')
}
if (legacy) {
element.dataset.nativeFileDropTarget = 'terminal'
}
document.body.append(element)
return element
}
beforeAll(() => {
installNativeFileDropHandlers()
installNativeFileDropHandlers()
})
beforeEach(() => {
document.body.replaceChildren()
electron.send.mockClear()
electron.getPathForFile.mockClear()
})
describe('preload migration release', () => {
it.each([false, true])(
'keeps unmarked and legacy roots on the relay exactly once: legacy=%s',
(legacy) => {
const { event, stop } = dispatch(root(false, legacy))
expect(event.defaultPrevented).toBe(true)
expect(stop).toHaveBeenCalledTimes(1)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
paths: ['/drop/notes.txt'],
target: legacy ? 'terminal' : 'editor'
})
}
)
it.each(['drop', 'dragover'])('leaves %s inside a migrated owner untouched', (type) => {
const owner = root(true)
const child = document.createElement('span')
owner.append(child)
const { event, transfer, stop } = dispatch(child, type)
expect(event.defaultPrevented).toBe(false)
expect(stop).not.toHaveBeenCalled()
expect(transfer.dropEffect).toBe('none')
expect(electron.send).not.toHaveBeenCalled()
expect(electron.getPathForFile).not.toHaveBeenCalled()
})
it.each(['drop', 'dragover'])('keeps a nested legacy boundary on the legacy %s path', (type) => {
const owner = root(true)
const legacy = root(false, true)
owner.append(legacy)
const { event, transfer } = dispatch(legacy, type)
expect(event.defaultPrevented).toBe(true)
if (type === 'drop') {
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
paths: ['/drop/notes.txt'],
target: 'terminal'
})
} else {
expect(transfer.dropEffect).toBe('copy')
expect(electron.send).not.toHaveBeenCalled()
}
})
it('chooses an inner owner over an outer legacy boundary', () => {
const legacy = root(false, true)
const owner = root(true)
legacy.append(owner)
expect(dispatch(owner).event.defaultPrevented).toBe(false)
expect(electron.send).not.toHaveBeenCalled()
})
it.each(['drop', 'dragover'])(
'leaves internal %s unchanged even inside a migrated owner',
(type) => {
const { event, transfer, stop } = dispatch(root(true), type, [
'Files',
ORCA_INTERNAL_FILE_DRAG_TYPE
])
expect(event.defaultPrevented).toBe(false)
expect(stop).not.toHaveBeenCalled()
expect(transfer.dropEffect).toBe('none')
expect(electron.send).not.toHaveBeenCalled()
}
)
it('keeps the existing non-file drop cancellation, regardless of owner markers', () => {
const event = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['text/plain'], files: [] } })
root(true).dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(electron.send).not.toHaveBeenCalled()
expect(dispatch(root(true), 'dragover', ['text/plain']).event.defaultPrevented).toBe(false)
})
})
+1 -169
View File
@@ -1,26 +1,9 @@
import { ipcRenderer, webUtils } from 'electron'
import { ipcRenderer } from 'electron'
import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests'
import { createBrowserFindSubscriptions } from './browser-find-subscriptions'
import { registerRendererRestartIpcRelays } from './renderer-restart-wiring'
import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation'
import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events'
import {
OS_FILE_DROP_BOUNDARY_ATTRIBUTE,
OS_FILE_DROP_OWNER_ATTRIBUTE
} from '../shared/native-file-drop-preparation'
import {
ORCA_INTERNAL_FILE_DRAG_TYPE,
createNativeFileDropPayload,
createRejectedNativeFileDropPayload,
hasNativeFileDragTypes,
NATIVE_FILE_DROP_MAX_PATHS,
resolveNativeFileDropPath,
type NativeDropResolution,
type NativeFileDropPayload,
type NativeFileDropPathEntry,
type NativeFileDropRejectedPayload
} from '../shared/native-file-drop'
/** Joins the synchronous unload checkpoint with its durable renderer write. */
export async function awaitBeforeUnloadCheckpoint(): Promise<void> {
const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as {
@@ -47,157 +30,6 @@ export function getLinuxDisplayServer(): 'wayland' | 'x11' | null {
return process.env.DISPLAY ? 'x11' : null
}
type NativeFileDropCallback = (data: NativeFileDropPayload) => void
const nativeFileDropCallbacks: NativeFileDropCallback[] = []
let nativeFileDropListenerRegistered = false
let nativeFileDropHandlersInstalled = false
const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => {
for (const callback of Array.from(nativeFileDropCallbacks)) {
callback(data)
}
}
export function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void {
nativeFileDropCallbacks.push(callback)
if (!nativeFileDropListenerRegistered) {
ipcRenderer.on('terminal:file-drop', onNativeFileDrop)
nativeFileDropListenerRegistered = true
}
return () => {
const callbackIndex = nativeFileDropCallbacks.indexOf(callback)
if (callbackIndex !== -1) {
nativeFileDropCallbacks.splice(callbackIndex, 1)
}
if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) {
ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop)
nativeFileDropListenerRegistered = false
}
}
}
function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null {
const pathEntries: NativeFileDropPathEntry[] = []
for (const entry of event.composedPath()) {
if (entry instanceof HTMLElement) {
pathEntries.push({
nativeFileDropTarget: entry.dataset.nativeFileDropTarget,
nativeFileDropDir: entry.dataset.nativeFileDropDir,
composerScopeKey: entry.dataset.composerScopeKey,
terminalTabId: entry.dataset.terminalTabId,
terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId
})
}
}
return resolveNativeFileDropPath(pathEntries)
}
function nearestDropBoundaryIsMigrated(event: DragEvent): boolean {
for (const entry of event.composedPath()) {
if (!(entry instanceof HTMLElement)) {
continue
}
if (
entry.hasAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE) ||
entry.hasAttribute(OS_FILE_DROP_BOUNDARY_ATTRIBUTE)
) {
return true
}
if (entry.hasAttribute('data-native-file-drop-target')) {
return false
}
}
return false
}
/** Installs the one preload-side listener that converts native File objects to paths. */
export function installNativeFileDropHandlers(): void {
// Preload entry points can be evaluated more than once in tests and during development reloads;
// duplicate document listeners retain every closure and process each drop repeatedly.
if (nativeFileDropHandlersInstalled) {
return
}
document.addEventListener(
'dragover',
(event) => {
if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) {
return
}
if (
hasNativeFileDragTypes(event.dataTransfer?.types) &&
nearestDropBoundaryIsMigrated(event)
) {
return
}
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
},
true
)
document.addEventListener(
'drop',
(event) => {
if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) {
return
}
if (
hasNativeFileDragTypes(event.dataTransfer?.types) &&
nearestDropBoundaryIsMigrated(event)
) {
return
}
event.preventDefault()
event.stopPropagation()
const files = event.dataTransfer?.files
if (!files || files.length === 0) {
return
}
const resolution = resolveNativeFileDrop(event)
if (files.length > NATIVE_FILE_DROP_MAX_PATHS) {
ipcRenderer.send(
'terminal:file-dropped-from-preload',
createRejectedNativeFileDropPayload({
byteLength: 0,
pathCount: files.length,
reason: 'too-many-paths',
status: 'rejected'
})
)
return
}
const paths: string[] = []
for (let index = 0; index < files.length; index += 1) {
const filePath = webUtils.getPathForFile(files[index])
if (filePath) {
paths.push(filePath)
}
}
if (resolution?.target === 'rejected') {
return
}
if (paths.length === 0) {
// The OS offered file items we could read no path from (promised or
// virtual files). Report it — silence here is #15782.
ipcRenderer.send('terminal:file-dropped-from-preload', {
byteLength: 0,
pathCount: files.length,
reason: 'unresolved-paths',
target: 'rejected'
} satisfies NativeFileDropRejectedPayload)
return
}
const payload = createNativeFileDropPayload(resolution, paths)
if (payload) {
ipcRenderer.send('terminal:file-dropped-from-preload', payload)
}
},
true
)
nativeFileDropHandlersInstalled = true
}
export const browserFindSubscriptions = createBrowserFindSubscriptions()
export const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({
ipc: ipcRenderer,
@@ -4,7 +4,6 @@ import { useIpcEvents } from '../hooks/useIpcEvents'
import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents'
import { useAutoAckViewedAgent } from '../hooks/useAutoAckViewedAgent'
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
import { useGlobalFileDrop } from '../hooks/useGlobalFileDrop'
import { useAppMenuPaste } from '../hooks/useAppMenuPaste'
import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions'
import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste'
@@ -55,7 +54,6 @@ export function useAppShellServices(): void {
useGitStatusPolling({ enabled: workspaceSessionReady })
// Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks).
useEditorExternalWatch()
useGlobalFileDrop()
useAutoAckViewedAgent()
useAppMenuPaste()
useAppMenuSelectionActions()
@@ -199,7 +199,6 @@ describe('new workspace card file drop ownership', () => {
cards.splice(cards.indexOf(b), 1)
await drop(a)
expect(first).toHaveBeenCalledTimes(2)
expect(a.querySelector('[data-native-file-drop-target]')).toBeNull()
})
it('keeps a preparation captured for the original card when another card mounts', async () => {
const gate = Promise.withResolvers<{ paths: string[]; failures: never[] }>()
@@ -22,7 +22,6 @@ vi.mock('react-i18next', async (importOriginal) => ({
const prepare = vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] }))
const stat = vi.fn(async () => ({ isDirectory: false }))
const onFileDrop = vi.fn<Window['api']['ui']['onFileDrop']>(() => () => {})
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('api', {
@@ -31,7 +30,6 @@ beforeEach(() => {
prepareDroppedPaths: prepare,
stat
},
ui: { onFileDrop },
gh: {
repoSlug: vi.fn(async () => null),
listWorkItems: vi.fn(async () => ({ items: [], hasMore: false }))
@@ -55,7 +55,7 @@ export function getEditorFileDropOperationContext(
export type EditorFileDropDestination = {
worktreeId: string
/** Omitted only by the legacy route, which lets the store pick the worktree's active group. */
/** When omitted, the store chooses this workspace's default editor destination. */
groupId?: string
}
@@ -5,7 +5,7 @@ import {
useOsFileDropOwner,
type OsFileDropSequence
} from '@/hooks/use-os-file-drop-owner'
import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message'
import {
captureEditorFileDropOpen,
editorGroupStillExists,
@@ -217,8 +217,7 @@ describe('NativeChatComposer', () => {
configurable: true,
value: {
git: { discoverCommitMessageModels: mocks.discoverCommitMessageModels },
pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot },
ui: { onFileDrop: () => vi.fn() }
pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot }
}
})
})
@@ -3,7 +3,7 @@ import { Paperclip } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { toast } from 'sonner'
import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner'
import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message'
import {
makeNativeChatPaneFileDropHandlers,
type NativeChatPaneDropClaim
@@ -80,7 +80,6 @@ function textarea(): HTMLTextAreaElement {
describe('native chat composer drop ownership', () => {
it('keeps the conversation editor key without publishing drop routing markers', () => {
const view = render(<TestField {...fieldProps({ draftScopeKey: 'agent-session:session-9' })} />)
expect(view.container.querySelector('[data-native-file-drop-target]')).toBeNull()
expect(view.container.querySelector('[data-composer-scope-key]')).toBeNull()
expect(view.container.querySelector('.ProseMirror')).not.toBeNull()
})
@@ -1,6 +1,5 @@
// @vitest-environment happy-dom
import { EventEmitter } from 'node:events'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { useRef, useState } from 'react'
@@ -13,7 +12,6 @@ import { useNewWorkspaceComposerFileDrop } from '../new-workspace/use-new-worksp
import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
import { toast } from 'sonner'
import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc'
import type { NativeFileDropPayload } from '../../../../shared/native-file-drop'
import {
clearNativeChatAttachmentCacheForTests,
readNativeChatAttachmentCache,
@@ -51,10 +49,7 @@ vi.mock('sonner', () => ({ toast: { error: vi.fn(), loading: vi.fn(), dismiss: v
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false }))
import {
installNativeFileDropHandlers,
subscribeNativeFileDrop
} from '../../../../preload/preload-runtime-support'
import { installOsFileDropCancellationGuard } from '@/lib/os-file-drop-cancellation-guard'
// Exercises real element delivery, path authorization and the draft attachment cache.
function ComposerBody({
@@ -167,21 +162,9 @@ function WorkspaceComposerProbe({
describe('native chat composer drop scoping', () => {
beforeAll(() => {
const ipc = new EventEmitter()
electron.on.mockImplementation((channel, listener) => ipc.on(channel, listener))
electron.removeListener.mockImplementation((channel, listener) =>
ipc.removeListener(channel, listener)
)
// Mirror registerFileDropRelay: one window-wide notification per valid drop.
electron.send.mockImplementation((channel: string, payload: NativeFileDropPayload) => {
if (channel === 'terminal:file-dropped-from-preload') {
ipc.emit('terminal:file-drop', {}, payload)
}
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: { onFileDrop: subscribeNativeFileDrop },
shell: { pickAttachments: intake.pick },
fs: {
...intake,
@@ -190,12 +173,11 @@ describe('native chat composer drop scoping', () => {
}
}
})
installNativeFileDropHandlers()
// Repeated preload setup must stay singleton or every OS drop is processed once per install.
installNativeFileDropHandlers()
})
let disposeGuard: (() => void) | undefined
beforeEach(() => {
disposeGuard = installOsFileDropCancellationGuard()
intake.owner = { kind: 'local' }
electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`)
intake.stat.mockReset().mockResolvedValue(undefined)
@@ -207,6 +189,7 @@ describe('native chat composer drop scoping', () => {
})
afterEach(() => {
disposeGuard?.()
cleanup()
resetLocalImageSrcStateForTests()
vi.unstubAllGlobals()
@@ -365,19 +348,16 @@ describe('native chat composer drop scoping', () => {
])
})
it('keeps a drop into an unscoped composer out of every chat pane', async () => {
it('keeps a drop into an unowned composer out of every chat pane', async () => {
const view = render(
<>
<ComposerProbe pane="chat-a" />
<ComposerProbe pane="chat-b" hidden />
<div data-native-file-drop-target="composer" data-unscoped-composer="true" />
<div data-unscoped-composer="true" />
</>
)
await dropTwoImages(view.container.querySelector('[data-unscoped-composer="true"]')!)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
target: 'composer',
paths: ['/repro/first.png', '/repro/second.png']
})
expect(electron.send).not.toHaveBeenCalled()
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
})
@@ -465,19 +445,16 @@ describe('native chat composer drop scoping', () => {
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
})
it('control: an editor-targeted drop does not attach images to either chat', async () => {
it('control: an unowned control drop does not attach images to either chat', async () => {
const view = render(
<>
<ComposerProbe pane="chat-a" />
<ComposerProbe pane="chat-b" hidden />
<div data-native-file-drop-target="editor" />
<div data-testid="unowned-editor" />
</>
)
await dropTwoImages(view.container.querySelector('[data-native-file-drop-target="editor"]')!)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
target: 'editor',
paths: ['/repro/first.png', '/repro/second.png']
})
await dropTwoImages(view.container.querySelector('[data-testid="unowned-editor"]')!)
expect(electron.send).not.toHaveBeenCalled()
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
})
@@ -118,8 +118,7 @@ beforeEach(() => {
configurable: true,
value: {
git: { discoverCommitMessageModels: vi.fn().mockResolvedValue({ success: false }) },
pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) },
ui: { onFileDrop: () => vi.fn() }
pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) }
}
})
})
@@ -2,7 +2,7 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import {
@@ -24,7 +24,6 @@ vi.mock('electron', () => ({
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { installNativeFileDropHandlers } from '../../../../preload/preload-runtime-support'
const prepare = vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] }))
function Composer({
@@ -117,7 +116,6 @@ function WorkspaceWithoutPath({ attach }: { attach: () => Promise<void> }) {
})
return <div ref={owner} className="workspace-card" />
}
beforeAll(() => installNativeFileDropHandlers())
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('api', {
@@ -158,11 +156,9 @@ describe('element-owned native chat drops', () => {
expect(view.queryByText('Drop to attach to this chat')).toBeNull()
expect(attach).toHaveBeenCalledExactlyOnceWith(['/drop/a.png'])
})
it('claims a portaled chat inside a legacy terminal while the terminal outside stays legacy exactly once', async () => {
it('claims a portaled chat while refusing the unowned container around it', async () => {
const attach = vi.fn()
const terminal = document.createElement('div')
terminal.dataset.nativeFileDropTarget = 'terminal'
terminal.dataset.terminalTabId = 'terminal-a'
document.body.append(terminal)
const terminalDrop = vi.fn()
terminal.addEventListener('drop', terminalDrop)
@@ -173,11 +169,7 @@ describe('element-owned native chat drops', () => {
expect(terminalDrop).not.toHaveBeenCalled()
expect(electron.send).not.toHaveBeenCalled()
await drop(terminal)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
target: 'terminal',
tabId: 'terminal-a',
paths: ['/drop/a.png']
})
expect(electron.send).not.toHaveBeenCalled()
expect(attach).toHaveBeenCalledOnce()
} finally {
terminal.remove()
@@ -188,7 +180,7 @@ describe('element-owned native chat drops', () => {
async (disabled) => {
const attach = vi.fn()
const view = render(
<div data-native-file-drop-target="terminal">
<div>
<NativeChatPaneFileDropSurface className="chat">
{disabled ? <Composer attach={attach} disabled /> : <span>Question</span>}
</NativeChatPaneFileDropSurface>
@@ -210,10 +202,10 @@ describe('element-owned native chat drops', () => {
expect(electron.send).not.toHaveBeenCalled()
}
)
it('keeps a workspace card without a path silent and out of the legacy terminal relay', async () => {
it('keeps a workspace card without a path silent', async () => {
const attach = vi.fn(async () => {})
const view = render(
<div data-native-file-drop-target="terminal">
<div>
<WorkspaceWithoutPath attach={attach} />
</div>
)
@@ -127,9 +127,7 @@ describe('NativeChatPaneFileDropSurface', () => {
const { transcript, container } = renderPane(null)
fireDrag(transcript, 'dragover', workspaceDrag())
expect(container.querySelector(OVERLAY)).toBeNull()
expect(container.querySelector('.pane')?.hasAttribute('data-native-file-drop-target')).toBe(
false
)
expect(container.querySelector('.pane')?.hasAttribute('data-os-file-drop-owner')).toBe(true)
})
it('does not invite a drop the guarded composer will refuse', () => {
@@ -184,13 +182,12 @@ describe('NativeChatPaneFileDropSurface', () => {
expect(container.querySelector(OVERLAY)).toBeNull()
})
it('clears an OS drag overlay from the document drop the preload route consumes', () => {
it('clears an OS drag overlay when a drop ends outside the pane', () => {
const { transcript, container } = renderPane(<ClaimingComposer />)
fireDrag(transcript, 'dragover', osDrag())
expect(container.querySelector(OVERLAY)).not.toBeNull()
// The preload listener stops this event at `document`, so the surface never
// sees it as a React drop.
// A drop outside the pane still ends its hover state.
act(() => {
document.dispatchEvent(new Event('drop', { bubbles: false }))
})
@@ -108,7 +108,7 @@ function ChatRoot({
useNativeChatPasteBridge({ rootRef, composerRef, questionAnswerInputRef: answerRef })
return (
// Mirrors NativeChatPaneFileDropSurface, which publishes the composer scope pane-wide.
<div data-composer-scope-key="pane" data-native-file-drop-target="composer">
<div data-composer-scope-key="pane">
<div
ref={(node) => {
rootRef.current = node
@@ -139,8 +139,7 @@ beforeEach(() => {
configurable: true,
value: {
git: { discoverCommitMessageModels: vi.fn().mockResolvedValue({ success: false }) },
pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) },
ui: { onFileDrop: () => vi.fn() }
pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) }
}
})
})
@@ -135,8 +135,7 @@ beforeEach(() => {
configurable: true,
value: {
git: { discoverCommitMessageModels: vi.fn().mockResolvedValue({ success: false }) },
pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) },
ui: { onFileDrop: () => vi.fn() }
pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) }
}
})
})
@@ -5,7 +5,6 @@ import { cleanup, renderHook } from '@testing-library/react'
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
let pickAttachments: ReturnType<typeof vi.fn>
const onFileDrop = vi.fn()
function renderProbe(
attachExternalPaths: (paths: string[]) => void
): () => { pickAttachments: () => void } {
@@ -15,13 +14,11 @@ function renderProbe(
describe('useNativeChatFileAttachmentActions', () => {
beforeEach(() => {
onFileDrop.mockClear()
pickAttachments = vi.fn()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
shell: { pickAttachments },
ui: { onFileDrop }
shell: { pickAttachments }
}
})
})
@@ -51,9 +48,4 @@ describe('useNativeChatFileAttachmentActions', () => {
// Whether the empty batch is forwarded or dropped here, no file may attach.
expect(attachExternalPaths.mock.calls.flatMap(([paths]) => paths)).toEqual([])
})
it('does not subscribe to legacy window drops', () => {
renderProbe(vi.fn())
expect(onFileDrop).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner'
import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
@@ -31,7 +31,6 @@ function dropOn(target: Element): { dropEffect: string } {
it('keeps native drops on committed scope when a new scope render suspends', async () => {
vi.stubGlobal('api', {
ui: { onFileDrop: () => () => undefined },
fs: {
getPathForFile: () => '/source/file.ts',
prepareDroppedPaths: mocks.prepare.mockImplementation(async ({ paths }) => ({
@@ -91,8 +91,6 @@ beforeEach(() => {
results: [{ status: 'imported', destPath: '/repo/app/src/a.txt' }]
})
vi.stubGlobal('api', {
// The legacy broadcast stays available; only the element drop may import.
ui: { onFileDrop: () => () => undefined },
fs: {
getPathForFile: (file: File) => `/source/${file.name}`,
prepareDroppedPaths: mocks.prepare
@@ -5,7 +5,7 @@ import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
import { translate } from '@/i18n/i18n'
import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner'
import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message'
import type { FileExplorerOperationOwner } from './file-explorer-types'
import {
captureFileExplorerOperationGuard,
@@ -3,42 +3,38 @@
import { act } from '@testing-library/react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../../../../shared/native-file-drop-preparation'
import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../../../../shared/native-file-drop'
import { installOsFileDropCancellationGuard } from '../../lib/os-file-drop-cancellation-guard'
import { useFeedbackImageDrop } from './use-feedback-image-drop'
let container: HTMLDivElement
let root: Root
let preloadDropSpy: ReturnType<typeof vi.fn<(event: Event) => void>>
function preloadDropListener(event: Event): void {
if (
event
.composedPath()
.some(
(entry) => entry instanceof HTMLElement && entry.hasAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE)
)
) {
return
}
preloadDropSpy(event)
// Why: preload consumes the gesture, which is why React's onDrop never runs.
event.preventDefault()
event.stopPropagation()
}
let disposeGuard: () => void
let unclaimedDropSpy: ReturnType<typeof vi.fn<(event: Event) => void>>
const showNotice = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({
toast: Object.assign(showNotice, {
error: showNotice,
warning: showNotice,
info: showNotice,
success: showNotice,
message: showNotice
})
}))
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
preloadDropSpy = vi.fn()
// Registered before the hook mounts, exactly like preload's document listener.
document.addEventListener('drop', preloadDropListener, true)
showNotice.mockClear()
disposeGuard = installOsFileDropCancellationGuard()
unclaimedDropSpy = vi.fn()
document.addEventListener('drop', unclaimedDropSpy)
})
afterEach(() => {
document.removeEventListener('drop', preloadDropListener, true)
disposeGuard()
document.removeEventListener('drop', unclaimedDropSpy)
act(() => {
root.unmount()
})
@@ -82,15 +78,15 @@ function pngFile(name = 'shot.png'): File {
}
function dialogChild(): HTMLElement {
const child = container.querySelector('[data-testid="child"]')
const child = container.querySelector<HTMLElement>('[data-testid="child"]')
if (!child) {
throw new Error('harness child missing')
}
return child as HTMLElement
return child
}
describe('useFeedbackImageDrop', () => {
it('claims an image dropped on the dialog before preload can route it away', async () => {
it('claims an image dropped on the dialog and attaches it once', async () => {
const onAddFiles = vi.fn()
await renderHarness(true, onAddFiles)
@@ -103,21 +99,29 @@ describe('useFeedbackImageDrop', () => {
expect(onAddFiles).toHaveBeenCalledTimes(1)
expect(onAddFiles.mock.calls[0][0].map((file: File) => file.name)).toEqual(['shot.png'])
expect(event.defaultPrevented).toBe(true)
expect(preloadDropSpy).not.toHaveBeenCalled()
expect(unclaimedDropSpy).not.toHaveBeenCalled()
})
it('leaves drops outside the dialog to the existing native lane', async () => {
it('silently cancels drops outside the dialog and clears the hover state', async () => {
const onAddFiles = vi.fn()
await renderHarness(true, onAddFiles)
const outside = document.createElement('div')
document.body.appendChild(outside)
act(() => {
outside.dispatchEvent(dragEvent('drop', [pngFile()]))
dialogChild().dispatchEvent(dragEvent('dragenter', []))
})
expect(container.querySelector('[data-drag-active="true"]')).not.toBeNull()
const event = dragEvent('drop', [pngFile()])
act(() => {
outside.dispatchEvent(event)
})
expect(event.defaultPrevented).toBe(true)
expect(event).toHaveProperty('dataTransfer.dropEffect', 'none')
expect(onAddFiles).not.toHaveBeenCalled()
expect(preloadDropSpy).toHaveBeenCalledTimes(1)
expect(showNotice).not.toHaveBeenCalled()
expect(container.querySelector('[data-drag-active="true"]')).toBeNull()
})
it('claims non-image drops so they cannot open in the editor behind the dialog', async () => {
@@ -130,27 +134,29 @@ describe('useFeedbackImageDrop', () => {
})
expect(onAddFiles).not.toHaveBeenCalled()
expect(preloadDropSpy).not.toHaveBeenCalled()
// A refused file still cancels browser navigation.
expect(event.defaultPrevented).toBe(true)
expect(unclaimedDropSpy).not.toHaveBeenCalled()
})
it('stops listening once the dialog is closed', async () => {
const onAddFiles = vi.fn()
await renderHarness(true, onAddFiles)
await renderHarness(false, onAddFiles)
expect(container.querySelector('[data-os-file-drop-owner]')).toBeNull()
const event = dragEvent('drop', [pngFile()])
act(() => {
dialogChild().dispatchEvent(dragEvent('drop', [pngFile()]))
dialogChild().dispatchEvent(event)
})
expect(event.defaultPrevented).toBe(true)
expect(event).toHaveProperty('dataTransfer.dropEffect', 'none')
expect(onAddFiles).not.toHaveBeenCalled()
expect(preloadDropSpy).toHaveBeenCalledTimes(1)
expect(showNotice).not.toHaveBeenCalled()
})
// Why: only preload preventDefaults dragover, and the web client has no
// preload — without this the browser rejects the drop and opens the file.
it('accepts the drag on dragover so the drop can fire without preload', async () => {
it('claims dragover so the document guard preserves the accepted copy cursor', async () => {
await renderHarness(true, vi.fn())
const event = dragEvent('dragover', [])
@@ -159,7 +165,7 @@ describe('useFeedbackImageDrop', () => {
})
expect(event.defaultPrevented).toBe(true)
expect((event as DragEvent).dataTransfer?.dropEffect).toBe('copy')
expect(event).toHaveProperty('dataTransfer.dropEffect', 'copy')
})
it('leaves in-app drags alone on dragover', async () => {
@@ -174,49 +180,44 @@ describe('useFeedbackImageDrop', () => {
})
it('keeps a web feedback screenshot accepted with the document guard and attaches it once', async () => {
document.removeEventListener('drop', preloadDropListener, true)
const disposeGuard = installOsFileDropCancellationGuard()
try {
const onAddFiles = vi.fn()
await renderHarness(true, onAddFiles)
expect(container.querySelector('[data-os-file-drop-owner]')).not.toBeNull()
const image = pngFile()
const hover = dragEvent('dragover', [])
act(() => {
dialogChild().dispatchEvent(hover)
})
expect(hover.defaultPrevented).toBe(true)
expect(hover).toHaveProperty('dataTransfer.dropEffect', 'copy')
const drop = dragEvent('drop', [image])
act(() => {
dialogChild().dispatchEvent(drop)
})
expect(drop.defaultPrevented).toBe(true)
expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([image])
expect(preloadDropSpy).not.toHaveBeenCalled()
const onAddFiles = vi.fn()
await renderHarness(true, onAddFiles)
expect(container.querySelector('[data-os-file-drop-owner]')).not.toBeNull()
const image = pngFile()
const hover = dragEvent('dragover', [])
act(() => {
dialogChild().dispatchEvent(hover)
})
expect(hover.defaultPrevented).toBe(true)
expect(hover).toHaveProperty('dataTransfer.dropEffect', 'copy')
const drop = dragEvent('drop', [image])
act(() => {
dialogChild().dispatchEvent(drop)
})
expect(drop.defaultPrevented).toBe(true)
expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([image])
const outside = document.createElement('div')
document.body.appendChild(outside)
const refusedHover = dragEvent('dragover', [])
act(() => {
outside.dispatchEvent(refusedHover)
})
expect(refusedHover.defaultPrevented).toBe(true)
expect(refusedHover).toHaveProperty('dataTransfer.dropEffect', 'none')
const refusedDrop = dragEvent('drop', [image])
act(() => {
outside.dispatchEvent(refusedDrop)
})
expect(refusedDrop.defaultPrevented).toBe(true)
expect(onAddFiles).toHaveBeenCalledOnce()
} finally {
disposeGuard()
}
const outside = document.createElement('div')
document.body.appendChild(outside)
const refusedHover = dragEvent('dragover', [])
act(() => {
outside.dispatchEvent(refusedHover)
})
expect(refusedHover.defaultPrevented).toBe(true)
expect(refusedHover).toHaveProperty('dataTransfer.dropEffect', 'none')
const refusedDrop = dragEvent('drop', [image])
act(() => {
outside.dispatchEvent(refusedDrop)
})
expect(refusedDrop.defaultPrevented).toBe(true)
expect(refusedDrop).toHaveProperty('dataTransfer.dropEffect', 'none')
expect(onAddFiles).toHaveBeenCalledOnce()
expect(showNotice).not.toHaveBeenCalled()
})
it('highlights from the advertised drag types, which is all a dragenter exposes', async () => {
await renderHarness(true, vi.fn())
const dialog = container.querySelector('[data-testid="dialog"]') as HTMLElement
const dialog = container.querySelector<HTMLElement>('[data-testid="dialog"]')!
// Why: DataTransfer.files is empty until drop; only `types` is populated.
act(() => {
@@ -48,8 +48,6 @@ beforeEach(() => {
failures: []
}))
vi.stubGlobal('api', {
// The legacy broadcast stays available; only the element drop may add a project.
ui: { onFileDrop: () => () => undefined },
fs: {
getPathForFile: () => '/Users/me/project',
prepareDroppedPaths: mocks.prepare,
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { hasNativeFileDragTypes } from '../../../../shared/native-file-drop'
import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner'
import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import {
@@ -125,7 +125,6 @@ export function TerminalPaneSurface({
<div
ref={setContainerRef}
className="absolute inset-0 min-h-0 min-w-0"
data-os-file-drop-boundary=""
data-terminal-tab-id={tabId}
data-terminal-chat-view={effectiveChatViewMode && activePaneIsChatLeaf ? 'true' : undefined}
data-terminal-layout-leaf-ids={expectedLayoutLeafIdsAttr}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { createPortal } from 'react-dom'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { PreparedDroppedPaths } from '../../../../shared/native-file-drop-preparation'
@@ -15,7 +15,6 @@ import {
useNativeChatPaneFileDropClaim
} from '@/components/native-chat/NativeChatPaneFileDropSurface'
import { TerminalPaneSurface } from './TerminalPaneSurface'
import { installNativeFileDropHandlers } from '../../../../preload/preload-runtime-support'
import { installOsFileDropCancellationGuard } from '@/lib/os-file-drop-cancellation-guard'
const mocks = vi.hoisted(() => ({
@@ -35,7 +34,6 @@ const mocks = vi.hoisted(() => ({
prepare: vi.fn(),
importPaths: vi.fn(),
resolvePaths: vi.fn(),
broadcast: vi.fn(),
chatDrop: vi.fn(),
toastError: vi.fn(),
legacyIpc: vi.fn(),
@@ -224,7 +222,7 @@ function mountSurface(
}
}
function drop(target: Element, name = 'file.txt'): void {
function drop(target: Element, name = 'file.txt') {
const event = new Event('drop', { bubbles: true, cancelable: true, composed: true })
Object.defineProperty(event, 'isTrusted', { value: true })
Object.defineProperty(event, 'dataTransfer', {
@@ -233,13 +231,13 @@ function drop(target: Element, name = 'file.txt'): void {
act(() => {
target.dispatchEvent(event)
})
return event
}
async function settle(): Promise<void> {
await act(async () => undefined)
}
let disposeGuard: (() => void) | undefined
beforeAll(() => installNativeFileDropHandlers())
beforeEach(() => {
vi.clearAllMocks()
disposeGuard = installOsFileDropCancellationGuard()
@@ -270,8 +268,7 @@ beforeEach(() => {
getPathForFile: (file: File) => `/client/${file.name}`,
prepareDroppedPaths: mocks.prepare,
resolveDroppedPathsForAgent: mocks.resolvePaths
},
ui: { onFileDrop: mocks.broadcast }
}
})
})
afterEach(() => {
@@ -303,15 +300,11 @@ describe('terminal element file drops', () => {
expect(fixture.panes[0].terminal.focus).not.toHaveBeenCalled()
expect(fixture.activate).not.toHaveBeenCalled()
expect(fixture.manager.getActivePane()).toEqual(fixture.panes[1])
expect(mocks.broadcast).not.toHaveBeenCalled()
expect(mocks.legacyIpc).not.toHaveBeenCalled()
expect(mocks.prepare).toHaveBeenCalledExactlyOnceWith({
paths: ['/client/file.txt'],
consumer: 'agent'
})
expect(
fixture.view.container.querySelector('[data-native-file-drop-target="terminal"]')
).toBeNull()
}
)
it.each(['title', 'body'] as const)(
@@ -362,11 +355,31 @@ describe('terminal element file drops', () => {
Object.defineProperty(hover, 'dataTransfer', { value: transfer })
fixture.divider.dispatchEvent(hover)
expect(transfer.dropEffect).toBe('none')
drop(fixture.divider)
const dropped = drop(fixture.divider)
expect(dropped.defaultPrevented).toBe(true)
expect(dropped).toHaveProperty('dataTransfer.dropEffect', 'none')
await settle()
expect(mocks.prepare).not.toHaveBeenCalled()
expect(mocks.legacyIpc).not.toHaveBeenCalled()
expect(mocks.editorOpen).not.toHaveBeenCalled()
expect(mocks.importPaths).not.toHaveBeenCalled()
expect(mocks.resolvePaths).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(fixture.sends[0]).not.toHaveBeenCalled()
expect(fixture.sends[1]).not.toHaveBeenCalled()
})
it('refuses unowned chrome without opening, uploading, or showing a toast', async () => {
const fixture = mountSurface()
const dropped = drop(fixture.view.container)
await settle()
expect(dropped.defaultPrevented).toBe(true)
expect(dropped).toHaveProperty('dataTransfer.dropEffect', 'none')
expect(mocks.prepare).not.toHaveBeenCalled()
expect(mocks.legacyIpc).not.toHaveBeenCalled()
expect(mocks.editorOpen).not.toHaveBeenCalled()
expect(mocks.importPaths).not.toHaveBeenCalled()
expect(mocks.resolvePaths).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(fixture.sends[0]).not.toHaveBeenCalled()
expect(fixture.sends[1]).not.toHaveBeenCalled()
})
@@ -7,7 +7,7 @@ import {
useOsFileDropOwner,
type OsFileDropSequence
} from '@/hooks/use-os-file-drop-owner'
import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message'
import { makePaneKey, type PaneKey } from '../../../../shared/stable-pane-id'
import type { PtyTransport } from './pty-transport'
import { captureNativeTerminalFileDrop } from './terminal-native-file-drop-destination'
@@ -1,206 +0,0 @@
import type * as ReactModule from 'react'
import type * as StoreModule from '@/store'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
import {
cleanupGlobalEffectsTestWindow,
installGlobalEffectsTestWindow
} from './use-terminal-pane-global-effects-test-harness'
const mocks = vi.hoisted(() => ({
captureScrollState: vi.fn(),
fitAndFocusPanes: vi.fn(),
fitPanes: vi.fn(),
focusActivePane: vi.fn(),
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
enforceTerminalCurrentScrollIntent: vi.fn(),
syncTerminalScrollIntentFromViewport: vi.fn(),
pasteTerminalText: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
restoreScrollState: vi.fn(),
restoreScrollStateAfterLayout: vi.fn()
}))
const reactRefState = vi.hoisted(() => ({
slots: [] as { current: unknown }[],
index: 0
}))
function beginHookRender(): void {
reactRefState.index = 0
}
function resetHookRefs(): void {
reactRefState.slots = []
reactRefState.index = 0
}
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>()
return {
...actual,
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
useEffect: (effect: () => void | (() => void)) => {
effect()
},
useRef: <T>(value: T) => {
const index = reactRefState.index
reactRefState.index += 1
if (!reactRefState.slots[index]) {
reactRefState.slots[index] = { current: value }
}
return reactRefState.slots[index] as { current: T }
}
}
})
vi.mock('./pane-helpers', () => ({
fitAndFocusPanes: mocks.fitAndFocusPanes,
fitPanes: mocks.fitPanes,
focusActivePane: mocks.focusActivePane
}))
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
flushTerminalOutput: mocks.flushTerminalOutput,
requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery
}))
vi.mock('@/lib/pane-manager/pane-scroll', () => ({
captureScrollState: mocks.captureScrollState,
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch,
restoreScrollState: mocks.restoreScrollState,
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent,
syncTerminalScrollIntentFromViewport: mocks.syncTerminalScrollIntentFromViewport
}))
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
vi.mock('./terminal-bracketed-paste', () => ({
BRACKETED_PASTE_END: '\u001b[201~',
BRACKETED_PASTE_START: '\u001b[200~',
pasteTerminalText: mocks.pasteTerminalText,
sanitizeTerminalPasteText: (text: string) => text.split('\u001b').join('\u241b')
}))
vi.mock('./terminal-input-activity', () => ({
recordTerminalUserInputForLeaf: mocks.recordTerminalUserInputForLeaf
}))
// Why: this suite invokes the hook outside a real React render (the react mock
// above runs effects synchronously and manages refs by hand), so a reactive
// useAppStore(selector) call would throw an "Invalid hook call". Read the
// current snapshot synchronously instead; getState/setState stay real so tests
// can seed terminalLayoutsByTabId.
vi.mock('@/store', async (importOriginal) => {
const actual = await importOriginal<typeof StoreModule>()
const realHook = actual.useAppStore
const testHook = ((selector?: (state: ReturnType<typeof realHook.getState>) => unknown) =>
selector ? selector(realHook.getState()) : realHook.getState()) as typeof realHook
Object.assign(testHook, realHook)
return { ...actual, useAppStore: testHook }
})
type DropCallback = (data: {
paths: string[]
target: string
tabId?: string
paneLeafId?: string
}) => void
function useMountForFileDrop(
options: {
tabId?: string
worktreeId?: string
cwd?: string
isActive?: boolean
isVisible?: boolean
isWorktreeActive?: boolean
isSyncFitEnabled?: boolean
paneCount?: number
} = {}
): {
onFileDrop: DropCallback
manager: {
getPanes: ReturnType<typeof vi.fn>
resumeRendering: ReturnType<typeof vi.fn>
resetWebglTextureAtlases: ReturnType<typeof vi.fn>
scheduleRevealRepaint: ReturnType<typeof vi.fn>
scheduleRevealPresent: ReturnType<typeof vi.fn>
suspendRendering: ReturnType<typeof vi.fn>
getActivePane: ReturnType<typeof vi.fn>
fitAllRevealedPanes: ReturnType<typeof vi.fn>
}
paneTransports: Map<number, never>
} {
let onFileDrop: DropCallback = () => {
throw new Error('onFileDrop callback was not registered')
}
window.api.ui.onFileDrop = vi.fn((callback) => {
onFileDrop = callback
return vi.fn()
})
const manager = {
getPanes: vi.fn(() => []),
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => null),
fitAllRevealedPanes: vi.fn()
}
const paneTransports = new Map<number, never>()
beginHookRender()
useTerminalPaneGlobalEffects({
tabId: options.tabId ?? 'tab-1',
worktreeId: options.worktreeId ?? 'wt-1',
cwd: options.cwd,
isActive: options.isActive ?? true,
isVisible: options.isVisible ?? true,
isWorktreeActive: options.isWorktreeActive ?? options.isVisible ?? true,
isSyncFitEnabled: options.isSyncFitEnabled ?? options.isVisible ?? true,
paneCount: options.paneCount ?? 0,
managerRef: { current: manager as never },
containerRef: { current: null },
paneTransportsRef: { current: paneTransports },
isActiveRef: { current: false },
isVisibleRef: { current: false },
toggleExpandPane: vi.fn()
})
return { onFileDrop, manager, paneTransports }
}
describe('useTerminalPaneGlobalEffects', () => {
beforeEach(() => {
resetHookRefs()
vi.clearAllMocks()
installGlobalEffectsTestWindow()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
cleanupGlobalEffectsTestWindow()
})
it.each([
{ isActive: true, isVisible: true },
{ isActive: false, isVisible: true },
{ isActive: false, isVisible: false }
])('never subscribes to legacy broadcasts (active=$isActive, visible=$isVisible)', (options) => {
useMountForFileDrop(options)
expect(window.api.ui.onFileDrop).not.toHaveBeenCalled()
expect(mocks.handleTerminalFileDrop).not.toHaveBeenCalled()
})
})
@@ -17,9 +17,7 @@ export function installGlobalEffectsTestWindow(): void {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
api: {
ui: {
onFileDrop: vi.fn(() => vi.fn())
},
ui: {},
pty: {
setActiveRendererPty: vi.fn()
}
@@ -11,15 +11,12 @@ let originalApiDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
const ui = {
onFileDrop: vi.fn<Window['api']['ui']['onFileDrop']>()
} satisfies Pick<Window['api']['ui'], 'onFileDrop'>
const preflight = {
detectAgents: vi.fn<Window['api']['preflight']['detectAgents']>().mockResolvedValue([])
} satisfies Pick<Window['api']['preflight'], 'detectAgents'>
Object.defineProperty(window, 'api', {
configurable: true,
value: { preflight, ui }
value: { preflight }
})
})
@@ -83,10 +80,8 @@ describe('useComposerState integrated lifecycle', () => {
expect(first.result.current.cardProps.parentWorktreeId).toBe('repo-1::/parent')
expect(first.result.current.cardProps.onNativeFileDrop).toBeUndefined()
expect(second.result.current.cardProps.onNativeFileDrop).toBeUndefined()
expect(window.api.ui.onFileDrop).not.toHaveBeenCalled()
second.unmount()
first.unmount()
expect(window.api.ui.onFileDrop).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getNativeFileDropRejectionMessage } from './useGlobalFileDrop'
import { getNativeFileDropRejectionMessage } from './native-file-drop-rejection-message'
describe('getNativeFileDropRejectionMessage', () => {
it('formats metadata-only rejection messages for oversized native drops', () => {
@@ -1,40 +1,9 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
NATIVE_FILE_DROP_MAX_PATHS,
type NativeFileDropRejectedPayload
} from '../../../shared/native-file-drop'
import { describeDropTempCopyFailure } from '@/lib/drop-temp-copy-failure-copy'
import { captureEditorFileDropOpen } from '@/components/editor/editor-dropped-file-open'
export function useGlobalFileDrop(): void {
useEffect(() => {
return window.api.ui.onFileDrop((data) => {
if (data.target === 'rejected') {
showNativeFileDropRejection(data)
return
}
if (data.target !== 'editor') {
return
}
// Only unmarked chrome still uses the preload drop route.
const activeWorktreeId = useAppStore.getState().activeWorktreeId
if (!activeWorktreeId) {
return
}
void captureEditorFileDropOpen({ worktreeId: activeWorktreeId })(data.paths)
})
}, [])
}
function showNativeFileDropRejection(data: NativeFileDropRejectedPayload): void {
const message = getNativeFileDropRejectionMessage(data)
toast.error(message.title, { description: message.description })
}
import { describeDropTempCopyFailure } from './drop-temp-copy-failure-copy'
export function getNativeFileDropRejectionMessage(data: NativeFileDropRejectedPayload): {
description: string
@@ -16,7 +16,6 @@ vi.mock('electron', () => ({
webUtils: { getPathForFile: electron.getPathForFile }
}))
import { installNativeFileDropHandlers } from '../../../preload/preload-runtime-support'
import { createOsFileDropSequence, useOsFileDropOwner } from '../hooks/use-os-file-drop-owner'
import { installOsFileDropCancellationGuard } from './os-file-drop-cancellation-guard'
@@ -25,11 +24,9 @@ const prepareDroppedPaths = vi.fn(
)
function OwnerProbe({
onDrop,
legacyChild = false
onDrop
}: {
onDrop: (prepared: PreparedDroppedPaths) => void
legacyChild?: boolean
}): React.JSX.Element {
const ownerElementRef = useRef<HTMLElement | null>(null)
const [sequence] = useState(createOsFileDropSequence)
@@ -40,10 +37,7 @@ function OwnerProbe({
})
return (
<div ref={attach}>
<span
data-testid="target"
data-native-file-drop-target={legacyChild ? 'composer' : undefined}
/>
<span data-testid="target" />
</div>
)
}
@@ -71,8 +65,15 @@ function drag(
let disposeGuard: (() => void) | null = null
beforeAll(() => {
installNativeFileDropHandlers()
beforeAll(async () => {
// Exercise the stacked base's preload setup when replaying this regression.
const preload = await import('../../../preload/preload-runtime-support')
if (
'installNativeFileDropHandlers' in preload &&
typeof preload.installNativeFileDropHandlers === 'function'
) {
preload.installNativeFileDropHandlers()
}
})
beforeEach(() => {
@@ -92,20 +93,23 @@ afterEach(() => {
})
describe('preload and renderer file drop guard', () => {
it('preserves preload copy acceptance and one IPC delivery on unmarked chrome', () => {
it('refuses unowned chrome without opening, uploading, or showing a toast', async () => {
const target = document.createElement('div')
document.body.append(target)
const hover = drag(target, 'dragover')
expect(hover.event.defaultPrevented).toBe(true)
expect(hover.transfer.dropEffect).toBe('copy')
expect(hover.transfer.dropEffect).toBe('none')
drag(target, 'drop')
expect(electron.send).toHaveBeenCalledOnce()
expect(electron.send.mock.calls[0][0]).toBe('terminal:file-dropped-from-preload')
const dropped = drag(target, 'drop')
await act(async () => undefined)
expect(dropped.event.defaultPrevented).toBe(true)
expect(dropped.transfer.dropEffect).toBe('none')
expect(electron.send).not.toHaveBeenCalled()
expect(electron.getPathForFile).not.toHaveBeenCalled()
expect(prepareDroppedPaths).not.toHaveBeenCalled()
})
it('lets a migrated owner claim the real event and keep the copy cursor', async () => {
it('lets a registered owner claim the real event and keep the copy cursor', async () => {
const onDrop = vi.fn()
const view = render(<OwnerProbe onDrop={onDrop} />)
const target = view.getByTestId('target')
@@ -123,16 +127,4 @@ describe('preload and renderer file drop guard', () => {
failures: []
})
})
it('keeps a legacy child as a barrier inside a migrated owner', () => {
const onDrop = vi.fn()
const view = render(<OwnerProbe onDrop={onDrop} legacyChild />)
const target = view.getByTestId('target')
const hover = drag(target, 'dragover')
expect(hover.transfer.dropEffect).toBe('copy')
drag(target, 'drop')
expect(electron.send).toHaveBeenCalledOnce()
expect(prepareDroppedPaths).not.toHaveBeenCalled()
expect(onDrop).not.toHaveBeenCalled()
})
})
@@ -282,7 +282,6 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onTerminalZoom: () => noopUnsubscribe,
// Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery.
onSystemResumed: () => noopUnsubscribe,
onFileDrop: () => noopUnsubscribe,
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},
setRichMarkdownContextMenuTarget: () => {},
@@ -80,6 +80,7 @@ describe('web preload API composition', () => {
'telemetryGetConsentState',
'telemetryAcknowledgeBanner'
])
expect('onFileDrop' in globals.window.api.ui).toBe(false)
expect(Object.keys(globals.window.api.projects)).toEqual([])
const projects: Record<string, unknown> = globals.window.api.projects
expect(projects.then).toBeUndefined()
@@ -1,8 +1,6 @@
import type { NativeFileDropRejectedPayload } from './native-file-drop'
export const OS_FILE_DROP_OWNER_ATTRIBUTE = 'data-os-file-drop-owner'
// PR6 removes this release boundary together with legacy routing of unowned gaps.
export const OS_FILE_DROP_BOUNDARY_ATTRIBUTE = 'data-os-file-drop-boundary'
export type DroppedPathConsumer = 'agent' | 'main-reader'
+14 -266
View File
@@ -2,14 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
NATIVE_FILE_DROP_MAX_PATH_BYTES,
NATIVE_FILE_DROP_MAX_PATHS,
NATIVE_FILE_DROP_TARGET,
ORCA_INTERNAL_FILE_DRAG_TYPE,
createNativeFileDropPayload,
createRejectedNativeFileDropPayload,
hasNativeFileDragTypes,
isNativeFileDropPayload,
validateNativeFileDropPaths,
resolveNativeFileDropPath
validateNativeFileDropPaths
} from './native-file-drop'
describe('hasNativeFileDragTypes', () => {
@@ -24,67 +20,6 @@ describe('hasNativeFileDragTypes', () => {
})
})
describe('resolveNativeFileDropPath', () => {
it('routes drops on the left sidebar to the add-project surface', () => {
expect(
resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar }])
).toEqual({ target: NATIVE_FILE_DROP_TARGET.projectSidebar })
})
it('preserves terminal tab and pane routing for native file drops', () => {
expect(
resolveNativeFileDropPath([
{
terminalPaneLeafId: 'leaf-1'
},
{
nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.terminal,
terminalTabId: 'tab-1'
}
])
).toEqual({
target: NATIVE_FILE_DROP_TARGET.terminal,
tabId: 'tab-1',
paneLeafId: 'leaf-1'
})
})
it('carries the innermost composer scope key and omits an absent one', () => {
expect(
resolveNativeFileDropPath([
{ composerScopeKey: 'pane-inner' },
{
nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.composer,
composerScopeKey: 'pane-outer'
}
])
).toEqual({ target: NATIVE_FILE_DROP_TARGET.composer, scopeKey: 'pane-inner' })
expect(
resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.composer }])
).toEqual({ target: NATIVE_FILE_DROP_TARGET.composer })
})
it('uses the nearest file-explorer destination and fails closed without one', () => {
expect(
resolveNativeFileDropPath([
{ nativeFileDropDir: '/repo/src' },
{
nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.fileExplorer,
nativeFileDropDir: '/repo'
}
])
).toEqual({
target: NATIVE_FILE_DROP_TARGET.fileExplorer,
destinationDir: '/repo/src'
})
expect(
resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.fileExplorer }])
).toEqual({ target: 'rejected' })
})
})
describe('validateNativeFileDropPaths', () => {
it('rejects native drops by file count before path byte accounting is needed', () => {
const paths = Array.from({ length: NATIVE_FILE_DROP_MAX_PATHS + 1 }, (_value, index) =>
@@ -133,209 +68,22 @@ describe('validateNativeFileDropPaths', () => {
status: 'rejected'
})
})
})
describe('createNativeFileDropPayload', () => {
it('preserves terminal tab and pane routing in accepted payloads', () => {
it('enforces file count and byte limits at their boundaries', () => {
expect(
createNativeFileDropPayload(
{ target: NATIVE_FILE_DROP_TARGET.terminal, tabId: 'tab-1', paneLeafId: 'leaf-1' },
['/tmp/a']
)
).toEqual({
paneLeafId: 'leaf-1',
paths: ['/tmp/a'],
tabId: 'tab-1',
target: NATIVE_FILE_DROP_TARGET.terminal
})
})
it('preserves file explorer destination routing in accepted payloads', () => {
validateNativeFileDropPaths(Array.from({ length: NATIVE_FILE_DROP_MAX_PATHS }, () => '/a'))
.status
).toBe('accepted')
expect(
createNativeFileDropPayload(
{ target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir: '/repo/src' },
['/tmp/a']
)
).toEqual({
destinationDir: '/repo/src',
paths: ['/tmp/a'],
target: NATIVE_FILE_DROP_TARGET.fileExplorer
})
})
it('preserves composer scope routing and omits an absent scope key', () => {
validateNativeFileDropPaths(
Array.from({ length: NATIVE_FILE_DROP_MAX_PATHS + 1 }, () => '/a')
).status
).toBe('rejected')
expect(validateNativeFileDropPaths(['a'.repeat(NATIVE_FILE_DROP_MAX_PATH_BYTES)]).status).toBe(
'accepted'
)
expect(
createNativeFileDropPayload(
{ target: NATIVE_FILE_DROP_TARGET.composer, scopeKey: 'pane-1' },
['/tmp/a']
)
).toEqual({ paths: ['/tmp/a'], scopeKey: 'pane-1', target: NATIVE_FILE_DROP_TARGET.composer })
expect(
createNativeFileDropPayload({ target: NATIVE_FILE_DROP_TARGET.composer }, ['/tmp/a'])
).toEqual({ paths: ['/tmp/a'], target: NATIVE_FILE_DROP_TARGET.composer })
})
it('falls back to editor for unmarked drops and fails closed for rejected targets', () => {
expect(createNativeFileDropPayload(null, ['/tmp/a'])).toEqual({
paths: ['/tmp/a'],
target: NATIVE_FILE_DROP_TARGET.editor
})
expect(createNativeFileDropPayload({ target: 'rejected' }, ['/tmp/a'])).toBeNull()
})
it('returns metadata-only rejected payloads for oversized path lists', () => {
const payload = createNativeFileDropPayload(null, [
'C:\\Users\\alice\\',
'a'.repeat(NATIVE_FILE_DROP_MAX_PATH_BYTES)
])
expect(payload).toEqual({
byteLength: NATIVE_FILE_DROP_MAX_PATH_BYTES + 1,
pathCount: 2,
reason: 'paths-too-large',
target: 'rejected'
})
expect(JSON.stringify(payload)).not.toContain('alice')
})
})
describe('isNativeFileDropPayload', () => {
it('accepts bounded native file-drop payload shapes', () => {
expect(
isNativeFileDropPayload({
paths: ['/tmp/a'],
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(true)
expect(
isNativeFileDropPayload({
destinationDir: '/repo/src',
paths: ['/tmp/a'],
target: NATIVE_FILE_DROP_TARGET.fileExplorer
})
).toBe(true)
expect(
isNativeFileDropPayload({
paneLeafId: 'leaf-1',
paths: ['/tmp/a'],
tabId: 'tab-1',
target: NATIVE_FILE_DROP_TARGET.terminal
})
).toBe(true)
expect(
isNativeFileDropPayload({
byteLength: 0,
pathCount: NATIVE_FILE_DROP_MAX_PATHS + 1,
reason: 'too-many-paths',
target: 'rejected'
})
).toBe(true)
expect(
isNativeFileDropPayload({
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason: 'permission-denied'
})
).toBe(true)
expect(
isNativeFileDropPayload({
paths: ['/tmp/a'],
scopeKey: 'pane-1',
target: NATIVE_FILE_DROP_TARGET.composer
})
).toBe(true)
})
it('rejects malformed or unbounded native file-drop payloads', () => {
expect(isNativeFileDropPayload(null)).toBe(false)
expect(isNativeFileDropPayload({ paths: ['/tmp/a'], target: 'browser' })).toBe(false)
expect(
isNativeFileDropPayload({
paths: ['/tmp/a'],
target: NATIVE_FILE_DROP_TARGET.fileExplorer
})
).toBe(false)
expect(
isNativeFileDropPayload({
paths: ['/tmp/a'],
tabId: 42,
target: NATIVE_FILE_DROP_TARGET.terminal
})
).toBe(false)
expect(
isNativeFileDropPayload({
paths: ['/tmp/a'],
scopeKey: 42,
target: NATIVE_FILE_DROP_TARGET.composer
})
).toBe(false)
expect(
isNativeFileDropPayload({
paths: Array.from({ length: NATIVE_FILE_DROP_MAX_PATHS + 1 }, () => '/tmp/a'),
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(false)
expect(
isNativeFileDropPayload({
paths: ['a'.repeat(NATIVE_FILE_DROP_MAX_PATH_BYTES + 1)],
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(false)
expect(
isNativeFileDropPayload({
byteLength: 0,
pathCount: 1,
reason: 'contains-secret-path',
target: 'rejected'
})
).toBe(false)
// commonReason is rendered in a toast, so only known tokens may cross.
for (const commonReason of [{ text: 'x' }, 'Raw English failure text']) {
expect(
isNativeFileDropPayload({
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason
})
).toBe(false)
}
})
it('enforces native file-drop count and byte limits at their boundaries', () => {
expect(
isNativeFileDropPayload({
paths: Array.from(
{ length: NATIVE_FILE_DROP_MAX_PATHS },
(_value, index) => `/tmp/${index}`
),
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(true)
expect(
isNativeFileDropPayload({
paths: Array.from(
{ length: NATIVE_FILE_DROP_MAX_PATHS + 1 },
(_value, index) => `/tmp/${index}`
),
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(false)
expect(
isNativeFileDropPayload({
paths: ['a'.repeat(NATIVE_FILE_DROP_MAX_PATH_BYTES)],
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(true)
expect(
isNativeFileDropPayload({
paths: ['a'.repeat(NATIVE_FILE_DROP_MAX_PATH_BYTES + 1)],
target: NATIVE_FILE_DROP_TARGET.editor
})
).toBe(false)
validateNativeFileDropPaths(['a'.repeat(NATIVE_FILE_DROP_MAX_PATH_BYTES + 1)]).status
).toBe('rejected')
})
})
-207
View File
@@ -5,39 +5,6 @@ export const ORCA_INTERNAL_FILE_DRAG_TYPE = 'text/x-orca-file-path'
export const NATIVE_FILE_DROP_MAX_PATHS = 256
export const NATIVE_FILE_DROP_MAX_PATH_BYTES = 256 * 1024
export const NATIVE_FILE_DROP_TARGET = {
editor: 'editor',
terminal: 'terminal',
composer: 'composer',
fileExplorer: 'file-explorer',
projectSidebar: 'project-sidebar'
} as const
export type NativeDropResolution =
| { target: typeof NATIVE_FILE_DROP_TARGET.editor }
| { target: typeof NATIVE_FILE_DROP_TARGET.terminal; tabId?: string; paneLeafId?: string }
| { target: typeof NATIVE_FILE_DROP_TARGET.composer; scopeKey?: string }
| { target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer; destinationDir: string }
| { target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar }
| { target: 'rejected' }
export type NativeFileDropPayload =
| { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.editor }
| {
paths: string[]
target: typeof NATIVE_FILE_DROP_TARGET.terminal
tabId?: string
paneLeafId?: string
}
| { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.composer; scopeKey?: string }
| {
paths: string[]
target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer
destinationDir: string
}
| { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar }
| NativeFileDropRejectedPayload
export type NativeFileDropRejectedPayload = {
byteLength: number
pathCount: number
@@ -76,14 +43,6 @@ export type NativeFileDropRejectionReason =
| 'unresolved-paths'
| 'temp-copy-failed'
export type NativeFileDropPathEntry = {
nativeFileDropTarget?: string
nativeFileDropDir?: string
composerScopeKey?: string
terminalTabId?: string
terminalPaneLeafId?: string
}
export type NativeFileDropPathValidation =
| { byteLength: number; pathCount: number; status: 'accepted' }
| {
@@ -93,39 +52,6 @@ export type NativeFileDropPathValidation =
status: 'rejected'
}
function isNativeFileDropRejectedReason(
reason: unknown
): reason is NativeFileDropRejectedPayload['reason'] {
return (
reason === 'paths-too-large' ||
reason === 'too-many-paths' ||
reason === 'unresolved-paths' ||
reason === 'temp-copy-failed'
)
}
function isNativeFileDropCopyFailureReason(
reason: unknown
): reason is NativeFileDropCopyFailureReason {
return NATIVE_FILE_DROP_COPY_FAILURE_REASONS.some((known) => known === reason)
}
function isNativeFileDropTarget(target: unknown): target is NativeFileDropPayload['target'] {
return Object.values(NATIVE_FILE_DROP_TARGET).includes(target as never) || target === 'rejected'
}
function isOptionalNativeFileDropString(value: unknown): value is string | undefined {
return value === undefined || typeof value === 'string'
}
function isNativeFileDropPathList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((path) => typeof path === 'string')
}
function isNonNegativeFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function getDataTransferTypes(
types: Iterable<string> | ArrayLike<string> | null | undefined
): string[] {
@@ -139,52 +65,6 @@ export function hasNativeFileDragTypes(
return values.includes('Files') && !values.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)
}
export function resolveNativeFileDropPath(
path: readonly NativeFileDropPathEntry[]
): NativeDropResolution | null {
let foundExplorer = false
let destinationDir: string | undefined
let terminalPaneLeafId: string | undefined
let composerScopeKey: string | undefined
for (const entry of path) {
terminalPaneLeafId ??= entry.terminalPaneLeafId
composerScopeKey ??= entry.composerScopeKey
const target = entry.nativeFileDropTarget
if (target === NATIVE_FILE_DROP_TARGET.terminal) {
return { target, tabId: entry.terminalTabId, paneLeafId: terminalPaneLeafId }
}
if (target === NATIVE_FILE_DROP_TARGET.composer) {
// Composer drops fan out window-wide, so carry the receiving composer's
// scope key the way a terminal drop carries its pane leaf id.
return { target, ...(composerScopeKey ? { scopeKey: composerScopeKey } : {}) }
}
if (target === NATIVE_FILE_DROP_TARGET.editor) {
return { target }
}
if (target === NATIVE_FILE_DROP_TARGET.projectSidebar) {
return { target }
}
if (target === NATIVE_FILE_DROP_TARGET.fileExplorer) {
foundExplorer = true
}
// Pick the nearest (innermost) destination directory marker.
if (destinationDir === undefined && entry.nativeFileDropDir) {
destinationDir = entry.nativeFileDropDir
}
}
if (foundExplorer) {
if (!destinationDir) {
return { target: 'rejected' }
}
return { target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir }
}
return null
}
export function validateNativeFileDropPaths(
paths: readonly string[],
options: {
@@ -233,90 +113,3 @@ export function createRejectedNativeFileDropPayload(
target: 'rejected'
}
}
export function createNativeFileDropPayload(
resolution: NativeDropResolution | null,
paths: readonly string[]
): NativeFileDropPayload | null {
const validation = validateNativeFileDropPaths(paths)
if (validation.status === 'rejected') {
return createRejectedNativeFileDropPayload(validation)
}
if (resolution?.target === 'rejected') {
return null
}
if (resolution?.target === NATIVE_FILE_DROP_TARGET.fileExplorer) {
return {
paths: [...paths],
target: NATIVE_FILE_DROP_TARGET.fileExplorer,
destinationDir: resolution.destinationDir
}
}
if (resolution?.target === NATIVE_FILE_DROP_TARGET.composer) {
return {
paths: [...paths],
target: resolution.target,
...(resolution.scopeKey ? { scopeKey: resolution.scopeKey } : {})
}
}
const target = resolution?.target ?? NATIVE_FILE_DROP_TARGET.editor
if (resolution?.target === NATIVE_FILE_DROP_TARGET.terminal) {
return {
paths: [...paths],
target: resolution.target,
...(resolution.tabId ? { tabId: resolution.tabId } : {}),
...(resolution.paneLeafId ? { paneLeafId: resolution.paneLeafId } : {})
}
}
return { paths: [...paths], target }
}
export function isNativeFileDropPayload(value: unknown): value is NativeFileDropPayload {
if (!value || typeof value !== 'object') {
return false
}
const payload = value as Record<string, unknown>
const { target } = payload
if (!isNativeFileDropTarget(target)) {
return false
}
if (target === 'rejected') {
return (
isNonNegativeFiniteNumber(payload.byteLength) &&
isNonNegativeFiniteNumber(payload.pathCount) &&
isNativeFileDropRejectedReason(payload.reason) &&
(payload.commonReason === undefined ||
isNativeFileDropCopyFailureReason(payload.commonReason))
)
}
if (!isNativeFileDropPathList(payload.paths)) {
return false
}
if (validateNativeFileDropPaths(payload.paths).status !== 'accepted') {
return false
}
if (target === NATIVE_FILE_DROP_TARGET.terminal) {
return (
isOptionalNativeFileDropString(payload.tabId) &&
isOptionalNativeFileDropString(payload.paneLeafId)
)
}
if (target === NATIVE_FILE_DROP_TARGET.fileExplorer) {
return typeof payload.destinationDir === 'string'
}
if (target === NATIVE_FILE_DROP_TARGET.composer) {
return isOptionalNativeFileDropString(payload.scopeKey)
}
return (
target === NATIVE_FILE_DROP_TARGET.editor || target === NATIVE_FILE_DROP_TARGET.projectSidebar
)
}
+9 -5
View File
@@ -298,11 +298,15 @@ async function renderedTabIds(page: Page): Promise<string[]> {
}
async function expectTerminalSurface(page: Page): Promise<void> {
await expect
.poll(() => page.locator('[data-terminal-tab-id]').count(), { timeout: 30_000 })
.toBeGreaterThan(0)
const terminalSurface = page.locator('[data-terminal-tab-id]').first()
await expect(terminalSurface).toHaveAttribute('data-native-file-drop-target', 'terminal')
const terminalSurface = page
.locator('[data-terminal-tab-id][data-terminal-layout-leaf-ids]:visible')
.first()
await expect(terminalSurface).toBeVisible({ timeout: 30_000 })
await expect(terminalSurface).not.toHaveAttribute('data-os-file-drop-owner')
await expect(terminalSurface.locator('.pane[data-leaf-id]').first()).toHaveAttribute(
'data-os-file-drop-owner',
''
)
}
async function waitForTerminalPaneManager(page: Page): Promise<void> {
@@ -243,7 +243,7 @@ test.describe('Tab strip scroll render isolation', () => {
// A narrower pane shrinks the strip and the track without a scroll event.
await strip.evaluate((el) => {
el.closest<HTMLElement>('[data-native-file-drop-target]')!.style.maxWidth = '700px'
el.closest<HTMLElement>('[data-os-file-drop-owner]')!.style.maxWidth = '700px'
})
await expectThumbToTrackStrip(strip)
})
@@ -88,7 +88,7 @@ test.describe('Terminal Panes', () => {
.toBe(otherPane.leafId)
const titleBar = orcaPage.locator('.pane-title-bar', { hasText: title }).first()
await expect(titleBar).toHaveAttribute('data-native-file-drop-target', 'terminal')
await expect(titleBar).toHaveAttribute('data-os-file-drop-owner', '')
await expect(titleBar).toHaveAttribute('data-terminal-tab-id', splitSnapshot.tabId)
await titleBar.evaluate((element, path) => {
@@ -169,7 +169,7 @@ test.describe('Terminal Panes', () => {
(element) => element.textContent?.includes(title)
)
const titleDragHandle =
titleBar.querySelector<HTMLElement>('.pane-title-drag-handle') ?? null
titleBar?.querySelector<HTMLElement>('.pane-title-drag-handle') ?? null
const pane = document.querySelector<HTMLElement>(`.pane[data-leaf-id="${titledLeafId}"]`)
if (!titleBar || !pane || !titleDragHandle) {
return null