Fix ownership for terminal and chat file drops (STA-6940, PR 1/6) (#25749)

* Fix terminal and chat file drop destination ownership

* fix runtime terminal drop ownership and queued chat retries
This commit is contained in:
Brennan Benson
2026-10-06 16:15:06 -07:00
committed by GitHub
parent deb737478a
commit 5345ba34bf
22 changed files with 1296 additions and 239 deletions
@@ -32,7 +32,7 @@ const intake = vi.hoisted(() => ({
readFile: vi.fn(),
upload: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ tabsByWorktree: {} }) } }))
// Keeps the real notice strings so the silent-failure guards assert what users see.
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
@@ -0,0 +1,214 @@
// @vitest-environment happy-dom
import { act, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
import {
clearNativeChatAttachmentCacheForTests,
useNativeChatComposerAttachments
} from './use-native-chat-composer-attachments'
const mocks = vi.hoisted(() => {
const state: {
owner: NativeChatAttachmentOwner
remoteTarget: boolean
stat: ReturnType<typeof vi.fn>
upload: ReturnType<typeof vi.fn>
tabsByWorktree: Record<string, { id: string }[]>
} = {
owner: { kind: 'local' },
remoteTarget: false,
stat: vi.fn(),
upload: vi.fn(),
tabsByWorktree: { 'workspace-1': [{ id: 'tab-1' }] }
}
return state
})
vi.mock('@/store', () => ({
useAppStore: { getState: () => ({ tabsByWorktree: mocks.tabsByWorktree }) }
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: () => mocks.remoteTarget
}))
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: () => mocks.owner,
resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner,
uploadNativeChatAttachmentPaths: mocks.upload
}))
type Api = ReturnType<typeof useNativeChatExternalAttachments> &
ReturnType<typeof useNativeChatComposerAttachments>
let api: Api | undefined
let root: Root
let container: HTMLDivElement
let composing = true
function Probe({ worktreeId }: { worktreeId?: string }): React.JSX.Element {
const [draft, setDraft] = useState('')
const [caret, setCaret] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const attachments = useNativeChatComposerAttachments({
attachmentScopeKey: 'drop-test',
caret,
disabled: false,
isComposing: () => composing,
resolveTarget: () => ({ ptyId: 'pty-1', settings: { activeRuntimeEnvironmentId: null } }),
textareaRef,
setCaret,
setDraft,
setNotice
})
const external = useNativeChatExternalAttachments({
terminalTabId: 'tab-1',
structuredWorktreeId: worktreeId,
disabled: false,
attachResolvedPaths: attachments.attachResolvedPaths,
setNotice
})
api = { ...attachments, ...external }
return (
<>
<textarea ref={textareaRef} />
<output data-draft>{draft}</output>
<output data-notice>{notice}</output>
</>
)
}
function latest(): Api {
if (!api) {
throw new Error('Attachment probe did not render')
}
return api
}
async function render(worktreeId?: string): Promise<void> {
await act(async () => root.render(<Probe worktreeId={worktreeId} />))
}
beforeEach(() => {
api = undefined
composing = true
mocks.owner = { kind: 'local' }
mocks.remoteTarget = false
mocks.stat.mockReset().mockResolvedValue(undefined)
mocks.upload.mockReset().mockResolvedValue(['/remote/file.txt'])
mocks.tabsByWorktree = { 'workspace-1': [{ id: 'tab-1' }] }
vi.stubGlobal('api', { fs: { stat: mocks.stat } })
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
clearNativeChatAttachmentCacheForTests()
vi.unstubAllGlobals()
})
describe('OS attachment destination through input-method composition', () => {
it.each(['workspace', 'host', 'ssh-generation', 'terminal-workspace'])(
'refuses queued files after the %s changes',
async (change) => {
if (change === 'ssh-generation') {
mocks.owner = {
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
}
}
await render(change === 'terminal-workspace' ? undefined : 'workspace-1')
await act(async () => latest().attachExternalPaths(['/local/file.txt', '/local/image.png']))
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
if (change === 'workspace') {
await render('workspace-2')
}
if (change === 'host') {
mocks.owner = { kind: 'runtime' }
}
if (change === 'ssh-generation' && mocks.owner.kind === 'ssh') {
mocks.owner = { ...mocks.owner, expectedSshConnectionGeneration: 2 }
}
if (change === 'terminal-workspace') {
mocks.tabsByWorktree = { 'workspace-1': [] }
}
composing = false
act(() => latest().flushPendingAttachments())
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
expect(latest().imageAttachments).toEqual([])
expect(container.querySelector('[data-notice]')?.textContent).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
}
)
it('attaches an unchanged queued OS destination normally', async () => {
await render('workspace-1')
await act(async () => latest().attachExternalPaths(['/local/file.txt']))
composing = false
act(() => latest().flushPendingAttachments())
expect(container.querySelector('[data-draft]')?.textContent).toBe('@/local/file.txt ')
})
it('refuses an obsolete SSH drop and attaches the retry once during the same composition', async () => {
mocks.owner = {
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
}
mocks.upload
.mockResolvedValueOnce(['/remote/a.txt', '/remote/a.png'])
.mockResolvedValueOnce(['/remote/b.txt', '/remote/b.png'])
await render('workspace-1')
await act(async () => latest().attachExternalPaths(['/local/a.txt', '/local/a.png']))
mocks.owner = { ...mocks.owner, expectedSshConnectionGeneration: 2 }
await act(async () => latest().attachExternalPaths(['/local/b.txt', '/local/b.png']))
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
composing = false
act(() => {
latest().flushPendingAttachments()
latest().flushPendingAttachments()
})
expect(container.querySelector('[data-draft]')?.textContent).toBe('@/remote/b.txt ')
expect(latest().imageAttachments).toEqual([
expect.objectContaining({ path: '/remote/b.png', connectionId: 'ssh-1' })
])
expect(container.querySelector('[data-notice]')?.textContent).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
it.each([true, false])(
'keeps the workspace-file exemption off OS drops (queued=$queued)',
async (queued) => {
composing = queued
await render('workspace-1')
if (!queued) {
mocks.remoteTarget = true
}
await act(async () => latest().attachExternalPaths(['/local/file.txt', '/local/image.png']))
if (queued) {
mocks.remoteTarget = true
composing = false
act(() => latest().flushPendingAttachments())
}
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
expect(latest().imageAttachments).toEqual([])
expect(container.querySelector('[data-notice]')?.textContent).toBe(
'Local attachments are not available for remote sessions.'
)
}
)
})
@@ -2,6 +2,8 @@ import { translate } from '@/i18n/i18n'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
export type NativeChatResolvedPathOptions = {
/** Revalidates the destination without granting workspace-file provenance. */
destinationIsCurrent?: () => boolean
/** Revalidates internal path ownership when an IME-delayed attachment is applied. */
targetOwnerIsCurrent?: () => boolean
}
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
const mocks = vi.hoisted(() => ({
storeState: { tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] } },
stat: vi.fn(),
resolveNativeChatAttachmentOwner: vi.fn(),
resolveNativeChatAttachmentOwnerForWorktree: vi.fn(),
@@ -12,7 +13,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/store', () => ({
useAppStore: { getState: () => ({}) }
useAppStore: { getState: () => mocks.storeState }
}))
// Real notice strings, so the tests below assert what a user would actually read
@@ -140,7 +141,9 @@ describe('useNativeChatExternalAttachments', () => {
filePath: '/local/a.txt',
access: { kind: 'user-file' }
})
expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'])
expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'], undefined, {
destinationIsCurrent: expect.any(Function)
})
expect(mocks.uploadNativeChatAttachmentPaths).not.toHaveBeenCalled()
})
@@ -181,10 +184,11 @@ describe('useNativeChatExternalAttachments', () => {
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(mocks.stat).toHaveBeenCalledTimes(1)
await act(async () => fileCheck.resolve())
expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([
'/external/a.png',
'/external/c.png'
])
expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith(
['/external/a.png', '/external/c.png'],
undefined,
{ destinationIsCurrent: expect.any(Function) }
)
expect(mocks.stat).toHaveBeenCalledTimes(3)
})
@@ -329,7 +333,9 @@ describe('useNativeChatExternalAttachments', () => {
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt'], 'conn-1')
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt'], 'conn-1', {
destinationIsCurrent: expect.any(Function)
})
expect(mocks.stat).not.toHaveBeenCalled()
})
@@ -362,8 +368,12 @@ describe('useNativeChatExternalAttachments', () => {
})
expect(attachResolvedPaths.mock.calls).toEqual([
[['/remote/wt/.orca/drops/b.txt', '/remote/wt/.orca/drops/b.txt'], 'conn-1'],
[['/remote/wt/.orca/drops/a.txt'], 'conn-1']
[
['/remote/wt/.orca/drops/b.txt', '/remote/wt/.orca/drops/b.txt'],
'conn-1',
{ destinationIsCurrent: expect.any(Function) }
],
[['/remote/wt/.orca/drops/a.txt'], 'conn-1', { destinationIsCurrent: expect.any(Function) }]
])
})
@@ -1,7 +1,10 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
import {
nativeChatAttachmentOwnerUnchanged,
type NativeChatResolvedPathOptions
} from './native-chat-resolved-path-ownership'
import {
nativeChatAttachmentOwnerChangedNotice,
nativeChatAttachmentUnreadableNotice,
@@ -14,6 +17,7 @@ import {
type NativeChatAttachmentOwner
} from './native-chat-attachment-upload'
import { userNamedFileAccess } from '@/lib/local-file-access'
import { findTerminalTabWorktreeId } from './native-chat-file-link'
export type UseNativeChatExternalAttachmentsArgs = {
terminalTabId: string
@@ -21,7 +25,11 @@ export type UseNativeChatExternalAttachmentsArgs = {
/** Live composer-disabled state; read at await-resume via a ref so a flip
* mid-upload doesn't attach into a guarded composer. */
disabled: boolean
attachResolvedPaths: (paths: string[], connectionId?: string | null) => void
attachResolvedPaths: (
paths: string[],
connectionId?: string | null,
options?: NativeChatResolvedPathOptions
) => void
setNotice: (notice: string | null) => void
}
@@ -95,10 +103,18 @@ export function useNativeChatExternalAttachments({
// the silent-failure complaint in #15782. Only a disabled composer stays
// quiet — it is being torn down or guarded, and has no notice surface.
const capturedWorkspace = workspaceRef.current
const currentWorktreeId = (): string | null =>
workspaceRef.current.structuredWorktreeId ??
findTerminalTabWorktreeId(
useAppStore.getState().tabsByWorktree,
workspaceRef.current.terminalTabId
)
const capturedWorktreeId = currentWorktreeId()
// Both halves matter: a moved tab can land on a workspace that reports the
// same owner kind, and the owner alone would call that unchanged.
const ownerStillCurrent = (): boolean =>
isSameComposerWorkspace(capturedWorkspace, workspaceRef.current) &&
capturedWorktreeId === currentWorktreeId() &&
nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner())
if (owner.kind !== 'ssh') {
void (async () => {
@@ -129,7 +145,7 @@ export function useNativeChatExternalAttachments({
setNotice(nativeChatAttachmentUnreadableNotice())
return
}
attachResolvedPaths(readablePaths)
attachResolvedPaths(readablePaths, undefined, { destinationIsCurrent: ownerStillCurrent })
})()
return
}
@@ -148,7 +164,9 @@ export function useNativeChatExternalAttachments({
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
}
attachResolvedPaths(remotePaths, owner.connectionId)
attachResolvedPaths(remotePaths, owner.connectionId, {
destinationIsCurrent: ownerStillCurrent
})
})()
},
[attachResolvedPaths, resolveAttachmentOwner, setNotice]
@@ -4,6 +4,7 @@ import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
import { formatNativeChatFileReference } from './native-chat-composer-target'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
import { nativeChatAttachmentOwnerChangedNotice } from './native-chat-attachment-upload'
import {
nativeChatWorkspaceAttachmentMismatchNotice,
type NativeChatResolvedPathOptions
@@ -12,8 +13,7 @@ import {
type ResolvedAttachmentPath = {
path: string
connectionId?: string | null
targetOwnerIsCurrent?: () => boolean
}
} & NativeChatResolvedPathOptions
type Args = {
appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void
@@ -48,14 +48,14 @@ export function useNativeChatResolvedPathAttachments({
disabledRef: RefObject<boolean>
flushPendingAttachments: () => void
} {
const pendingResolvedPathsRef = useRef<ResolvedAttachmentPath[]>([])
const pendingResolvedBatchesRef = useRef<ResolvedAttachmentPath[][]>([])
const pendingPathLimitRejectedRef = useRef(false)
const disabledRef = useRef(disabled)
useLayoutEffect(() => {
disabledRef.current = disabled
if (disabled) {
pendingResolvedPathsRef.current = []
pendingResolvedBatchesRef.current = []
pendingPathLimitRejectedRef.current = false
}
}, [disabled])
@@ -78,15 +78,24 @@ export function useNativeChatResolvedPathAttachments({
[caret, setCaret, setDraft, textareaRef]
)
const applyResolvedPaths = useCallback(
(resolvedPaths: ResolvedAttachmentPath[], focus: boolean, preserveNotice = false) => {
if (resolvedPaths.length === 0) {
return
const resolvedBatchIsCurrent = useCallback(
(resolvedPaths: ResolvedAttachmentPath[]): boolean => {
if (resolvedPaths.some(({ destinationIsCurrent }) => destinationIsCurrent?.() === false)) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return false
}
// A failed ownership verdict refuses the whole completion (see the limit
// rejection below): an ordered batch is never partially applied.
if (resolvedPaths.some(({ targetOwnerIsCurrent }) => targetOwnerIsCurrent?.() === false)) {
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
return false
}
return true
},
[setNotice]
)
const applyResolvedPaths = useCallback(
(resolvedPaths: ResolvedAttachmentPath[], focus: boolean, preserveNotice = false) => {
if (resolvedPaths.length === 0 || !resolvedBatchIsCurrent(resolvedPaths)) {
return
}
// Ownership is per path, so the verdict is too: a queued batch can mix a
@@ -128,6 +137,7 @@ export function useNativeChatResolvedPathAttachments({
attachmentTargetBlocked,
insertFileReferences,
noteAttachmentTargetBlocked,
resolvedBatchIsCurrent,
setNotice,
textareaRef
]
@@ -142,6 +152,10 @@ export function useNativeChatResolvedPathAttachments({
if (paths.length === 0 || disabledRef.current) {
return
}
if (options.destinationIsCurrent?.() === false) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
}
const targetOwnerIsCurrent = options.targetOwnerIsCurrent?.()
if (targetOwnerIsCurrent === false) {
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
@@ -152,7 +166,11 @@ export function useNativeChatResolvedPathAttachments({
return
}
if (isComposing()) {
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS - pendingResolvedPathsRef.current.length) {
const pendingCount = pendingResolvedBatchesRef.current.reduce(
(count, batch) => count + batch.length,
0
)
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS - pendingCount) {
// Reject the whole completion so ordered path batches are never partially applied.
pendingPathLimitRejectedRef.current = true
setNotice(
@@ -163,11 +181,11 @@ export function useNativeChatResolvedPathAttachments({
)
return
}
pendingResolvedPathsRef.current.push(
...paths.map((path) => ({
pendingResolvedBatchesRef.current.push(
paths.map((path) => ({
path,
connectionId,
targetOwnerIsCurrent: options.targetOwnerIsCurrent
...options
}))
)
return
@@ -176,7 +194,7 @@ export function useNativeChatResolvedPathAttachments({
paths.map((path) => ({
path,
connectionId,
targetOwnerIsCurrent: options.targetOwnerIsCurrent
...options
})),
true
)
@@ -191,15 +209,22 @@ export function useNativeChatResolvedPathAttachments({
)
const flushPendingAttachments = useCallback(() => {
const paths = pendingResolvedPathsRef.current
const preserveNotice = pendingPathLimitRejectedRef.current
pendingResolvedPathsRef.current = []
const batches = pendingResolvedBatchesRef.current
let preserveNotice = pendingPathLimitRejectedRef.current
pendingResolvedBatchesRef.current = []
pendingPathLimitRejectedRef.current = false
if (paths.length === 0 || disabledRef.current) {
if (batches.length === 0 || disabledRef.current) {
return
}
const paths = batches.flatMap((batch) => {
if (resolvedBatchIsCurrent(batch)) {
return batch
}
preserveNotice = true
return []
})
applyResolvedPaths(paths, false, preserveNotice)
}, [applyResolvedPaths])
}, [applyResolvedPaths, resolvedBatchIsCurrent])
return { attachResolvedPaths, disabledRef, flushPendingAttachments }
}
@@ -1,13 +1,17 @@
/**
* @vitest-environment happy-dom
*/
import { act, createRef, type ReactNode, type RefObject } from 'react'
import { act, createRef, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import path from 'node:path'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
import { handleTerminalFileDrop } from './terminal-drop-handler'
import { resolveNativeFileDropPath } from '../../../../shared/native-file-drop'
import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => children,
@@ -22,17 +26,35 @@ vi.mock('@/i18n/i18n', () => ({
fallback
)
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
settings: { activeRuntimeEnvironmentId: null },
repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }],
worktreesByRepo: { repo1: [{ id: 'wt-1', repoId: 'repo1', hostId: 'local', path: '/repo' }] },
detectedWorktreesByRepo: {},
folderWorkspaces: [],
sshConnectionStates: new Map()
})
}
}))
vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: vi.fn() }))
const mounted: { container: HTMLDivElement; root: Root }[] = []
function makePane(id: number): ManagedPane {
const leafId = `leaf-${id}` as ManagedPane['leafId']
const leafId = `00000000-0000-4000-8000-00000000000${id}`
if (!isTerminalLeafId(leafId)) {
throw new Error('Invalid test leaf')
}
return {
id,
leafId,
stablePaneId: leafId,
container: document.createElement('div'),
linkTooltip: document.createElement('div'),
terminal: {} as ManagedPane['terminal'],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Drops only call focus on the test terminal.
terminal: { focus: vi.fn() } as unknown as ManagedPane['terminal'],
fitAddon: {} as ManagedPane['fitAddon'],
searchAddon: {} as ManagedPane['searchAddon'],
serializeAddon: {} as ManagedPane['serializeAddon']
@@ -51,7 +73,8 @@ function renderOverlay({
canContinueAgentSessionInNewSession = false,
onContinueAgentSessionInNewSession = vi.fn(),
renameValue = '',
renamingPaneId = null
renamingPaneId = null,
dropSetup
}: {
paneTitles: Record<number, string>
paneCount?: number
@@ -65,13 +88,19 @@ function renderOverlay({
onContinueAgentSessionInNewSession?: ReturnType<typeof vi.fn>
renameValue?: string
renamingPaneId?: number | null
dropSetup?: {
panes: ManagedPane[]
manager: PaneManager
transports: Map<number, PtyTransport>
activate: (id: number) => void
}
}): {
container: HTMLDivElement
onClosePane: ReturnType<typeof vi.fn>
onRemoveTitle: ReturnType<typeof vi.fn>
onRenameSubmit: ReturnType<typeof vi.fn>
} {
const panes = [makePane(1), makePane(2)].slice(0, paneCount)
const panes = dropSetup?.panes ?? [makePane(1), makePane(2)].slice(0, paneCount)
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
@@ -99,15 +128,15 @@ function renderOverlay({
paneTitleBackground="transparent"
terminalContentVisible
hiddenStartupStyle={{}}
managerRef={{ current: null } as RefObject<PaneManager | null>}
paneTransportsRef={{ current: new Map() } as RefObject<Map<number, PtyTransport>>}
managerRef={{ current: dropSetup?.manager ?? null }}
paneTransportsRef={{ current: dropSetup?.transports ?? new Map<number, PtyTransport>() }}
canContinueAgentSessionInNewSession={canContinueAgentSessionInNewSession}
onContinueAgentSessionInNewSession={
onContinueAgentSessionInNewSession as (pane: ManagedPane) => void
}
onSplitPane={vi.fn()}
onBeginPaneDrag={vi.fn()}
onActivatePaneTitleInteraction={vi.fn()}
onActivatePaneTitleInteraction={dropSetup?.activate ?? vi.fn()}
onPaneTitleContextMenu={vi.fn()}
onStartRename={vi.fn()}
onRemoveTitle={onRemoveTitle as (paneId: number) => void}
@@ -272,3 +301,100 @@ describe('TerminalPaneHeaderOverlay', () => {
)
})
})
function dispatchFileDrag(target: Element, type: 'dragover' | 'drop', internal: boolean): void {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', {
value: {
types: internal ? [WORKSPACE_FILE_PATHS_MIME] : ['Files'],
getData: () => encodeWorkspaceFilePaths(['/repo/file.txt']),
dropEffect: 'none'
}
})
target.dispatchEvent(event)
}
describe('terminal title drop ownership', () => {
it.each([
{ internal: true, dragover: true },
{ internal: true, dragover: false },
{ internal: false, dragover: true },
{ internal: false, dragover: false }
])(
'delivers to pane A while B stays active (internal=$internal, dragover=$dragover)',
async ({ internal, dragover }) => {
const panes = [makePane(1), makePane(2)]
let active = panes[1]
const activate = vi.fn((id: number) => {
active = panes.find((pane) => pane.id === id) ?? active
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The drop handler only reads panes and the active pane from this manager.
const manager = { getPanes: () => panes, getActivePane: () => active } as PaneManager
const sends = [vi.fn(() => true), vi.fn(() => true)]
const transports = new Map<number, PtyTransport>()
panes.forEach((pane, index) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every transport method used by local file drops.
const transport = {
sendInput: sends[index],
getPtyId: () => `pty-${pane.id}`,
isConnected: () => true,
getExecutionHostId: () => 'local'
} as unknown as PtyTransport
transports.set(pane.id, transport)
})
const { container } = renderOverlay({
paneTitles: { 1: 'A', 2: 'B' },
dropSetup: { panes, manager, transports, activate }
})
const title = container.querySelector('.pane-title-bar')
if (!title) {
throw new Error('Title A did not render')
}
const deliveries: Promise<void>[] = []
const legacyCapture = (event: Event): void => {
if (internal) {
return
}
event.preventDefault()
event.stopPropagation()
const entries = event
.composedPath()
.filter((entry): entry is HTMLElement => entry instanceof HTMLElement)
.map((entry) => ({
nativeFileDropTarget: entry.dataset.nativeFileDropTarget,
terminalTabId: entry.dataset.terminalTabId,
terminalPaneLeafId: entry.dataset.terminalPaneLeafId
}))
const resolution = resolveNativeFileDropPath(entries)
if (resolution?.target === 'terminal') {
deliveries.push(
handleTerminalFileDrop({
manager,
paneTransports: transports,
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: '/repo',
data: { paths: ['/repo/file.txt'], ...resolution }
})
)
}
}
document.addEventListener('drop', legacyCapture, true)
try {
await act(async () => {
if (dragover) {
dispatchFileDrag(title, 'dragover', internal)
}
dispatchFileDrag(title, 'drop', internal)
await Promise.all(deliveries)
})
} finally {
document.removeEventListener('drop', legacyCapture, true)
}
expect(sends[0]).toHaveBeenCalledExactlyOnceWith('/repo/file.txt ', 'driving')
expect(sends[1]).not.toHaveBeenCalled()
expect(activate).not.toHaveBeenCalled()
expect(manager.getActivePane()).toBe(panes[1])
}
)
})
@@ -150,6 +150,7 @@ export default function TerminalPaneHeaderOverlay({
className="pane-title-bar"
data-native-file-drop-target="terminal"
data-terminal-tab-id={tabId}
data-terminal-pane-leaf-id={pane.leafId}
data-pane-prevent-terminal-focus=""
{...(isActivePane ? { 'data-active-pane': '' } : {})}
{...(isChromeless ? { 'data-chromeless': '' } : {})}
@@ -158,7 +159,6 @@ export default function TerminalPaneHeaderOverlay({
title || isEditing ? () => onActivatePaneTitleInteraction(pane.id) : undefined
}
onDragOver={(event) => {
onActivatePaneTitleInteraction(pane.id)
if (
event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
@@ -176,7 +176,6 @@ export default function TerminalPaneHeaderOverlay({
}
event.preventDefault()
event.stopPropagation()
onActivatePaneTitleInteraction(pane.id)
const manager = managerRef.current
if (!manager) {
return
@@ -188,7 +187,7 @@ export default function TerminalPaneHeaderOverlay({
tabId,
cwd,
dataTransfer: event.dataTransfer,
dropTarget: event.target
paneLeafId: pane.leafId
})
}}
onContextMenuCapture={(event) => onPaneTitleContextMenu(event, pane.id)}
@@ -1,45 +1,52 @@
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastLoading: vi.fn(() => 'toast-1'),
toastDismiss: vi.fn(),
toastError: vi.fn(),
importExternalPathsToRuntime: vi.fn(),
resolveDroppedPathsForAgent: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
storeState: {
activeRepoId: 'repo1',
activeWorktreeId: 'wt-1',
settings: { activeRuntimeEnvironmentId: 'env-1' as string | null },
projects: [
{
id: 'repo1',
localWindowsRuntimePreference: { kind: 'inherit-global' as const }
}
] as {
id: string
localWindowsRuntimePreference:
| { kind: 'inherit-global' }
| { kind: 'windows-host' }
| { kind: 'wsl'; distro: string | null }
}[],
repos: [
{
id: 'repo1',
connectionId: null as string | null,
path: '/remote/repo',
executionHostId: 'runtime:env-1' as string | null
}
],
worktreesByRepo: {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }]
},
sshConnectionStates: new Map<
string,
{ remotePlatform?: NodeJS.Platform; connectionGeneration?: number }
>()
const mocks = vi.hoisted(() => {
const worktreesByRepo: Record<
string,
{ id: string; repoId: string; path: string; hostId?: string | null }[]
> = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'runtime:env-1' }]
}
}))
return {
toastLoading: vi.fn(() => 'toast-1'),
toastDismiss: vi.fn(),
toastError: vi.fn(),
importExternalPathsToRuntime: vi.fn(),
resolveDroppedPathsForAgent: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
storeState: {
activeRepoId: 'repo1',
activeWorktreeId: 'wt-1',
settings: { activeRuntimeEnvironmentId: 'env-1' as string | null },
projects: [
{
id: 'repo1',
localWindowsRuntimePreference: { kind: 'inherit-global' as const }
}
] as {
id: string
localWindowsRuntimePreference:
| { kind: 'inherit-global' }
| { kind: 'windows-host' }
| { kind: 'wsl'; distro: string | null }
}[],
repos: [
{
id: 'repo1',
connectionId: null as string | null,
path: '/remote/repo',
executionHostId: 'runtime:env-1' as string | null
}
],
worktreesByRepo,
sshConnectionStates: new Map<
string,
{ remotePlatform?: NodeJS.Platform; connectionGeneration?: number }
>()
}
}
})
vi.mock('sonner', () => ({
toast: {
@@ -76,11 +83,14 @@ function createTerminalTransport(
ptyId = 'pty-1',
sendInputAccepted?: ReturnType<typeof vi.fn>
) {
const host = parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)
return {
sendInput,
...(sendInputAccepted ? { sendInputAccepted } : {}),
getPtyId: vi.fn(() => ptyId),
isConnected: vi.fn(() => true)
isConnected: vi.fn(() => true),
getExecutionHostId: () => (host?.kind === 'runtime' ? 'local' : (host?.id ?? 'local')),
getRuntimeEnvironmentId: () => (host?.kind === 'runtime' ? host.environmentId : null)
}
}
@@ -107,7 +117,14 @@ describe('handleTerminalFileDrop', () => {
{ id: 'repo1', connectionId: null, path: '/remote/repo', executionHostId: 'runtime:env-1' }
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/remote/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map()
})
@@ -127,10 +144,7 @@ describe('handleTerminalFileDrop', () => {
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => pane,
getPanes: () => [pane]
}
const manager = { getActivePane: () => pane, getPanes: () => [pane] }
const paneTransports = new Map([[1, createTerminalTransport(sendInput)]])
await handleTerminalFileDrop({
@@ -139,7 +153,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/logo.png'], target: 'terminal' }
data: { paths: ['/Users/me/logo.png'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith(
@@ -184,10 +198,7 @@ describe('handleTerminalFileDrop', () => {
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => pane,
getPanes: () => [pane]
}
const manager = { getActivePane: () => pane, getPanes: () => [pane] }
const transport = createTerminalTransport(sendInput)
transport.getPtyId.mockImplementation(() => ptyId)
@@ -197,7 +208,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/logo.png'], target: 'terminal' }
data: { paths: ['/Users/me/logo.png'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(sendInput).not.toHaveBeenCalled()
@@ -208,7 +219,14 @@ describe('handleTerminalFileDrop', () => {
it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => {
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '//server/share/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '//server/share/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.importExternalPathsToRuntime.mockResolvedValue({
results: [
@@ -236,7 +254,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/logo.png'], target: 'terminal' }
data: { paths: ['/Users/me/logo.png'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith(
@@ -268,6 +286,11 @@ describe('handleTerminalFileDrop', () => {
executionHostId: 'runtime:owner-runtime'
}
]
mocks.storeState.worktreesByRepo = {
repo1: [
{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'runtime:owner-runtime' }
]
}
mocks.importExternalPathsToRuntime.mockResolvedValue({
results: [
{
@@ -294,7 +317,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' }
data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith(
@@ -318,6 +341,9 @@ describe('handleTerminalFileDrop', () => {
mocks.storeState.repos = [
{ id: 'repo1', connectionId: null, path: '/remote/repo', executionHostId: 'local' }
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'local' }]
}
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
@@ -333,7 +359,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' }
data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled()
@@ -358,13 +384,21 @@ describe('handleTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Users\\alice\\repo\\feature' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: 'C:\\Users\\alice\\repo\\feature',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const paneTransports = new Map([[1, createTerminalTransport(sendInput)]])
@@ -379,7 +413,8 @@ describe('handleTerminalFileDrop', () => {
'C:\\Users\\alice\\Desktop\\notes one.txt',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md'
],
target: 'terminal'
target: 'terminal',
paneLeafId: 'leaf-1'
}
})
@@ -402,7 +437,14 @@ describe('handleTerminalFileDrop', () => {
{ id: 'repo1', connectionId: null, path: 'C:\\Users\\alice\\repo', executionHostId: 'local' }
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Users\\alice\\repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: 'C:\\Users\\alice\\repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
const sendInput = vi.fn(() => true)
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } }
@@ -413,7 +455,11 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['C:\\Users\\alice\\Desktop\\Screenshot 1.png'], target: 'terminal' }
data: {
paths: ['C:\\Users\\alice\\Desktop\\Screenshot 1.png'],
target: 'terminal',
paneLeafId: 'leaf-1'
}
})
// Why: the agent runs in Linux, so a Windows-style quote would reach it as a literal.
@@ -428,6 +474,9 @@ describe('handleTerminalFileDrop', () => {
mocks.storeState.repos = [
{ id: 'repo1', connectionId: null, path: '/repo', executionHostId: 'local' }
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/repo', hostId: 'local' }]
}
const sendInput = vi.fn(() => true)
const sendInputAccepted = vi.fn(async () => true)
const focus = vi.fn()
@@ -446,7 +495,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' }
data: { paths: ['/Users/me/spec.pdf'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving')
@@ -461,7 +510,14 @@ describe('handleTerminalFileDrop', () => {
{ id: 'repo1', connectionId: null, path: '/repo', executionHostId: 'local' }
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
const activeSendInput = vi.fn(() => true)
const targetSendInput = vi.fn(() => true)
@@ -532,7 +588,8 @@ describe('handleTerminalFileDrop', () => {
'C:\\Users\\Name\\My Project\\file.txt',
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo\\README.md'
],
target: 'terminal'
target: 'terminal',
paneLeafId: 'leaf-1'
}
})
@@ -590,7 +647,11 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['C:\\Users\\Name\\My Project\\file.txt'], target: 'terminal' }
data: {
paths: ['C:\\Users\\Name\\My Project\\file.txt'],
target: 'terminal',
paneLeafId: 'leaf-1'
}
})
expect(sendInput).not.toHaveBeenCalled()
@@ -609,7 +670,14 @@ describe('handleTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Remote Repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: 'C:\\Remote Repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map([
['ssh-win', { remotePlatform: 'win32', connectionGeneration: 4 }]
@@ -633,7 +701,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['C:\\Users\\Name\\A&B.txt'], target: 'terminal' }
data: { paths: ['C:\\Users\\Name\\A&B.txt'], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({
@@ -660,7 +728,14 @@ describe('handleTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Remote Repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: 'C:\\Remote Repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map([
['ssh-win', { remotePlatform: 'win32', connectionGeneration: 4 }]
@@ -679,7 +754,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/Users/me/Screenshot 1.png'], target: 'terminal' }
data: { paths: ['/Users/me/Screenshot 1.png'], target: 'terminal', paneLeafId: 'leaf-1' }
})
// Why: agents on Windows keep backslashes, so POSIX escaping would corrupt the path.
@@ -700,7 +775,14 @@ describe('handleTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/remote/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map([['ssh-stale', { remotePlatform: 'linux' }]])
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } }
@@ -712,7 +794,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ['/local/a.txt'], target: 'terminal' }
data: { paths: ['/local/a.txt'], target: 'terminal', paneLeafId: 'leaf-1' }
})
).resolves.toBeUndefined()
@@ -733,7 +815,14 @@ describe('handleTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/remote/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map([
['ssh-linux', { remotePlatform: 'linux', connectionGeneration: 5 }]
@@ -757,7 +846,7 @@ describe('handleTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data: { paths: ["/Users/me/it's here.txt"], target: 'terminal' }
data: { paths: ["/Users/me/it's here.txt"], target: 'terminal', paneLeafId: 'leaf-1' }
})
expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving')
@@ -1,8 +1,7 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { useAppStore } from '@/store'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { readWorkspaceFileDragPaths } from '@/lib/workspace-file-drag'
import { captureTerminalDropTarget } from './terminal-drop-target'
@@ -24,6 +23,7 @@ export { handleTerminalFileDrop }
type InternalArgs = Omit<NativeTerminalFileDropArgs, 'data'> & {
dataTransfer: Pick<DataTransfer, 'getData'>
dropTarget?: EventTarget | null
paneLeafId?: string
}
export type InternalTerminalFileDropResult =
@@ -43,7 +43,8 @@ export async function handleInternalTerminalFileDrop({
tabId,
cwd,
dataTransfer,
dropTarget
dropTarget,
paneLeafId
}: InternalArgs): Promise<InternalTerminalFileDropResult> {
const dragPaths = readWorkspaceFileDragPaths(dataTransfer)
if (dragPaths.status === 'rejected') {
@@ -56,7 +57,7 @@ export async function handleInternalTerminalFileDrop({
return { status: 'ignored', reason: 'empty' }
}
const pane = resolveInternalTerminalDropPane(manager, dropTarget)
const pane = resolveInternalTerminalDropPane(manager, dropTarget, paneLeafId)
if (!pane) {
return { status: 'ignored', reason: 'no-pane' }
}
@@ -66,12 +67,18 @@ export async function handleInternalTerminalFileDrop({
}
const dropTargetSnapshot = captureTerminalDropTarget(pane, transport)
const state = useAppStore.getState()
const worktreePath = resolveTerminalDropWorktreePath(worktreeId, cwd) ?? paths[0]
const host = parseExecutionHostId(transport.getExecutionHostId?.())
const runtimeEnvironmentId =
transport.getRuntimeEnvironmentId?.() ?? (host?.kind === 'runtime' ? host.environmentId : null)
const worktreePath = resolveTerminalDropWorktreePath(
worktreeId,
runtimeEnvironmentId ? undefined : cwd,
host?.id,
runtimeEnvironmentId
)
if (!worktreePath) {
return { status: 'ignored', reason: 'worktree-unavailable' }
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
const connectionId = getConnectionId(worktreeId)
if (!runtimeEnvironmentId && connectionId === undefined) {
// Why: unresolved connection metadata means we cannot know whether these
@@ -1,42 +1,50 @@
// @vitest-environment happy-dom
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastLoading: vi.fn(() => 'toast-1'),
toastDismiss: vi.fn(),
toastError: vi.fn(),
importExternalPathsToRuntime: vi.fn(),
resolveDroppedPathsForAgent: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
storeState: {
activeRepoId: 'repo1',
activeWorktreeId: 'wt-1',
settings: { activeRuntimeEnvironmentId: 'env-1' as string | null },
projects: [
{
id: 'repo1',
localWindowsRuntimePreference: { kind: 'inherit-global' as const }
}
] as {
id: string
localWindowsRuntimePreference:
| { kind: 'inherit-global' }
| { kind: 'windows-host' }
| { kind: 'wsl'; distro: string | null }
}[],
repos: [
{
id: 'repo1',
connectionId: null as string | null,
path: '/remote/repo',
executionHostId: 'runtime:env-1' as string | null
}
],
worktreesByRepo: {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }]
},
sshConnectionStates: new Map<string, { remotePlatform?: NodeJS.Platform }>()
const mocks = vi.hoisted(() => {
const worktreesByRepo: Record<
string,
{ id: string; repoId: string; path: string; hostId?: string | null }[]
> = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'runtime:env-1' }]
}
}))
return {
toastLoading: vi.fn(() => 'toast-1'),
toastDismiss: vi.fn(),
toastError: vi.fn(),
importExternalPathsToRuntime: vi.fn(),
resolveDroppedPathsForAgent: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
storeState: {
activeRepoId: 'repo1',
activeWorktreeId: 'wt-1',
settings: { activeRuntimeEnvironmentId: 'env-1' as string | null },
projects: [
{
id: 'repo1',
localWindowsRuntimePreference: { kind: 'inherit-global' as const }
}
] as {
id: string
localWindowsRuntimePreference:
| { kind: 'inherit-global' }
| { kind: 'windows-host' }
| { kind: 'wsl'; distro: string | null }
}[],
repos: [
{
id: 'repo1',
connectionId: null as string | null,
path: '/remote/repo',
executionHostId: 'runtime:env-1' as string | null
}
],
worktreesByRepo,
sshConnectionStates: new Map<string, { remotePlatform?: NodeJS.Platform }>()
}
}
})
vi.mock('sonner', () => ({
toast: {
@@ -73,11 +81,14 @@ function createTerminalTransport(
ptyId = 'pty-1',
sendInputAccepted?: ReturnType<typeof vi.fn>
) {
const host = parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)
return {
sendInput,
...(sendInputAccepted ? { sendInputAccepted } : {}),
getPtyId: vi.fn(() => ptyId),
isConnected: vi.fn(() => true)
isConnected: vi.fn(() => true),
getExecutionHostId: () => (host?.kind === 'runtime' ? 'local' : (host?.id ?? 'local')),
getRuntimeEnvironmentId: () => (host?.kind === 'runtime' ? host.environmentId : null)
}
}
@@ -89,7 +100,14 @@ describe('handleInternalTerminalFileDrop', () => {
{ id: 'repo1', connectionId: null, path: '/repo', executionHostId: 'local' }
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map()
})
@@ -97,9 +115,10 @@ describe('handleInternalTerminalFileDrop', () => {
it('pastes every selected internal file path with shell spacing', async () => {
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const paths = ['/repo/a.ts', '/repo/my file.ts']
@@ -109,6 +128,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(paths) : ''
@@ -148,6 +168,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME
@@ -170,9 +191,10 @@ describe('handleInternalTerminalFileDrop', () => {
const sendInput = vi.fn(() => true)
const sendInputAccepted = vi.fn(async () => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const result = await handleInternalTerminalFileDrop({
@@ -183,6 +205,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/repo/a.ts']) : ''
@@ -199,11 +222,15 @@ describe('handleInternalTerminalFileDrop', () => {
it('does not paste internal paths when connection metadata is not hydrated', async () => {
mocks.storeState.settings = { activeRuntimeEnvironmentId: null }
mocks.storeState.repos = []
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/repo', hostId: '' }]
}
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const result = await handleInternalTerminalFileDrop({
@@ -212,6 +239,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/repo/a.ts']) : ''
@@ -235,13 +263,21 @@ describe('handleInternalTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: 'C:\\repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const result = await handleInternalTerminalFileDrop({
@@ -250,6 +286,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['C:\\repo\\a&b.txt']) : ''
@@ -271,14 +308,22 @@ describe('handleInternalTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Remote Repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: 'C:\\Remote Repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map([['ssh-win', { remotePlatform: 'win32' }]])
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const result = await handleInternalTerminalFileDrop({
@@ -287,6 +332,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME
@@ -312,14 +358,22 @@ describe('handleInternalTerminalFileDrop', () => {
}
]
mocks.storeState.worktreesByRepo = {
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }]
repo1: [
{
id: 'wt-1',
repoId: 'repo1',
path: '/remote/repo',
hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local'
}
]
}
mocks.storeState.sshConnectionStates = new Map([['ssh-linux', { remotePlatform: 'linux' }]])
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const result = await handleInternalTerminalFileDrop({
@@ -328,6 +382,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME
@@ -347,11 +402,7 @@ describe('handleInternalTerminalFileDrop', () => {
const targetSendInput = vi.fn(() => true)
const activeFocus = vi.fn()
const targetFocus = vi.fn()
const dropTarget: EventTarget = {
addEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
removeEventListener: vi.fn()
}
const dropTarget = document.createElement('div')
const activePane = {
id: 1,
leafId: 'leaf-active',
@@ -398,9 +449,10 @@ describe('handleInternalTerminalFileDrop', () => {
it('rejects too many internal paths before writing terminal input', async () => {
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
const manager = {
getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }),
getPanes: () => []
getActivePane: () => pane,
getPanes: () => [pane]
}
const paths = Array.from({ length: 257 }, (_value, index) =>
['/repo/secret-', String(index), '.txt'].join('')
@@ -412,6 +464,7 @@ describe('handleInternalTerminalFileDrop', () => {
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(paths) : ''
@@ -427,3 +480,32 @@ describe('handleInternalTerminalFileDrop', () => {
expect(JSON.stringify(mocks.toastError.mock.calls)).not.toContain('secret-')
})
})
it('refuses an unresolvable workspace instead of using the dropped path as its root', async () => {
mocks.storeState.worktreesByRepo = {}
mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
mocks.storeState.repos = [
{ id: 'repo1', path: '/repo', executionHostId: 'local', connectionId: null }
]
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every manager method used by internal drops.
const manager = { getPanes: () => [pane], getActivePane: () => pane } as never
const result = await handleInternalTerminalFileDrop({
manager,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every transport method used by local drops.
paneTransports: new Map([[1, createTerminalTransport(sendInput)]]) as never,
worktreeId: 'repo1::/repo',
tabId: 'tab-1',
cwd: undefined,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME ? encodeWorkspaceFilePaths(['/other/file.txt']) : ''
}
})
expect(result).toEqual({ status: 'ignored', reason: 'worktree-unavailable' })
expect(sendInput).not.toHaveBeenCalled()
expect(focus).not.toHaveBeenCalled()
})
@@ -0,0 +1,84 @@
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'
const mocks = vi.hoisted(() => ({
state: {
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
repos: [],
worktreesByRepo: {},
detectedWorktreesByRepo: {},
folderWorkspaces: [],
sshConnectionStates: new Map()
},
importPaths: vi.fn(),
resolvePaths: vi.fn(),
recordInput: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/runtime/runtime-file-client', () => ({
importExternalPathsToRuntime: mocks.importPaths
}))
vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: mocks.recordInput }))
afterEach(() => vi.unstubAllGlobals())
describe('local terminals without catalog workspaces', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('window', { api: { fs: { resolveDroppedPathsForAgent: mocks.resolvePaths } } })
})
it.each(['native', 'internal'])(
'preserves %s floating-terminal drops while a runtime is focused',
async (lane) => {
const sendInput = vi.fn(() => true)
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 panes and captures their identity.
const manager = { getPanes: () => [pane], getActivePane: () => pane } as never
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every transport method used by local drops.
const paneTransports = new Map([
[
1,
{
sendInput,
getPtyId: () => 'pty-1',
isConnected: () => true,
getExecutionHostId: () => 'local'
}
]
]) as never
const args = {
manager,
paneTransports,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
tabId: 'floating',
cwd: '/terminal/cwd'
}
if (lane === 'native') {
await handleTerminalFileDrop({
...args,
data: { target: 'terminal', paneLeafId: 'leaf-1', paths: ['/local/file.txt'] }
})
} else {
const result = await handleInternalTerminalFileDrop({
...args,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME
? encodeWorkspaceFilePaths(['/local/file.txt'])
: ''
}
})
expect(result).toEqual({ status: 'pasted', pathCount: 1 })
}
expect(sendInput).toHaveBeenCalledExactlyOnceWith('/local/file.txt ', 'driving')
expect(focus).toHaveBeenCalled()
expect(mocks.importPaths).not.toHaveBeenCalled()
expect(mocks.resolvePaths).not.toHaveBeenCalled()
}
)
})
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import {
resolveInternalTerminalDropPane,
resolveNativeTerminalDropPane
} from './terminal-drop-pane-resolution'
describe('terminal drop pane resolution', () => {
const container = document.createElement('div')
const child = document.createElement('span')
container.append(child)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Resolution only reads leaf identity and DOM containment.
const pane = {
id: 1,
leafId: '00000000-0000-4000-8000-000000000001',
container
} as unknown as ManagedPane
const getActivePane = vi.fn(() => pane)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Resolution only enumerates panes; active-pane access must stay unused.
const manager = { getPanes: () => [pane], getActivePane } as unknown as PaneManager
it('refuses missing or stale native leaves without consulting focus', () => {
expect(resolveNativeTerminalDropPane(manager, undefined)).toBeNull()
expect(resolveNativeTerminalDropPane(manager, 'stale-leaf')).toBeNull()
expect(getActivePane).not.toHaveBeenCalled()
})
it('refuses missing, outside, and stale internal destinations', () => {
expect(resolveInternalTerminalDropPane(manager, undefined)).toBeNull()
expect(resolveInternalTerminalDropPane(manager, document.createElement('div'))).toBeNull()
expect(resolveInternalTerminalDropPane(manager, child, 'stale-leaf')).toBeNull()
expect(getActivePane).not.toHaveBeenCalled()
})
it('resolves internal pane content by DOM containment', () => {
expect(resolveInternalTerminalDropPane(manager, child)).toBe(pane)
})
})
@@ -4,35 +4,29 @@ export function resolveNativeTerminalDropPane(
manager: PaneManager,
paneLeafId: string | undefined
): ManagedPane | null {
const panes = manager.getPanes()
if (paneLeafId) {
const targetedPane = panes.find((pane) => pane.leafId === paneLeafId)
if (targetedPane) {
return targetedPane
}
}
return manager.getActivePane() ?? panes[0] ?? null
return paneLeafId ? (manager.getPanes().find((pane) => pane.leafId === paneLeafId) ?? null) : null
}
export function resolveInternalTerminalDropPane(
manager: PaneManager,
dropTarget: EventTarget | null | undefined
dropTarget: EventTarget | null | undefined,
paneLeafId?: string
): ManagedPane | null {
const panes = manager.getPanes()
if (paneLeafId !== undefined) {
return resolveNativeTerminalDropPane(manager, paneLeafId)
}
if (dropTarget) {
const targetedPane = panes.find((pane) => paneContainsDropTarget(pane, dropTarget))
if (targetedPane) {
return targetedPane
}
}
return manager.getActivePane() ?? panes[0] ?? null
return null
}
function paneContainsDropTarget(pane: ManagedPane, dropTarget: EventTarget): boolean {
try {
// Why: synthetic drag targets are not always DOM Nodes, but browser drops are.
return pane.container.contains(dropTarget as Node)
} catch {
return false
}
return (
typeof Node !== 'undefined' && dropTarget instanceof Node && pane.container.contains(dropTarget)
)
}
@@ -0,0 +1,209 @@
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 { handleInternalTerminalFileDrop, handleTerminalFileDrop } from './terminal-drop-handler'
const mocks = vi.hoisted(() => {
const worktreesByRepo: Record<
string,
{
id: string
repoId: string
path: string
hostId: ExecutionHostId
runtimeOwnerEnvironmentId?: string
}[]
> = {}
const folderWorkspaces: {
id: string
projectGroupId: string
folderPath: string
executionHostId: ExecutionHostId
}[] = []
return {
state: {
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
repos: [],
worktreesByRepo,
detectedWorktreesByRepo: {},
folderWorkspaces,
sshConnectionStates: new Map([['target-1', { connectionGeneration: 99 }]]),
sshStateByEnvironment: new Map([
[
'owner-runtime',
{ connectionStates: new Map([['target-1', { connectionGeneration: 1 }]]) }
]
])
},
importPaths: vi.fn(),
toastError: vi.fn()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/runtime/runtime-file-client', () => ({
importExternalPathsToRuntime: mocks.importPaths
}))
vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: vi.fn() }))
vi.mock('sonner', () => ({
toast: { loading: vi.fn(), dismiss: vi.fn(), error: mocks.toastError, message: vi.fn() }
}))
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
vi.clearAllMocks()
mocks.state.worktreesByRepo = {
local: [{ id: 'wt-1', repoId: 'local', path: 'C:\\wrong-local', hostId: 'local' }],
remote: [
{
id: 'wt-1',
repoId: 'remote',
path: '/owner/workspace',
hostId: 'runtime:owner-runtime',
runtimeOwnerEnvironmentId: 'owner-runtime'
}
]
}
mocks.state.folderWorkspaces = [
{
id: 'notes',
projectGroupId: 'local',
folderPath: 'C:\\wrong-local',
executionHostId: 'local'
},
{
id: 'notes',
projectGroupId: 'remote',
folderPath: '/owner/workspace',
executionHostId: 'runtime:owner-runtime'
}
]
mocks.importPaths.mockResolvedValue({
results: [{ status: 'imported', destPath: '/owner/workspace/.orca/drops/file.txt' }]
})
})
async function drop(lane: string, workspaceId: string, executionHostId: ExecutionHostId) {
const sendInput = vi.fn(() => true)
const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Drops only enumerate this pane and capture its identity.
const manager = { getPanes: () => [pane], getActivePane: () => pane } as never
const transport = {
sendInput,
getPtyId: () => 'runtime-pty-1',
isConnected: () => true,
getExecutionHostId: () => executionHostId,
getRuntimeEnvironmentId: () => 'owner-runtime'
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The transport supplies every method used by these drop handlers.
const paneTransports = new Map([[1, transport]]) as never
const args = {
manager,
paneTransports,
worktreeId: workspaceId,
tabId: 'tab-1',
cwd: '/wrong-cwd'
}
let result: Awaited<ReturnType<typeof handleInternalTerminalFileDrop>> | undefined
if (lane === 'native') {
await handleTerminalFileDrop({
...args,
data: { target: 'terminal', paneLeafId: 'leaf-1', paths: ['/client/file.txt'] }
})
} else {
result = await handleInternalTerminalFileDrop({
...args,
paneLeafId: 'leaf-1',
dataTransfer: {
getData: (type) =>
type === WORKSPACE_FILE_PATHS_MIME
? encodeWorkspaceFilePaths(['/owner/workspace/file with spaces.txt'])
: ''
}
})
}
return { sendInput, result, transport }
}
describe.each(['native', 'internal'])('%s runtime terminal catalog ownership', (lane) => {
it.each(['worktree', 'folder', 'ssh'])(
'resolves a runtime %s without choosing a local duplicate',
async (kind) => {
const executionHostId = kind === 'ssh' ? 'ssh:target-1' : 'local'
if (kind === 'ssh') {
mocks.state.worktreesByRepo.remote[0].hostId = 'ssh:target-1'
mocks.state.worktreesByRepo.local.push({
id: 'wt-1',
repoId: 'local',
path: 'C:\\wrong-direct-ssh',
hostId: 'ssh:target-1'
})
mocks.state.worktreesByRepo.otherRuntime = [
{
id: 'wt-1',
repoId: 'other',
path: 'C:\\wrong-runtime',
hostId: 'ssh:target-1',
runtimeOwnerEnvironmentId: 'other-runtime'
}
]
}
const workspaceId = kind === 'folder' ? 'folder:notes' : 'wt-1'
const { sendInput, result, transport } = await drop(lane, workspaceId, executionHostId)
expect(transport.getExecutionHostId()).toBe(executionHostId)
expect(transport.getRuntimeEnvironmentId()).toBe('owner-runtime')
if (lane === 'native') {
expect(mocks.importPaths).toHaveBeenCalledExactlyOnceWith(
{
settings: { activeRuntimeEnvironmentId: 'owner-runtime' },
worktreeId: workspaceId,
worktreePath: '/owner/workspace',
expectedExecutionHostId: executionHostId,
expectedSshTargetId: kind === 'ssh' ? 'target-1' : undefined,
expectedSshConnectionGeneration: kind === 'ssh' ? 1 : undefined
},
['/client/file.txt'],
'/owner/workspace/.orca/drops',
{ assertCurrent: expect.any(Function) }
)
expect(sendInput).toHaveBeenCalledExactlyOnceWith(
'/owner/workspace/.orca/drops/file.txt ',
'driving'
)
} else {
expect(result).toEqual({ status: 'pasted', pathCount: 1 })
expect(sendInput).toHaveBeenCalledExactlyOnceWith(
"'/owner/workspace/file with spaces.txt' ",
'driving'
)
expect(mocks.importPaths).not.toHaveBeenCalled()
}
expect(mocks.toastError).not.toHaveBeenCalled()
}
)
it.each(['missing-owner', 'wrong-environment', 'wrong-ssh-target'])(
'refuses %s instead of borrowing a catalog root',
async (mismatch) => {
if (mismatch === 'missing-owner') {
delete mocks.state.worktreesByRepo.remote
} else if (mismatch === 'wrong-environment') {
mocks.state.worktreesByRepo.remote[0].hostId = 'ssh:target-1'
mocks.state.worktreesByRepo.remote[0].runtimeOwnerEnvironmentId = 'other-runtime'
} else {
mocks.state.worktreesByRepo.remote[0].hostId = 'ssh:other-target'
}
const { sendInput, result } = await drop(
lane,
'wt-1',
mismatch === 'missing-owner' ? 'local' : 'ssh:target-1'
)
expect(sendInput).not.toHaveBeenCalled()
expect(mocks.importPaths).not.toHaveBeenCalled()
if (lane === 'native') {
expect(mocks.toastError).toHaveBeenCalledWith('Worktree path not available.')
} else {
expect(result).toEqual({ status: 'ignored', reason: 'worktree-unavailable' })
}
}
)
})
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'
import { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner'
const mocks = vi.hoisted(() => ({
state: {
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
sshConnectionStates: new Map([['host-1', { connectionGeneration: 1 }]])
}
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
describe('terminal drop transport owner', () => {
it('uses the receiving host rather than the focused runtime', () => {
const owner = captureTerminalDropTransportOwner({ getExecutionHostId: () => 'local' })
expect(owner?.executionHostId).toBe('local')
expect(owner?.runtimeEnvironmentId).toBeNull()
expect(captureTerminalDropTransportOwner({})).toBeNull()
})
it('captures and rechecks a runtime transport after settings change', () => {
let environmentId = 'owner-runtime'
const owner = captureTerminalDropTransportOwner({
getExecutionHostId: () => 'local',
getRuntimeEnvironmentId: () => environmentId
})
expect(owner?.executionHostId).toBe('local')
expect(owner?.expectedExecutionHostId).toBe('local')
expect(owner?.runtimeEnvironmentId).toBe('owner-runtime')
mocks.state.settings.activeRuntimeEnvironmentId = 'unrelated-runtime'
expect(() => owner?.assertCurrent()).not.toThrow()
environmentId = 'replacement-runtime'
expect(() => owner?.assertCurrent()).toThrow('Terminal upload host changed')
})
it('refuses an SSH attachment after the connection generation changes', () => {
const owner = captureTerminalDropTransportOwner({ getExecutionHostId: () => 'ssh:host-1' })
expect(owner?.expectedExecutionHostId).toBe('ssh:host-1')
mocks.state.sshConnectionStates = new Map([['host-1', { connectionGeneration: 2 }]])
expect(() => owner?.assertCurrent()).toThrow('Terminal upload host changed')
})
})
@@ -0,0 +1,46 @@
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
import { useAppStore } from '@/store'
import type { PtyTransport } from './pty-transport'
export function captureTerminalDropTransportOwner(
transport: Pick<PtyTransport, 'getExecutionHostId' | 'getRuntimeEnvironmentId'>
) {
const host = parseExecutionHostId(transport.getExecutionHostId?.())
if (!host) {
return null
}
const runtimeEnvironmentId =
transport.getRuntimeEnvironmentId?.() ?? (host.kind === 'runtime' ? host.environmentId : null)
const connectionId = host.kind === 'ssh' ? host.targetId : null
const captureExpectation = () =>
connectionId
? captureDirectSshMutationExpectation(
useAppStore.getState(),
connectionId,
runtimeEnvironmentId
)
: { expectedExecutionHostId: 'local' as const }
const expectation = captureExpectation()
const assertCurrent = (): void => {
const current = captureExpectation()
if (
transport.getExecutionHostId?.() !== host.id ||
(transport.getRuntimeEnvironmentId?.() ??
(host.kind === 'runtime' ? host.environmentId : null)) !== runtimeEnvironmentId ||
current.expectedExecutionHostId !== expectation.expectedExecutionHostId ||
('expectedSshConnectionGeneration' in current &&
'expectedSshConnectionGeneration' in expectation &&
current.expectedSshConnectionGeneration !== expectation.expectedSshConnectionGeneration)
) {
throw new Error('Terminal upload host changed; retry the drop.')
}
}
return {
executionHostId: host.id,
runtimeEnvironmentId,
connectionId,
assertCurrent,
...expectation
}
}
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const state: {
detectedWorktreesByRepo: Record<string, never>
folderWorkspaces: { id: string; folderPath: string; executionHostId: string }[]
worktreesByRepo: Record<string, { id: string; path: string; hostId: string }[]>
} = { detectedWorktreesByRepo: {}, folderWorkspaces: [], worktreesByRepo: {} }
return { state }
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
import { resolveTerminalDropWorktreePath } from './terminal-drop-worktree-path'
describe('resolveTerminalDropWorktreePath', () => {
beforeEach(() => {
mocks.state.folderWorkspaces = []
mocks.state.worktreesByRepo = {}
})
it('resolves folder workspaces through their recorded host', () => {
mocks.state.folderWorkspaces = [
{ id: 'notes', folderPath: '/folders/notes', executionHostId: 'ssh:host-1' }
]
expect(resolveTerminalDropWorktreePath('folder:notes', undefined, 'ssh:host-1')).toBe(
'/folders/notes'
)
expect(resolveTerminalDropWorktreePath('folder:notes', undefined, 'local')).toBeNull()
})
it('resolves the receiving host when the same workspace id exists on two hosts', () => {
mocks.state.worktreesByRepo = {
local: [{ id: 'wt-1', path: '/local/repo', hostId: 'local' }],
remote: [{ id: 'wt-1', path: '/remote/repo', hostId: 'runtime:host-1' }]
}
expect(resolveTerminalDropWorktreePath('wt-1', undefined, 'runtime:host-1')).toBe(
'/remote/repo'
)
expect(resolveTerminalDropWorktreePath('wt-1', undefined, 'local')).toBe('/local/repo')
expect(resolveTerminalDropWorktreePath('wt-1', undefined, 'ssh:unknown')).toBeNull()
})
it('preserves a local terminal cwd without granting remote upload roots', () => {
expect(resolveTerminalDropWorktreePath('missing', '/terminal/cwd', 'local')).toBe(
'/terminal/cwd'
)
expect(resolveTerminalDropWorktreePath('missing', '/terminal/cwd', 'ssh:host-1')).toBeNull()
})
it('refuses an unknown workspace or an unknown execution host', () => {
expect(resolveTerminalDropWorktreePath('missing', undefined, 'local')).toBeNull()
mocks.state.worktreesByRepo = {
local: [{ id: 'wt-1', path: '/local/repo', hostId: 'local' }]
}
expect(resolveTerminalDropWorktreePath('wt-1', undefined, undefined)).toBeNull()
})
})
@@ -1,14 +1,42 @@
import { useAppStore } from '@/store'
import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta'
import {
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route'
import { isTerminalDropWindowsPathLike } from './terminal-drop-shell'
export function resolveTerminalDropWorktreePath(
worktreeId: string,
fallbackCwd: string | undefined
fallbackCwd: string | undefined,
executionHostId: ExecutionHostId | null | undefined,
runtimeEnvironmentId?: string | null
): string | null {
const host = parseExecutionHostId(executionHostId)
if (!host) {
return null
}
const environmentId =
runtimeEnvironmentId ?? (host.kind === 'runtime' ? host.environmentId : null)
// A runtime's physical local host is a different catalog namespace from the client's local host.
const catalogHostId = environmentId ? toRuntimeExecutionHostId(environmentId) : host.id
const state = useAppStore.getState()
const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
return worktree?.path ?? fallbackCwd ?? null
const worktree = findKnownWorktreeById(state, worktreeId, catalogHostId)
if (host.kind === 'ssh' && environmentId) {
const resolution = worktree && resolveExactWorktreeRoute(state, worktree)
if (
resolution?.kind !== 'resolved' ||
resolution.route.runtimeEnvironmentId !== environmentId ||
resolution.route.executionHostId !== host.id
) {
return null
}
} else if (environmentId && parseExecutionHostId(worktree?.hostId)?.kind === 'ssh') {
return null
}
return worktree?.path ?? (catalogHostId === 'local' ? fallbackCwd : null) ?? null
}
export function joinRuntimeTerminalDropDir(worktreePath: string): string {
@@ -1,5 +1,4 @@
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
@@ -22,12 +21,11 @@ import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-wri
import { resolveNativeTerminalDropPane } from './terminal-drop-pane-resolution'
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import { showTerminalDropWriteFailure } from './terminal-drop-write-failure'
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
import {
joinRuntimeTerminalDropDir,
resolveTerminalDropWorktreePath
} from './terminal-drop-worktree-path'
import { captureRuntimeTerminalDropOwner } from './terminal-drop-runtime-owner'
import { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner'
export type NativeTerminalFileDropArgs = {
manager: PaneManager
@@ -75,8 +73,13 @@ async function handleNativeTerminalFileDropWithCapturedOwner(
const dropTarget = captureTerminalDropTarget(pane, transport)
const state = useAppStore.getState()
const settings = state.settings
const runtimeOwner = captureRuntimeTerminalDropOwner(worktreeId)
const worktreePath = resolveTerminalDropWorktreePath(worktreeId, cwd)
const owner = captureTerminalDropTransportOwner(transport)
const worktreePath = resolveTerminalDropWorktreePath(
worktreeId,
owner?.runtimeEnvironmentId ? undefined : cwd,
owner?.executionHostId,
owner?.runtimeEnvironmentId
)
if (!worktreePath) {
toast.error(
translate(
@@ -87,7 +90,7 @@ async function handleNativeTerminalFileDropWithCapturedOwner(
return
}
if (runtimeOwner) {
if (owner?.runtimeEnvironmentId) {
await uploadRuntimeDropPaths({
dataPaths: data.paths,
dropTarget,
@@ -98,16 +101,13 @@ async function handleNativeTerminalFileDropWithCapturedOwner(
tabId,
worktreeId,
worktreePath,
...runtimeOwner
...owner,
runtimeEnvironmentId: owner.runtimeEnvironmentId
})
return
}
// Why: `getConnectionId` returns `string` (SSH), `null` (local repo found),
// or `undefined` (store not hydrated / worktree not found). Treat
// `undefined` as an error — otherwise a drop during hydration would
// silently paste local paths into a remote shell.
const connectionId = getConnectionId(worktreeId)
const connectionId = owner?.connectionId
if (connectionId === undefined) {
toast.error(
translate(
@@ -134,6 +134,7 @@ async function handleNativeTerminalFileDropWithCapturedOwner(
manager,
paneTransports,
pane,
assertCurrent: owner?.assertCurrent,
tabId,
targetShell: localWslDrop ? 'posix' : targetShell,
worktreePath
@@ -142,8 +143,8 @@ async function handleNativeTerminalFileDropWithCapturedOwner(
}
await uploadRemoteDropPaths({
...owner,
connectionId,
...captureDirectSshMutationExpectation(state, connectionId),
dataPaths: data.paths,
dropTarget,
manager,
@@ -278,6 +279,7 @@ async function uploadRemoteDropPaths(
async function pasteResolvedDropPaths(
args: NativeDropFlowArgs & { paths: string[]; targetShell: 'posix' | 'windows' }
): Promise<void> {
args.assertCurrent?.()
// Why: pane may have unmounted during upload/resolution (tab closed,
// worktree switched). Re-check before writing so we do not call sendInput
// on a torn-down PTY.
@@ -225,20 +225,13 @@ describe('useTerminalPaneGlobalEffects', () => {
})
})
it('keeps handling legacy terminal file drops without a terminal tab id', () => {
const { onFileDrop, manager, paneTransports } = useMountForFileDrop()
it('refuses terminal file drops without a terminal tab id even in the active tab', () => {
const { onFileDrop } = useMountForFileDrop()
const data = { paths: ['/tmp/image.png'], target: 'terminal' }
onFileDrop(data)
expect(mocks.handleTerminalFileDrop).toHaveBeenCalledWith({
manager,
paneTransports,
worktreeId: 'wt-1',
tabId: 'tab-1',
cwd: undefined,
data
})
expect(mocks.handleTerminalFileDrop).not.toHaveBeenCalled()
})
it('handles terminal file drops for visible unfocused split-group terminals', () => {
@@ -297,22 +297,13 @@ export function useTerminalPaneGlobalEffects({
return () => document.removeEventListener('dictation:insertText', onDictationInsert)
}, [isActiveRef, managerRef, paneTransportsRef, tabId])
// Why: visible but unfocused split-group terminals can still receive native
// OS drops. Route tab-id-aware payloads to the dropped pane, while legacy
// payloads without a tab id keep the old active-terminal-only behavior.
// Why: visible, unfocused terminals receive drops only when the payload names their tab.
useEffect(() => {
if (!isActive && !isVisible) {
return
}
return window.api.ui.onFileDrop((data) => {
if (data.target !== 'terminal') {
return
}
if (data.tabId) {
if (data.tabId !== tabId) {
return
}
} else if (!isActive) {
if (data.target !== 'terminal' || data.tabId !== tabId) {
return
}
const manager = managerRef.current