mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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:
@@ -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>()),
|
||||
|
||||
+214
@@ -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
|
||||
}
|
||||
|
||||
+19
-9
@@ -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]
|
||||
|
||||
+45
-20
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
+209
@@ -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.
|
||||
|
||||
+3
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user