diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 424e276418ed..696ff1ecfbff 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -4,7 +4,10 @@ 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_OWNER_ATTRIBUTE } from '../shared/native-file-drop-preparation' +import { + OS_FILE_DROP_BOUNDARY_ATTRIBUTE, + OS_FILE_DROP_OWNER_ATTRIBUTE +} from '../shared/native-file-drop-preparation' import { ORCA_INTERNAL_FILE_DRAG_TYPE, createNativeFileDropPayload, @@ -89,12 +92,15 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { return resolveNativeFileDropPath(pathEntries) } -function nearestDropBoundaryIsOwner(event: DragEvent): boolean { +function nearestDropBoundaryIsMigrated(event: DragEvent): boolean { for (const entry of event.composedPath()) { if (!(entry instanceof HTMLElement)) { continue } - if (entry.hasAttribute(OS_FILE_DROP_OWNER_ATTRIBUTE)) { + 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')) { @@ -117,7 +123,10 @@ export function installNativeFileDropHandlers(): void { if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { return } - if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { + if ( + hasNativeFileDragTypes(event.dataTransfer?.types) && + nearestDropBoundaryIsMigrated(event) + ) { return } event.preventDefault() @@ -133,7 +142,10 @@ export function installNativeFileDropHandlers(): void { if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { return } - if (hasNativeFileDragTypes(event.dataTransfer?.types) && nearestDropBoundaryIsOwner(event)) { + if ( + hasNativeFileDragTypes(event.dataTransfer?.types) && + nearestDropBoundaryIsMigrated(event) + ) { return } event.preventDefault() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneFileDropOwner.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneFileDropOwner.tsx new file mode 100644 index 000000000000..1be806689931 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneFileDropOwner.tsx @@ -0,0 +1,15 @@ +import { useLayoutEffect } from 'react' +import { + useTerminalPaneFileDropOwner, + type TerminalPaneFileDropOwnerArgs +} from './use-terminal-pane-file-drop-owner' + +/** The pane manager creates the body container outside React. */ +export function TerminalPaneFileDropOwner(args: TerminalPaneFileDropOwnerArgs): null { + const attach = useTerminalPaneFileDropOwner(args) + useLayoutEffect(() => { + attach(args.pane.container) + return () => attach(null) + }, [attach, args.pane.container]) + return null +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderDropSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderDropSurface.tsx new file mode 100644 index 000000000000..4e3e63da8578 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderDropSurface.tsx @@ -0,0 +1,46 @@ +import type { ComponentProps } from 'react' +import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { handleInternalTerminalFileDrop } from './terminal-drop-handler' +import { + useTerminalPaneFileDropOwner, + type TerminalPaneFileDropOwnerArgs +} from './use-terminal-pane-file-drop-owner' + +export function TerminalPaneHeaderDropSurface({ + destination, + ...props +}: ComponentProps<'div'> & { destination: TerminalPaneFileDropOwnerArgs }): React.JSX.Element { + const attach = useTerminalPaneFileDropOwner(destination) + const isInternal = (types: readonly string[]) => + types.includes(WORKSPACE_FILE_PATH_MIME) || types.includes(WORKSPACE_FILE_PATHS_MIME) + return ( +
{ + if (isInternal(event.dataTransfer.types)) { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + }} + onDrop={(event) => { + if (!isInternal(event.dataTransfer.types)) { + return + } + event.preventDefault() + event.stopPropagation() + const manager = destination.managerRef.current + if (!manager) { + return + } + void handleInternalTerminalFileDrop({ + ...destination, + manager, + paneTransports: destination.paneTransportsRef.current, + dataTransfer: event.dataTransfer, + paneLeafId: destination.pane.leafId + }) + }} + /> + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index e9db4adc4bfd..a5444f44715f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -9,8 +9,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay' -import { handleTerminalFileDrop } from './terminal-drop-handler' -import { resolveNativeFileDropPath } from '../../../../shared/native-file-drop' import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' vi.mock('@/components/ui/tooltip', () => ({ @@ -170,6 +168,7 @@ function pressInputKey( } afterEach(() => { + vi.unstubAllGlobals() for (const { container, root } of mounted.splice(0)) { act(() => root.unmount()) container.remove() @@ -304,9 +303,11 @@ describe('TerminalPaneHeaderOverlay', () => { function dispatchFileDrag(target: Element, type: 'dragover' | 'drop', internal: boolean): void { const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'isTrusted', { value: true }) Object.defineProperty(event, 'dataTransfer', { value: { types: internal ? [WORKSPACE_FILE_PATHS_MIME] : ['Files'], + files: [new File([], 'file.txt')], getData: () => encodeWorkspaceFilePaths(['/repo/file.txt']), dropEffect: 'none' } @@ -350,47 +351,18 @@ describe('terminal title drop ownership', () => { if (!title) { throw new Error('Title A did not render') } - const deliveries: Promise[] = [] - const legacyCapture = (event: Event): void => { - if (internal) { - return + vi.stubGlobal('api', { + fs: { + getPathForFile: () => '/repo/file.txt', + prepareDroppedPaths: async ({ paths }: { paths: string[] }) => ({ paths, failures: [] }) } - event.preventDefault() - event.stopPropagation() - const entries = event - .composedPath() - .filter((entry): entry is HTMLElement => entry instanceof HTMLElement) - .map((entry) => ({ - nativeFileDropTarget: entry.dataset.nativeFileDropTarget, - terminalTabId: entry.dataset.terminalTabId, - terminalPaneLeafId: entry.dataset.terminalPaneLeafId - })) - const resolution = resolveNativeFileDropPath(entries) - if (resolution?.target === 'terminal') { - deliveries.push( - handleTerminalFileDrop({ - manager, - paneTransports: transports, - worktreeId: 'wt-1', - tabId: 'tab-1', - cwd: '/repo', - data: { paths: ['/repo/file.txt'], ...resolution } - }) - ) + }) + await act(async () => { + if (dragover) { + dispatchFileDrag(title, 'dragover', internal) } - } - document.addEventListener('drop', legacyCapture, true) - try { - await act(async () => { - if (dragover) { - dispatchFileDrag(title, 'dragover', internal) - } - dispatchFileDrag(title, 'drop', internal) - await Promise.all(deliveries) - }) - } finally { - document.removeEventListener('drop', legacyCapture, true) - } + dispatchFileDrag(title, 'drop', internal) + }) expect(sends[0]).toHaveBeenCalledExactlyOnceWith('/repo/file.txt ', 'driving') expect(sends[1]).not.toHaveBeenCalled() expect(activate).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index f3b405f6b8e5..fef4e48ae591 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -11,10 +11,10 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { PtyTransport } from './pty-transport' -import { handleInternalTerminalFileDrop } from './terminal-drop-handler' +import { TerminalPaneHeaderDropSurface } from './TerminalPaneHeaderDropSurface' +import { makePaneKey } from '../../../../shared/stable-pane-id' export type PaneTitleOverlayRect = { left: number @@ -145,12 +145,11 @@ export default function TerminalPaneHeaderOverlay({ } return ( -
onActivatePaneTitleInteraction(pane.id) : undefined } - onDragOver={(event) => { - if ( - event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) || - event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME) - ) { - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - }} - onDrop={(event) => { - if ( - !event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) && - !event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME) - ) { - return - } - event.preventDefault() - event.stopPropagation() - const manager = managerRef.current - if (!manager) { - return - } - void handleInternalTerminalFileDrop({ - manager, - paneTransports: paneTransportsRef.current, - worktreeId, - tabId, - cwd, - dataTransfer: event.dataTransfer, - paneLeafId: pane.leafId - }) - }} onContextMenuCapture={(event) => onPaneTitleContextMenu(event, pane.id)} style={{ left: overlayRect.left, @@ -407,7 +374,7 @@ export default function TerminalPaneHeaderOverlay({
)} -
+ ) })} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 76e5bf88d0ae..ec7ece552523 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -1,4 +1,6 @@ import { createPortal } from 'react-dom' +import { TerminalPaneFileDropOwner } from './TerminalPaneFileDropOwner' +import { makePaneKey } from '../../../../shared/stable-pane-id' import TerminalSearch from '@/components/TerminalSearch' import { DaemonActionDialog } from '@/components/shared/useDaemonActions' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' @@ -123,7 +125,7 @@ export function TerminalPaneSurface({
+ {managedPanes.map((pane) => ( + + ))} { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/logo.png'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/logo.png'] }) expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( @@ -173,7 +174,7 @@ describe('handleTerminalFileDrop', () => { wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png'), 'driving' ) - expect(focus).toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(mocks.toastError).not.toHaveBeenCalled() expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') @@ -208,7 +209,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/logo.png'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/logo.png'] }) expect(sendInput).not.toHaveBeenCalled() @@ -254,7 +256,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/logo.png'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/logo.png'] }) expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( @@ -317,7 +320,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/spec.pdf'] }) expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( @@ -359,12 +363,13 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/spec.pdf'] }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') - expect(focus).toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() }) it('pastes Linux-readable paths for local Windows-path projects forced to WSL', async () => { @@ -408,14 +413,11 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { - paths: [ - 'C:\\Users\\alice\\Desktop\\notes one.txt', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md' - ], - target: 'terminal', - paneLeafId: 'leaf-1' - } + pane, + paths: [ + 'C:\\Users\\alice\\Desktop\\notes one.txt', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md' + ] }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() @@ -455,11 +457,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { - paths: ['C:\\Users\\alice\\Desktop\\Screenshot 1.png'], - target: 'terminal', - paneLeafId: 'leaf-1' - } + pane, + paths: ['C:\\Users\\alice\\Desktop\\Screenshot 1.png'] }) // Why: the agent runs in Linux, so a Windows-style quote would reach it as a literal. @@ -495,16 +494,17 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/spec.pdf'] }) expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(sendInput).not.toHaveBeenCalled() - expect(focus).toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) - it('pastes native file drops into the pane identified by the payload leaf id', async () => { + it('pastes native file drops into the pane captured by its element', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } mocks.storeState.repos = [ { id: 'repo1', connectionId: null, path: '/repo', executionHostId: 'local' } @@ -539,13 +539,14 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-target' } + pane: targetPane, + paths: ['/Users/me/spec.pdf'] }) expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') - expect(targetFocus).toHaveBeenCalled() + expect(targetFocus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) @@ -583,14 +584,11 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { - paths: [ - 'C:\\Users\\Name\\My Project\\file.txt', - '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo\\README.md' - ], - target: 'terminal', - paneLeafId: 'leaf-1' - } + pane, + paths: [ + 'C:\\Users\\Name\\My Project\\file.txt', + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo\\README.md' + ] }) expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({ @@ -604,7 +602,7 @@ describe('handleTerminalFileDrop', () => { ["'/mnt/c/Users/Name/My Project/file.txt' ", 'driving'], ['/home/user/repo/README.md ', 'driving'] ]) - expect(focus).toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -647,11 +645,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { - paths: ['C:\\Users\\Name\\My Project\\file.txt'], - target: 'terminal', - paneLeafId: 'leaf-1' - } + pane, + paths: ['C:\\Users\\Name\\My Project\\file.txt'] }) expect(sendInput).not.toHaveBeenCalled() @@ -701,7 +696,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['C:\\Users\\Name\\A&B.txt'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['C:\\Users\\Name\\A&B.txt'] }) expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({ @@ -713,7 +709,7 @@ describe('handleTerminalFileDrop', () => { expectedSshConnectionGeneration: 4 }) expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') - expect(focus).toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -754,7 +750,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/Users/me/Screenshot 1.png'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/Users/me/Screenshot 1.png'] }) // Why: agents on Windows keep backslashes, so POSIX escaping would corrupt the path. @@ -794,7 +791,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ['/local/a.txt'], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ['/local/a.txt'] }) ).resolves.toBeUndefined() @@ -846,7 +844,8 @@ describe('handleTerminalFileDrop', () => { worktreeId: 'wt-1', tabId: 'tab-1', cwd: undefined, - data: { paths: ["/Users/me/it's here.txt"], target: 'terminal', paneLeafId: 'leaf-1' } + pane, + paths: ["/Users/me/it's here.txt"] }) expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts index eef9a6a7f846..4d84c2297085 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -16,11 +16,11 @@ import { resolveTerminalDropWorktreePath } from './terminal-drop-worktree-path' import { handleNativeTerminalFileDrop as handleTerminalFileDrop, type NativeTerminalFileDropArgs -} from './terminal-native-file-drop' +} from './terminal-native-file-drop-destination' export { handleTerminalFileDrop } -type InternalArgs = Omit & { +type InternalArgs = Omit & { dataTransfer: Pick dropTarget?: EventTarget | null paneLeafId?: string diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-local-workspace.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-local-workspace.test.ts index bb53ff4ab31b..3832b7b77688 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-local-workspace.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-local-workspace.test.ts @@ -60,7 +60,8 @@ describe('local terminals without catalog workspaces', () => { if (lane === 'native') { await handleTerminalFileDrop({ ...args, - data: { target: 'terminal', paneLeafId: 'leaf-1', paths: ['/local/file.txt'] } + pane: pane, + paths: ['/local/file.txt'] }) } else { const result = await handleInternalTerminalFileDrop({ @@ -76,7 +77,7 @@ describe('local terminals without catalog workspaces', () => { expect(result).toEqual({ status: 'pasted', pathCount: 1 }) } expect(sendInput).toHaveBeenCalledExactlyOnceWith('/local/file.txt ', 'driving') - expect(focus).toHaveBeenCalled() + expect(focus).toHaveBeenCalledTimes(lane === 'internal' ? 1 : 0) expect(mocks.importPaths).not.toHaveBeenCalled() expect(mocks.resolvePaths).not.toHaveBeenCalled() } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-local-wsl.ts b/src/renderer/src/components/terminal-pane/terminal-drop-local-wsl.ts new file mode 100644 index 000000000000..dfa86acbb583 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-local-wsl.ts @@ -0,0 +1,28 @@ +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import type { useAppStore } from '@/store' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { parseWslUncPath } from '../../../../shared/wsl-paths' + +export function isWorktreeUsingLocalWslRuntime( + state: ReturnType, + worktreeId: string +): boolean { + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM) + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' + } + return projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' +} + +export function toLocalWslDropPath(path: string): string { + const wslUnc = parseWslUncPath(path) + if (wslUnc) { + return wslUnc.linuxPath + } + if (isWindowsAbsolutePathLike(path)) { + const drive = path[0].toLowerCase() + return `/mnt/${drive}/${path.slice(3).replace(/\\/g, '/')}` + } + return path.replace(/\\/g, '/') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts index 17fe8d02b1c9..00953e328e67 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts @@ -1,10 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it, vi } from 'vitest' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' -import { - resolveInternalTerminalDropPane, - resolveNativeTerminalDropPane -} from './terminal-drop-pane-resolution' +import { resolveInternalTerminalDropPane } from './terminal-drop-pane-resolution' describe('terminal drop pane resolution', () => { const container = document.createElement('div') @@ -20,12 +17,6 @@ describe('terminal drop pane resolution', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Resolution only enumerates panes; active-pane access must stay unused. const manager = { getPanes: () => [pane], getActivePane } as unknown as PaneManager - it('refuses missing or stale native leaves without consulting focus', () => { - expect(resolveNativeTerminalDropPane(manager, undefined)).toBeNull() - expect(resolveNativeTerminalDropPane(manager, 'stale-leaf')).toBeNull() - expect(getActivePane).not.toHaveBeenCalled() - }) - it('refuses missing, outside, and stale internal destinations', () => { expect(resolveInternalTerminalDropPane(manager, undefined)).toBeNull() expect(resolveInternalTerminalDropPane(manager, document.createElement('div'))).toBeNull() diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.ts b/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.ts index f1822b364ced..6e85dbba9a77 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.ts @@ -1,12 +1,5 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' -export function resolveNativeTerminalDropPane( - manager: PaneManager, - paneLeafId: string | undefined -): ManagedPane | null { - return paneLeafId ? (manager.getPanes().find((pane) => pane.leafId === paneLeafId) ?? null) : null -} - export function resolveInternalTerminalDropPane( manager: PaneManager, dropTarget: EventTarget | null | undefined, @@ -14,7 +7,7 @@ export function resolveInternalTerminalDropPane( ): ManagedPane | null { const panes = manager.getPanes() if (paneLeafId !== undefined) { - return resolveNativeTerminalDropPane(manager, paneLeafId) + return panes.find((pane) => pane.leafId === paneLeafId) ?? null } if (dropTarget) { const targetedPane = panes.find((pane) => paneContainsDropTarget(pane, dropTarget)) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts index 4868e2697880..e55fdc6c39e3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts @@ -107,7 +107,8 @@ async function drop(lane: string, workspaceId: string, executionHostId: Executio if (lane === 'native') { await handleTerminalFileDrop({ ...args, - data: { target: 'terminal', paneLeafId: 'leaf-1', paths: ['/client/file.txt'] } + pane: pane, + paths: ['/client/file.txt'] }) } else { result = await handleInternalTerminalFileDrop({ diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-target.ts b/src/renderer/src/components/terminal-pane/terminal-drop-target.ts index 3247bb5ce7da..fc9464aaf559 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-target.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-target.ts @@ -33,9 +33,8 @@ export function getCurrentTerminalDropTransport( ) { return null } - const activePane = manager.getActivePane() - const paneStillMounted = - manager.getPanes().some((pane) => pane.id === target.paneId && pane.leafId === target.leafId) || - (activePane?.id === target.paneId && activePane.leafId === target.leafId) + const paneStillMounted = manager + .getPanes() + .some((pane) => pane.id === target.paneId && pane.leafId === target.leafId) return paneStillMounted ? liveTransport : null } diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts new file mode 100644 index 000000000000..c29668543b68 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts @@ -0,0 +1,92 @@ +import { toast } from 'sonner' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { PtyTransport } from './pty-transport' +import { captureTerminalDropTarget, getCurrentTerminalDropTransport } from './terminal-drop-target' +import { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner' +import { resolveTerminalDropWorktreePath } from './terminal-drop-worktree-path' +import { isWorktreeUsingLocalWslRuntime } from './terminal-drop-local-wsl' +import { deliverNativeTerminalFileDrop } from './terminal-native-file-drop' + +export type NativeTerminalFileDropArgs = { + manager: PaneManager + paneTransports: Map + worktreeId: string + tabId: string + cwd: string | undefined + pane: { id: number; leafId: string } + paths: string[] +} + +/** Capture before preparation so asynchronous work cannot choose a replacement PTY. */ +export function captureNativeTerminalFileDrop( + args: Omit +): (paths: string[]) => Promise { + try { + return captureNativeTerminalFileDropDestination(args) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to drop files.')) + return async () => undefined + } +} + +export async function handleNativeTerminalFileDrop( + args: NativeTerminalFileDropArgs +): Promise { + await captureNativeTerminalFileDrop(args)(args.paths) +} + +function captureNativeTerminalFileDropDestination( + args: Omit +): (paths: string[]) => Promise { + const { manager, paneTransports, worktreeId, tabId, cwd, pane } = args + const transport = paneTransports.get(pane.id) + if (!transport) { + return async () => undefined + } + const dropTarget = captureTerminalDropTarget(pane, transport) + const state = useAppStore.getState() + const settings = state.settings + const owner = captureTerminalDropTransportOwner(transport) + const worktreePath = resolveTerminalDropWorktreePath( + worktreeId, + owner?.runtimeEnvironmentId ? undefined : cwd, + owner?.executionHostId, + owner?.runtimeEnvironmentId + ) + if (!worktreePath) { + toast.error( + translate( + 'auto.components.terminal.pane.terminal.drop.handler.ce8248b835', + 'Worktree path not available.' + ) + ) + return async () => undefined + } + const localWslDrop = isWorktreeUsingLocalWslRuntime(state, worktreeId) + return async (paths) => { + if (!paths.length || !getCurrentTerminalDropTransport(manager, paneTransports, dropTarget)) { + return + } + try { + owner?.assertCurrent() + await deliverNativeTerminalFileDrop({ + manager, + paneTransports, + worktreeId, + tabId, + pane, + dataPaths: paths, + dropTarget, + settings, + owner, + worktreePath, + localWslDrop + }) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to drop files.')) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts index b43305c82f0a..9bd32dbe3cc2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts @@ -1,98 +1,53 @@ import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' -import { useAppStore } from '@/store' +import type { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' -import { isWslUncPath, parseWslUncPath } from '../../../../shared/wsl-paths' +import { isWslUncPath } from '../../../../shared/wsl-paths' import type { PtyTransport } from './pty-transport' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' -import { captureTerminalDropTarget, getCurrentTerminalDropTransport } from './terminal-drop-target' +import { + type captureTerminalDropTarget, + getCurrentTerminalDropTransport +} from './terminal-drop-target' import { getTerminalTargetShellForWorktreePath, isTerminalDropWindowsPathLike, resolveTerminalDropTargetShell } from './terminal-drop-shell' import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' -import { resolveNativeTerminalDropPane } from './terminal-drop-pane-resolution' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' -import { - joinRuntimeTerminalDropDir, - resolveTerminalDropWorktreePath -} from './terminal-drop-worktree-path' -import { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner' +import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' +import type { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner' +import { toLocalWslDropPath } from './terminal-drop-local-wsl' -export type NativeTerminalFileDropArgs = { - manager: PaneManager - paneTransports: Map - worktreeId: string - tabId: string - cwd: string | undefined - data: { paths: string[]; target: string; tabId?: string; paneLeafId?: string } -} - -/** - * Handle a native file drop targeted at a terminal pane. - * - * Local worktrees: paste the local absolute path (reference-in-place; no copy - * or IPC). SSH worktrees: upload each file into `${worktreePath}/.orca/drops` - * and paste the remote path so the remote agent can read it. See - * docs/terminal-drop-ssh.md. - */ -export async function handleNativeTerminalFileDrop( - args: NativeTerminalFileDropArgs -): Promise { - try { - await handleNativeTerminalFileDropWithCapturedOwner(args) - } catch (err) { - // Why: native drop listeners fire-and-forget, so owner-capture failures must terminate here. - toast.error(extractIpcErrorMessage(err, 'Failed to drop files.')) +export async function deliverNativeTerminalFileDrop( + args: NativeDropFlowArgs & { + settings: ReturnType['settings'] + owner: ReturnType + worktreeId: string + localWslDrop: boolean } -} - -async function handleNativeTerminalFileDropWithCapturedOwner( - args: NativeTerminalFileDropArgs ): Promise { - const { manager, paneTransports, worktreeId, tabId, cwd, data } = args - if (data.paths.length === 0) { - return - } - const pane = resolveNativeTerminalDropPane(manager, data.paneLeafId) - if (!pane) { - return - } - const transport = paneTransports.get(pane.id) - if (!transport) { - return - } - const dropTarget = captureTerminalDropTarget(pane, transport) - const state = useAppStore.getState() - const settings = state.settings - const owner = captureTerminalDropTransportOwner(transport) - const worktreePath = resolveTerminalDropWorktreePath( + const { + manager, + paneTransports, worktreeId, - owner?.runtimeEnvironmentId ? undefined : cwd, - owner?.executionHostId, - owner?.runtimeEnvironmentId - ) - if (!worktreePath) { - toast.error( - translate( - 'auto.components.terminal.pane.terminal.drop.handler.ce8248b835', - 'Worktree path not available.' - ) - ) - return - } - + tabId, + pane, + dataPaths, + dropTarget, + settings, + owner, + worktreePath, + localWslDrop + } = args if (owner?.runtimeEnvironmentId) { await uploadRuntimeDropPaths({ - dataPaths: data.paths, + dataPaths, dropTarget, manager, paneTransports, @@ -124,11 +79,10 @@ async function handleNativeTerminalFileDropWithCapturedOwner( remotePlatform: getTerminalPasteSshRemotePlatform(connectionId) }) const isRemote = connectionId !== null - const localWslDrop = !isRemote && isWorktreeUsingLocalWslRuntime(state, worktreeId) if (!isRemote) { await pasteLocalDropPaths({ - dataPaths: data.paths, + dataPaths, dropTarget, localWslDrop, manager, @@ -145,7 +99,7 @@ async function handleNativeTerminalFileDropWithCapturedOwner( await uploadRemoteDropPaths({ ...owner, connectionId, - dataPaths: data.paths, + dataPaths, dropTarget, manager, paneTransports, @@ -156,12 +110,12 @@ async function handleNativeTerminalFileDropWithCapturedOwner( }) } -type NativeDropFlowArgs = { +export type NativeDropFlowArgs = { dataPaths: string[] dropTarget: ReturnType manager: PaneManager paneTransports: Map - pane: ReturnType & {} + pane: { id: number; leafId: string } tabId: string worktreePath: string expectedSshTargetId?: string @@ -302,30 +256,4 @@ async function pasteResolvedDropPaths( if (writeResult.sentAnyPath) { recordTerminalUserInputForLeaf(args.tabId, args.pane.leafId) } - if (writeResult.targetCurrent) { - args.pane.terminal.focus() - } -} - -function isWorktreeUsingLocalWslRuntime( - state: ReturnType, - worktreeId: string -): boolean { - const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM) - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' - } - return projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' -} - -function toLocalWslDropPath(path: string): string { - const wslUnc = parseWslUncPath(path) - if (wslUnc) { - return wslUnc.linuxPath - } - if (isWindowsAbsolutePathLike(path)) { - const drive = path[0].toLowerCase() - return `/mnt/${drive}/${path.slice(3).replace(/\\/g, '/')}` - } - return path.replace(/\\/g, '/') } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx new file mode 100644 index 000000000000..3d78c459ea75 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx @@ -0,0 +1,510 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { createPortal } from 'react-dom' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { PreparedDroppedPaths } from '../../../../shared/native-file-drop-preparation' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' +import { collectPublicPanes, toPublicPane } from '@/lib/pane-manager/pane-public-view' +import type { PtyTransport } from './pty-transport' +import type { TerminalPaneController } from './use-terminal-pane-controller' +import { + NativeChatPaneFileDropSurface, + useNativeChatPaneFileDropClaim +} from '@/components/native-chat/NativeChatPaneFileDropSurface' +import { TerminalPaneSurface } from './TerminalPaneSurface' +import { installNativeFileDropHandlers } from '../../../../preload/preload-runtime-support' +import { installOsFileDropCancellationGuard } from '@/lib/os-file-drop-cancellation-guard' + +const mocks = vi.hoisted(() => ({ + state: { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + projects: [], + repos: [], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Every fixture record has the declared catalog shape. + worktreesByRepo: {} as Record< + string, + { id: string; repoId: string; path: string; hostId: string }[] + >, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + sshConnectionStates: new Map([['target', { connectionGeneration: 4, remotePlatform: 'linux' }]]) + }, + prepare: vi.fn(), + importPaths: vi.fn(), + resolvePaths: vi.fn(), + broadcast: vi.fn(), + chatDrop: vi.fn(), + toastError: vi.fn(), + legacyIpc: vi.fn(), + editorOpen: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The portal target starts absent and is assigned only a fixture pane container. + chatPane: null as HTMLElement | null, + chatDisabled: false +})) +vi.mock('electron', () => ({ + ipcRenderer: { on: vi.fn(), send: mocks.legacyIpc, removeListener: vi.fn() }, + webUtils: { getPathForFile: (file: File) => `/client/${file.name}` } +})) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof mocks.state) => T) => selector(mocks.state), + { getState: () => mocks.state } + ) +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: mocks.importPaths +})) +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError, loading: vi.fn(), dismiss: vi.fn(), message: vi.fn() } +})) +vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: vi.fn() })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, + TooltipContent: () => null +})) +vi.mock('@/components/TerminalSearch', () => ({ default: () => null })) +vi.mock('@/components/shared/useDaemonActions', () => ({ DaemonActionDialog: () => null })) +vi.mock('@/components/agent-session-continuation/AgentSessionContinuationDialog', () => ({ + AgentSessionContinuationDialog: () => null +})) +vi.mock('./CloseTerminalDialog', () => ({ default: () => null })) +vi.mock('./TerminalContextMenu', () => ({ default: () => null })) +vi.mock('./TerminalErrorToast', () => ({ + isPaneOwnerUnverifiedError: () => false, + TerminalErrorToast: () => null +})) +vi.mock('./terminal-pane-recovery', () => ({ requestTerminalPaneRecovery: vi.fn() })) +vi.mock('./TerminalSessionStateSaveFailureDialog', () => ({ + TerminalSessionStateSaveFailureDialog: () => null +})) +vi.mock('@/components/link-actions/LinkActionPopover', () => ({ LinkActionPopover: () => null })) +vi.mock('./TerminalAgentSessionForkDialog', () => ({ TerminalAgentSessionForkDialog: () => null })) +vi.mock('./SessionRestoredBannerPortals', () => ({ SessionRestoredBannerPortals: () => null })) +vi.mock('./TerminalQuickCommandEditorDialog', () => ({ + TerminalQuickCommandEditorDialog: () => null +})) +vi.mock('./TerminalPaneRuntimePortals', () => ({ + TerminalPaneCodexRestartPortals: () => null, + TerminalPaneMobileDriverPortals: () => null, + TerminalPaneProcessExitPortals: () => null, + TerminalPaneRecoveryPortals: () => null, + TerminalPaneSshReconnectPortals: () => null +})) + +function ChatClaim(): null { + useNativeChatPaneFileDropClaim({ + disabled: mocks.chatDisabled, + destinationKey: 'chat-A', + captureExternalDrop: () => mocks.chatDrop, + onDragOverCapture: vi.fn(), + onDropCapture: vi.fn() + }) + return null +} +vi.mock('./TerminalPaneNativeChatPortal', () => ({ + TerminalPaneNativeChatPortal: () => + mocks.chatPane && + createPortal( + + + + , + mocks.chatPane + ) +})) + +function makePane(id: number): ManagedPaneInternal { + const leafId = `00000000-0000-4000-8000-00000000000${id}` + if (!isTerminalLeafId(leafId)) { + throw new Error('Invalid fixture leaf') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The surface reads identity, DOM container, and terminal focus; add-ons are mocked out. + return { + id, + leafId, + stablePaneId: leafId, + container: document.createElement('div'), + terminal: { focus: vi.fn() } + } as unknown as ManagedPaneInternal +} + +function mountSurface( + options: { + chat?: boolean + hidden?: 'display' | 'inert' + host?: string + runtime?: string + cwd?: string + worktreeId?: string + } = {} +) { + const internalPanes = [makePane(1), makePane(2)] + const paneRecords = new Map(internalPanes.map((pane) => [pane.id, pane])) + const getPanes = () => collectPublicPanes(paneRecords, paneRecords.size) + const panes = getPanes() + const sends = panes.map(() => vi.fn(() => true)) + const ptyIds = ['pty-A', 'pty-B'] + const transports = new Map() + panes.forEach((pane, index) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every transport method used by file drop capture and writes. + transports.set(pane.id, { + sendInput: sends[index], + getPtyId: () => ptyIds[index], + isConnected: () => true, + getExecutionHostId: () => options.host ?? 'local', + getRuntimeEnvironmentId: () => options.runtime ?? null + } as unknown as PtyTransport) + }) + const active = internalPanes[1] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The surface and drop handlers only enumerate panes and read active identity. + const manager = { getPanes, getActivePane: () => toPublicPane(active) } as unknown as PaneManager + const divider = document.createElement('div') + const activate = vi.fn() + mocks.chatPane = options.chat ? panes[0].container : null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All live surface fields are supplied; unrelated dialogs and controller operations are mocked out. + const controller = { + activePane: active, + managedPanes: panes, + managerRef: { current: manager }, + paneTransportsRef: { current: transports }, + tabId: 'tab-1', + worktreeId: options.worktreeId ?? 'wt-1', + cwd: options.cwd, + isActive: true, + terminalContentVisible: true, + hiddenStartupStyle: {}, + terminalContainerStyle: {}, + paneCount: 2, + paneTitles: { 1: 'A', 2: 'B' }, + paneTitleOverlayRects: { + 1: { left: 0, top: 0, width: 200 }, + 2: { left: 220, top: 0, width: 200 } + }, + renamingPaneId: null, + renameInputRef: { current: null }, + paneTitleBackground: 'transparent', + activatePaneTitleInteraction: activate, + contextMenu: {}, + daemonActions: {}, + sessionRestoredBannerPaneIds: [], + setContainerRef: (root: HTMLElement | null) => + root?.append(panes[0].container, divider, panes[1].container) + } as unknown as TerminalPaneController + const view = render( +
+ +
+ ) + const titles = view.container.querySelectorAll('.pane-title-bar') + return { + view, + panes, + sends, + manager, + activate, + transports, + ptyIds, + divider, + title: titles[0], + body: panes[0].container, + replacePaneContainer: () => { + paneRecords.set(panes[0].id, { + ...internalPanes[0], + container: document.createElement('div') + }) + } + } +} + +function drop(target: Element, name = 'file.txt'): void { + const event = new Event('drop', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'isTrusted', { value: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types: ['Files'], files: [new File([], name)], dropEffect: 'move' } + }) + act(() => { + target.dispatchEvent(event) + }) +} +async function settle(): Promise { + await act(async () => undefined) +} + +let disposeGuard: (() => void) | undefined +beforeAll(() => installNativeFileDropHandlers()) +beforeEach(() => { + vi.clearAllMocks() + disposeGuard = installOsFileDropCancellationGuard() + mocks.legacyIpc.mockImplementation((_channel: string, payload: { target: string }) => { + if (payload.target === 'editor') { + mocks.editorOpen() + } + }) + mocks.chatPane = null + mocks.chatDisabled = false + mocks.state.worktreesByRepo = { + repo: [{ id: 'wt-1', repoId: 'repo', path: '/owner/workspace', hostId: 'local' }] + } + mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ + paths, + failures: [] + })) + mocks.resolvePaths.mockResolvedValue({ + resolvedPaths: ['/host/file.txt'], + skipped: [], + failed: [] + }) + mocks.importPaths.mockResolvedValue({ + results: [{ status: 'imported', destPath: '/owner/workspace/.orca/drops/file.txt' }] + }) + vi.stubGlobal('api', { + fs: { + getPathForFile: (file: File) => `/client/${file.name}`, + prepareDroppedPaths: mocks.prepare, + resolveDroppedPathsForAgent: mocks.resolvePaths + }, + ui: { onFileDrop: mocks.broadcast } + }) +}) +afterEach(() => { + disposeGuard?.() + cleanup() + vi.unstubAllGlobals() +}) + +describe('terminal element file drops', () => { + it('enumerates fresh public views of the same pane identities', () => { + const fixture = mountSurface() + const first = fixture.manager.getPanes() + const second = fixture.manager.getPanes() + for (const [index, pane] of first.entries()) { + expect(pane).not.toBe(fixture.panes[index]) + expect(pane).not.toBe(second[index]) + expect(second[index]).toEqual(pane) + expect(pane.container).toBe(fixture.panes[index].container) + } + }) + it.each(['title', 'body'] as const)( + 'delivers to A from its %s while B stays active and never broadcasts', + async (root) => { + const fixture = mountSurface() + drop(fixture[root]) + await settle() + expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith('/client/file.txt ', 'driving') + expect(fixture.sends[1]).not.toHaveBeenCalled() + expect(fixture.panes[0].terminal.focus).not.toHaveBeenCalled() + expect(fixture.activate).not.toHaveBeenCalled() + expect(fixture.manager.getActivePane()).toEqual(fixture.panes[1]) + expect(mocks.broadcast).not.toHaveBeenCalled() + expect(mocks.legacyIpc).not.toHaveBeenCalled() + expect(mocks.prepare).toHaveBeenCalledExactlyOnceWith({ + paths: ['/client/file.txt'], + consumer: 'agent' + }) + expect( + fixture.view.container.querySelector('[data-native-file-drop-target="terminal"]') + ).toBeNull() + } + ) + it.each(['title', 'body'] as const)( + 'refuses a retired %s owner with the same pane and leaf IDs but a replaced container', + async (root) => { + const fixture = mountSurface() + fixture.replacePaneContainer() + expect(fixture[root].isConnected).toBe(true) + expect(fixture.manager.getPanes()[0]).toMatchObject({ + id: fixture.panes[0].id, + leafId: fixture.panes[0].leafId + }) + expect(fixture.manager.getPanes()[0].container).not.toBe(fixture.body) + drop(fixture[root]) + await settle() + expect(mocks.prepare).not.toHaveBeenCalled() + expect(fixture.sends[0]).not.toHaveBeenCalled() + expect(fixture.sends[1]).not.toHaveBeenCalled() + } + ) + it.each(['title', 'body'] as const)( + 'refuses delivery from %s when its container is replaced during preparation', + async (root) => { + let finish: ((prepared: PreparedDroppedPaths) => void) | undefined + mocks.prepare.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const fixture = mountSurface() + drop(fixture[root]) + expect(mocks.prepare).toHaveBeenCalledOnce() + fixture.replacePaneContainer() + await act(async () => { + finish?.({ paths: ['/client/file.txt'], failures: [] }) + }) + expect(fixture.sends[0]).not.toHaveBeenCalled() + expect(fixture.sends[1]).not.toHaveBeenCalled() + } + ) + it('leaves divider drops unowned', async () => { + const fixture = mountSurface() + expect(fixture.body.hasAttribute('data-os-file-drop-owner')).toBe(true) + expect(fixture.body.parentElement?.hasAttribute('data-os-file-drop-owner')).toBe(false) + const hover = new Event('dragover', { bubbles: true, cancelable: true, composed: true }) + const transfer = { types: ['Files'], dropEffect: 'move' } + Object.defineProperty(hover, 'dataTransfer', { value: transfer }) + fixture.divider.dispatchEvent(hover) + expect(transfer.dropEffect).toBe('none') + drop(fixture.divider) + await settle() + expect(mocks.prepare).not.toHaveBeenCalled() + expect(mocks.legacyIpc).not.toHaveBeenCalled() + expect(mocks.editorOpen).not.toHaveBeenCalled() + expect(fixture.sends[0]).not.toHaveBeenCalled() + expect(fixture.sends[1]).not.toHaveBeenCalled() + }) + it('shares ordering across title and body during delayed preparation', async () => { + let finish: ((prepared: PreparedDroppedPaths) => void) | undefined + mocks.prepare.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const fixture = mountSurface() + drop(fixture.title, 'first.txt') + drop(fixture.body, 'second.txt') + await settle() + expect(fixture.sends[0]).not.toHaveBeenCalled() + await act(async () => { + finish?.({ paths: ['/prepared/first.txt'], failures: [] }) + }) + expect(fixture.sends[0].mock.calls).toEqual([ + ['/prepared/first.txt ', 'driving'], + ['/client/second.txt ', 'driving'] + ]) + }) + it.each([false, true])( + 'keeps nested chat drops out of terminal (disabled=%s)', + async (disabled) => { + mocks.chatDisabled = disabled + const fixture = mountSurface({ chat: true }) + drop(fixture.view.getByTestId('chat-target')) + await settle() + expect(mocks.chatDrop).toHaveBeenCalledTimes(disabled ? 0 : 1) + expect(fixture.sends[0]).not.toHaveBeenCalled() + drop(fixture.body) + await settle() + expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith('/client/file.txt ', 'driving') + expect(mocks.chatDrop).toHaveBeenCalledTimes(disabled ? 0 : 1) + } + ) + it.each(['display', 'inert'] as const)('refuses hidden terminal owners (%s)', async (hidden) => { + const fixture = mountSurface({ hidden }) + expect(fixture.body.hasAttribute('data-os-file-drop-owner')).toBe(true) + drop(fixture.title) + drop(fixture.body) + await settle() + expect(mocks.prepare).not.toHaveBeenCalled() + expect(fixture.sends[0]).not.toHaveBeenCalled() + }) + it('captures the PTY before preparation and refuses a replacement', async () => { + let finish: ((prepared: PreparedDroppedPaths) => void) | undefined + mocks.prepare.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const fixture = mountSurface() + drop(fixture.title) + fixture.ptyIds[0] = 'replacement' + await act(async () => { + finish?.({ paths: ['/client/file.txt'], failures: [] }) + }) + expect(fixture.sends[0]).not.toHaveBeenCalled() + drop(fixture.body) + await settle() + expect(fixture.sends[0]).toHaveBeenCalledOnce() + }) + it('uploads through the captured runtime catalog with physical local transport', async () => { + mocks.state.worktreesByRepo = { + local: [{ id: 'wt-1', repoId: 'local', path: 'C:\\wrong', hostId: 'local' }], + remote: [ + { id: 'wt-1', repoId: 'remote', path: '/owner/workspace', hostId: 'runtime:owner-runtime' } + ] + } + const fixture = mountSurface({ runtime: 'owner-runtime', cwd: '/wrong' }) + drop(fixture.title) + await settle() + expect(mocks.importPaths).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + worktreePath: '/owner/workspace', + expectedExecutionHostId: 'local' + }), + ['/client/file.txt'], + '/owner/workspace/.orca/drops', + { assertCurrent: expect.any(Function) } + ) + expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith( + '/owner/workspace/.orca/drops/file.txt ', + 'driving' + ) + }) + it('uploads through the captured SSH connection', async () => { + mocks.state.worktreesByRepo = { + repo: [{ id: 'wt-1', repoId: 'repo', path: '/owner/workspace', hostId: 'ssh:target' }] + } + const fixture = mountSurface({ host: 'ssh:target' }) + drop(fixture.body) + await settle() + expect(mocks.resolvePaths).toHaveBeenCalledExactlyOnceWith({ + paths: ['/client/file.txt'], + worktreePath: '/owner/workspace', + connectionId: 'target', + expectedExecutionHostId: 'ssh:target', + expectedSshTargetId: 'target', + expectedSshConnectionGeneration: 4 + }) + expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith('/host/file.txt ', 'driving') + }) + it('uses the existing WSL resolver for a local distro', async () => { + mocks.state.worktreesByRepo = { + repo: [ + { + id: 'wt-1', + repoId: 'repo', + path: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + hostId: 'local' + } + ] + } + const fixture = mountSurface() + drop(fixture.body) + await settle() + expect(mocks.resolvePaths).toHaveBeenCalledExactlyOnceWith({ + paths: ['/client/file.txt'], + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo' + }) + expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith('/host/file.txt ', 'driving') + }) + it('preserves the floating terminal local cwd while another runtime is focused', async () => { + const fixture = mountSurface({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + cwd: '/floating/cwd' + }) + drop(fixture.title) + await settle() + expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith('/client/file.txt ', 'driving') + expect(mocks.importPaths).not.toHaveBeenCalled() + expect(mocks.resolvePaths).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-file-drop-owner.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-file-drop-owner.ts new file mode 100644 index 000000000000..ea4d9b51776d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-file-drop-owner.ts @@ -0,0 +1,95 @@ +import { useRef, useState, type RefObject } from 'react' +import { toast } from 'sonner' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { isElementDisplayNone } from '@/lib/pane-manager/pane-display-visibility' +import { + createOsFileDropSequence, + useOsFileDropOwner, + type OsFileDropSequence +} from '@/hooks/use-os-file-drop-owner' +import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop' +import { makePaneKey, type PaneKey } from '../../../../shared/stable-pane-id' +import type { PtyTransport } from './pty-transport' +import { captureNativeTerminalFileDrop } from './terminal-native-file-drop-destination' + +export type TerminalPaneFileDropOwnerArgs = { + pane: ManagedPane + tabId: string + worktreeId: string + cwd: string | undefined + managerRef: RefObject + paneTransportsRef: RefObject> +} + +// Sibling title/body roots share ordering for exactly this mounted pane. +const paneSequences = new WeakMap() + +function sequenceForPane(pane: ManagedPane, paneKey: PaneKey): OsFileDropSequence { + const existing = paneSequences.get(pane.container) + if (existing?.paneKey === paneKey) { + return existing.sequence + } + const sequence = createOsFileDropSequence() + paneSequences.set(pane.container, { paneKey, sequence }) + return sequence +} + +function paneIsCurrent(manager: PaneManager | null, pane: ManagedPane): boolean { + return Boolean( + manager + ?.getPanes() + .some( + (current) => + current.id === pane.id && + current.leafId === pane.leafId && + current.container === pane.container + ) + ) +} + +export function useTerminalPaneFileDropOwner( + args: TerminalPaneFileDropOwnerArgs +): (root: HTMLElement | null) => void { + const { pane, tabId, managerRef, paneTransportsRef } = args + const rootRef = useRef(null) + const paneKey = makePaneKey(tabId, pane.leafId) + const [sequence] = useState(() => sequenceForPane(pane, paneKey)) + return useOsFileDropOwner(rootRef, { + consumer: 'agent', + sequence, + canAccept: () => + Boolean( + rootRef.current?.isConnected && + !rootRef.current.closest('[inert]') && + !isElementDisplayNone(rootRef.current) && + paneIsCurrent(managerRef.current, pane) && + paneTransportsRef.current.get(pane.id)?.isConnected() + ), + captureDestination: () => { + const manager = managerRef.current + const paneTransports = paneTransportsRef.current + if (!manager) { + return undefined + } + const deliver = captureNativeTerminalFileDrop({ ...args, manager, paneTransports }) + return async (paths: string[]) => { + if ( + managerRef.current !== manager || + paneTransportsRef.current !== paneTransports || + makePaneKey(tabId, pane.leafId) !== paneKey || + !paneIsCurrent(manager, pane) + ) { + return + } + await deliver(paths) + } + }, + onDrop: async (prepared, { destination }) => { + for (const failure of prepared.failures) { + const message = getNativeFileDropRejectionMessage(failure) + toast.error(message.title, { description: message.description }) + } + await destination?.(prepared.paths) + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts index 1eacf04cb3df..f2b8f8ec6e48 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts @@ -194,59 +194,13 @@ describe('useTerminalPaneGlobalEffects', () => { cleanupGlobalEffectsTestWindow() }) - it('ignores terminal file drops for another terminal tab', () => { - const { onFileDrop } = useMountForFileDrop() - - onFileDrop({ paths: ['/tmp/image.png'], target: 'terminal', tabId: 'tab-2' }) - - expect(mocks.handleTerminalFileDrop).not.toHaveBeenCalled() - }) - - it('handles terminal file drops for the matching terminal tab', () => { - const { onFileDrop, manager, paneTransports } = useMountForFileDrop({ - cwd: '/worktree' - }) - - const data = { - paths: ['/tmp/image.png'], - target: 'terminal', - tabId: 'tab-1', - paneLeafId: 'leaf-1' - } - onFileDrop(data) - - expect(mocks.handleTerminalFileDrop).toHaveBeenCalledWith({ - manager, - paneTransports, - worktreeId: 'wt-1', - tabId: 'tab-1', - cwd: '/worktree', - data - }) - }) - - it('refuses terminal file drops without a terminal tab id even in the active tab', () => { - const { onFileDrop } = useMountForFileDrop() - - const data = { paths: ['/tmp/image.png'], target: 'terminal' } - onFileDrop(data) - - expect(mocks.handleTerminalFileDrop).not.toHaveBeenCalled() - }) - - it('handles terminal file drops for visible unfocused split-group terminals', () => { - const { onFileDrop } = useMountForFileDrop({ isActive: false, isVisible: true }) - - onFileDrop({ paths: ['/tmp/image.png'], target: 'terminal', tabId: 'tab-1' }) - - expect(mocks.handleTerminalFileDrop).toHaveBeenCalledTimes(1) - }) - - it('ignores legacy terminal file drops in visible unfocused split-group terminals', () => { - const { onFileDrop } = useMountForFileDrop({ isActive: false, isVisible: true }) - - onFileDrop({ paths: ['/tmp/image.png'], target: 'terminal' }) - + it.each([ + { isActive: true, isVisible: true }, + { isActive: false, isVisible: true }, + { isActive: false, isVisible: false } + ])('never subscribes to legacy broadcasts (active=$isActive, visible=$isVisible)', (options) => { + useMountForFileDrop(options) + expect(window.api.ui.onFileDrop).not.toHaveBeenCalled() expect(mocks.handleTerminalFileDrop).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index f9d749e8b92b..994df83d2b06 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -9,7 +9,6 @@ import { import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import type { IDisposable } from '@xterm/xterm' -import { handleTerminalFileDrop } from './terminal-drop-handler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' @@ -65,7 +64,6 @@ function reportRendererPtyVisibility( export function useTerminalPaneGlobalEffects({ tabId, worktreeId, - cwd, isActive, isVisible, isChatViewMode = false, @@ -82,8 +80,6 @@ export function useTerminalPaneGlobalEffects({ }: UseTerminalPaneGlobalEffectsArgs): void { const worktreeIdRef = useRef(worktreeId) worktreeIdRef.current = worktreeId - const cwdRef = useRef(cwd) - cwdRef.current = cwd // Starts true so the first render with isVisible=false triggers a // suspendRendering(). Background worktrees that mount hidden would // otherwise leak WebGL contexts — openTerminal() unconditionally creates @@ -296,32 +292,4 @@ export function useTerminalPaneGlobalEffects({ document.addEventListener('dictation:insertText', onDictationInsert) return () => document.removeEventListener('dictation:insertText', onDictationInsert) }, [isActiveRef, managerRef, paneTransportsRef, tabId]) - - // Why: visible, unfocused terminals receive drops only when the payload names their tab. - useEffect(() => { - if (!isActive && !isVisible) { - return - } - return window.api.ui.onFileDrop((data) => { - if (data.target !== 'terminal' || data.tabId !== tabId) { - return - } - const manager = managerRef.current - if (!manager) { - return - } - const wtId = worktreeIdRef.current - if (!wtId) { - return - } - void handleTerminalFileDrop({ - manager, - paneTransports: paneTransportsRef.current, - worktreeId: wtId, - tabId, - cwd: cwdRef.current, - data - }) - }) - }, [isActive, isVisible, managerRef, paneTransportsRef, tabId]) } diff --git a/src/renderer/src/lib/pane-manager/pane-display-visibility.ts b/src/renderer/src/lib/pane-manager/pane-display-visibility.ts index 7c7fc7f637b1..6a0746d7053b 100644 --- a/src/renderer/src/lib/pane-manager/pane-display-visibility.ts +++ b/src/renderer/src/lib/pane-manager/pane-display-visibility.ts @@ -2,6 +2,10 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' export function isManagedPaneDisplayNone(pane: ManagedPane): boolean { const element = (pane as ManagedPaneInternal).xtermContainer ?? pane.container + return isElementDisplayNone(element) +} + +export function isElementDisplayNone(element: HTMLElement | null | undefined): boolean { const view = element?.ownerDocument?.defaultView if (!element || !view) { return false diff --git a/src/shared/native-file-drop-preparation.ts b/src/shared/native-file-drop-preparation.ts index 94c553c86cc2..668d8ca84961 100644 --- a/src/shared/native-file-drop-preparation.ts +++ b/src/shared/native-file-drop-preparation.ts @@ -1,6 +1,8 @@ import type { NativeFileDropRejectedPayload } from './native-file-drop' export const OS_FILE_DROP_OWNER_ATTRIBUTE = 'data-os-file-drop-owner' +// PR6 removes this release boundary together with legacy routing of unowned gaps. +export const OS_FILE_DROP_BOUNDARY_ATTRIBUTE = 'data-os-file-drop-boundary' export type DroppedPathConsumer = 'agent' | 'main-reader'