From e4cd14d914b75be4accbeb43b9ce42eab7ebdcd0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:11:37 -0700 Subject: [PATCH] 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 --- config/scripts/ci-shard-timings.json | 2 - .../attach-main-window-services.test.ts | 75 --- .../window/attach-main-window-services.ts | 4 +- .../dropped-path-preparation-ipc.test.ts | 192 +++++++ .../window/dropped-path-preparation-ipc.ts | 78 +++ .../window/dropped-path-preparation.test.ts | 96 +++- .../window/native-file-drop-relay.test.ts | 536 ------------------ src/main/window/native-file-drop-relay.ts | 219 ------- ...ui-bridge-clipboard-and-window-controls.ts | 4 - src/preload/api/ui-window-api.ts | 2 - src/preload/index.ts | 6 +- src/preload/preload-file-drop-absence.test.ts | 25 + .../preload-native-file-drop-release.test.ts | 122 ---- src/preload/preload-runtime-support.ts | 170 +----- .../src/app-shell/use-app-shell-services.ts | 2 - ...ewWorkspaceComposerCard.file-drop.test.tsx | 1 - ...spaceComposerCard.quick-file-drop.test.tsx | 2 - .../editor/editor-dropped-file-open.ts | 2 +- .../use-editor-group-file-drop-owner.ts | 2 +- .../native-chat/NativeChatComposer.test.tsx | 3 +- .../NativeChatPaneFileDropSurface.tsx | 2 +- .../native-chat-composer-composition.test.tsx | 1 - .../native-chat-composer-drop-scope.test.tsx | 45 +- ...ative-chat-composer-prompt-recall.test.tsx | 3 +- .../native-chat-element-file-drop.test.tsx | 20 +- .../native-chat-pane-file-drop.test.tsx | 9 +- .../native-chat-paste-routing.test.tsx | 2 +- .../native-chat-queue-send-confirm.test.tsx | 3 +- ...structured-send-composition-clear.test.tsx | 3 +- ...tive-chat-file-attachment-actions.test.tsx | 10 +- .../use-new-workspace-composer-file-drop.ts | 2 +- ...ileExplorerImport.committed-scope.test.tsx | 1 - ...seFileExplorerImport.os-file-drop.test.tsx | 2 - .../right-sidebar/useFileExplorerImport.ts | 2 +- .../sidebar/use-feedback-image-drop.test.tsx | 149 ++--- ...seSidebarProjectDrop.os-file-drop.test.tsx | 2 - .../sidebar/useSidebarProjectDrop.ts | 2 +- .../terminal-pane/TerminalPaneSurface.tsx | 1 - .../terminal-pane-element-file-drop.test.tsx | 37 +- .../use-terminal-pane-file-drop-owner.ts | 2 +- ...inal-pane-global-effects-file-drop.test.ts | 206 ------- ...rminal-pane-global-effects-test-harness.ts | 4 +- .../useComposerState.integration.test.ts | 7 +- ...ative-file-drop-rejection-message.test.ts} | 2 +- .../native-file-drop-rejection-message.ts} | 33 +- .../os-file-drop-preload-integration.test.tsx | 48 +- .../src/web/preload-api/web-ui-api.ts | 1 - .../web/web-preload-api-composition.test.ts | 1 + src/shared/native-file-drop-preparation.ts | 2 - src/shared/native-file-drop.test.ts | 280 +-------- src/shared/native-file-drop.ts | 207 ------- tests/e2e/golden-core-flows.spec.ts | 14 +- .../tab-strip-scroll-render-isolation.spec.ts | 2 +- ...erminal-pane-title-strip-placement.spec.ts | 4 +- 54 files changed, 553 insertions(+), 2099 deletions(-) create mode 100644 src/main/window/dropped-path-preparation-ipc.test.ts create mode 100644 src/main/window/dropped-path-preparation-ipc.ts delete mode 100644 src/main/window/native-file-drop-relay.test.ts delete mode 100644 src/main/window/native-file-drop-relay.ts create mode 100644 src/preload/preload-file-drop-absence.test.ts delete mode 100644 src/preload/preload-native-file-drop-release.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts rename src/renderer/src/{hooks/useGlobalFileDrop.test.ts => lib/native-file-drop-rejection-message.test.ts} (95%) rename src/renderer/src/{hooks/useGlobalFileDrop.ts => lib/native-file-drop-rejection-message.ts} (62%) 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( <>