From 7e905872f1a514cdac03c2729de895a470a08eb6 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 01:57:08 -0700 Subject: [PATCH 1/7] Reject terminal file drops from a different execution host --- .github/workflows/e2e.yml | 7 +- config/scripts/ci-e2e-job-selection.mjs | 7 +- config/scripts/ci-e2e-job-selection.test.mjs | 19 ++ config/scripts/pr-e2e-source-routing.mjs | 12 + .../combined-diff-file-tree-row-drag.test.tsx | 2 +- .../right-sidebar/FileExplorerRow.tsx | 14 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 31 +- .../file-explorer-drag-scroll-marker.test.tsx | 51 ++-- .../terminal-pane/terminal-drop-handler.ts | 33 ++- .../terminal-drop-internal-handler.test.ts | 114 ++++++-- ...erminal-drop-internal-rejection-message.ts | 8 +- .../terminal-drop-local-workspace.test.ts | 14 +- ...erminal-drop-runtime-catalog-owner.test.ts | 20 +- .../terminal-drop-source-owner.test.ts | 123 ++++++++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...workspace-file-drag-source-runtime.test.ts | 44 +++ .../src/lib/workspace-file-drag-source.ts | 13 +- .../src/lib/workspace-file-drag.test.ts | 25 ++ src/renderer/src/lib/workspace-file-drag.ts | 16 +- tests/e2e/ssh-orcad-idle-exit.spec.ts | 25 +- .../e2e/ssh-orcad-terminal-drop-owner.spec.ts | 275 ++++++++++++++++++ 26 files changed, 761 insertions(+), 98 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts create mode 100644 src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts create mode 100644 tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d700112f4860..933d6e39d726 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -358,7 +358,8 @@ jobs: . != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" and . != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and . != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and - . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" + . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and + . != "tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -592,7 +593,7 @@ jobs: orcad-auto-convert-docker: name: ssh host auto-converts to managed orcad (Docker) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -628,7 +629,7 @@ jobs: ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts --project=electron-headless --workers=1 + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 if: failure() with: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 920301e70879..29058ef7d4d3 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -62,6 +62,7 @@ export const ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts' export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts' +export const ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts' export const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, @@ -80,7 +81,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_MARKDOWN_CONVERSION_E2E_SPEC, ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC, ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC, - ORCAD_OPEN_IN_OWNER_E2E_SPEC + ORCAD_OPEN_IN_OWNER_E2E_SPEC, + ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -130,7 +132,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_MARKDOWN_CONVERSION_E2E_SPEC || spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC || spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC || - spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC + spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC || + spec === ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index f842d6b6cab9..69d01b9583f2 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -44,6 +44,25 @@ const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) +it('runs actual terminal drop ownership in the template-building Docker job', () => { + const spec = 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts' + expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) + const job = workflow.jobs['orcad-auto-convert-docker'] + expect(job.if).toContain(spec) + expect( + job.steps.some((step) => step.run?.includes(spec) && step.env?.ORCA_E2E_SSH_DOCKER === '1') + ).toBe(true) + for (const source of [ + 'src/renderer/src/components/terminal-pane/terminal-drop-handler.ts', + 'src/renderer/src/components/terminal-pane/terminal-drop-internal-rejection-message.ts', + 'src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx', + 'src/renderer/src/lib/workspace-file-drag.ts', + 'src/renderer/src/components/right-sidebar/FileExplorerRow.tsx' + ]) { + expect(selectPrE2eSpecs([source]), source).toContain(spec) + } +}) + it('skips the general consumer only when every requested spec has a dedicated owner', () => { for (const spec of DEDICATED_E2E_SPECS) { expect(classify([spec]).e2e_run_changed, spec).toBe(false) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 1a5880aea147..0243221daff9 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -109,6 +109,18 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-terminal-drop-owner', + specs: ['tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts'], + matches: (file) => + /^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-relay-target)\.ts$/.test( + file + ) || + (isProductSource(file) && + /^src\/renderer\/src\/(?:lib\/(?:workspace-file-drag(?:-source)?|worktree-runtime-owner)\.ts|components\/(?:right-sidebar\/FileExplorer(?:Row|VirtualRows)\.tsx|terminal-pane\/(?:terminal-drop-(?:handler|internal-rejection-message|target|worktree-path)|TerminalPaneSurface)\.tsx?))$/.test( + file + )) + }, { id: 'ssh.orcad-open-in-owner', specs: ['tests/e2e/ssh-orcad-open-in-owner.spec.ts'], 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 d71b02da7690..6b64cbde135e 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -7,9 +7,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' @@ -38,7 +38,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 @@ -83,7 +85,7 @@ export function FileExplorerRow({ deleteShortcutLabel, connectionId, sourceWorkspaceId, - resolveDragSourceHostId, + resolveDragSourceOwner, runtimeDownloadContext, supportsFolderDownload = false, canOpenInOrcaBrowser, @@ -164,10 +166,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 eb4e74255160..bb6b48a060cb 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 (
@@ -236,7 +241,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/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 f4851e093635..b0c9a3132233 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,7 +73,11 @@ 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 createTerminalTransport( @@ -131,7 +135,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(paths) : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(paths) + : '' } }) @@ -171,9 +183,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['/repo/a.ts', '/repo/b.ts']) - : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/repo/a.ts', '/repo/b.ts']) + : '' } }) @@ -208,7 +226,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/repo/a.ts']) : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/repo/a.ts']) + : '' } }) @@ -242,7 +268,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/repo/a.ts']) : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/repo/a.ts']) + : '' } }) @@ -289,7 +323,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['C:\\repo\\a&b.txt']) : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['C:\\repo\\a&b.txt']) + : '' } }) @@ -335,9 +377,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['C:\\Remote Repo\\A&B.txt']) - : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['C:\\Remote Repo\\A&B.txt']) + : '' } }) @@ -385,9 +433,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(["/remote/repo/it's here.txt"]) - : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(["/remote/repo/it's here.txt"]) + : '' } }) @@ -431,9 +485,15 @@ describe('handleInternalTerminalFileDrop', () => { cwd: undefined, dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME - ? encodeWorkspaceFilePaths(['/repo/drop-target.ts']) - : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/repo/drop-target.ts']) + : '' }, dropTarget }) @@ -467,7 +527,15 @@ describe('handleInternalTerminalFileDrop', () => { paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(paths) : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(paths) + : '' } }) @@ -502,7 +570,15 @@ it('refuses an unresolvable workspace instead of using the dropped path as its r paneLeafId: 'leaf-1', dataTransfer: { getData: (type) => - type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/other/file.txt']) : '' + type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? JSON.stringify({ + version: 1, + workspaceId: 'wt-1', + executionHostId: mocks.storeState.repos[0]?.executionHostId ?? 'local' + }) + : type === WORKSPACE_FILE_PATHS_MIME + ? encodeWorkspaceFilePaths(['/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 fa1afe01cbc6..80be8acba52d 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 f1456c9c6781..efe663c92b7d 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 74954b2510cf..cd76b1535d94 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 71a8d66f1648..0bd48e7b9f29 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 2a4cee1663bd..088b0a1a4ada 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 b1306527066e..6605548dd61d 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 diff --git a/tests/e2e/ssh-orcad-idle-exit.spec.ts b/tests/e2e/ssh-orcad-idle-exit.spec.ts index 9e1a732bd37b..7168fccbe434 100644 --- a/tests/e2e/ssh-orcad-idle-exit.spec.ts +++ b/tests/e2e/ssh-orcad-idle-exit.spec.ts @@ -65,23 +65,7 @@ test('a managed orcad stops after idling and starts again on the next connect', app = first.app await waitForSessionReady(first.page) // A managed host is reached through its server, not a relay, so no relay repo is added. - const remote = await first.page.evaluate( - async (input) => { - const { target: created } = await window.api.ssh.addTarget({ target: input }) - const state = await window.api.ssh.connect({ targetId: created.id }) - return { targetId: created.id, managedServer: state?.managedServer ?? null } - }, - { - label: `orcad idle E2E ${Date.now()}`, - host: target.host, - port: target.port, - username: 'root', - identityFile: target.identityFile, - identitiesOnly: true, - relayGracePeriodSeconds: 1 - } - ) - expect(remote.managedServer).toMatchObject({ kind: 'managed' }) + const remote = await connectManagedHost(first.page, target) expect(runningOrcadPids(target)).toHaveLength(1) // While the client is connected the server stays up past its quiet period. @@ -112,6 +96,13 @@ test('a managed orcad stops after idling and starts again on the next connect', // A start inherited from the launch-time connect this reconnect dropped would report `serving`. expect(JSON.parse(connected)).toMatchObject({ kind: 'managed' }) expect(JSON.parse(connected)).not.toHaveProperty('serving') + await expect + .poll( + async () => + (await callEnvironment(second.page, remote.environmentId, 'terminal.list', {})).ok, + { timeout: 60_000 } + ) + .toBe(true) expect(runningOrcadPids(target)).toHaveLength(1) // The restarted server read the record, so a later crash cannot be mistaken for an idle stop. expect(readIdleStopRecord(target)).toBeNull() diff --git a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts new file mode 100644 index 000000000000..2b6fcbab0086 --- /dev/null +++ b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts @@ -0,0 +1,275 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { startOrcadConvertHost } from './helpers/orcad-convert-host' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' +import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' +import { waitForSessionReady } from './helpers/store' +import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' +import { openFileExplorer } from './helpers/file-explorer' +import { shellQuote } from './helpers/docker-ssh-relay-target' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs owned Docker host') +test.skip(process.platform === 'win32', 'Owned POSIX host collision fixture') + +async function terminalText(page: Page, tabId: string) { + return page.evaluate((id) => { + const pane = window.__paneManagers?.get(id)?.getActivePane() + if (!pane) { + throw new Error('Missing actual floating pane') + } + const buffer = pane.terminal.buffer.active + let text = '' + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i) + if (line) { + text += (line.isWrapped ? '' : '\n') + line.translateToString(true) + } + } + return text.trimEnd() + }, tabId) +} + +async function focusTerminalTab(page: Page, tabId: string) { + await page.evaluate((id) => { + const pane = window.__paneManagers?.get(id)?.getActivePane() + const textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('No real floating input') + } + pane.terminal.focus() + textarea.focus() + }, tabId) +} + +async function dropExplorerFile(page: Page, name: string, tabId: string) { + const row = page.locator('[data-file-explorer-row]').filter({ hasText: name }) + await expect(row).toBeVisible() + return row.evaluate((element, id) => { + const pane = window.__paneManagers?.get(id)?.getActivePane() + const surface = pane?.container.querySelector('.xterm') + if (!pane || !surface) { + throw new Error('Missing actual terminal drop surface') + } + const dataTransfer = new DataTransfer() + element.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer })) + const payload = Object.fromEntries( + [...dataTransfer.types].map((type) => [type, dataTransfer.getData(type)]) + ) + surface.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })) + element.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer })) + return payload + }, tabId) +} + +test('managed Files drag into a desktop terminal cannot name an unrelated desktop file', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(4 * 60_000) + const fileName = 'DROP_OWNER.txt' + const filePath = path.join(testRepoPath, fileName) + writeFileSync(filePath, 'DESKTOP_BROWSER_DROP_OWNER') + const host = startOrcadConvertHost('docker', testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + let app: ElectronApplication | null = null + try { + if (!host.exec) { + throw new Error('Missing owned host control') + } + host.exec( + `mkdir -p ${shellQuote(path.dirname(testRepoPath))} && git clone --quiet ${shellQuote(host.remoteRepoPath)} ${shellQuote(testRepoPath)} && printf '%s' 'REMOTE_BROWSER_DROP_OWNER' > ${shellQuote(filePath)}` + ) + host.exec( + `printf '%s' REMOTE_DROP_READY > ${shellQuote(path.join(testRepoPath, 'REMOTE_DROP_READY.txt'))}` + ) + host.exec(`mkdir -p ${shellQuote(path.join(testRepoPath, 'DROP_SOURCE_DIRECTORY'))}`) + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + const nativeId = await first.page.evaluate(async (repoPath) => { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + return `${result.repo.id}::${repoPath}` + }, testRepoPath) + await session.close(app) + app = null + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: testRepoPath, + folderPath: host.remoteFolderPath + }) + const launched = await session.launch() + app = launched.app + const page = launched.page + await waitForSessionReady(page) + await convertAndRetain(page, session.userDataDir, seeded) + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId + ) + if (!environment) { + throw new Error('Missing actual managed environment') + } + await dismissTransientAnnouncement(page) + const nativeIdentity = getWorktreeHostIdentity({ id: nativeId, hostId: 'local' }) + await page.locator(`[data-worktree-host-identity="${nativeIdentity}"]:visible`).click() + await openFileExplorer(page) + const floatingId = await page.evaluate(() => { + const store = window.__store + const state = store?.getState() + if (!store || !state?.settings) { + throw new Error('Missing ready settings') + } + store.setState({ settings: { ...state.settings, floatingTerminalEnabled: true } }) + return store + .getState() + .createTab('global-floating-terminal', undefined, undefined, { activate: true }).id + }) + await expect(page.locator('[data-floating-terminal-panel]')).toHaveCount(1) + if ((await page.locator('[data-floating-terminal-panel][aria-hidden="false"]').count()) === 0) { + await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + } + await expect( + page.locator(`[data-terminal-tab-id="${floatingId}"][data-terminal-layout-leaf-ids]:visible`) + ).toBeVisible() + await expect + .poll(() => + page.evaluate( + (id) => window.__paneManagers?.get(id)?.getActivePane()?.container.dataset.ptyId ?? null, + floatingId + ) + ) + .not.toBeNull() + await focusTerminalTab(page, floatingId) + await page.keyboard.insertText("printf '\\nNATIVE_DROP_READY\\n'") + await page.keyboard.press('Enter') + await expect.poll(() => terminalText(page, floatingId)).toMatch(/\nNATIVE_DROP_READY\s*\n/) + await focusTerminalTab(page, floatingId) + await page.keyboard.insertText('cat ') + console.log('[native-actual-terminal-drag]', await dropExplorerFile(page, fileName, floatingId)) + await expect.poll(() => terminalText(page, floatingId)).toContain(filePath) + await page.keyboard.press('Enter') + await expect.poll(() => terminalText(page, floatingId)).toContain('DESKTOP_BROWSER_DROP_OWNER') + console.log('[native-positive-actual-terminal]', await terminalText(page, floatingId)) + const remoteIdentity = getWorktreeHostIdentity({ + id: seeded.worktreeId, + hostId: toRuntimeExecutionHostId(environment.id) + }) + await page.locator(`[data-worktree-host-identity="${remoteIdentity}"]:visible`).click() + await openFileExplorer(page) + await page.getByRole('button', { name: 'Refresh Explorer', exact: true }).click() + await expect( + page.locator('[data-file-explorer-row]').filter({ hasText: 'REMOTE_DROP_READY.txt' }) + ).toBeVisible() + expect( + await page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId) + ).toBe(toRuntimeExecutionHostId(environment.id)) + console.log( + '[independent-managed-source]', + await serverCall(page, environment.id, 'files.read', { + worktree: `id:${seeded.worktreeId}`, + relativePath: fileName + }) + ) + await focusTerminalTab(page, floatingId) + await page.keyboard.insertText("printf '\\nREMOTE_DROP_RESULT\\n'; cat ") + const remotePayload = await dropExplorerFile(page, fileName, floatingId) + console.log('[managed-actual-terminal-drag-payload]', remotePayload) + expect( + JSON.parse(remotePayload['application/x-orca-workspace-file-source'] ?? '{}') + ).toMatchObject({ + executionHostId: toRuntimeExecutionHostId(environment.id), + workspaceId: seeded.worktreeId + }) + await expect + .poll(async () => { + const refused = await page + .getByText('Drop files from the same host as this terminal.', { exact: true }) + .count() + const echo = (await terminalText(page, floatingId)).split('REMOTE_DROP_RESULT').at(-1) ?? '' + return refused > 0 || echo.includes(filePath) + }) + .toBe(true) + const refused = page.getByText('Drop files from the same host as this terminal.', { + exact: true + }) + if (await refused.count()) { + await page.keyboard.press('Control+c') + await expect(refused).toBeVisible() + } else { + await page.keyboard.insertText("; printf '\\nREMOTE_DROP_COMPLETED\\n'") + await page.keyboard.press('Enter') + await expect + .poll(() => terminalText(page, floatingId)) + .toMatch(/\nREMOTE_DROP_COMPLETED\s*\n/) + } + const content = await terminalText(page, floatingId) + console.log('[managed-drop-actual-native-shell]', content) + await page.screenshot({ path: testInfo.outputPath('managed-terminal-drop-owner.png') }) + expect(content.split('REMOTE_DROP_RESULT').at(-1)).not.toContain('DESKTOP_BROWSER_DROP_OWNER') + await expect(refused).toBeVisible() + console.log( + '[files-before-positive-control]', + await page.locator('[data-file-explorer-row]').allTextContents() + ) + const priorManagedTabs = await page.evaluate( + (worktreeId) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id), + seeded.worktreeId + ) + await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + await page + .locator('[data-file-explorer-row]') + .filter({ hasText: 'DROP_SOURCE_DIRECTORY' }) + .click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in Terminal', exact: true }).click() + const readNewManagedTab = () => + page.evaluate( + ({ worktreeId, prior }) => { + const state = window.__store?.getState() + const id = state?.activeTabIdByWorktree[worktreeId] + return id && !prior.includes(id) ? id : null + }, + { worktreeId: seeded.worktreeId, prior: priorManagedTabs } + ) + await expect.poll(readNewManagedTab).not.toBeNull() + const managedTab = await readNewManagedTab() + if (!managedTab) { + throw new Error('No newly opened managed terminal') + } + await expect + .poll(() => + page.evaluate( + (id) => window.__paneManagers?.get(id)?.getActivePane()?.container.dataset.ptyId ?? null, + managedTab + ) + ) + .not.toBeNull() + await expect.poll(() => terminalText(page, managedTab)).toMatch(/[$#]\s*$/) + await focusTerminalTab(page, managedTab) + await page.keyboard.insertText("printf '\\nMANAGED_DROP_READY\\n'") + await page.keyboard.press('Enter') + await expect.poll(() => terminalText(page, managedTab)).toMatch(/\nMANAGED_DROP_READY\s*\n/) + await focusTerminalTab(page, managedTab) + await page.keyboard.insertText('cat ') + console.log('[managed-positive-drag]', await dropExplorerFile(page, fileName, managedTab)) + await expect.poll(() => terminalText(page, managedTab)).toContain(filePath) + await page.keyboard.press('Enter') + await expect.poll(() => terminalText(page, managedTab)).toContain('REMOTE_BROWSER_DROP_OWNER') + console.log('[managed-positive-shell]', await terminalText(page, managedTab)) + } finally { + try { + if (app) { + await session.close(app) + } + await session.dispose() + } finally { + host.cleanup() + } + } +}) From 4b66efae862a1259d1b3acd6ca319585e9acdfa2 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 02:20:33 -0700 Subject: [PATCH 2/7] Wait for the terminal-drop refusal to finish painting in its proof --- tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts index 2b6fcbab0086..a61122638d97 100644 --- a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts @@ -210,6 +210,11 @@ test('managed Files drag into a desktop terminal cannot name an unrelated deskto } const content = await terminalText(page, floatingId) console.log('[managed-drop-actual-native-shell]', content) + const refusalToast = page.locator('[data-sonner-toast]').filter({ has: refused }) + await expect(refusalToast).toHaveAttribute('data-mounted', 'true') + await refusalToast.evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)) + }) await page.screenshot({ path: testInfo.outputPath('managed-terminal-drop-owner.png') }) expect(content.split('REMOTE_DROP_RESULT').at(-1)).not.toContain('DESKTOP_BROWSER_DROP_OWNER') await expect(refused).toBeVisible() From 5d9b4348bb9f7d1c5f53ddc77dde51244b8200cf Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 02:23:54 -0700 Subject: [PATCH 3/7] Wait for the floating panel to close before the managed drop control --- tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts index a61122638d97..36df6ade37e5 100644 --- a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts @@ -153,6 +153,7 @@ test('managed Files drag into a desktop terminal cannot name an unrelated deskto await page.keyboard.insertText('cat ') console.log('[native-actual-terminal-drag]', await dropExplorerFile(page, fileName, floatingId)) await expect.poll(() => terminalText(page, floatingId)).toContain(filePath) + await focusTerminalTab(page, floatingId) await page.keyboard.press('Enter') await expect.poll(() => terminalText(page, floatingId)).toContain('DESKTOP_BROWSER_DROP_OWNER') console.log('[native-positive-actual-terminal]', await terminalText(page, floatingId)) @@ -228,11 +229,17 @@ test('managed Files drag into a desktop terminal cannot name an unrelated deskto seeded.worktreeId ) await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + await expect(page.locator('[data-floating-terminal-panel]')).toHaveAttribute( + 'aria-hidden', + 'true' + ) await page .locator('[data-file-explorer-row]') .filter({ hasText: 'DROP_SOURCE_DIRECTORY' }) .click({ button: 'right' }) - await page.getByRole('menuitem', { name: 'Open in Terminal', exact: true }).click() + const openInTerminal = page.getByRole('menuitem', { name: 'Open in Terminal', exact: true }) + await expect(openInTerminal).toBeVisible() + await openInTerminal.press('Enter') const readNewManagedTab = () => page.evaluate( ({ worktreeId, prior }) => { @@ -264,6 +271,7 @@ test('managed Files drag into a desktop terminal cannot name an unrelated deskto await page.keyboard.insertText('cat ') console.log('[managed-positive-drag]', await dropExplorerFile(page, fileName, managedTab)) await expect.poll(() => terminalText(page, managedTab)).toContain(filePath) + await focusTerminalTab(page, managedTab) await page.keyboard.press('Enter') await expect.poll(() => terminalText(page, managedTab)).toContain('REMOTE_BROWSER_DROP_OWNER') console.log('[managed-positive-shell]', await terminalText(page, managedTab)) From f0215efa0be381397c7b336df316c2fd9fd261e3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 02:34:47 -0700 Subject: [PATCH 4/7] Assert terminal file drop refusal directly --- .../e2e/ssh-orcad-terminal-drop-owner.spec.ts | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts index 36df6ade37e5..a0d0da939e8b 100644 --- a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts @@ -187,28 +187,15 @@ test('managed Files drag into a desktop terminal cannot name an unrelated deskto executionHostId: toRuntimeExecutionHostId(environment.id), workspaceId: seeded.worktreeId }) - await expect - .poll(async () => { - const refused = await page - .getByText('Drop files from the same host as this terminal.', { exact: true }) - .count() - const echo = (await terminalText(page, floatingId)).split('REMOTE_DROP_RESULT').at(-1) ?? '' - return refused > 0 || echo.includes(filePath) - }) - .toBe(true) const refused = page.getByText('Drop files from the same host as this terminal.', { exact: true }) - if (await refused.count()) { - await page.keyboard.press('Control+c') - await expect(refused).toBeVisible() - } else { - await page.keyboard.insertText("; printf '\\nREMOTE_DROP_COMPLETED\\n'") - await page.keyboard.press('Enter') - await expect - .poll(() => terminalText(page, floatingId)) - .toMatch(/\nREMOTE_DROP_COMPLETED\s*\n/) - } + await expect(refused).toBeVisible() + expect((await terminalText(page, floatingId)).split('REMOTE_DROP_RESULT').at(-1)).not.toContain( + filePath + ) + await focusTerminalTab(page, floatingId) + await page.keyboard.press('Control+c') const content = await terminalText(page, floatingId) console.log('[managed-drop-actual-native-shell]', content) const refusalToast = page.locator('[data-sonner-toast]').filter({ has: refused }) From faee64cc483fb820df03be39542fd6df28de2067 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 04:00:53 -0700 Subject: [PATCH 5/7] Fix terminal drop owner fixtures and toast proof waits --- .../TerminalPaneHeaderOverlay.test.tsx | 18 +++++++++++++++--- .../e2e/ssh-orcad-terminal-drop-owner.spec.ts | 3 ++- 2 files changed, 17 insertions(+), 4 deletions(-) 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/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts index a0d0da939e8b..a5d2a95e1b56 100644 --- a/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts @@ -201,8 +201,9 @@ test('managed Files drag into a desktop terminal cannot name an unrelated deskto const refusalToast = page.locator('[data-sonner-toast]').filter({ has: refused }) await expect(refusalToast).toHaveAttribute('data-mounted', 'true') await refusalToast.evaluate(async (element) => { - await Promise.all(element.getAnimations().map((animation) => animation.finished)) + await Promise.allSettled(element.getAnimations().map((animation) => animation.finished)) }) + await expect(refusalToast).toHaveCSS('opacity', '1') await page.screenshot({ path: testInfo.outputPath('managed-terminal-drop-owner.png') }) expect(content.split('REMOTE_DROP_RESULT').at(-1)).not.toContain('DESKTOP_BROWSER_DROP_OWNER') await expect(refused).toBeVisible() From 37af1ea6c341ab6ef7248fa099deba54eae1b694 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 03:10:38 -0700 Subject: [PATCH 6/7] Reject Explorer move drops from a different execution host --- .github/workflows/e2e.yml | 7 +- config/scripts/ci-e2e-job-selection.mjs | 7 +- config/scripts/ci-e2e-job-selection.test.mjs | 23 ++ config/scripts/pr-e2e-source-routing.mjs | 14 +- .../right-sidebar/FileExplorerRow.tsx | 3 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 3 +- .../file-explorer-move-drop-payload.test.tsx | 114 ++++++++ ...e-explorer-move-drop-source-owner.test.tsx | 180 ++++++++++++ .../use-file-explorer-tree-pane-state.ts | 3 +- .../right-sidebar/useFileExplorerDragDrop.ts | 11 +- ...seFileExplorerImport.os-file-drop.test.tsx | 2 +- .../right-sidebar/useFileExplorerMoveDrop.ts | 36 ++- .../right-sidebar/useFileExplorerRowDrag.ts | 7 +- .../terminal-pane/terminal-drop-handler.ts | 26 +- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../src/lib/workspace-file-drag-owner.ts | 36 +++ .../e2e/ssh-orcad-explorer-move-owner.spec.ts | 273 ++++++++++++++++++ 22 files changed, 723 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-move-drop-payload.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-move-drop-source-owner.test.tsx create mode 100644 src/renderer/src/lib/workspace-file-drag-owner.ts create mode 100644 tests/e2e/ssh-orcad-explorer-move-owner.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 933d6e39d726..d7172cd96476 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -359,7 +359,8 @@ jobs: . != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and . != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and - . != "tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts" + . != "tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts" and + . != "tests/e2e/ssh-orcad-explorer-move-owner.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -593,7 +594,7 @@ jobs: orcad-auto-convert-docker: name: ssh host auto-converts to managed orcad (Docker) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-move-owner.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -629,7 +630,7 @@ jobs: ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts --project=electron-headless --workers=1 + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts tests/e2e/ssh-orcad-explorer-move-owner.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 if: failure() with: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 29058ef7d4d3..4619328d0ac7 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -62,6 +62,7 @@ export const ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts' export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts' +export const ORCAD_EXPLORER_MOVE_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-explorer-move-owner.spec.ts' export const ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts' export const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts' export const DEDICATED_E2E_SPECS = [ @@ -82,7 +83,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC, ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC, ORCAD_OPEN_IN_OWNER_E2E_SPEC, - ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC + ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC, + ORCAD_EXPLORER_MOVE_OWNER_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -133,7 +135,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC || spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC || spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC || - spec === ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC + spec === ORCAD_TERMINAL_DROP_OWNER_E2E_SPEC || + spec === ORCAD_EXPLORER_MOVE_OWNER_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 69d01b9583f2..09bde3ecdf56 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -44,6 +44,29 @@ const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) +it('runs Explorer move ownership through the owned Docker fixture', () => { + const spec = 'tests/e2e/ssh-orcad-explorer-move-owner.spec.ts' + expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) + expect(selectGeneralE2eSpecs([spec])).toEqual([]) + const job = workflow.jobs['orcad-auto-convert-docker'] + expect(job.if).toContain(spec) + expect( + job.steps.some((step) => step.run?.includes(spec) && step.env?.ORCA_E2E_SSH_DOCKER === '1') + ).toBe(true) + for (const source of [ + 'src/renderer/src/lib/workspace-file-drag-owner.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts', + 'src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts' + ]) { + expect(selectPrE2eSpecs([source]), source).toContain(spec) + } + expect(selectPrE2eSpecs(['src/renderer/src/lib/workspace-file-drag-owner.ts'])).toContain( + 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts' + ) +}) + it('runs actual terminal drop ownership in the template-building Docker job', () => { const spec = 'tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts' expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 0243221daff9..daada7bd1cf9 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -109,6 +109,18 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-explorer-move-owner', + specs: ['tests/e2e/ssh-orcad-explorer-move-owner.spec.ts'], + matches: (file) => + /^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-relay-target)\.ts$/.test( + file + ) || + (isProductSource(file) && + /^src\/renderer\/src\/(?:lib\/workspace-file-drag(?:-source|-owner)?\.ts|components\/right-sidebar\/(?:FileExplorer(?:Row|VirtualRows)\.tsx|useFileExplorer(?:MoveDrop|DragDrop|RowDrag)\.ts|use-file-explorer-tree-pane-state\.ts|file-explorer-operation-owner\.ts))$/.test( + file + )) + }, { id: 'ssh.orcad-terminal-drop-owner', specs: ['tests/e2e/ssh-orcad-terminal-drop-owner.spec.ts'], @@ -117,7 +129,7 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) || (isProductSource(file) && - /^src\/renderer\/src\/(?:lib\/(?:workspace-file-drag(?:-source)?|worktree-runtime-owner)\.ts|components\/(?:right-sidebar\/FileExplorer(?:Row|VirtualRows)\.tsx|terminal-pane\/(?:terminal-drop-(?:handler|internal-rejection-message|target|worktree-path)|TerminalPaneSurface)\.tsx?))$/.test( + /^src\/renderer\/src\/(?:lib\/(?:workspace-file-drag(?:-source|-owner)?|worktree-runtime-owner)\.ts|components\/(?:right-sidebar\/FileExplorer(?:Row|VirtualRows)\.tsx|terminal-pane\/(?:terminal-drop-(?:handler|internal-rejection-message|target|worktree-path)|TerminalPaneSurface)\.tsx?))$/.test( file )) }, diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 6b64cbde135e..787193ab0a8a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -11,6 +11,7 @@ import { type WorkspaceFileDragSource } from '@/lib/workspace-file-drag' import type { GitFileStatus } from '../../../../shared/git-status-types' +import type { FileExplorerMoveDropHandler } from './useFileExplorerMoveDrop' import { STATUS_LABELS } from './status-display' import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing' import type { TreeNode } from './file-explorer-types' @@ -62,7 +63,7 @@ export type FileExplorerRowProps = { onRequestDelete: () => void onCollapseFolderSubtree: () => void onFindInFolder: () => void - onMoveDrop: (sourcePath: string, destDir: string) => void + onMoveDrop: FileExplorerMoveDropHandler onDragTargetChange: (dir: string | null) => void onDragSourceChange: (path: string | null) => void onDragExpandDir: (dirPath: string) => void diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index bb6b48a060cb..96c8f32c6aa1 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -11,6 +11,7 @@ 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 { WorkspaceFileDragSource } from '@/lib/workspace-file-drag' +import type { FileExplorerMoveDropHandler } from './useFileExplorerMoveDrop' type FileExplorerVirtualRowsProps = { virtualizer: Virtualizer @@ -52,7 +53,7 @@ type FileExplorerVirtualRowsProps = { onRequestDelete: (node: TreeNode) => void onCollapseFolderSubtree: (node: TreeNode) => void onFindInFolder: (node: TreeNode) => void - onMoveDrop: (sourcePath: string, destDir: string) => void + onMoveDrop: FileExplorerMoveDropHandler onDragTargetChange: (dir: string | null) => void onDragSourceChange: (path: string | null) => void onDragExpandDir: (dirPath: string) => void diff --git a/src/renderer/src/components/right-sidebar/file-explorer-move-drop-payload.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-move-drop-payload.test.tsx new file mode 100644 index 000000000000..42188c551a89 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-move-drop-payload.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { useRef } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + WORKSPACE_FILE_DRAG_SOURCE_MIME, + WORKSPACE_FILE_PATH_MIME, + WORKSPACE_FILE_PATHS_MIME +} from '@/lib/workspace-file-drag' +import { useFileExplorerDragDrop } from './useFileExplorerDragDrop' +import { useFileExplorerRowDrag } from './useFileExplorerRowDrag' + +const move = vi.hoisted(() => vi.fn()) +vi.mock('./useFileExplorerMoveDrop', () => ({ useFileExplorerMoveDrop: () => move })) +vi.mock('./useFileExplorerDragEdgeScroll', () => ({ + useFileExplorerDragEdgeScroll: () => ({ + startDragEdgeScroll: vi.fn(), + stopDragEdgeScroll: vi.fn() + }) +})) +vi.mock('./useFileExplorerDragExpand', () => ({ + useFileExplorerDragExpand: () => ({ + handleDragExpandDir: vi.fn(), + handleNativeDragExpandDir: vi.fn() + }) +})) + +function Targets() { + const scrollRef = useRef(null) + const root = useFileExplorerDragDrop({ + worktreePath: '/repo', + activeWorktreeId: 'destination', + expanded: new Set(), + toggleDir: vi.fn(), + refreshDir: vi.fn(), + scrollRef, + getOperationOwnerForPath: vi.fn() + }) + const row = useFileExplorerRowDrag({ + rowDropDir: '/repo/dir', + isDirectory: true, + nodePath: '/repo/dir', + isExpanded: true, + onDragTargetChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + onMoveDrop: move + }) + return ( +
+
+ ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('Explorer move drop source payload dispatch', () => { + it.each(['row', 'root'])('forwards the source namespace for every path at the %s', (target) => { + const view = render() + const source = { + version: 1, + executionHostId: 'ssh:target', + workspaceId: 'source', + runtimeEnvironmentId: 'env-a' + } + const data = new Map([ + [WORKSPACE_FILE_PATH_MIME, '/repo/a.txt'], + [WORKSPACE_FILE_PATHS_MIME, JSON.stringify(['/repo/a.txt', '/repo/b.txt'])], + [WORKSPACE_FILE_DRAG_SOURCE_MIME, JSON.stringify(source)] + ]) + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types: [...data.keys()], getData: (type: string) => data.get(type) ?? '' } + }) + act(() => { + view.getByTestId(target).dispatchEvent(event) + }) + const expectedSource = { + executionHostId: 'ssh:target', + workspaceId: 'source', + runtimeEnvironmentId: 'env-a' + } + const destination = target === 'row' ? '/repo/dir' : '/repo' + expect(move.mock.calls).toEqual([ + ['/repo/a.txt', destination, expectedSource], + ['/repo/b.txt', destination, expectedSource] + ]) + }) + + it.each(['row', 'root'])('passes an unknown source to the mutation guard at the %s', (target) => { + const view = render() + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { + types: [WORKSPACE_FILE_PATH_MIME], + getData: (type: string) => + type === WORKSPACE_FILE_PATH_MIME + ? '/repo/a.txt' + : type === WORKSPACE_FILE_DRAG_SOURCE_MIME + ? '{invalid' + : '' + } + }) + act(() => { + view.getByTestId(target).dispatchEvent(event) + }) + expect(move).toHaveBeenCalledWith('/repo/a.txt', target === 'row' ? '/repo/dir' : '/repo', null) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-move-drop-source-owner.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-move-drop-source-owner.test.tsx new file mode 100644 index 000000000000..bac48eceec71 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-move-drop-source-owner.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceFileDragSource } from '@/lib/workspace-file-drag' +import type { FileExplorerOperationOwner } from './file-explorer-types' +import type * as FileExplorerOperationOwnerModule from './file-explorer-operation-owner' +import { useFileExplorerMoveDrop } from './useFileExplorerMoveDrop' + +const mocks = vi.hoisted(() => ({ + move: vi.fn(), + error: vi.fn(), + commit: vi.fn<(operation: { undo: () => Promise; redo: () => Promise }) => void>(), + capture: vi.fn(), + assertCurrent: vi.fn(), + refresh: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: mocks.error } })) +vi.mock('@/lib/execute-open-editor-path-move', () => ({ executeOpenEditorPathMove: mocks.move })) +vi.mock('./fileExplorerUndoRedo', () => ({ commitFileExplorerOp: mocks.commit })) +vi.mock('./file-explorer-operation-owner', async (importOriginal) => ({ + ...(await importOriginal()), + captureFileExplorerOperationGuard: mocks.capture +})) + +const localSource: WorkspaceFileDragSource = { executionHostId: 'local', workspaceId: 'source' } +const runtimeSource: WorkspaceFileDragSource = { + executionHostId: 'runtime:env-a', + workspaceId: 'source' +} +const localOwner = { kind: 'local' } as const +const runtimeOwner = { + kind: 'runtime', + executionHostId: 'runtime:env-a', + environmentId: 'env-a' +} as const + +async function drop( + source: WorkspaceFileDragSource | null, + sourceOwner: FileExplorerOperationOwner = localOwner, + destinationOwner: FileExplorerOperationOwner = sourceOwner, + destination = '/repo/target' +) { + const { result } = renderHook(() => + useFileExplorerMoveDrop({ + worktreePath: '/repo', + activeWorktreeId: 'folder:destination', + refreshDir: mocks.refresh, + getOperationOwnerForPath: (path) => + path === '/repo/file.txt' ? sourceOwner : destinationOwner, + setDropTargetDir: vi.fn() + }) + ) + await act(async () => result.current('/repo/file.txt', destination, source)) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.move.mockResolvedValue(undefined) + mocks.refresh.mockResolvedValue(undefined) + mocks.capture.mockReturnValue({ + route: { settings: { activeRuntimeEnvironmentId: null }, expectedExecutionHostId: 'local' }, + assertCurrent: mocks.assertCurrent + }) +}) +afterEach(cleanup) + +describe('Explorer move drop source ownership', () => { + it.each([ + [runtimeSource, localOwner, localOwner], + [localSource, runtimeOwner, runtimeOwner], + [null, localOwner, localOwner], + [ + { executionHostId: 'runtime:unresolved-owner', workspaceId: 'source' }, + localOwner, + localOwner + ], + [localSource, localOwner, runtimeOwner], + [localSource, localOwner, { kind: 'unresolved' }], + [ + { executionHostId: 'ssh:target', runtimeEnvironmentId: 'env-a', workspaceId: 'source' }, + { kind: 'runtime', executionHostId: 'ssh:target', environmentId: 'env-b' }, + { kind: 'runtime', executionHostId: 'ssh:target', environmentId: 'env-b' } + ] + ] satisfies [ + WorkspaceFileDragSource | null, + FileExplorerOperationOwner, + FileExplorerOperationOwner + ][])( + 'refuses a foreign, unknown, or stale cached owner: %j / %j / %j', + async (source, sourceOwner, destinationOwner) => { + await drop(source, sourceOwner, destinationOwner) + expect(mocks.move).not.toHaveBeenCalled() + expect(mocks.commit).not.toHaveBeenCalled() + expect(mocks.refresh).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalledWith('Move files from the same host as this workspace.') + } + ) + + it.each([ + [localSource, localOwner], + [runtimeSource, runtimeOwner], + [ + { executionHostId: 'ssh:target', runtimeEnvironmentId: 'env-a', workspaceId: 'source' }, + { kind: 'runtime', executionHostId: 'ssh:target', environmentId: 'env-a' } + ] + ] satisfies [WorkspaceFileDragSource, FileExplorerOperationOwner][])( + 'keeps a verified same-host move: %j / %j', + async (source, owner) => { + await drop(source, owner) + expect(mocks.move).toHaveBeenCalledWith( + expect.objectContaining({ fromPath: '/repo/file.txt', toPath: '/repo/target/file.txt' }) + ) + expect(mocks.assertCurrent).toHaveBeenCalled() + expect(mocks.commit).toHaveBeenCalledOnce() + expect(mocks.refresh).toHaveBeenCalledWith('/repo') + expect(mocks.refresh).toHaveBeenCalledWith('/repo/target') + expect(mocks.error).not.toHaveBeenCalled() + } + ) + + it('keeps existing self-drop and descendant protections', async () => { + await drop(localSource, localOwner, localOwner, '/repo') + await drop(localSource, localOwner, localOwner, '/repo/file.txt/child') + expect(mocks.move).not.toHaveBeenCalled() + expect(mocks.commit).not.toHaveBeenCalled() + }) + + it('does not move after the workspace owner changes before mutation', async () => { + mocks.assertCurrent.mockImplementationOnce(() => { + throw new Error('Workspace host changed') + }) + await drop(localSource) + expect(mocks.move).not.toHaveBeenCalled() + expect(mocks.commit).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalledWith('Workspace host changed') + }) + + it('keeps undo and redo on the captured server and checks its generation', async () => { + const route = { + settings: { activeRuntimeEnvironmentId: 'env-a' }, + expectedExecutionHostId: 'ssh:target', + connectionId: 'target', + expectedSshTargetId: 'target', + expectedSshConnectionGeneration: 1 + } + mocks.capture.mockReturnValue({ route, assertCurrent: mocks.assertCurrent }) + await drop( + { executionHostId: 'ssh:target', runtimeEnvironmentId: 'env-a', workspaceId: 'source' }, + { kind: 'runtime', executionHostId: 'ssh:target', environmentId: 'env-a' } + ) + const operation = mocks.commit.mock.calls[0]?.[0] + if (!operation) { + throw new Error('Move did not register undo') + } + await operation.undo() + await operation.redo() + expect(mocks.move).toHaveBeenCalledTimes(3) + expect(mocks.move).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + context: expect.objectContaining(route), + fromPath: '/repo/target/file.txt', + toPath: '/repo/file.txt' + }) + ) + expect(mocks.move).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + context: expect.objectContaining(route), + fromPath: '/repo/file.txt', + toPath: '/repo/target/file.txt' + }) + ) + mocks.assertCurrent.mockImplementationOnce(() => { + throw new Error('SSH generation changed') + }) + await expect(operation.undo()).rejects.toThrow('SSH generation changed') + expect(mocks.move).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts index 2a50566fc84b..507b2480de58 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts @@ -174,7 +174,8 @@ export function useFileExplorerTreePaneState({ toggleDir, refreshDir, scrollRef, - getOperationOwnerForPath: (path) => rowProjection.getRowByPath(path)?.operationOwner + getOperationOwnerForPath: (path) => + rowProjection.getRowByPath(path)?.operationOwner ?? dirCache[path]?.operationOwner }) useFileExplorerTreeLoadEffects({ diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index f892163179d0..d019f2c95ee6 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -4,11 +4,15 @@ import { toast } from 'sonner' import { getWorkspaceFileDragRejectionMessage, readWorkspaceFileDragPaths, + readWorkspaceFileDragSource, WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import type { FileExplorerOperationOwner } from './file-explorer-types' import { useFileExplorerDragEdgeScroll } from './useFileExplorerDragEdgeScroll' -import { useFileExplorerMoveDrop } from './useFileExplorerMoveDrop' +import { + useFileExplorerMoveDrop, + type FileExplorerMoveDropHandler +} from './useFileExplorerMoveDrop' import { useFileExplorerDragExpand } from './useFileExplorerDragExpand' type UseFileExplorerDragDropParams = { @@ -24,7 +28,7 @@ type UseFileExplorerDragDropParams = { } type UseFileExplorerDragDropResult = { - handleMoveDrop: (sourcePath: string, destDir: string) => void + handleMoveDrop: FileExplorerMoveDropHandler handleDragExpandDir: (dirPath: string) => void dropTargetDir: string | null setDropTargetDir: (dir: string | null) => void @@ -195,8 +199,9 @@ export function useFileExplorerDragDrop({ toast.error(getWorkspaceFileDragRejectionMessage(dragPaths.reason)) return } + const source = readWorkspaceFileDragSource(e.dataTransfer) for (const sourcePath of dragPaths.paths) { - handleMoveDrop(sourcePath, displayRootPath) + handleMoveDrop(sourcePath, displayRootPath, source) } } }, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx index 746a70b2bb89..bb66c54ef511 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx @@ -232,7 +232,7 @@ describe('internal explorer row drags under the OS-drop owner', () => { 'move' ) drag(view.getByTestId('row'), 'drop', [WORKSPACE_FILE_PATH_MIME]) - expect(onMoveDrop).toHaveBeenCalledWith('/repo/app/moved.ts', '/repo/app/src') + expect(onMoveDrop).toHaveBeenCalledWith('/repo/app/moved.ts', '/repo/app/src', null) expect(mocks.prepare).not.toHaveBeenCalled() expect(mocks.importPaths).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts index b917353240b6..0f4050adb547 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts @@ -2,9 +2,21 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { basename, dirname, joinPath } from '@/lib/path' import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move' +import type { WorkspaceFileDragSource } from '@/lib/workspace-file-drag' +import { isWorkspaceFileDragSourceOnHost } from '@/lib/workspace-file-drag-owner' +import { translate } from '@/i18n/i18n' import { commitFileExplorerOp } from './fileExplorerUndoRedo' import type { FileExplorerOperationOwner } from './file-explorer-types' -import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner' +import { + captureFileExplorerOperationGuard, + getFileExplorerOperationExecutionHostId +} from './file-explorer-operation-owner' + +export type FileExplorerMoveDropHandler = ( + sourcePath: string, + destDir: string, + source: WorkspaceFileDragSource | null +) => void function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { @@ -28,9 +40,9 @@ export function useFileExplorerMoveDrop({ refreshDir, getOperationOwnerForPath, setDropTargetDir -}: UseFileExplorerMoveDropParams): (sourcePath: string, destDir: string) => void { +}: UseFileExplorerMoveDropParams): FileExplorerMoveDropHandler { return useCallback( - (sourcePath: string, destDir: string) => { + (sourcePath, destDir, source) => { if (!worktreePath || !activeWorktreeId) { return } @@ -52,6 +64,24 @@ export function useFileExplorerMoveDrop({ const newPath = joinPath(destDir, fileName) const operationOwner = getOperationOwnerForPath(sourcePath) + const destinationOwner = getOperationOwnerForPath(destDir) + if ( + ![operationOwner, destinationOwner].every((owner) => + isWorkspaceFileDragSourceOnHost( + source, + getFileExplorerOperationExecutionHostId(owner), + owner?.kind === 'runtime' ? owner.environmentId : null + ) + ) + ) { + toast.error( + translate( + 'auto.components.right.sidebar.useFileExplorerMoveDrop.sourceHostMismatch', + 'Move files from the same host as this workspace.' + ) + ) + return + } const run = async (): Promise => { try { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts b/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts index ce372b118525..f915a0503d69 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts @@ -3,8 +3,10 @@ import { toast } from 'sonner' import { getWorkspaceFileDragRejectionMessage, readWorkspaceFileDragPaths, + readWorkspaceFileDragSource, WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import type { FileExplorerMoveDropHandler } from './useFileExplorerMoveDrop' const DRAG_EXPAND_DELAY_MS = 500 @@ -17,7 +19,7 @@ type UseFileExplorerRowDragParams = { onDragExpandDir: (dirPath: string) => void onNativeDragTargetChange: (dir: string | null) => void onNativeDragExpandDir: (dirPath: string) => void - onMoveDrop: (sourcePath: string, destDir: string) => void + onMoveDrop: FileExplorerMoveDropHandler } type RowDragHandlers = { @@ -170,8 +172,9 @@ export function useFileExplorerRowDrag({ toast.error(getWorkspaceFileDragRejectionMessage(dragPaths.reason)) return } + const source = readWorkspaceFileDragSource(e.dataTransfer) for (const sourcePath of dragPaths.paths) { - onMoveDrop(sourcePath, rowDropDir) + onMoveDrop(sourcePath, rowDropDir, source) } }, [ 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 f933ffe70f6a..fc05df583148 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -1,13 +1,10 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { getConnectionId } from '@/lib/connection-context' -import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId } from '../../../../shared/execution-host' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' -import { - isResolvedWorkspaceFileDragExecutionHost, - readWorkspaceFileDragPaths, - readWorkspaceFileDragSource -} from '@/lib/workspace-file-drag' +import { readWorkspaceFileDragPaths, readWorkspaceFileDragSource } from '@/lib/workspace-file-drag' +import { isWorkspaceFileDragSourceOnHost } from '@/lib/workspace-file-drag-owner' import { captureTerminalDropTarget } from './terminal-drop-target' import { resolveTerminalDropTargetShell } from './terminal-drop-shell' import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' @@ -72,23 +69,8 @@ export async function handleInternalTerminalFileDrop({ 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) - ) { + if (!isWorkspaceFileDragSourceOnHost(source, host?.id, runtimeEnvironmentId)) { toast.error(getTerminalInternalFileDropRejectionMessage('source-host-mismatch')) return { status: 'rejected', reason: 'source-host-mismatch' } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 80be8acba52d..50752e3fc1f2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13828,6 +13828,9 @@ "useFileExplorerKeys": { "8adb953095": "Operation failed" }, + "useFileExplorerMoveDrop": { + "sourceHostMismatch": "Move files from the same host as this workspace." + }, "GitHistoryGraphSvg": { "47eff48230": "HEAD" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index efe663c92b7d..88e6340843be 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13599,6 +13599,9 @@ "useFileExplorerKeys": { "8adb953095": "Operación fallida" }, + "useFileExplorerMoveDrop": { + "sourceHostMismatch": "Mueve archivos del mismo host que este espacio de trabajo." + }, "GitHistoryGraphSvg": { "47eff48230": "CABEZA" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cd76b1535d94..dd9576151c69 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -13704,6 +13704,9 @@ "useFileExplorerKeys": { "8adb953095": "Échec de l'opération" }, + "useFileExplorerMoveDrop": { + "sourceHostMismatch": "Déplacez des fichiers du même hôte que cet espace de travail." + }, "GitHistoryGraphSvg": { "47eff48230": "HEAD" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0bd48e7b9f29..9c4a69eab427 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13599,6 +13599,9 @@ "useFileExplorerKeys": { "8adb953095": "操作が失敗しました" }, + "useFileExplorerMoveDrop": { + "sourceHostMismatch": "このワークスペースと同じホストのファイルを移動してください。" + }, "GitHistoryGraphSvg": { "47eff48230": "HEAD" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 088b0a1a4ada..c94e2a9cc9eb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13599,6 +13599,9 @@ "useFileExplorerKeys": { "8adb953095": "작업 실패" }, + "useFileExplorerMoveDrop": { + "sourceHostMismatch": "이 작업 공간과 같은 호스트의 파일을 이동하세요." + }, "GitHistoryGraphSvg": { "47eff48230": "HEAD" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6605548dd61d..9ba511119b70 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13599,6 +13599,9 @@ "useFileExplorerKeys": { "8adb953095": "操作失败" }, + "useFileExplorerMoveDrop": { + "sourceHostMismatch": "请移动与此工作区位于同一主机上的文件。" + }, "GitHistoryGraphSvg": { "47eff48230": "HEAD" }, diff --git a/src/renderer/src/lib/workspace-file-drag-owner.ts b/src/renderer/src/lib/workspace-file-drag-owner.ts new file mode 100644 index 000000000000..7dd44a58d9fa --- /dev/null +++ b/src/renderer/src/lib/workspace-file-drag-owner.ts @@ -0,0 +1,36 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { + isResolvedWorkspaceFileDragExecutionHost, + type WorkspaceFileDragSource +} from './workspace-file-drag' + +export function isWorkspaceFileDragSourceOnHost( + source: WorkspaceFileDragSource | null, + executionHostId: ExecutionHostId | null | undefined, + runtimeEnvironmentId: string | null +): boolean { + const targetHost = parseExecutionHostId(executionHostId) + if (!source || !targetHost || !isResolvedWorkspaceFileDragExecutionHost(source.executionHostId)) { + return false + } + const sourceHost = parseExecutionHostId(source.executionHostId) + const sourceEnvironmentId = + source.runtimeEnvironmentId ?? + (sourceHost?.kind === 'runtime' ? sourceHost.environmentId : null) + const targetEnvironmentId = + runtimeEnvironmentId ?? (targetHost.kind === 'runtime' ? targetHost.environmentId : null) + // A paired host's local pane belongs to that host, not this desktop. + const targetHostId = + targetHost.kind === 'local' && targetEnvironmentId + ? toRuntimeExecutionHostId(targetEnvironmentId) + : targetHost.id + return ( + source.executionHostId === targetHostId && + sourceEnvironmentId === targetEnvironmentId && + (targetHost.kind !== 'runtime' || targetEnvironmentId === targetHost.environmentId) + ) +} diff --git a/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts b/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts new file mode 100644 index 000000000000..95eb66aeddee --- /dev/null +++ b/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts @@ -0,0 +1,273 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { startOrcadConvertHost } from './helpers/orcad-convert-host' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' +import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' +import { waitForSessionReady } from './helpers/store' +import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' +import { openFileExplorer } from './helpers/file-explorer' +import { shellQuote } from './helpers/docker-ssh-relay-target' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs owned Docker host') +test.skip(process.platform === 'win32', 'Owned POSIX host collision fixture') + +async function readManagedFile( + page: Page, + environmentId: string, + worktreeId: string, + relativePath: string +) { + return page.evaluate( + async ({ environmentId, worktreeId, relativePath }) => + JSON.stringify( + await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'files.read', + params: { worktree: `id:${worktreeId}`, relativePath }, + timeoutMs: 120_000 + }) + ), + { environmentId, worktreeId, relativePath } + ) +} + +async function refreshExplorer(page: Page) { + const refresh = page.getByRole('button', { name: 'Refresh Explorer', exact: true }) + await refresh.click() + await expect(refresh).toBeEnabled() +} + +async function sameHostMove( + page: Page, + fileName: string, + sourceDir: string, + targetName: string | null +) { + const source = page + .locator(`[data-file-explorer-row][data-file-explorer-drop-dir="${sourceDir}"]`) + .filter({ hasText: fileName }) + await expect(source).toBeVisible() + const destination = targetName + ? page.locator('[data-file-explorer-row]').filter({ hasText: targetName }) + : page.locator('[data-orca-explorer-shell] [data-os-file-drop-owner]') + await expect(destination).toBeVisible() + const transfer = await page.evaluateHandle(() => new DataTransfer()) + try { + await source.evaluate((element, dataTransfer) => { + element.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer })) + }, transfer) + const owner = await transfer.evaluate((dataTransfer) => + JSON.parse(dataTransfer.getData('application/x-orca-workspace-file-source')) + ) + await destination.evaluate((element, dataTransfer) => { + element.dispatchEvent( + new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }) + ) + document.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer })) + }, transfer) + return owner + } finally { + await transfer.dispose() + } +} + +for (const target of ['directory', 'root'] as const) { + test(`managed Files ${target} move drop cannot move an unrelated desktop file`, async ({ + testRepoPath + }, testInfo) => { + test.setTimeout(4 * 60_000) + const fileName = `MOVE_${target.toUpperCase()}_OWNER.txt` + const relativePath = target === 'root' ? `MOVE_SOURCE/${fileName}` : fileName + mkdirSync(path.join(testRepoPath, 'MOVE_SOURCE'), { recursive: true }) + const filePath = path.join(testRepoPath, relativePath) + writeFileSync(filePath, 'DESKTOP_MOVE_OWNER') + mkdirSync(path.join(testRepoPath, 'MOVE_DESTINATION'), { recursive: true }) + const host = startOrcadConvertHost('docker', testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + let app: ElectronApplication | null = null + try { + if (!host.exec) { + throw new Error('Missing owned host control') + } + host.exec( + `mkdir -p ${shellQuote(path.dirname(testRepoPath))} && git clone --quiet ${shellQuote(host.remoteRepoPath)} ${shellQuote(testRepoPath)} && mkdir -p ${shellQuote(path.dirname(filePath))} && printf '%s' 'REMOTE_MOVE_OWNER' > ${shellQuote(filePath)}` + ) + host.exec( + `printf '%s' REMOTE_DROP_READY > ${shellQuote(path.join(testRepoPath, 'REMOTE_DROP_READY.txt'))}` + ) + host.exec(`mkdir -p ${shellQuote(path.join(testRepoPath, 'DROP_SOURCE_DIRECTORY'))}`) + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + const nativeId = await first.page.evaluate(async (repoPath) => { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + return `${result.repo.id}::${repoPath}` + }, testRepoPath) + await session.close(app) + app = null + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: testRepoPath, + folderPath: host.remoteFolderPath + }) + const launched = await session.launch() + app = launched.app + const page = launched.page + await waitForSessionReady(page) + await convertAndRetain(page, session.userDataDir, seeded) + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId + ) + if (!environment) { + throw new Error('Missing actual managed environment') + } + await dismissTransientAnnouncement(page) + const nativeIdentity = getWorktreeHostIdentity({ id: nativeId, hostId: 'local' }) + await page.locator(`[data-worktree-host-identity="${nativeIdentity}"]:visible`).click() + await openFileExplorer(page) + const remoteIdentity = getWorktreeHostIdentity({ + id: seeded.worktreeId, + hostId: toRuntimeExecutionHostId(environment.id) + }) + await page.locator(`[data-worktree-host-identity="${remoteIdentity}"]:visible`).click() + await openFileExplorer(page) + await refreshExplorer(page) + if (target === 'root') { + await page.locator('[data-file-explorer-row]').filter({ hasText: 'MOVE_SOURCE' }).click() + } + const source = page.locator('[data-file-explorer-row]').filter({ hasText: fileName }) + await expect(source).toBeVisible() + const transfer = await page.evaluateHandle(() => new DataTransfer()) + await source.evaluate((element, dataTransfer) => { + element.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer })) + }, transfer) + const payload = await transfer.evaluate((dataTransfer) => + Object.fromEntries( + [...dataTransfer.types].map((type) => [type, dataTransfer.getData(type)]) + ) + ) + console.log('[actual-managed-move-source]', payload) + expect(JSON.parse(payload['application/x-orca-workspace-file-source'] ?? '{}')).toMatchObject( + { + executionHostId: toRuntimeExecutionHostId(environment.id), + workspaceId: seeded.worktreeId + } + ) + console.log( + '[independent-managed-source-before]', + await serverCall(page, environment.id, 'files.read', { + worktree: `id:${seeded.worktreeId}`, + relativePath + }) + ) + await page.locator(`[data-worktree-host-identity="${nativeIdentity}"]:visible`).click() + await openFileExplorer(page) + await refreshExplorer(page) + if (target === 'root') { + await page.locator('[data-file-explorer-row]').filter({ hasText: 'MOVE_SOURCE' }).click() + } + await expect( + page.locator('[data-file-explorer-row]').filter({ hasText: fileName }) + ).toBeVisible() + const destination = + target === 'root' + ? page.locator('[data-orca-explorer-shell] [data-os-file-drop-owner]') + : page.locator('[data-file-explorer-row]').filter({ hasText: 'MOVE_DESTINATION' }) + await expect(destination).toBeVisible() + await destination.evaluate((element, dataTransfer) => { + element.dispatchEvent( + new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }) + ) + document.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer })) + }, transfer) + await transfer.dispose() + const refusal = page.getByText('Move files from the same host as this workspace.', { + exact: true + }) + await expect(refusal).toBeVisible() + const destinationPath = + target === 'root' ? testRepoPath : path.join(testRepoPath, 'MOVE_DESTINATION') + const moved = path.join(destinationPath, fileName) + console.log('[actual-desktop-move-result]', { + sourceExists: existsSync(filePath), + destinationExists: existsSync(moved), + destinationContent: existsSync(moved) ? readFileSync(moved, 'utf8') : null + }) + console.log( + '[independent-managed-source-after]', + await serverCall(page, environment.id, 'files.read', { + worktree: `id:${seeded.worktreeId}`, + relativePath + }) + ) + const refusalToast = page.locator('[data-sonner-toast]').filter({ has: refusal }) + await expect(refusalToast).toHaveAttribute('data-mounted', 'true') + await refusalToast.evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)) + }) + await page.screenshot({ path: testInfo.outputPath('managed-explorer-move-owner.png') }) + expect(existsSync(filePath)).toBe(true) + expect(existsSync(moved)).toBe(false) + expect(readFileSync(filePath, 'utf8')).toBe('DESKTOP_MOVE_OWNER') + expect( + await sameHostMove( + page, + fileName, + path.dirname(filePath), + target === 'root' ? null : 'MOVE_DESTINATION' + ) + ).toMatchObject({ + executionHostId: 'local', + workspaceId: nativeId + }) + await expect.poll(() => existsSync(moved)).toBe(true) + expect(readFileSync(moved, 'utf8')).toBe('DESKTOP_MOVE_OWNER') + expect(existsSync(filePath)).toBe(false) + await page.locator(`[data-worktree-host-identity="${remoteIdentity}"]:visible`).click() + await openFileExplorer(page) + await refreshExplorer(page) + if (target === 'root') { + const child = page.locator('[data-file-explorer-row]').filter({ hasText: fileName }) + if (!(await child.count())) { + await page.locator('[data-file-explorer-row]').filter({ hasText: 'MOVE_SOURCE' }).click() + } + } + expect( + await sameHostMove( + page, + fileName, + path.dirname(filePath), + target === 'root' ? null : 'DROP_SOURCE_DIRECTORY' + ) + ).toMatchObject({ + executionHostId: toRuntimeExecutionHostId(environment.id), + workspaceId: seeded.worktreeId + }) + const managedDestination = target === 'root' ? fileName : `DROP_SOURCE_DIRECTORY/${fileName}` + await expect + .poll(() => readManagedFile(page, environment.id, seeded.worktreeId, managedDestination)) + .toContain('REMOTE_MOVE_OWNER') + const missingOriginal = JSON.parse( + await readManagedFile(page, environment.id, seeded.worktreeId, relativePath) + ) + expect(missingOriginal.ok).toBe(false) + } finally { + try { + if (app) { + await session.close(app) + } + await session.dispose() + } finally { + host.cleanup() + } + } + }) +} From 39f779916c32d0b03c0a350ba4363edb5ea9b1d9 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 04:05:02 -0700 Subject: [PATCH 7/7] Keep Explorer refusal proof stable when toast animations cancel --- tests/e2e/ssh-orcad-explorer-move-owner.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts b/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts index 95eb66aeddee..0c34afec33fd 100644 --- a/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts +++ b/tests/e2e/ssh-orcad-explorer-move-owner.spec.ts @@ -211,8 +211,9 @@ for (const target of ['directory', 'root'] as const) { const refusalToast = page.locator('[data-sonner-toast]').filter({ has: refusal }) await expect(refusalToast).toHaveAttribute('data-mounted', 'true') await refusalToast.evaluate(async (element) => { - await Promise.all(element.getAnimations().map((animation) => animation.finished)) + await Promise.allSettled(element.getAnimations().map((animation) => animation.finished)) }) + await expect(refusalToast).toHaveCSS('opacity', '1') await page.screenshot({ path: testInfo.outputPath('managed-explorer-move-owner.png') }) expect(existsSync(filePath)).toBe(true) expect(existsSync(moved)).toBe(false)