Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
14 changes: 9 additions & 5 deletions src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import {
encodeWorkspaceFilePaths,
WORKSPACE_FILE_PATH_MIME,
WORKSPACE_FILE_PATHS_MIME,
writeWorkspaceFileDragSourceIfResolved
writeWorkspaceFileDragSourceIfResolved,
type WorkspaceFileDragSource
} from '@/lib/workspace-file-drag'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { GitFileStatus } from '../../../../shared/git-status-types'
import { STATUS_LABELS } from './status-display'
import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing'
Expand Down Expand Up @@ -48,7 +48,9 @@ export type FileExplorerRowProps = {
connectionId?: string | null
sourceWorkspaceId?: string | null
/** Resolved at dragstart so the virtualized list pays nothing per render. */
resolveDragSourceHostId?: (paths: readonly string[]) => ExecutionHostId | null
resolveDragSourceOwner?: (
paths: readonly string[]
) => Omit<WorkspaceFileDragSource, 'workspaceId'> | null
runtimeDownloadContext?: RuntimeFileOperationArgs | null
supportsFolderDownload?: boolean
canOpenInOrcaBrowser: boolean
Expand Down Expand Up @@ -94,7 +96,7 @@ export function FileExplorerRow({
deleteShortcutLabel,
connectionId,
sourceWorkspaceId,
resolveDragSourceHostId,
resolveDragSourceOwner,
runtimeDownloadContext,
supportsFolderDownload = false,
canOpenInOrcaBrowser,
Expand Down Expand Up @@ -176,10 +178,12 @@ export function FileExplorerRow({
if (paths.length > 1) {
event.dataTransfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths))
}
const sourceOwner = resolveDragSourceOwner?.(paths)
writeWorkspaceFileDragSourceIfResolved(
event.dataTransfer,
sourceWorkspaceId,
resolveDragSourceHostId?.(paths)
sourceOwner?.executionHostId,
sourceOwner?.runtimeEnvironmentId
)
event.dataTransfer.effectAllowed = 'copyMove'
onDragSourceChange(node.path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement, Element>
Expand Down Expand Up @@ -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<string, DirCache> | undefined,
paths: readonly string[]
): ExecutionHostId | null {
let sourceExecutionHostId: ExecutionHostId | null = null
): Omit<WorkspaceFileDragSource, 'workspaceId'> | null {
let sourceOwner: Omit<WorkspaceFileDragSource, 'workspaceId'> | 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. */
Expand Down Expand Up @@ -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 (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
Expand Down Expand Up @@ -237,7 +242,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
deleteShortcutLabel={deleteShortcutLabel}
connectionId={connectionId}
sourceWorkspaceId={sourceWorkspaceId}
resolveDragSourceHostId={resolveDragSourceHostId}
resolveDragSourceOwner={resolveSourceOwner}
runtimeDownloadContext={runtimeDownloadContext}
supportsFolderDownload={supportsFolderDownload}
canOpenInOrcaBrowser={canOpenInOrcaBrowser(n.path)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
}
})
Expand Down
33 changes: 28 additions & 5 deletions src/renderer/src/components/terminal-pane/terminal-drop-handler.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading