diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 0badaf6a758..41039fa66f3 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 6a685f2f982..bd37b20307c 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -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() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index b448681857e..4c412e9fedf 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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) diff --git a/src/main/window/dropped-path-preparation-ipc.test.ts b/src/main/window/dropped-path-preparation-ipc.test.ts new file mode 100644 index 00000000000..74b8a29a00a --- /dev/null +++ b/src/main/window/dropped-path-preparation-ipc.test.ts @@ -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 + +const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ + materializeMock: vi.fn(), + sweepMock: vi.fn(), + ipcListeners: new Map(), + ipcHandlers: new Map() +})) + +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()), + 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() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +beforeEach(() => { + materializeMock.mockReset() +}) + +function createWindow() { + let destroyed = false + const windowListeners = new Map 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() + 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() + } + ) +}) diff --git a/src/main/window/dropped-path-preparation-ipc.ts b/src/main/window/dropped-path-preparation-ipc.ts new file mode 100644 index 00000000000..df87886c0a7 --- /dev/null +++ b/src/main/window/dropped-path-preparation-ipc.ts @@ -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 => { + 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') + ) +} diff --git a/src/main/window/dropped-path-preparation.test.ts b/src/main/window/dropped-path-preparation.test.ts index d04aa90ef60..54d288d32fa 100644 --- a/src/main/window/dropped-path-preparation.test.ts +++ b/src/main/window/dropped-path-preparation.test.ts @@ -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[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: [] + }) + }) }) diff --git a/src/main/window/native-file-drop-relay.test.ts b/src/main/window/native-file-drop-relay.test.ts deleted file mode 100644 index 7200be84eba..00000000000 --- a/src/main/window/native-file-drop-relay.test.ts +++ /dev/null @@ -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 - -const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ - materializeMock: vi.fn(), - sweepMock: vi.fn(), - ipcListeners: new Map(), - ipcHandlers: new Map() -})) - -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()), - 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 - 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() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -async function settle(): Promise { - 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() - 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() - 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() - 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(() => 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 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() - 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() - } - ) -}) diff --git a/src/main/window/native-file-drop-relay.ts b/src/main/window/native-file-drop-relay.ts deleted file mode 100644 index 3d7d4c8f50b..00000000000 --- a/src/main/window/native-file-drop-relay.ts +++ /dev/null @@ -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 -type NativeFileDropQueueDeps = DroppedPathPreparationDeps & { - forward: (payload: NativeFileDropPayload) => void -} -type NativeFileDropQueue = ((payload: NativeFileDropPayload) => void) & { - prepare: (request: PrepareDroppedPathsRequest) => Promise -} - -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 => { - 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 -): 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 => { - 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 { - 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') - ) -} diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 68dd74dd0b0..73b800c0e3d 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -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 => diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index 1baa264a784..c06dd79dab7 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f64a1e2e35..fcccbffe7d4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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. diff --git a/src/preload/preload-file-drop-absence.test.ts b/src/preload/preload-file-drop-absence.test.ts new file mode 100644 index 00000000000..618a683b6b3 --- /dev/null +++ b/src/preload/preload-file-drop-absence.test.ts @@ -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() + } +}) diff --git a/src/preload/preload-native-file-drop-release.test.ts b/src/preload/preload-native-file-drop-release.test.ts deleted file mode 100644 index dd556fb2e5e..00000000000 --- a/src/preload/preload-native-file-drop-release.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 696ff1ecfbf..2d043bb8cd0 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -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 { 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, diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index c340dd5281a..246670406f9 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -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() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx index 579c495dfdf..7146e77d6d1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -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[] }>() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx index f1d6517b48b..1fe178fa703 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx @@ -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(() => () => {}) 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 })) diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.ts b/src/renderer/src/components/editor/editor-dropped-file-open.ts index a19ec6ee939..a07b57b658c 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.ts @@ -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 } diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts index f26cd8c5e8a..59950560493 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 871eb6f5a13..85bfeff5573 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -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 } } }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx index 580054ae773..48157e3f595 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx @@ -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 diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index 1d94a92471f..4b4ce0ffa61 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -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() - 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() }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index d7affebe5fe..945f776cc6d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -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( <>