diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx index 89b109c716f6..eefa76cabd34 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx @@ -11,7 +11,7 @@ const testState: { executionHostId: ExecutionHostId } = vi.hoisted(() => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getExecutionHostIdForWorktree: () => testState.executionHostId + getKnownExecutionHostIdForWorktree: () => testState.executionHostId })) const { CombinedDiffFileTreeRow } = await import('./combined-diff-file-tree-row') diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 0ab942541f45..5691b21acec3 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -15,9 +15,9 @@ import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME, - writeWorkspaceFileDragSourceIfResolved + writeWorkspaceFileDragSourceIfResolved, + type WorkspaceFileDragSource } from '@/lib/workspace-file-drag' -import type { ExecutionHostId } from '../../../../shared/execution-host' import type { GitFileStatus } from '../../../../shared/git-status-types' import { STATUS_LABELS } from './status-display' import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing' @@ -48,7 +48,9 @@ export type FileExplorerRowProps = { connectionId?: string | null sourceWorkspaceId?: string | null /** Resolved at dragstart so the virtualized list pays nothing per render. */ - resolveDragSourceHostId?: (paths: readonly string[]) => ExecutionHostId | null + resolveDragSourceOwner?: ( + paths: readonly string[] + ) => Omit | null runtimeDownloadContext?: RuntimeFileOperationArgs | null supportsFolderDownload?: boolean canOpenInOrcaBrowser: boolean @@ -94,7 +96,7 @@ export function FileExplorerRow({ deleteShortcutLabel, connectionId, sourceWorkspaceId, - resolveDragSourceHostId, + resolveDragSourceOwner, runtimeDownloadContext, supportsFolderDownload = false, canOpenInOrcaBrowser, @@ -176,10 +178,12 @@ export function FileExplorerRow({ if (paths.length > 1) { event.dataTransfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths)) } + const sourceOwner = resolveDragSourceOwner?.(paths) writeWorkspaceFileDragSourceIfResolved( event.dataTransfer, sourceWorkspaceId, - resolveDragSourceHostId?.(paths) + sourceOwner?.executionHostId, + sourceOwner?.runtimeEnvironmentId ) event.dataTransfer.effectAllowed = 'copyMove' onDragSourceChange(node.path) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index c91c040e696e..09e6e9b0e663 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -10,7 +10,7 @@ import type { DirCache, FileExplorerOperationOwner, TreeNode } from './file-expl import type { FileExplorerRowProjection } from './file-explorer-row-projection' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { getFileExplorerOperationExecutionHostId } from './file-explorer-operation-owner' -import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceFileDragSource } from '@/lib/workspace-file-drag' type FileExplorerVirtualRowsProps = { virtualizer: Virtualizer @@ -83,22 +83,27 @@ function getDraggedPathOperationOwner( /** Null unless every dragged row came from one host: a mixed-owner drag has no * single source to stamp, so it must fail closed at the drop target. */ -function resolveDragSourceExecutionHostId( +function resolveDragSourceOwner( rowProjection: FileExplorerRowProjection, dirCache: Record | undefined, paths: readonly string[] -): ExecutionHostId | null { - let sourceExecutionHostId: ExecutionHostId | null = null +): Omit | null { + let sourceOwner: Omit | null = null for (const path of paths) { - const executionHostId = getFileExplorerOperationExecutionHostId( - getDraggedPathOperationOwner(rowProjection, dirCache, path) - ) - if (!executionHostId || (sourceExecutionHostId && executionHostId !== sourceExecutionHostId)) { + const owner = getDraggedPathOperationOwner(rowProjection, dirCache, path) + const executionHostId = getFileExplorerOperationExecutionHostId(owner) + const runtimeEnvironmentId = owner?.kind === 'runtime' ? owner.environmentId : undefined + if ( + !executionHostId || + (sourceOwner && + (executionHostId !== sourceOwner.executionHostId || + runtimeEnvironmentId !== sourceOwner.runtimeEnvironmentId)) + ) { return null } - sourceExecutionHostId = executionHostId + sourceOwner = { executionHostId, ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) } } - return sourceExecutionHostId + return sourceOwner } /** Renders virtual and inline-input rows using display-relative indentation but worktree-relative operation paths. */ @@ -155,8 +160,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re const visibleSelectionCount = rowProjection.countVisiblePaths(selectedPaths) // Resolved at dragstart, not per render: the virtualizer re-renders on every // scroll frame and only a drag ever reads this. - const resolveDragSourceHostId = (paths: readonly string[]): ExecutionHostId | null => - resolveDragSourceExecutionHostId(rowProjection, dirCache, paths) + const resolveSourceOwner = (paths: readonly string[]) => + resolveDragSourceOwner(rowProjection, dirCache, paths) return (
@@ -237,7 +242,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re deleteShortcutLabel={deleteShortcutLabel} connectionId={connectionId} sourceWorkspaceId={sourceWorkspaceId} - resolveDragSourceHostId={resolveDragSourceHostId} + resolveDragSourceOwner={resolveSourceOwner} runtimeDownloadContext={runtimeDownloadContext} supportsFolderDownload={supportsFolderDownload} canOpenInOrcaBrowser={canOpenInOrcaBrowser(n.path)} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx index 73b7100671ef..6930a374a971 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx @@ -147,30 +147,41 @@ describe('file explorer draggable rows carry the wheel-scroll marker', () => { expect(readWorkspaceFileDragSource(transfer)).toEqual({ executionHostId: 'runtime:old-env', - workspaceId: 'old-workspace' + workspaceId: 'old-workspace', + runtimeEnvironmentId: 'old-env' }) }) - it('omits ownership when selected cached rows came from different hosts', async () => { - const localNode: TreeNode = { ...fileNode, operationOwner: { kind: 'local' } } - const sshNode: TreeNode = { - ...directoryNode, - operationOwner: { kind: 'ssh', connectionId: 'remote-1' } + it.each([false, true])( + 'omits mixed-owner selections, including nested SSH: %s', + async (nested) => { + const localNode: TreeNode = { + ...fileNode, + operationOwner: nested + ? { kind: 'runtime', executionHostId: 'ssh:remote-1', environmentId: 'env-a' } + : { kind: 'local' } + } + const sshNode: TreeNode = { + ...directoryNode, + operationOwner: nested + ? { kind: 'runtime', executionHostId: 'ssh:remote-1', environmentId: 'env-b' } + : { kind: 'ssh', connectionId: 'remote-1' } + } + const container = await renderToBody( + virtualRowsElement([localNode, sshNode], { + selectedPaths: new Set([localNode.path, sshNode.path]), + sourceWorkspaceId: 'workspace-1' + }) + ) + const transfer = new DataTransfer() + const event = new Event('dragstart', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + + container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event) + + expect(readWorkspaceFileDragSource(transfer)).toBeNull() } - const container = await renderToBody( - virtualRowsElement([localNode, sshNode], { - selectedPaths: new Set([localNode.path, sshNode.path]), - sourceWorkspaceId: 'workspace-1' - }) - ) - const transfer = new DataTransfer() - const event = new Event('dragstart', { bubbles: true, cancelable: true }) - Object.defineProperty(event, 'dataTransfer', { value: transfer }) - - container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event) - - expect(readWorkspaceFileDragSource(transfer)).toBeNull() - }) + ) // A selection outlives the rows that showed it: nothing prunes selectedPaths // when a directory collapses, and the drag still carries every selected path. diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 006fbf05864b..af0094d6b8ef 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -9,7 +9,12 @@ 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 { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { + encodeWorkspaceFilePaths, + WORKSPACE_FILE_PATHS_MIME, + WORKSPACE_FILE_DRAG_SOURCE_MIME, + writeWorkspaceFileDragSource +} from '@/lib/workspace-file-drag' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children?: ReactNode }) => children, @@ -302,13 +307,20 @@ describe('TerminalPaneHeaderOverlay', () => { }) function dispatchFileDrag(target: Element, type: 'dragover' | 'drop', internal: boolean): void { + const payload = new Map([ + [WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(['/repo/file.txt'])] + ]) + writeWorkspaceFileDragSource( + { setData: (mime, value) => payload.set(mime, value) }, + { executionHostId: 'local', workspaceId: 'wt-1' } + ) 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'], + types: internal ? [WORKSPACE_FILE_PATHS_MIME, WORKSPACE_FILE_DRAG_SOURCE_MIME] : ['Files'], files: [new File([], 'file.txt')], - getData: () => encodeWorkspaceFilePaths(['/repo/file.txt']), + getData: (mime: string) => (internal ? (payload.get(mime) ?? '') : ''), dropEffect: 'none' } }) 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 4d84c2297085..f933ffe70f6a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -1,9 +1,13 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { getConnectionId } from '@/lib/connection-context' -import { parseExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' -import { readWorkspaceFileDragPaths } from '@/lib/workspace-file-drag' +import { + isResolvedWorkspaceFileDragExecutionHost, + readWorkspaceFileDragPaths, + readWorkspaceFileDragSource +} from '@/lib/workspace-file-drag' import { captureTerminalDropTarget } from './terminal-drop-target' import { resolveTerminalDropTargetShell } from './terminal-drop-shell' import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' @@ -34,7 +38,7 @@ export type InternalTerminalFileDropResult = pathCount: number } | { status: 'pasted'; pathCount: number } - | { status: 'rejected'; reason: 'paths-too-large' | 'too-many-paths' } + | { status: 'rejected'; reason: 'paths-too-large' | 'too-many-paths' | 'source-host-mismatch' } export async function handleInternalTerminalFileDrop({ manager, @@ -65,11 +69,30 @@ export async function handleInternalTerminalFileDrop({ if (!transport) { return { status: 'ignored', reason: 'no-transport' } } - const dropTargetSnapshot = captureTerminalDropTarget(pane, transport) - const host = parseExecutionHostId(transport.getExecutionHostId?.()) const runtimeEnvironmentId = transport.getRuntimeEnvironmentId?.() ?? (host?.kind === 'runtime' ? host.environmentId : null) + // A paired host's local terminal belongs to that host, not this desktop. + const targetExecutionHostId = + host?.kind === 'local' && runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : host?.id + const source = readWorkspaceFileDragSource(dataTransfer) + const sourceHost = parseExecutionHostId(source?.executionHostId) + const sourceRuntimeEnvironmentId = + source?.runtimeEnvironmentId ?? + (sourceHost?.kind === 'runtime' ? sourceHost.environmentId : null) + if ( + !source || + !isResolvedWorkspaceFileDragExecutionHost(source.executionHostId) || + source.executionHostId !== targetExecutionHostId || + sourceRuntimeEnvironmentId !== runtimeEnvironmentId || + (host?.kind === 'runtime' && runtimeEnvironmentId !== host.environmentId) + ) { + toast.error(getTerminalInternalFileDropRejectionMessage('source-host-mismatch')) + return { status: 'rejected', reason: 'source-host-mismatch' } + } + const dropTargetSnapshot = captureTerminalDropTarget(pane, transport) const worktreePath = resolveTerminalDropWorktreePath( worktreeId, runtimeEnvironmentId ? undefined : cwd, diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts index dea1cfe06ae9..5a333b78c9b5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts @@ -73,9 +73,27 @@ vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: mocks.recordTerminalUserInputForLeaf })) -import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { + encodeWorkspaceFilePaths, + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATHS_MIME +} from '@/lib/workspace-file-drag' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' +function sameHostDragData(paths: string[]) { + const source = JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + return (type: string): string => + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? source + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(paths) + : '' +} + function createTerminalTransport( sendInput: ReturnType, ptyId = 'pty-1', @@ -130,8 +148,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(paths) : '' + getData: sameHostDragData(paths) } }) @@ -170,10 +187,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['/repo/a.ts', '/repo/b.ts']) - : '' + getData: sameHostDragData(['/repo/a.ts', '/repo/b.ts']) } }) @@ -209,8 +223,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/repo/a.ts']) : '' + getData: sameHostDragData(['/repo/a.ts']) } }) @@ -245,8 +258,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/repo/a.ts']) : '' + getData: sameHostDragData(['/repo/a.ts']) } }) @@ -292,8 +304,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['C:\\repo\\a&b.txt']) : '' + getData: sameHostDragData(['C:\\repo\\a&b.txt']) } }) @@ -338,10 +349,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['C:\\Remote Repo\\A&B.txt']) - : '' + getData: sameHostDragData(['C:\\Remote Repo\\A&B.txt']) } }) @@ -388,10 +396,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(["/remote/repo/it's here.txt"]) - : '' + getData: sameHostDragData(["/remote/repo/it's here.txt"]) } }) @@ -434,10 +439,7 @@ describe('handleInternalTerminalFileDrop', () => { tabId: 'tab-1', cwd: undefined, dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['/repo/drop-target.ts']) - : '' + getData: sameHostDragData(['/repo/drop-target.ts']) }, dropTarget }) @@ -470,8 +472,7 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(paths) : '' + getData: sameHostDragData(paths) } }) @@ -505,8 +506,7 @@ it('refuses an unresolvable workspace instead of using the dropped path as its r cwd: undefined, paneLeafId: 'leaf-1', dataTransfer: { - getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/other/file.txt']) : '' + getData: sameHostDragData(['/other/file.txt']) } }) expect(result).toEqual({ status: 'ignored', reason: 'worktree-unavailable' }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-internal-rejection-message.ts b/src/renderer/src/components/terminal-pane/terminal-drop-internal-rejection-message.ts index baadb44f1bfe..1914f42fffd0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-internal-rejection-message.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-internal-rejection-message.ts @@ -1,8 +1,14 @@ import { translate } from '@/i18n/i18n' export function getTerminalInternalFileDropRejectionMessage( - reason: 'paths-too-large' | 'too-many-paths' + reason: 'paths-too-large' | 'too-many-paths' | 'source-host-mismatch' ): string { + if (reason === 'source-host-mismatch') { + return translate( + 'auto.components.terminal.pane.terminal.drop.handler.internalHostMismatch', + 'Drop files from the same host as this terminal.' + ) + } if (reason === 'too-many-paths') { return translate( 'auto.components.terminal.pane.terminal.drop.handler.internalTooManyPaths', 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 3832b7b77688..b417b59d8c7e 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 @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { handleInternalTerminalFileDrop, handleTerminalFileDrop } from './terminal-drop-handler' -import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { + encodeWorkspaceFilePaths, + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATHS_MIME +} from '@/lib/workspace-file-drag' const mocks = vi.hoisted(() => ({ state: { @@ -69,9 +73,11 @@ describe('local terminals without catalog workspaces', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['/local/file.txt']) - : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ version: 1, workspaceId: 'wt-1', executionHostId: 'local' }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/local/file.txt']) + : '' } }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) 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 e55fdc6c39e3..df4ace370dae 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 @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionHostId } from '../../../../shared/execution-host' -import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { + encodeWorkspaceFilePaths, + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATHS_MIME +} from '@/lib/workspace-file-drag' import { handleInternalTerminalFileDrop, handleTerminalFileDrop } from './terminal-drop-handler' const mocks = vi.hoisted(() => { @@ -116,9 +120,17 @@ async function drop(lane: string, workspaceId: string, executionHostId: Executio paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['/owner/workspace/file with spaces.txt']) - : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + runtimeEnvironmentId: 'owner-runtime', + executionHostId: + executionHostId === 'local' ? 'runtime:owner-runtime' : executionHostId + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/owner/workspace/file with spaces.txt']) + : '' } }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts new file mode 100644 index 000000000000..56fd987a4050 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATHS_MIME +} from '@/lib/workspace-file-drag' +import { handleInternalTerminalFileDrop } from './terminal-drop-handler' + +const mocks = vi.hoisted(() => ({ write: vi.fn(), error: vi.fn(), activity: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: mocks.error } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('./terminal-drop-worktree-path', () => ({ resolveTerminalDropWorktreePath: () => '/repo' })) +vi.mock('./terminal-drop-shell', () => ({ + resolveTerminalDropTargetShell: () => ({ kind: 'posix' }) +})) +vi.mock('./terminal-paste-ssh-platform', () => ({ getTerminalPasteSshRemotePlatform: () => null })) +vi.mock('./terminal-drop-path-writer', () => ({ + writeTerminalDropPathsToCapturedTarget: mocks.write +})) +vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: mocks.activity })) +vi.mock('./terminal-native-file-drop-destination', () => ({ + handleNativeTerminalFileDrop: vi.fn() +})) + +async function drop( + source: string | null, + targetHost: string | null, + environmentId: string | null +) { + const focus = vi.fn() + const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The handler only enumerates the fixture pane and captures its identity; path writes are recorded separately. + const manager = { getPanes: () => [pane] } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are all transport methods used by the handler before the recorded path writer. + const paneTransports = new Map([ + [ + 1, + { + getPtyId: () => 'pty-1', + getExecutionHostId: () => targetHost, + getRuntimeEnvironmentId: () => environmentId + } + ] + ]) as never + const payload = new Map([[WORKSPACE_FILE_PATHS_MIME, '/same/path/file.txt']]) + if (source !== null) { + payload.set(WORKSPACE_FILE_DRAG_SOURCE_MIME, source) + } + const result = await handleInternalTerminalFileDrop({ + manager, + paneTransports, + worktreeId: 'destination-workspace', + tabId: 'tab-1', + cwd: undefined, + paneLeafId: 'leaf-1', + dataTransfer: { getData: (type) => payload.get(type) ?? '' } + }) + return { result, focus } +} + +const source = (executionHostId: string, runtimeEnvironmentId?: string) => + JSON.stringify({ + version: 1, + executionHostId, + workspaceId: 'source-workspace', + runtimeEnvironmentId + }) + +describe('internal terminal drop source ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.write.mockResolvedValue({ sentAnyPath: true, targetCurrent: true, pathsWritten: 1 }) + }) + + it.each([ + [source('runtime:env-a'), 'local', null], + [source('local'), 'local', 'env-a'], + [source('runtime:env-a'), 'local', 'env-b'], + [source('ssh:target-a'), 'ssh:target-b', null], + [source('runtime:unresolved-owner'), 'local', null], + [null, 'local', null], + ['{broken', 'local', null], + [source('local'), null, null], + [source('runtime:env-a'), 'runtime:env-a', 'env-b'], + [source('ssh:target-a'), 'ssh:target-a', 'env-a'], + [source('ssh:target-a', 'env-a'), 'ssh:target-a', null], + [source('ssh:target-a', 'env-a'), 'ssh:target-a', 'env-b'], + [source('runtime:env-a', 'env-b'), 'runtime:env-a', 'env-a'] + ])( + 'refuses an unverifiable or foreign source %s for %s / %s', + async (payload, targetHost, environmentId) => { + const { result, focus } = await drop(payload, targetHost, environmentId) + expect(result).toEqual({ status: 'rejected', reason: 'source-host-mismatch' }) + expect(mocks.write).not.toHaveBeenCalled() + expect(mocks.activity).not.toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalledWith('Drop files from the same host as this terminal.') + } + ) + + it.each([ + ['local', 'local', null, undefined], + ['runtime:env-a', 'local', 'env-a', undefined], + ['runtime:env%20a', 'local', 'env a', undefined], + ['runtime:env-a', 'runtime:env-a', 'env-a', undefined], + ['ssh:target-a', 'ssh:target-a', null, undefined], + ['ssh:target-a', 'ssh:target-a', 'env-a', 'env-a'] + ])( + 'keeps same-host sources across workspaces: %s into %s / %s', + async (sourceHost, targetHost, environmentId, sourceEnvironmentId) => { + const { result, focus } = await drop( + source(sourceHost, sourceEnvironmentId), + targetHost, + environmentId + ) + expect(result).toEqual({ status: 'pasted', pathCount: 1 }) + expect(mocks.write).toHaveBeenCalledWith( + expect.objectContaining({ paths: ['/same/path/file.txt'] }) + ) + expect(focus).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 17f6bfe8b52f..f74296becfd6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3493,6 +3493,7 @@ "ce8248b835": "Worktree path not available.", "internalTooManyPaths": "Drop contains too many paths for a safe terminal paste.", "internalPathsTooLarge": "Drop path list is too large for a safe terminal paste.", + "internalHostMismatch": "Drop files from the same host as this terminal.", "b4cf68e889": "Skipped {{value0}} {{value1}}.", "writeTimeout": "File drop cancelled: terminal did not accept the path before the safety timeout.", "writeRejected": "File drop cancelled: terminal could not accept the path." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 34d51013aeca..7a8700990a49 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3263,6 +3263,7 @@ "ce8248b835": "Ruta del worktree no disponible.", "internalTooManyPaths": "El drop contiene demasiadas rutas para pegarlas de forma segura en el terminal.", "internalPathsTooLarge": "La lista de rutas del drop es demasiado grande para pegarla de forma segura en el terminal.", + "internalHostMismatch": "Arrastra archivos del mismo host que este terminal.", "b4cf68e889": "Se omitió {{value0}} {{value1}}.", "writeTimeout": "Se canceló el drop de archivos: el terminal no aceptó la ruta antes del tiempo límite de seguridad.", "writeRejected": "Se canceló el drop de archivos: el terminal no pudo aceptar la ruta." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 65ce26ce3f4f..ba8252eeac46 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3387,6 +3387,7 @@ "ce8248b835": "Chemin du worktree indisponible.", "internalTooManyPaths": "Le contenu déposé contient trop de chemins pour un collage sûr dans le terminal.", "internalPathsTooLarge": "La liste de chemins déposée est trop volumineuse pour un collage sûr dans le terminal.", + "internalHostMismatch": "Déposez des fichiers provenant du même hôte que ce terminal.", "b4cf68e889": "Ignoré : {{value0}} {{value1}}.", "writeTimeout": "Glisser-déposer de fichiers annulé : le terminal n'a pas accepté le chemin avant le délai de sécurité.", "writeRejected": "Glisser-déposer de fichiers annulé : le terminal n'a pas pu accepter le chemin." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1a92fa3d9313..1caa58d25c85 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3263,6 +3263,7 @@ "ce8248b835": "ワークツリーのパスが使用できません。", "internalTooManyPaths": "安全にターミナルへ貼り付けるにはドロップされたパスが多すぎます。", "internalPathsTooLarge": "安全にターミナルへ貼り付けるにはパス一覧が大きすぎます。", + "internalHostMismatch": "このターミナルと同じホストのファイルをドロップしてください。", "b4cf68e889": "{{value0}} {{value1}} をスキップしました。", "writeTimeout": "ファイルドロップをキャンセルしました: 安全タイムアウト前にターミナルがパスを受け付けませんでした。", "writeRejected": "ファイルドロップをキャンセルしました: ターミナルがパスを受け付けられませんでした。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a790d35056c4..1af45dd6f9b7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3263,6 +3263,7 @@ "ce8248b835": "작업 트리 경로를 사용할 수 없습니다.", "internalTooManyPaths": "터미널에 안전하게 붙여넣기에는 드롭된 경로가 너무 많습니다.", "internalPathsTooLarge": "터미널에 안전하게 붙여넣기에는 경로 목록이 너무 큽니다.", + "internalHostMismatch": "이 터미널과 같은 호스트의 파일을 드롭하세요.", "b4cf68e889": "{{value0}} {{value1}}을(를) 건너뛰었습니다.", "writeTimeout": "파일 드롭이 취소되었습니다. 안전 시간 제한 전에 터미널이 경로를 받지 않았습니다.", "writeRejected": "파일 드롭이 취소되었습니다. 터미널이 경로를 받을 수 없습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9f6a9eed9437..06fdb3230737 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3263,6 +3263,7 @@ "ce8248b835": "工作树路径不可用。", "internalTooManyPaths": "拖放的路径过多,无法安全粘贴到终端。", "internalPathsTooLarge": "拖放的路径列表过大,无法安全粘贴到终端。", + "internalHostMismatch": "请拖放与此终端位于同一主机的文件。", "b4cf68e889": "已跳过 {{value0}} {{value1}}。", "writeTimeout": "文件拖放已取消:终端在安全超时前未接受路径。", "writeRejected": "文件拖放已取消:终端无法接受路径。" diff --git a/src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts b/src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts new file mode 100644 index 000000000000..bc9b2c1114f1 --- /dev/null +++ b/src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { readWorkspaceFileDragSource } from './workspace-file-drag' +import { writeWorkspaceFileDragSourceForWorkspace } from './workspace-file-drag-source' + +const mocks = vi.hoisted((): { host: ExecutionHostId | null; environmentId: string | null } => ({ + host: 'ssh:target-a', + environmentId: null +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ settings: { activeRuntimeEnvironmentId: 'foreign-env' } }) } +})) +vi.mock('./worktree-runtime-owner', () => ({ + getKnownExecutionHostIdForWorktree: () => mocks.host, + getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.environmentId +})) + +describe('workspace drag source server namespace', () => { + beforeEach(() => { + mocks.host = 'ssh:target-a' + mocks.environmentId = null + }) + + it.each([null, 'env-a'])('stamps SSH ownership from its workspace: %s', (environmentId) => { + mocks.environmentId = environmentId + const payload = new Map() + writeWorkspaceFileDragSourceForWorkspace( + { setData: (type, value) => payload.set(type, value) }, + 'folder:source' + ) + expect(readWorkspaceFileDragSource({ getData: (type) => payload.get(type) ?? '' })).toEqual({ + executionHostId: 'ssh:target-a', + workspaceId: 'folder:source', + ...(environmentId ? { runtimeEnvironmentId: environmentId } : {}) + }) + }) + + it('leaves an unknown workspace unstamped despite the focused server', () => { + mocks.host = null + const setData = vi.fn() + writeWorkspaceFileDragSourceForWorkspace({ setData }, 'folder:missing') + expect(setData).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/workspace-file-drag-source.ts b/src/renderer/src/lib/workspace-file-drag-source.ts index f97393de6b3c..76215ae9a4e9 100644 --- a/src/renderer/src/lib/workspace-file-drag-source.ts +++ b/src/renderer/src/lib/workspace-file-drag-source.ts @@ -1,5 +1,9 @@ import { useAppStore } from '@/store' -import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' +import { + getKnownExecutionHostIdForWorktree, + getExplicitRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' +import { parseExecutionHostId } from '../../../shared/execution-host' import { writeWorkspaceFileDragSourceIfResolved } from './workspace-file-drag' /** Source-control rows list the live workspace, so the owner is resolved now @@ -8,9 +12,14 @@ export function writeWorkspaceFileDragSourceForWorkspace( dataTransfer: Pick, workspaceId: string ): void { + const state = useAppStore.getState() + const executionHostId = getKnownExecutionHostIdForWorktree(state, workspaceId) writeWorkspaceFileDragSourceIfResolved( dataTransfer, workspaceId, - getExecutionHostIdForWorktree(useAppStore.getState(), workspaceId) + executionHostId, + parseExecutionHostId(executionHostId)?.kind === 'ssh' + ? getExplicitRuntimeEnvironmentIdForWorktree(state, workspaceId) + : undefined ) } diff --git a/src/renderer/src/lib/workspace-file-drag.test.ts b/src/renderer/src/lib/workspace-file-drag.test.ts index 944a5f9d7197..a3722f43a9b5 100644 --- a/src/renderer/src/lib/workspace-file-drag.test.ts +++ b/src/renderer/src/lib/workspace-file-drag.test.ts @@ -52,6 +52,31 @@ describe('workspace file drag payloads', () => { expect(readWorkspaceFileDragSource(transfer)).toBeNull() }) + it('round-trips the server namespace of a nested SSH target', () => { + const transfer = new FakeDataTransfer() + const source = { + executionHostId: 'ssh:remote-1', + workspaceId: 'folder:docs', + runtimeEnvironmentId: 'env-1' + } as const + writeWorkspaceFileDragSource(transfer, source) + expect(readWorkspaceFileDragSource(transfer)).toEqual(source) + }) + + it.each([null, 123, '', ' '])('rejects a malformed server namespace %s', (value) => { + const transfer = new FakeDataTransfer() + transfer.setData( + 'application/x-orca-workspace-file-source', + JSON.stringify({ + version: 1, + executionHostId: 'ssh:remote-1', + workspaceId: 'folder:docs', + runtimeEnvironmentId: value + }) + ) + expect(readWorkspaceFileDragSource(transfer)).toBeNull() + }) + it('rejects only the exact unresolved execution-host sentinel', () => { expect(isResolvedWorkspaceFileDragExecutionHost('runtime:unresolved-owner')).toBe(false) expect(isResolvedWorkspaceFileDragExecutionHost('runtime:my-unresolved-owner-env')).toBe(true) diff --git a/src/renderer/src/lib/workspace-file-drag.ts b/src/renderer/src/lib/workspace-file-drag.ts index 26d1249393fc..e02a861bc2f3 100644 --- a/src/renderer/src/lib/workspace-file-drag.ts +++ b/src/renderer/src/lib/workspace-file-drag.ts @@ -16,6 +16,7 @@ const WORKSPACE_FILE_DRAG_SOURCE_MAX_BYTES = 4096 export type WorkspaceFileDragSource = { executionHostId: ExecutionHostId workspaceId: string + runtimeEnvironmentId?: string } export function isResolvedWorkspaceFileDragExecutionHost( @@ -60,7 +61,8 @@ export function writeWorkspaceFileDragSource( export function writeWorkspaceFileDragSourceIfResolved( dataTransfer: Pick, workspaceId: string | null | undefined, - executionHostId: ExecutionHostId | null | undefined + executionHostId: ExecutionHostId | null | undefined, + runtimeEnvironmentId?: string | null ): void { if ( !workspaceId || @@ -69,7 +71,11 @@ export function writeWorkspaceFileDragSourceIfResolved( ) { return } - writeWorkspaceFileDragSource(dataTransfer, { executionHostId, workspaceId }) + writeWorkspaceFileDragSource(dataTransfer, { + executionHostId, + workspaceId, + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) + }) } export function readWorkspaceFileDragSource( @@ -100,6 +106,12 @@ export function readWorkspaceFileDragSource( if (version !== 1 || !executionHostId || !workspaceId) { return null } + if ('runtimeEnvironmentId' in parsed) { + if (typeof parsed.runtimeEnvironmentId !== 'string' || !parsed.runtimeEnvironmentId.trim()) { + return null + } + return { executionHostId, workspaceId, runtimeEnvironmentId: parsed.runtimeEnvironmentId } + } return { executionHostId, workspaceId } } catch { return null