mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(native-chat): support file drag and drop (#20494)
* feat(native-chat): support workspace file drops * fix(native-chat): report OS file drops that attach nothing #15782 is a silent failure on the Finder route, and that route still swallowed every way it could fail: - the preload handler returned with no feedback when the OS handed us file items `webUtils.getPathForFile` could read no path from (promised or virtual files). It now sends the existing `rejected` payload with a new `unresolved-paths` reason, which the global drop toast names. - the composer's external-attach path dropped the batch with no notice when every path failed authorization, when an upload came back empty, and (new in this branch) when the owner changed mid-flight. Each exit now sets a notice; only a disabled composer stays quiet, because it has no notice surface. Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a drop/IME handler when an SSH connection's generation is gone mid-attach — that is an unknown owner, which the resolver already models as `not-ready`. * refactor(native-chat): one owner-identity check for composer attachments The branch had two near-identical "is this still the same owner" helpers, one per attach route, and they disagreed: the workspace-drop copy ignored the SSH connection generation, so a reconnect between the drop and the IME flush read as the same owner and the path landed on a new connection. Collapses both onto one predicate in the pure ownership module (the store/toast-free seam both routes already depend on), which compares the full SSH expectation and never treats `not-ready` as a match. * perf(file-explorer): resolve drag ownership at dragstart, not per render The virtualized row list resolved the selection's source execution host on every render — the virtualizer re-renders on every scroll frame, so a large multi-selection paid a full projection scan plus a route allocation per selected path per frame, and per visible row on top of that. Only `onDragStart` ever read the result. Rows now receive a resolver they call with the paths they are about to drag. The three copies of the "stamp only if both halves resolve" guard (explorer row, both combined-diff row shapes) collapse into one helper next to the writer. * fix(native-chat): refuse a guarded composer drop visibly The drop handlers claimed the drag (preventDefault + stopPropagation) before checking `disabled`, so a guarded composer told the browser it accepted the drop, left the copy cursor up, and then did nothing — the same silent swallow this branch exists to remove. Dragover now answers `none` when the composer is guarded, so the cursor refuses and no drop event follows. It still claims the event either way: the composer sits inside the terminal surface, which accepts the same drag and would paste the paths into the shell instead. Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already keeps the event off the editor below, so the stronger form only risked suppressing unrelated listeners on the React root. The fake DataTransfer in the test now starts at a dropEffect we never write, so asserting `none` or `copy` proves the handler set it. * fix(native-chat): decide attachment ownership per path, not per batch A queued batch can mix sources — a workspace drop the target host owns and a client-local paste it cannot read — because IME composition holds both until it settles. Collapsing the batch to one verdict refused the whole thing on a remote target, including the drop the user was entitled to make. The verdict now follows the path it belongs to: owned paths attach, client-local ones are refused, and the refusal is reported rather than dropped. A stale owner still refuses everything, since that means the target moved under all of them. Also guards the empty-batch case, which previously read as "every path owned". * refactor(combined-diff): resolve drag ownership from the live workspace The combined diff captured an execution host into the open-file record at tab open and drilled it through three components to reach the row. That host was never persisted, so after a restart every drag from a restored diff was refused until the tab was reopened, and the capture failure was swallowed into an undefined source with no trace. Rows now resolve the owner the same way the source-control rows already do, from the workspace the diff belongs to at the moment of the drag. That deletes the prop drilling, the store capture and its bare catch, and leaves one way to answer "who owns these paths" for every live listing. The file explorer keeps its per-node owner: its tree is a cache that can still be showing a previous host's listing, which is exactly what that field records. * revert(file-explorer): drop the workspace-id tree reset Resetting and reloading the tree when the workspace id changes at an unchanged path is not needed for the drag source to be correct. The tree already records the workspace whose root listing it committed, so a cache left over from a previous workspace stamps that workspace and the composer refuses the drop — the intended answer, reached without touching the reset rule. That rule clears selection, the name filter and undo history, which is more file-explorer behaviour change than this feature asked for. * test(native-chat): stop the external-attach mock hiding new notices The hook's test replaced the whole attachment-owner module with a hand-written stub, so the two notices added alongside the owner-change guards resolved to undefined. Calling them threw inside the async attach loop — an unhandled rejection, which leaves every test in the file reported as passing while the run as a whole fails. CI caught it; a local run reporting only pass/fail counts does not. The mock now spreads the real module, so a notice added later cannot go missing from it, and both owner-change tests assert the string a user would read instead of only asserting that nothing attached. * test(native-chat): guard the last-path owner change on a one-file drop The owner flipping while the final path is authorizing has no next loop iteration to catch it, so the post-loop check is all that stands between a single-file drop and a path attached to a host that no longer owns it — and a one-file drop is the ordinary shape. No test covered that exit. Removing the post-loop check now turns this red; before it, only the multi-path exit was guarded. * fix(native-chat): keep a mixed attachment batch in attach order applyResolvedPaths partitioned a queued batch into a target-owned half and a client-local half and concatenated them. An IME-delayed batch that mixed a workspace drop with a paste made earlier in the same composition was therefore inserted owned-first, so the dropped reference jumped ahead of the pasted one in the draft. Filter against the two verdicts in place instead. Membership is unchanged, the order the user attached in survives, and the two intermediate arrays go away. * fix(file-explorer): name the owner of a dragged path whose row is hidden A multi-selection outlives the rows that showed it. Nothing prunes selectedPaths when a directory collapses, when the name filter narrows, or when dotfiles are hidden, and the drag still carries every selected path. Drag-source resolution read those owners from the row projection, which is built from visible rows only, so one hidden path collapsed the whole drag to an unstamped one and the composer refused it as coming from another workspace. The owner was never unknowable — the dir cache the projection is built from still records which host listed that path. Fall back to it when the path has no visible row. A path in neither (a name-filter synthetic node for a directory that was never listed) still fails closed. * fix(native-chat): ask which workspace the composer serves now The IME-flush ownership check compared the workspace id captured when the drop happened against the same captured value, so for a structured pane the comparison could only ever hold. The live protection came from the host and owner checks beside it; this one asked nothing. Read the id through a ref so the check means what it reads as. A pane whose structured target moves between the drop and the composition settling now refuses the queued path instead of attaching it. * fix(native-chat): ask which workspace an external attach lands on The post-await ownership gate resolved the owner through the render closure, so it re-asked the workspace the attach started in and compared the answer with itself. A tab moved to another workspace mid-authorization passed the gate, and the paths landed in a composer that no longer served that workspace. Read the pane through a ref and compare the workspace identity as well as the owner: two workspaces can both report a local owner, so the owner alone cannot tell them apart. * test(native-chat): read the real notice on a workspace drop The drop tests hand-built their attachment-upload mock and hand-copied the not-ready wording into it, so the assertion tracked the copy rather than the string a user reads: rewording the real notice left all 15 tests green. Spread the real module and override only the owner resolver, matching the two sibling test files in this directory. Rewording the notice now fails the test. * docs(native-chat): restore the hook's doc comment to the hook The workspace comparison landed between the doc block and the function it describes, leaving the comment attached to a type alias. * test(native-chat): cover the upload window for a moved pane The workspace-currency gate guards two windows and only the authorize loop was covered. The upload window is the longer one: the paths go to the worktree the attach captured, so a pane that moved workspaces meanwhile must not receive remote paths living under the workspace it left. * test(native-chat): pin the two untested attachment refusals Refusing an already-blocked target at the drop rather than queueing it had no test: queued paths that can never attach still spend the pending budget, and the next legitimate drop is then turned away for being one too many. Also pins the immediate already-false ownership verdict. Today's only caller settles ownership synchronously so it cannot arrive false, but the hook exports this entry point and the fallback is not a refusal — a false verdict is not "owned", so a remote target blames client-local attachments for an ownership failure. Verified: removing the branch reports the wrong notice. * docs(native-chat): say which rule the ownership refusal follows The per-path comment sat directly above the batch-wide ownership refusal while describing the blocked-target logic below it, so the refusal read as a contradiction of the line under it rather than as the file's stated rule. Name the rule at the refusal: a failed ownership verdict refuses the whole completion, the same way the pending-limit rejection does. --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
241fb9ed9d
commit
5e70014da8
@@ -13,7 +13,8 @@ import {
|
||||
resolveNativeFileDropPath,
|
||||
type NativeDropResolution,
|
||||
type NativeFileDropPayload,
|
||||
type NativeFileDropPathEntry
|
||||
type NativeFileDropPathEntry,
|
||||
type NativeFileDropRejectedPayload
|
||||
} from '../shared/native-file-drop'
|
||||
|
||||
/** Joins the synchronous unload checkpoint with its durable renderer write. */
|
||||
@@ -133,7 +134,18 @@ export function installNativeFileDropHandlers(): void {
|
||||
paths.push(filePath)
|
||||
}
|
||||
}
|
||||
if (paths.length === 0 || resolution?.target === 'rejected') {
|
||||
if (resolution?.target === 'rejected') {
|
||||
return
|
||||
}
|
||||
if (paths.length === 0) {
|
||||
// The OS offered file items we could read no path from (promised or
|
||||
// virtual files). Report it — silence here is #15782.
|
||||
ipcRenderer.send('terminal:file-dropped-from-preload', {
|
||||
byteLength: 0,
|
||||
pathCount: files.length,
|
||||
reason: 'unresolved-paths',
|
||||
target: 'rejected'
|
||||
} satisfies NativeFileDropRejectedPayload)
|
||||
return
|
||||
}
|
||||
const payload = createNativeFileDropPayload(resolution, paths)
|
||||
|
||||
@@ -348,6 +348,7 @@ export default function CombinedDiffViewer({
|
||||
<CombinedDiffFileTree
|
||||
mode={entrySet.treeMode}
|
||||
worktreePath={file.filePath}
|
||||
sourceWorkspaceId={file.worktreeId}
|
||||
entries={entrySet.entries}
|
||||
sectionIndexByKey={treeNavigation.sectionIndexByKey}
|
||||
activeSectionKey={treeNavigation.activeTreeSectionKey}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
|
||||
const testState: { executionHostId: ExecutionHostId } = vi.hoisted(() => ({
|
||||
executionHostId: 'local'
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getExecutionHostIdForWorktree: () => testState.executionHostId
|
||||
}))
|
||||
|
||||
const { CombinedDiffFileTreeRow } = await import('./combined-diff-file-tree-row')
|
||||
const { readWorkspaceFileDragSource } = await import('@/lib/workspace-file-drag')
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const roots: Root[] = []
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => act(() => root.unmount()))
|
||||
document.body.replaceChildren()
|
||||
testState.executionHostId = 'local'
|
||||
})
|
||||
|
||||
function renderRow(sourceWorkspaceId?: string): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
act(() => {
|
||||
root.render(
|
||||
<CombinedDiffFileTreeRow
|
||||
node={{
|
||||
type: 'directory',
|
||||
key: 'dir::unstaged::src',
|
||||
path: 'src',
|
||||
name: 'src',
|
||||
depth: 0,
|
||||
area: 'unstaged',
|
||||
fileCount: 1,
|
||||
children: []
|
||||
}}
|
||||
mode="uncommitted"
|
||||
worktreePath="/repo/worktree"
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
activeSectionKey={null}
|
||||
sectionIndexByKey={new Map()}
|
||||
isCollapsed={false}
|
||||
onToggleDirectory={() => {}}
|
||||
onNavigate={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
function dragRow(container: HTMLDivElement): DataTransfer {
|
||||
const transfer = new DataTransfer()
|
||||
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer })
|
||||
act(() => {
|
||||
container.querySelector('[draggable="true"]')?.dispatchEvent(event)
|
||||
})
|
||||
return transfer
|
||||
}
|
||||
|
||||
describe('combined diff rows stamp their drag source', () => {
|
||||
// The tab's entry list is a snapshot, but the paths it drags belong to the
|
||||
// workspace as it is owned now — the same answer the source-control rows give.
|
||||
it('stamps the live owner of the workspace the diff belongs to', () => {
|
||||
testState.executionHostId = 'runtime:env-1'
|
||||
expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toEqual({
|
||||
executionHostId: 'runtime:env-1',
|
||||
workspaceId: 'wt-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the drag unstamped when the owner or the workspace is unknown', () => {
|
||||
expect(readWorkspaceFileDragSource(dragRow(renderRow(undefined)))).toBeNull()
|
||||
testState.executionHostId = 'runtime:unresolved-owner'
|
||||
expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toBeNull()
|
||||
})
|
||||
})
|
||||
+9
@@ -6,6 +6,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type {
|
||||
GitFileStatus,
|
||||
@@ -35,6 +36,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
|
||||
node,
|
||||
mode,
|
||||
worktreePath,
|
||||
sourceWorkspaceId,
|
||||
activeSectionKey,
|
||||
sectionIndexByKey,
|
||||
isCollapsed,
|
||||
@@ -45,6 +47,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
|
||||
node: CombinedDiffTreeNode
|
||||
mode: CombinedDiffFileTreeMode
|
||||
worktreePath: string
|
||||
sourceWorkspaceId?: string
|
||||
activeSectionKey: string | null
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
isCollapsed: boolean
|
||||
@@ -62,6 +65,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
|
||||
if (sourceWorkspaceId) {
|
||||
writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId)
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
@@ -117,6 +123,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
joinPath(worktreePath, node.entry.path)
|
||||
)
|
||||
if (sourceWorkspaceId) {
|
||||
writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId)
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
onClick={() => onNavigate(node.entry)}
|
||||
|
||||
+3
@@ -19,6 +19,7 @@ export function CombinedDiffFileTreeRows({
|
||||
rows,
|
||||
mode,
|
||||
worktreePath,
|
||||
sourceWorkspaceId,
|
||||
activeSectionKey,
|
||||
sectionIndexByKey,
|
||||
collapsedDirectoryKeys,
|
||||
@@ -30,6 +31,7 @@ export function CombinedDiffFileTreeRows({
|
||||
rows: readonly CombinedDiffTreeNode[]
|
||||
mode: CombinedDiffFileTreeMode
|
||||
worktreePath: string
|
||||
sourceWorkspaceId?: string
|
||||
activeSectionKey: string | null
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
collapsedDirectoryKeys: ReadonlySet<string>
|
||||
@@ -50,6 +52,7 @@ export function CombinedDiffFileTreeRows({
|
||||
node={node}
|
||||
mode={mode}
|
||||
worktreePath={worktreePath}
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
activeSectionKey={activeSectionKey}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
isCollapsed={collapsedDirectoryKeys.has(node.key)}
|
||||
|
||||
+3
@@ -36,6 +36,7 @@ const EMPTY_TREE_ROWS: CombinedDiffTreeNode[] = []
|
||||
export function CombinedDiffFileTree({
|
||||
mode,
|
||||
worktreePath,
|
||||
sourceWorkspaceId,
|
||||
entries,
|
||||
sectionIndexByKey,
|
||||
activeSectionKey,
|
||||
@@ -46,6 +47,7 @@ export function CombinedDiffFileTree({
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
worktreePath: string
|
||||
sourceWorkspaceId?: string
|
||||
entries: readonly CombinedDiffFileTreeEntry[]
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
activeSectionKey: string | null
|
||||
@@ -200,6 +202,7 @@ export function CombinedDiffFileTree({
|
||||
const sharedRowProps = {
|
||||
mode,
|
||||
worktreePath,
|
||||
sourceWorkspaceId,
|
||||
activeSectionKey,
|
||||
sectionIndexByKey,
|
||||
collapsedDirectoryKeys,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-sen
|
||||
import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send'
|
||||
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection'
|
||||
import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop'
|
||||
|
||||
export type {
|
||||
NativeChatComposerHandle,
|
||||
@@ -173,6 +174,13 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
resolvePendingImageAttachment,
|
||||
dropPendingImageAttachment
|
||||
} = attachments
|
||||
const workspaceFileDropHandlers = useNativeChatWorkspaceFileDrop({
|
||||
terminalTabId,
|
||||
structuredWorktreeId: structuredTransport?.worktreeId,
|
||||
disabled,
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
})
|
||||
// A pasted image has no agent-readable path until its save lands; sending
|
||||
// mid-save would ship the message without the image the chip promises.
|
||||
const hasPendingAttachment = imageAttachments.some((attachment) => attachment.pending)
|
||||
@@ -413,6 +421,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
}}
|
||||
onRemoveImageAttachment={(id) => removeImageAttachment(id)}
|
||||
onAttach={pickAttachment}
|
||||
workspaceFileDropHandlers={workspaceFileDropHandlers}
|
||||
onDictationToggle={toggleDictation}
|
||||
onDictationHoldStart={startHoldDictation}
|
||||
onDictationHoldEnd={stopHoldDictation}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
|
||||
import type {
|
||||
ClipboardEventHandler,
|
||||
DragEventHandler,
|
||||
KeyboardEventHandler,
|
||||
RefObject
|
||||
} from 'react'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { ImageOff } from 'lucide-react'
|
||||
import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
|
||||
@@ -48,6 +53,10 @@ export type NativeChatComposerFieldProps = {
|
||||
onAcceptMention: () => void
|
||||
onRemoveImageAttachment: (id: string) => void
|
||||
onAttach: () => void
|
||||
workspaceFileDropHandlers?: {
|
||||
onDragOverCapture: DragEventHandler<HTMLDivElement>
|
||||
onDropCapture: DragEventHandler<HTMLDivElement>
|
||||
}
|
||||
onDictationToggle: () => void
|
||||
onDictationHoldStart: () => void
|
||||
onDictationHoldEnd: () => void
|
||||
@@ -120,6 +129,7 @@ export function NativeChatComposerField({
|
||||
onAcceptMention,
|
||||
onRemoveImageAttachment,
|
||||
onAttach,
|
||||
workspaceFileDropHandlers,
|
||||
onDictationToggle,
|
||||
onDictationHoldStart,
|
||||
onDictationHoldEnd,
|
||||
@@ -185,6 +195,7 @@ export function NativeChatComposerField({
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
{...workspaceFileDropHandlers}
|
||||
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
|
||||
data-composer-scope-key={composerScopeKey}
|
||||
className={cn(
|
||||
|
||||
@@ -140,6 +140,18 @@ describe('resolveNativeChatAttachmentOwner', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports not-ready instead of throwing when the SSH generation is gone', () => {
|
||||
expect(
|
||||
resolveNativeChatAttachmentOwner(
|
||||
state({
|
||||
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
|
||||
sshConnectionStates: new Map()
|
||||
}),
|
||||
'tab-1'
|
||||
)
|
||||
).toEqual({ kind: 'not-ready' })
|
||||
})
|
||||
|
||||
it('reports not-ready when an SSH worktree has no known path yet', () => {
|
||||
expect(
|
||||
resolveNativeChatAttachmentOwner(
|
||||
|
||||
@@ -82,11 +82,17 @@ export function resolveNativeChatAttachmentOwnerForWorktree(
|
||||
if (!worktreePath) {
|
||||
return { kind: 'not-ready' }
|
||||
}
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId,
|
||||
worktreePath,
|
||||
...captureDirectSshMutationExpectation(state, connectionId)
|
||||
try {
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId,
|
||||
worktreePath,
|
||||
...captureDirectSshMutationExpectation(state, connectionId)
|
||||
}
|
||||
} catch {
|
||||
// The connection's generation is gone (disconnect mid-attach). That is an
|
||||
// unknown owner, not a reason to throw out of the drop/IME handler.
|
||||
return { kind: 'not-ready' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +103,20 @@ export function nativeChatWorktreeNotReadyNotice(): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatAttachmentOwnerChangedNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.attachmentOwnerChanged',
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatAttachmentUnreadableNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.attachmentUnreadable',
|
||||
"Couldn't read the dropped files."
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatLocalAttachmentUnsupportedNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.localAttachmentUnsupported',
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { useRef } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
|
||||
import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
|
||||
import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc'
|
||||
@@ -30,7 +33,9 @@ const intake = vi.hoisted(() => ({
|
||||
upload: vi.fn()
|
||||
}))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
vi.mock('./native-chat-attachment-upload', () => ({
|
||||
// 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>()),
|
||||
resolveNativeChatAttachmentOwner: () => intake.owner,
|
||||
uploadNativeChatAttachmentPaths: intake.upload
|
||||
}))
|
||||
@@ -49,7 +54,8 @@ import {
|
||||
|
||||
// Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache.
|
||||
function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaRef = useRef<NativeChatComposerInput>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const attachments = useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: pane,
|
||||
allowWithoutTarget: true,
|
||||
@@ -60,22 +66,28 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea
|
||||
textareaRef,
|
||||
setCaret: () => {},
|
||||
setDraft: () => {},
|
||||
setNotice: () => {}
|
||||
setNotice
|
||||
})
|
||||
const { attachExternalPaths } = useNativeChatExternalAttachments({
|
||||
terminalTabId: pane,
|
||||
disabled: false,
|
||||
attachResolvedPaths: attachments.attachResolvedPaths,
|
||||
setNotice: () => {}
|
||||
setNotice
|
||||
})
|
||||
useNativeChatFileAttachmentActions(pane, attachExternalPaths)
|
||||
return (
|
||||
<div data-pane={pane} style={{ display: hidden ? 'none' : 'block' }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-native-file-drop-target="composer"
|
||||
data-composer-scope-key={pane}
|
||||
/>
|
||||
<div data-native-file-drop-target="composer" data-composer-scope-key={pane}>
|
||||
<NativeChatPromptEditor
|
||||
scopeKey={pane}
|
||||
inputRef={textareaRef}
|
||||
initialValue="untouched draft"
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{attachments.imageAttachments.map((attachment) => (
|
||||
<NativeChatImageAttachmentPreview
|
||||
key={attachment.id}
|
||||
@@ -84,10 +96,20 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea
|
||||
/>
|
||||
))}
|
||||
<output>{JSON.stringify(attachments.imageAttachments.map(({ path }) => path))}</output>
|
||||
<output data-notice={pane}>{notice}</output>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The external-attach loop awaits once per path; drain those before asserting. */
|
||||
async function settleAttachments(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function dropTwoImages(target: Element): Promise<void> {
|
||||
const event = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
@@ -128,6 +150,7 @@ describe('native chat composer drop scoping', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
intake.owner = { kind: 'local' }
|
||||
electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`)
|
||||
intake.authorizeExternalPath.mockReset().mockResolvedValue(undefined)
|
||||
intake.readFile.mockReset().mockResolvedValue({ content: '', isBinary: false })
|
||||
intake.upload.mockReset()
|
||||
@@ -142,6 +165,51 @@ describe('native chat composer drop scoping', () => {
|
||||
electron.send.mockClear()
|
||||
})
|
||||
|
||||
// #15782: an OS drop that produces nothing must say so. Every assertion here
|
||||
// is about the absence of silence, not about which path was attached.
|
||||
it('reports an OS drop whose files carry no readable path', async () => {
|
||||
electron.getPathForFile.mockReturnValue('')
|
||||
const view = render(<ComposerProbe pane="chat-a" />)
|
||||
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
|
||||
|
||||
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
|
||||
byteLength: 0,
|
||||
pathCount: 2,
|
||||
reason: 'unresolved-paths',
|
||||
target: 'rejected'
|
||||
})
|
||||
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
|
||||
})
|
||||
|
||||
it('notices an OS drop whose every path fails authorization', async () => {
|
||||
intake.authorizeExternalPath.mockRejectedValue(new Error('denied'))
|
||||
const view = render(<ComposerProbe pane="chat-a" />)
|
||||
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
|
||||
await settleAttachments()
|
||||
|
||||
expect(view.container.querySelector('[data-notice="chat-a"]')?.textContent).toBe(
|
||||
"Couldn't read the dropped files."
|
||||
)
|
||||
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
|
||||
})
|
||||
|
||||
it('notices an OS drop whose owner changes during authorization', async () => {
|
||||
intake.authorizeExternalPath.mockImplementation(async () => {
|
||||
intake.owner = { kind: 'ssh', connectionId: 'conn-1' }
|
||||
})
|
||||
const view = render(<ComposerProbe pane="chat-a" />)
|
||||
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
|
||||
await settleAttachments()
|
||||
|
||||
expect(view.container.querySelector('[data-notice="chat-a"]')?.textContent).toBe(
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
|
||||
})
|
||||
|
||||
it('attaches only to the dropped pane and leaves a hidden pane clean on remount', async () => {
|
||||
const view = render(
|
||||
<>
|
||||
@@ -152,7 +220,7 @@ describe('native chat composer drop scoping', () => {
|
||||
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
|
||||
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
|
||||
|
||||
const target = view.container.querySelector('[data-pane="chat-a"] textarea')!
|
||||
const target = view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!
|
||||
await dropTwoImages(target)
|
||||
|
||||
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
|
||||
@@ -165,6 +233,7 @@ describe('native chat composer drop scoping', () => {
|
||||
'/repro/second.png'
|
||||
])
|
||||
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
|
||||
expect(target.textContent).toBe('untouched draft')
|
||||
|
||||
view.unmount()
|
||||
const returned = render(<ComposerProbe pane="chat-b" />)
|
||||
@@ -198,7 +267,7 @@ describe('native chat composer drop scoping', () => {
|
||||
</>
|
||||
)
|
||||
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] textarea')!)
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
|
||||
expect(workspaceDrop).not.toHaveBeenCalled()
|
||||
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
|
||||
expect(readNativeChatAttachmentCache('chat-a').map(({ path }) => path)).toEqual([
|
||||
@@ -239,7 +308,11 @@ describe('native chat composer drop scoping', () => {
|
||||
<ComposerProbe pane="chat-b" hidden />
|
||||
</>
|
||||
)
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] textarea')!)
|
||||
await dropTwoImages(
|
||||
view.container.querySelector(
|
||||
'[data-pane="chat-a"] [data-native-file-drop-target="composer"]'
|
||||
)!
|
||||
)
|
||||
expect(await screen.findByRole('img', { name: 'first.png' })).toBeTruthy()
|
||||
expect(await screen.findByRole('img', { name: 'second.png' })).toBeTruthy()
|
||||
expect(intake.authorizeExternalPath.mock.calls).toEqual([
|
||||
@@ -264,7 +337,7 @@ describe('native chat composer drop scoping', () => {
|
||||
<ComposerProbe pane="chat-b" hidden />
|
||||
</>
|
||||
)
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] textarea')!)
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
|
||||
expect(intake.upload).toHaveBeenCalledExactlyOnceWith(
|
||||
['/repro/first.png', '/repro/second.png'],
|
||||
intake.owner
|
||||
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
encodeWorkspaceFilePaths,
|
||||
WORKSPACE_FILE_PATHS_MIME,
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
writeWorkspaceFileDragSource
|
||||
} from '@/lib/workspace-file-drag'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { NativeChatComposerField } from './NativeChatComposerField'
|
||||
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
|
||||
import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop'
|
||||
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
|
||||
|
||||
const testState: {
|
||||
executionHostId: ExecutionHostId
|
||||
ownerConnectionId: string
|
||||
ownerKind: 'local' | 'not-ready' | 'runtime' | 'ssh'
|
||||
ownerSshGeneration: number
|
||||
ownerWorktreePath: string
|
||||
targetIsRemoteRuntime: boolean
|
||||
store: { tabsByWorktree: Record<string, { id: string }[]> }
|
||||
} = vi.hoisted(() => ({
|
||||
executionHostId: 'local',
|
||||
ownerConnectionId: 'ssh-1',
|
||||
ownerKind: 'local',
|
||||
ownerSshGeneration: 4,
|
||||
ownerWorktreePath: '/remote/repo',
|
||||
targetIsRemoteRuntime: false,
|
||||
store: {
|
||||
tabsByWorktree: {
|
||||
'worktree-1': [{ id: 'terminal-tab-1' }]
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = (selector: (state: typeof testState.store) => unknown) =>
|
||||
selector(testState.store)
|
||||
useAppStore.getState = () => testState.store
|
||||
return { useAppStore }
|
||||
})
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getExecutionHostIdForWorktree: () => testState.executionHostId
|
||||
}))
|
||||
// Real notice strings, so a copy of the wording here cannot outlive the string
|
||||
// users actually read, and a newly added export cannot go missing from the mock.
|
||||
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof AttachmentUploadModule>()),
|
||||
resolveNativeChatAttachmentOwnerForWorktree: () =>
|
||||
testState.ownerKind === 'ssh'
|
||||
? {
|
||||
kind: 'ssh',
|
||||
connectionId: testState.ownerConnectionId,
|
||||
worktreePath: testState.ownerWorktreePath,
|
||||
expectedExecutionHostId: `ssh:${testState.ownerConnectionId}`,
|
||||
expectedSshTargetId: testState.ownerConnectionId,
|
||||
expectedSshConnectionGeneration: testState.ownerSshGeneration
|
||||
}
|
||||
: { kind: testState.ownerKind }
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => testState.targetIsRemoteRuntime
|
||||
}))
|
||||
vi.mock('./NativeChatComposerActions', () => ({
|
||||
NativeChatComposerActions: () => <div data-testid="composer-actions" />
|
||||
}))
|
||||
vi.mock('./NativeChatAutocompleteMenus', () => ({
|
||||
NativeChatMentionHint: () => null,
|
||||
NativeChatPickerMenu: () => null
|
||||
}))
|
||||
vi.mock('./NativeChatImageAttachmentPreview', () => ({
|
||||
NativeChatImageAttachmentPreview: ({
|
||||
attachment
|
||||
}: {
|
||||
attachment: { connectionId?: string; path: string }
|
||||
}) => (
|
||||
<output data-image-attachment data-connection-id={attachment.connectionId}>
|
||||
{attachment.path}
|
||||
</output>
|
||||
)
|
||||
}))
|
||||
|
||||
class FileDragDataTransfer {
|
||||
// Not 'none' and not 'copy': the browser picks a default we did not choose, so
|
||||
// starting here is what makes an assertion on either verdict load-bearing.
|
||||
dropEffect = 'link'
|
||||
effectAllowed = 'copyMove'
|
||||
files: File[] = []
|
||||
private readonly data = new Map<string, string>()
|
||||
|
||||
get types(): string[] {
|
||||
return [...this.data.keys()]
|
||||
}
|
||||
|
||||
getData(type: string): string {
|
||||
return this.data.get(type) ?? ''
|
||||
}
|
||||
|
||||
setData(type: string, value: string): void {
|
||||
this.data.set(type, value)
|
||||
}
|
||||
}
|
||||
|
||||
type ProbeProps = {
|
||||
disabled?: boolean
|
||||
initialDraft?: string
|
||||
structured?: boolean
|
||||
/** Overrides only the structured target, leaving the pane's scope key alone. */
|
||||
structuredWorkspaceId?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
let latestInput: NativeChatComposerInput | null = null
|
||||
const bubbledDrop = vi.fn()
|
||||
|
||||
function ComposerProbe({
|
||||
disabled = false,
|
||||
initialDraft = '',
|
||||
structured = true,
|
||||
structuredWorkspaceId,
|
||||
workspaceId = 'worktree-1'
|
||||
}: ProbeProps): React.JSX.Element {
|
||||
const [draft, setDraft] = useState(initialDraft)
|
||||
const [caret, setCaret] = useState(initialDraft.length)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const inputRef = useRef<NativeChatComposerInput>(null)
|
||||
const imeEnterGesture = useImeEnterGestureOwnership()
|
||||
const attachments = useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: `pane:${workspaceId}`,
|
||||
allowWithoutTarget: structured,
|
||||
caret,
|
||||
disabled,
|
||||
isComposing: imeEnterGesture.isComposing,
|
||||
resolveTarget: () =>
|
||||
structured ? null : { ptyId: 'pty-1', settings: { activeRuntimeEnvironmentId: null } },
|
||||
textareaRef: inputRef,
|
||||
setCaret,
|
||||
setDraft,
|
||||
setNotice
|
||||
})
|
||||
const workspaceFileDropHandlers = useNativeChatWorkspaceFileDrop({
|
||||
terminalTabId: 'terminal-tab-1',
|
||||
structuredWorktreeId: structured ? (structuredWorkspaceId ?? workspaceId) : undefined,
|
||||
disabled,
|
||||
attachResolvedPaths: attachments.attachResolvedPaths,
|
||||
setNotice
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
latestInput = inputRef.current
|
||||
})
|
||||
|
||||
return (
|
||||
<div onDrop={bubbledDrop}>
|
||||
<NativeChatComposerField
|
||||
composerScopeKey={`pane:${workspaceId}`}
|
||||
textareaRef={inputRef}
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
hasPty
|
||||
canSend={!disabled}
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notice={notice}
|
||||
imageAttachments={attachments.imageAttachments}
|
||||
sendButtonDisabled={false}
|
||||
isWorking={false}
|
||||
attachDisabled={disabled}
|
||||
dictationDisabled
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
imeEnterGesture={imeEnterGesture}
|
||||
onDraftChange={(value, input) => {
|
||||
setDraft(value)
|
||||
setCaret(input.selectionStart ?? value.length)
|
||||
}}
|
||||
onTextareaSelect={(input) => setCaret(input.selectionStart ?? input.value.length)}
|
||||
onKeyDown={() => {}}
|
||||
onImeSettled={(input) => {
|
||||
setDraft(input.value)
|
||||
attachments.flushPendingAttachments()
|
||||
}}
|
||||
onPaste={() => {}}
|
||||
pickerListboxId="picker"
|
||||
onChoosePickerItem={() => {}}
|
||||
onRetrySkills={() => {}}
|
||||
onAcceptMention={() => {}}
|
||||
onRemoveImageAttachment={attachments.removeImageAttachment}
|
||||
onAttach={() => {}}
|
||||
workspaceFileDropHandlers={workspaceFileDropHandlers}
|
||||
onDictationToggle={() => {}}
|
||||
onDictationHoldStart={() => {}}
|
||||
onDictationHoldEnd={() => {}}
|
||||
onSend={() => {}}
|
||||
sessionOptionsSurface={null}
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
<output data-testid="draft">{draft}</output>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function internalTransfer(
|
||||
paths: string[],
|
||||
source: { executionHostId?: ExecutionHostId; workspaceId?: string } = {}
|
||||
): FileDragDataTransfer {
|
||||
const transfer = new FileDragDataTransfer()
|
||||
transfer.setData(WORKSPACE_FILE_PATH_MIME, paths[0] ?? '')
|
||||
if (paths.length > 1) {
|
||||
transfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths))
|
||||
}
|
||||
writeWorkspaceFileDragSource(transfer, {
|
||||
executionHostId: source.executionHostId ?? 'local',
|
||||
workspaceId: source.workspaceId ?? 'worktree-1'
|
||||
})
|
||||
return transfer
|
||||
}
|
||||
|
||||
function editor(): HTMLElement {
|
||||
return screen.getByRole('textbox')
|
||||
}
|
||||
|
||||
function dispatchDragEvent(
|
||||
type: 'dragover' | 'drop',
|
||||
target: Element,
|
||||
dataTransfer: FileDragDataTransfer
|
||||
): boolean {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
|
||||
let accepted = true
|
||||
act(() => {
|
||||
accepted = target.dispatchEvent(event)
|
||||
})
|
||||
return accepted
|
||||
}
|
||||
|
||||
describe('native chat workspace file drops', () => {
|
||||
beforeEach(() => {
|
||||
testState.executionHostId = 'local'
|
||||
testState.ownerConnectionId = 'ssh-1'
|
||||
testState.ownerKind = 'local'
|
||||
testState.ownerSshGeneration = 4
|
||||
testState.ownerWorktreePath = '/remote/repo'
|
||||
testState.targetIsRemoteRuntime = false
|
||||
testState.store.tabsByWorktree = { 'worktree-1': [{ id: 'terminal-tab-1' }] }
|
||||
latestInput = null
|
||||
bubbledDrop.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('consumes a nested editor drop once and inserts top-level paths at the caret', () => {
|
||||
render(<ComposerProbe initialDraft="$rev tail" />)
|
||||
act(() => {
|
||||
latestInput!.insertSkill!(0, 4, '$review')
|
||||
})
|
||||
expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
|
||||
|
||||
const transfer = internalTransfer([
|
||||
'/repo/src',
|
||||
'/repo/src/index.ts',
|
||||
'/repo/My File.ts',
|
||||
'/repo/My File.ts'
|
||||
])
|
||||
transfer.setData('text/plain', 'must not be inserted by ProseMirror')
|
||||
const accepted = dispatchDragEvent('drop', editor(), transfer)
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(screen.getByTestId('draft').textContent).toBe(
|
||||
'$review @/repo/src @"/repo/My File.ts" tail'
|
||||
)
|
||||
expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
|
||||
expect(editor().textContent).not.toContain('must not be inserted')
|
||||
expect(bubbledDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises a copy drop while leaving unrelated drags alone', () => {
|
||||
render(<ComposerProbe />)
|
||||
const internal = internalTransfer(['/repo/a.ts'])
|
||||
const accepted = dispatchDragEvent('dragover', editor(), internal)
|
||||
expect(accepted).toBe(false)
|
||||
expect(internal.dropEffect).toBe('copy')
|
||||
|
||||
const unrelated = new FileDragDataTransfer()
|
||||
unrelated.setData('text/plain', 'plain')
|
||||
unrelated.setData('text/html', '<b>plain</b>')
|
||||
dispatchDragEvent('dragover', editor(), unrelated)
|
||||
dispatchDragEvent('drop', editor(), unrelated)
|
||||
expect(bubbledDrop).toHaveBeenCalledOnce()
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
})
|
||||
|
||||
const mismatchedSources: [string, { executionHostId?: ExecutionHostId; workspaceId?: string }][] =
|
||||
[
|
||||
['different workspace', { workspaceId: 'worktree-2' }],
|
||||
['different execution host', { executionHostId: 'ssh:other' }]
|
||||
]
|
||||
|
||||
it.each(mismatchedSources)('rejects paths from a %s', (_label, source) => {
|
||||
render(<ComposerProbe />)
|
||||
dispatchDragEvent('drop', editor(), internalTransfer(['/repo/a.ts'], source))
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
})
|
||||
|
||||
it('rejects legacy unscoped payloads and unavailable owners', () => {
|
||||
const view = render(<ComposerProbe />)
|
||||
const unscoped = new FileDragDataTransfer()
|
||||
unscoped.setData(WORKSPACE_FILE_PATH_MIME, '/repo/a.ts')
|
||||
dispatchDragEvent('drop', editor(), unscoped)
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
|
||||
testState.ownerKind = 'not-ready'
|
||||
view.rerender(<ComposerProbe />)
|
||||
dispatchDragEvent('drop', editor(), internalTransfer(['/repo/b.ts']))
|
||||
expect(screen.getByText('Worktree not ready — try again in a moment.')).toBeTruthy()
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
})
|
||||
|
||||
// A guarded composer must refuse visibly. It still claims the event, because
|
||||
// the terminal surface behind it would otherwise paste the paths into the shell.
|
||||
it('refuses the drag outright while disabled instead of promising a copy', () => {
|
||||
render(<ComposerProbe disabled />)
|
||||
const hover = internalTransfer(['/repo/a.ts'])
|
||||
expect(dispatchDragEvent('dragover', editor(), hover)).toBe(false)
|
||||
expect(hover.dropEffect).toBe('none')
|
||||
expect(bubbledDrop).not.toHaveBeenCalled()
|
||||
|
||||
const transfer = internalTransfer(['/repo/a.ts'])
|
||||
expect(dispatchDragEvent('drop', editor(), transfer)).toBe(false)
|
||||
expect(transfer.dropEffect).toBe('none')
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
})
|
||||
|
||||
// The relaxation that lets a runtime-owned path reach a runtime pane: the
|
||||
// explorer lists that host's filesystem, so the agent can read what it drags.
|
||||
it('accepts a same-host drop on a remote runtime target and refuses a foreign one', () => {
|
||||
testState.executionHostId = 'runtime:env-1'
|
||||
testState.ownerKind = 'runtime'
|
||||
testState.targetIsRemoteRuntime = true
|
||||
const view = render(<ComposerProbe structured={false} />)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
editor(),
|
||||
internalTransfer(['/env/owned.ts'], { executionHostId: 'runtime:env-1' })
|
||||
)
|
||||
expect(screen.getByTestId('draft').textContent).toBe('@/env/owned.ts ')
|
||||
view.unmount()
|
||||
|
||||
render(<ComposerProbe structured={false} />)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
editor(),
|
||||
internalTransfer(['/elsewhere/foreign.ts'], { executionHostId: 'runtime:env-2' })
|
||||
)
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('queues an internal reference until composition settles without stealing focus', () => {
|
||||
render(<ComposerProbe initialDraft="preedit" />)
|
||||
const input = editor()
|
||||
act(() => latestInput!.setSelectionRange(7, 7))
|
||||
input.focus()
|
||||
fireEvent.compositionStart(input)
|
||||
dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
|
||||
expect(screen.getByTestId('draft').textContent).toBe('preedit')
|
||||
|
||||
fireEvent.compositionEnd(input, { data: '' })
|
||||
expect(screen.getByTestId('draft').textContent).toBe('preedit@/repo/a.ts ')
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('rejects an IME-queued path when its execution host changes before composition settles', () => {
|
||||
render(<ComposerProbe initialDraft="preedit" />)
|
||||
const input = editor()
|
||||
fireEvent.compositionStart(input)
|
||||
dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
|
||||
|
||||
testState.executionHostId = 'ssh:replacement'
|
||||
fireEvent.compositionEnd(input, { data: '' })
|
||||
|
||||
expect(screen.getByTestId('draft').textContent).toBe('preedit')
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects an IME-queued path when its SSH route changes under the same host', () => {
|
||||
testState.executionHostId = 'runtime:outer-env'
|
||||
testState.ownerKind = 'ssh'
|
||||
render(<ComposerProbe initialDraft="preedit" />)
|
||||
const input = editor()
|
||||
fireEvent.compositionStart(input)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
input,
|
||||
internalTransfer(['/remote/repo/a.ts'], { executionHostId: 'runtime:outer-env' })
|
||||
)
|
||||
|
||||
testState.ownerConnectionId = 'ssh-2'
|
||||
fireEvent.compositionEnd(input, { data: '' })
|
||||
|
||||
expect(screen.getByTestId('draft').textContent).toBe('preedit')
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects an IME-queued path when the SSH connection reconnects under the same id', () => {
|
||||
testState.executionHostId = 'ssh:ssh-1'
|
||||
testState.ownerKind = 'ssh'
|
||||
render(<ComposerProbe initialDraft="preedit" />)
|
||||
const input = editor()
|
||||
fireEvent.compositionStart(input)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
input,
|
||||
internalTransfer(['/remote/repo/a.ts'], { executionHostId: 'ssh:ssh-1' })
|
||||
)
|
||||
|
||||
testState.ownerSshGeneration = 5
|
||||
fireEvent.compositionEnd(input, { data: '' })
|
||||
|
||||
expect(screen.getByTestId('draft').textContent).toBe('preedit')
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The queued check must ask which workspace this composer serves NOW. Comparing
|
||||
// a captured id against itself would pass no matter where the pane ended up.
|
||||
it('rejects an IME-queued path when the pane changes workspace before settling', () => {
|
||||
const view = render(<ComposerProbe initialDraft="preedit" />)
|
||||
const input = editor()
|
||||
fireEvent.compositionStart(input)
|
||||
dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
|
||||
|
||||
view.rerender(<ComposerProbe initialDraft="preedit" structuredWorkspaceId="worktree-2" />)
|
||||
fireEvent.compositionEnd(input, { data: '' })
|
||||
|
||||
expect(screen.getByTestId('draft').textContent).toBe('preedit')
|
||||
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects only the exact unresolved-owner sentinel', () => {
|
||||
testState.executionHostId = 'runtime:unresolved-owner'
|
||||
const view = render(<ComposerProbe />)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
editor(),
|
||||
internalTransfer(['/repo/rejected.ts'], {
|
||||
executionHostId: 'runtime:unresolved-owner'
|
||||
})
|
||||
)
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
|
||||
testState.executionHostId = 'runtime:my-unresolved-owner-env'
|
||||
testState.ownerKind = 'runtime'
|
||||
view.rerender(<ComposerProbe />)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
editor(),
|
||||
internalTransfer(['/repo/accepted.ts'], {
|
||||
executionHostId: 'runtime:my-unresolved-owner-env'
|
||||
})
|
||||
)
|
||||
expect(screen.getByTestId('draft').textContent).toBe('@/repo/accepted.ts ')
|
||||
})
|
||||
|
||||
it('attaches same-owner SSH images without uploading or client authorization', () => {
|
||||
testState.executionHostId = 'ssh:ssh-1'
|
||||
testState.ownerKind = 'ssh'
|
||||
render(<ComposerProbe />)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
editor(),
|
||||
internalTransfer(['/remote/repo/image.png'], {
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
)
|
||||
|
||||
const image = screen.getByText('/remote/repo/image.png')
|
||||
expect(image.getAttribute('data-connection-id')).toBe('ssh-1')
|
||||
expect(screen.getByTestId('draft').textContent).toBe('')
|
||||
})
|
||||
|
||||
it('supports PTY-owned and folder-workspace paths with the same ownership gate', () => {
|
||||
const first = render(<ComposerProbe structured={false} />)
|
||||
dispatchDragEvent('drop', editor(), internalTransfer(['/repo/pty.ts']))
|
||||
expect(screen.getByTestId('draft').textContent).toBe('@/repo/pty.ts ')
|
||||
first.unmount()
|
||||
|
||||
render(<ComposerProbe workspaceId="folder:folder-1" />)
|
||||
dispatchDragEvent(
|
||||
'drop',
|
||||
editor(),
|
||||
internalTransfer(['/folder/note.md'], { workspaceId: 'folder:folder-1' })
|
||||
)
|
||||
expect(screen.getByTestId('draft').textContent).toBe('@/folder/note.md ')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
|
||||
import type { NativeChatSshAttachmentOwner } from './native-chat-attachment-upload'
|
||||
|
||||
function ssh(overrides: Partial<NativeChatSshAttachmentOwner> = {}): NativeChatSshAttachmentOwner {
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('nativeChatAttachmentOwnerUnchanged', () => {
|
||||
it('keeps same-kind local and runtime owners', () => {
|
||||
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'local' })).toBe(true)
|
||||
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'runtime' }, { kind: 'runtime' })).toBe(true)
|
||||
})
|
||||
|
||||
it('never treats an unknown owner as the same owner', () => {
|
||||
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'not-ready' }, { kind: 'not-ready' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'not-ready' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an SSH reconnect that keeps the same connection id', () => {
|
||||
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh())).toBe(true)
|
||||
expect(
|
||||
nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ expectedSshConnectionGeneration: 5 }))
|
||||
).toBe(false)
|
||||
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ connectionId: 'conn-2' }))).toBe(false)
|
||||
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ worktreePath: '/other' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
|
||||
|
||||
export type NativeChatResolvedPathOptions = {
|
||||
/** Revalidates internal path ownership when an IME-delayed attachment is applied. */
|
||||
targetOwnerIsCurrent?: () => boolean
|
||||
}
|
||||
|
||||
export function nativeChatWorkspaceAttachmentMismatchNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.workspaceAttachmentMismatch',
|
||||
'Files can only be attached to their source workspace.'
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether an attachment captured against `captured` may still land on `current`.
|
||||
* `not-ready` never matches: an unknown owner is not evidence of the same one. */
|
||||
export function nativeChatAttachmentOwnerUnchanged(
|
||||
captured: NativeChatAttachmentOwner,
|
||||
current: NativeChatAttachmentOwner
|
||||
): boolean {
|
||||
if (captured.kind !== current.kind || captured.kind === 'not-ready') {
|
||||
return false
|
||||
}
|
||||
if (captured.kind !== 'ssh' || current.kind !== 'ssh') {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
captured.connectionId === current.connectionId &&
|
||||
captured.worktreePath === current.worktreePath &&
|
||||
captured.expectedExecutionHostId === current.expectedExecutionHostId &&
|
||||
captured.expectedSshTargetId === current.expectedSshTargetId &&
|
||||
captured.expectedSshConnectionGeneration === current.expectedSshConnectionGeneration
|
||||
)
|
||||
}
|
||||
+137
-1
@@ -13,8 +13,9 @@ import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
const runtimeTarget = vi.hoisted(() => ({ remote: false }))
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false
|
||||
isRemoteRuntimePtyId: () => runtimeTarget.remote
|
||||
}))
|
||||
|
||||
type AttachmentApi = ReturnType<typeof useNativeChatComposerAttachments>
|
||||
@@ -129,6 +130,7 @@ async function renderProbe(
|
||||
|
||||
describe('useNativeChatComposerAttachments', () => {
|
||||
afterEach(() => {
|
||||
runtimeTarget.remote = false
|
||||
clearNativeChatAttachmentCacheForTests()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
@@ -169,6 +171,140 @@ describe('useNativeChatComposerAttachments', () => {
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
it('accepts only ownership-validated paths for a remote runtime target', async () => {
|
||||
runtimeTarget.remote = true
|
||||
const probe = await renderProbe('remote-pty')
|
||||
|
||||
act(() => probe.latest().attachResolvedPaths(['/remote/untrusted.txt']))
|
||||
expect(probe.draft()).toBe('')
|
||||
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
|
||||
|
||||
act(() =>
|
||||
probe.latest().attachResolvedPaths(['/remote/trusted.txt'], undefined, {
|
||||
targetOwnerIsCurrent: () => true
|
||||
})
|
||||
)
|
||||
expect(probe.draft()).toBe('@/remote/trusted.txt ')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
it('rejects an ownership-validated path when its owner changes before IME flush', async () => {
|
||||
let composing = true
|
||||
let ownerCurrent = true
|
||||
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
|
||||
|
||||
act(() =>
|
||||
probe.latest().attachResolvedPaths(['/remote/trusted.txt'], undefined, {
|
||||
targetOwnerIsCurrent: () => ownerCurrent
|
||||
})
|
||||
)
|
||||
ownerCurrent = false
|
||||
composing = false
|
||||
act(() => probe.latest().flushPendingAttachments())
|
||||
|
||||
expect(probe.draft()).toBe('')
|
||||
expect(probe.notice()).toBe('Files can only be attached to their source workspace.')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
// Today's only caller settles ownership synchronously before it calls, so this
|
||||
// verdict cannot arrive false — but the hook exports this entry point. Pinned
|
||||
// because the fallback is not a refusal: a false verdict is not "owned", so a
|
||||
// remote target would blame client-local attachments for an ownership failure.
|
||||
it('names the ownership failure when an immediate attach arrives already false', async () => {
|
||||
runtimeTarget.remote = true
|
||||
const probe = await renderProbe('pty-1', false, { isComposing: () => false })
|
||||
|
||||
act(() =>
|
||||
probe.latest().attachResolvedPaths(['/remote/moved.txt'], undefined, {
|
||||
targetOwnerIsCurrent: () => false
|
||||
})
|
||||
)
|
||||
|
||||
expect(probe.draft()).toBe('')
|
||||
expect(probe.notice()).toBe('Files can only be attached to their source workspace.')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
// Ownership is per path: the target-owned drop still lands, the client-local
|
||||
// paste is refused, and the refusal is reported rather than hidden.
|
||||
it('keeps the owned half of a mixed queued batch after the target becomes remote', async () => {
|
||||
let composing = true
|
||||
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
|
||||
|
||||
act(() => {
|
||||
probe.latest().attachResolvedPaths(['/remote/trusted.txt'], undefined, {
|
||||
targetOwnerIsCurrent: () => true
|
||||
})
|
||||
probe.latest().attachResolvedPaths(['/local/untrusted.txt'])
|
||||
})
|
||||
runtimeTarget.remote = true
|
||||
composing = false
|
||||
act(() => probe.latest().flushPendingAttachments())
|
||||
|
||||
expect(probe.draft()).toBe('@/remote/trusted.txt ')
|
||||
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
// References are inserted in the order the user made them. Splitting the queue
|
||||
// into an owned half and a client-local half would hoist every workspace drop
|
||||
// ahead of a paste that came first.
|
||||
it('keeps a mixed queued batch in the order it was attached', async () => {
|
||||
let composing = true
|
||||
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
|
||||
|
||||
act(() => {
|
||||
probe.latest().attachResolvedPaths(['/local/first.txt'])
|
||||
probe.latest().attachResolvedPaths(['/remote/second.txt'], undefined, {
|
||||
targetOwnerIsCurrent: () => true
|
||||
})
|
||||
})
|
||||
composing = false
|
||||
act(() => probe.latest().flushPendingAttachments())
|
||||
|
||||
expect(probe.draft()).toBe('@/local/first.txt @/remote/second.txt ')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
it('refuses a wholly client-local queued batch on a remote target', async () => {
|
||||
let composing = true
|
||||
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
|
||||
|
||||
act(() => probe.latest().attachResolvedPaths(['/local/untrusted.txt']))
|
||||
runtimeTarget.remote = true
|
||||
composing = false
|
||||
act(() => probe.latest().flushPendingAttachments())
|
||||
|
||||
expect(probe.draft()).toBe('')
|
||||
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
// An already-blocked target refuses at the drop instead of queueing. Queued
|
||||
// paths that can never attach would still spend the pending budget, and the
|
||||
// next legitimate drop would be turned away for being one too many.
|
||||
it('refuses an already-blocked target at the drop without spending the queue budget', async () => {
|
||||
runtimeTarget.remote = true
|
||||
let composing = true
|
||||
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
|
||||
|
||||
const refused = Array.from(
|
||||
{ length: NATIVE_FILE_DROP_MAX_PATHS },
|
||||
(_unused, index) => `/local/refused-${index}.txt`
|
||||
)
|
||||
act(() => probe.latest().attachResolvedPaths(refused))
|
||||
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
|
||||
|
||||
runtimeTarget.remote = false
|
||||
act(() => probe.latest().attachResolvedPaths(['/local/allowed.txt']))
|
||||
composing = false
|
||||
act(() => probe.latest().flushPendingAttachments())
|
||||
|
||||
expect(probe.draft()).toBe('@/local/allowed.txt ')
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
it('removes an attached image chip cleanly', async () => {
|
||||
const probe = await renderProbe('pty-1')
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { useCallback, useRef, useState, type RefObject } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
|
||||
import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
|
||||
import {
|
||||
formatNativeChatFileReference,
|
||||
nativeChatComposerTargetIsRemote,
|
||||
type NativeChatResolvedTarget
|
||||
} from './native-chat-composer-target'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
||||
import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership'
|
||||
import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments'
|
||||
|
||||
export type UseNativeChatComposerAttachmentsArgs = {
|
||||
attachmentScopeKey: string
|
||||
@@ -37,7 +36,11 @@ export function useNativeChatComposerAttachments({
|
||||
setNotice
|
||||
}: UseNativeChatComposerAttachmentsArgs): {
|
||||
imageAttachments: NativeChatComposerImageAttachment[]
|
||||
attachResolvedPaths: (paths: string[], connectionId?: string | null) => void
|
||||
attachResolvedPaths: (
|
||||
paths: string[],
|
||||
connectionId?: string | null,
|
||||
options?: NativeChatResolvedPathOptions
|
||||
) => void
|
||||
clearImageAttachments: () => void
|
||||
flushPendingAttachments: () => void
|
||||
removeImageAttachment: (id: string) => void
|
||||
@@ -49,17 +52,6 @@ export function useNativeChatComposerAttachments({
|
||||
() => readNativeChatAttachmentCache(attachmentScopeKey)
|
||||
)
|
||||
const imageAttachmentCounter = useRef(0)
|
||||
const pendingResolvedPathsRef = useRef<{ path: string; connectionId?: string | null }[]>([])
|
||||
const pendingPathLimitRejectedRef = useRef(false)
|
||||
const disabledRef = useRef(disabled)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
if (disabled) {
|
||||
pendingResolvedPathsRef.current = []
|
||||
pendingPathLimitRejectedRef.current = false
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
const updateImageAttachments = useCallback(
|
||||
(
|
||||
@@ -81,15 +73,18 @@ export function useNativeChatComposerAttachments({
|
||||
return `${Date.now()}-${imageAttachmentCounter.current}`
|
||||
}, [])
|
||||
|
||||
// Local paths are only attachable when the composer's target runs locally;
|
||||
// remote-runtime panes read a different filesystem than the one we resolved.
|
||||
const attachmentTargetBlocked = useCallback((): boolean => {
|
||||
const target = resolveTarget()
|
||||
return (
|
||||
(!target && !allowWithoutTarget) ||
|
||||
Boolean(target && nativeChatComposerTargetIsRemote(target.ptyId))
|
||||
)
|
||||
}, [allowWithoutTarget, resolveTarget])
|
||||
// Client-local paths cannot cross into a runtime target; workspace-owned
|
||||
// paths may only bypass this after the internal drop ownership gate.
|
||||
const attachmentTargetBlocked = useCallback(
|
||||
(targetOwned = false): boolean => {
|
||||
const target = resolveTarget()
|
||||
return (
|
||||
(!target && !allowWithoutTarget) ||
|
||||
Boolean(target && nativeChatComposerTargetIsRemote(target.ptyId) && !targetOwned)
|
||||
)
|
||||
},
|
||||
[allowWithoutTarget, resolveTarget]
|
||||
)
|
||||
|
||||
const noteAttachmentTargetBlocked = useCallback(() => {
|
||||
setNotice(
|
||||
@@ -117,6 +112,20 @@ export function useNativeChatComposerAttachments({
|
||||
[nextAttachmentId, updateImageAttachments]
|
||||
)
|
||||
|
||||
const { attachResolvedPaths, disabledRef, flushPendingAttachments } =
|
||||
useNativeChatResolvedPathAttachments({
|
||||
appendImageAttachments,
|
||||
attachmentTargetBlocked,
|
||||
caret,
|
||||
disabled,
|
||||
isComposing,
|
||||
noteAttachmentTargetBlocked,
|
||||
setCaret,
|
||||
setDraft,
|
||||
setNotice,
|
||||
textareaRef
|
||||
})
|
||||
|
||||
// Placeholder chip shown the instant a paste starts, so a clipboard image that
|
||||
// takes a beat to save (or upload over SSH) never reads as a dropped paste.
|
||||
const beginPendingImageAttachment = useCallback(
|
||||
@@ -132,7 +141,13 @@ export function useNativeChatComposerAttachments({
|
||||
updateImageAttachments((prev) => [...prev, { id, path: '', previewUrl, pending: true }])
|
||||
return id
|
||||
},
|
||||
[attachmentTargetBlocked, nextAttachmentId, noteAttachmentTargetBlocked, updateImageAttachments]
|
||||
[
|
||||
attachmentTargetBlocked,
|
||||
disabledRef,
|
||||
nextAttachmentId,
|
||||
noteAttachmentTargetBlocked,
|
||||
updateImageAttachments
|
||||
]
|
||||
)
|
||||
|
||||
const resolvePendingImageAttachment = useCallback(
|
||||
@@ -160,103 +175,6 @@ export function useNativeChatComposerAttachments({
|
||||
[updateImageAttachments]
|
||||
)
|
||||
|
||||
const insertFileReferences = useCallback(
|
||||
(paths: string[]) => {
|
||||
const references = paths.map(formatNativeChatFileReference).join(' ')
|
||||
if (references.length === 0) {
|
||||
return
|
||||
}
|
||||
const insertion = `${references} `
|
||||
const caretAtInsert = textareaRef.current?.selectionStart ?? caret
|
||||
setDraft((prev) => {
|
||||
const before = prev.slice(0, caretAtInsert)
|
||||
const after = prev.slice(caretAtInsert)
|
||||
const next = before + insertion + after
|
||||
setCaret(before.length + insertion.length)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[caret, setCaret, setDraft, textareaRef]
|
||||
)
|
||||
|
||||
// Attach paths the TARGET AGENT can read: local paths for local worktrees,
|
||||
// already-uploaded remote paths for SSH worktrees (the composer uploads
|
||||
// before calling this — see native-chat-attachment-upload.ts).
|
||||
const applyResolvedPaths = useCallback(
|
||||
(
|
||||
resolvedPaths: { path: string; connectionId?: string | null }[],
|
||||
focus: boolean,
|
||||
preserveNotice = false
|
||||
) => {
|
||||
if (attachmentTargetBlocked()) {
|
||||
noteAttachmentTargetBlocked()
|
||||
return
|
||||
}
|
||||
const imagePaths = resolvedPaths.filter(({ path }) => isNativeChatImageAttachmentPath(path))
|
||||
const filePaths = resolvedPaths
|
||||
.filter(({ path }) => !isNativeChatImageAttachmentPath(path))
|
||||
.map(({ path }) => path)
|
||||
// Images are NOT sent to the TUI here — they ride along on submit (see
|
||||
// NativeChatComposer.send) so the GUI chips and the TUI input never
|
||||
// diverge and removing a chip needs no TUI un-paste.
|
||||
appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId })))
|
||||
insertFileReferences(filePaths)
|
||||
if (!preserveNotice) {
|
||||
setNotice(null)
|
||||
}
|
||||
if (focus && resolvedPaths.length > 0) {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
}
|
||||
},
|
||||
[
|
||||
appendImageAttachments,
|
||||
attachmentTargetBlocked,
|
||||
insertFileReferences,
|
||||
noteAttachmentTargetBlocked,
|
||||
setNotice,
|
||||
textareaRef
|
||||
]
|
||||
)
|
||||
|
||||
const attachResolvedPaths = useCallback(
|
||||
(paths: string[], connectionId?: string | null) => {
|
||||
if (paths.length === 0 || disabledRef.current) {
|
||||
return
|
||||
}
|
||||
if (isComposing()) {
|
||||
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS - pendingResolvedPathsRef.current.length) {
|
||||
// Reject the whole completion so ordered path batches are never partially applied.
|
||||
pendingPathLimitRejectedRef.current = true
|
||||
setNotice(
|
||||
translate(
|
||||
'components.native-chat.composer.pendingAttachmentLimit',
|
||||
'Too many attachments are waiting. Finish composing before attaching more.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingResolvedPathsRef.current.push(...paths.map((path) => ({ path, connectionId })))
|
||||
return
|
||||
}
|
||||
applyResolvedPaths(
|
||||
paths.map((path) => ({ path, connectionId })),
|
||||
true
|
||||
)
|
||||
},
|
||||
[applyResolvedPaths, isComposing, setNotice]
|
||||
)
|
||||
|
||||
const flushPendingAttachments = useCallback(() => {
|
||||
const paths = pendingResolvedPathsRef.current
|
||||
const preserveNotice = pendingPathLimitRejectedRef.current
|
||||
pendingResolvedPathsRef.current = []
|
||||
pendingPathLimitRejectedRef.current = false
|
||||
if (paths.length === 0 || disabledRef.current) {
|
||||
return
|
||||
}
|
||||
applyResolvedPaths(paths, false, preserveNotice)
|
||||
}, [applyResolvedPaths])
|
||||
|
||||
return {
|
||||
imageAttachments,
|
||||
attachResolvedPaths,
|
||||
|
||||
+166
-9
@@ -2,10 +2,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authorizeExternalPath: vi.fn(),
|
||||
resolveNativeChatAttachmentOwner: vi.fn(),
|
||||
resolveNativeChatAttachmentOwnerForWorktree: vi.fn(),
|
||||
uploadNativeChatAttachmentPaths: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -13,12 +15,13 @@ vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => ({}) }
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-attachment-upload', () => ({
|
||||
nativeChatLocalAttachmentUnsupportedNotice: () =>
|
||||
'Local attachments are not available for remote sessions.',
|
||||
// Real notice strings, so the tests below assert what a user would actually read
|
||||
// and a newly added notice cannot go missing from this mock.
|
||||
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof AttachmentUploadModule>()),
|
||||
resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner,
|
||||
uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths,
|
||||
nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.'
|
||||
resolveNativeChatAttachmentOwnerForWorktree: mocks.resolveNativeChatAttachmentOwnerForWorktree,
|
||||
uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths
|
||||
}))
|
||||
|
||||
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
|
||||
@@ -35,11 +38,13 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
|
||||
function Probe({
|
||||
disabled,
|
||||
structuredWorktreeId,
|
||||
attachResolvedPaths,
|
||||
setNotice,
|
||||
onReady
|
||||
}: {
|
||||
disabled: boolean
|
||||
structuredWorktreeId?: string
|
||||
attachResolvedPaths: (paths: string[]) => void
|
||||
setNotice: (notice: string | null) => void
|
||||
onReady: (api: HookApi) => void
|
||||
@@ -47,6 +52,7 @@ function Probe({
|
||||
onReady(
|
||||
useNativeChatExternalAttachments({
|
||||
terminalTabId: 'tab-1',
|
||||
structuredWorktreeId,
|
||||
disabled,
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
@@ -59,18 +65,26 @@ let root: Root | null = null
|
||||
|
||||
async function renderProbe(args: {
|
||||
disabled?: boolean
|
||||
structuredWorktreeId?: string
|
||||
attachResolvedPaths: (paths: string[]) => void
|
||||
setNotice?: (notice: string | null) => void
|
||||
}): Promise<{ latest: () => HookApi; setDisabled: (disabled: boolean) => Promise<void> }> {
|
||||
}): Promise<{
|
||||
latest: () => HookApi
|
||||
setDisabled: (disabled: boolean) => Promise<void>
|
||||
setStructuredWorktreeId: (structuredWorktreeId: string) => Promise<void>
|
||||
}> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let api: HookApi | null = null
|
||||
root = createRoot(container)
|
||||
const render = async (disabled: boolean): Promise<void> => {
|
||||
let disabled = args.disabled ?? false
|
||||
let structuredWorktreeId = args.structuredWorktreeId
|
||||
const render = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement(Probe, {
|
||||
disabled,
|
||||
structuredWorktreeId,
|
||||
attachResolvedPaths: args.attachResolvedPaths,
|
||||
setNotice: args.setNotice ?? (() => {}),
|
||||
onReady: (next) => {
|
||||
@@ -80,7 +94,7 @@ async function renderProbe(args: {
|
||||
)
|
||||
})
|
||||
}
|
||||
await render(args.disabled ?? false)
|
||||
await render()
|
||||
return {
|
||||
latest: () => {
|
||||
if (!api) {
|
||||
@@ -88,12 +102,20 @@ async function renderProbe(args: {
|
||||
}
|
||||
return api
|
||||
},
|
||||
setDisabled: render
|
||||
setDisabled: async (next) => {
|
||||
disabled = next
|
||||
await render()
|
||||
},
|
||||
setStructuredWorktreeId: async (next) => {
|
||||
structuredWorktreeId = next
|
||||
await render()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.authorizeExternalPath.mockReset().mockResolvedValue(undefined)
|
||||
mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReset().mockReturnValue({ kind: 'local' })
|
||||
window.api = {
|
||||
fs: { authorizeExternalPath: mocks.authorizeExternalPath }
|
||||
} as unknown as Window['api']
|
||||
@@ -154,6 +176,111 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not attach local paths when the owner changes during authorization', async () => {
|
||||
const authorization = deferred<void>()
|
||||
let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' }
|
||||
mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner)
|
||||
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const notices: (string | null)[] = []
|
||||
const probe = await renderProbe({
|
||||
attachResolvedPaths,
|
||||
setNotice: (notice) => notices.push(notice)
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/external/a.png', '/external/b.png']))
|
||||
owner = { kind: 'runtime' }
|
||||
await act(async () => authorization.resolve())
|
||||
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1)
|
||||
expect(notices.at(-1)).toBe(
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
})
|
||||
|
||||
// The owner flipping during the LAST path has no next iteration to catch it,
|
||||
// so the post-loop check is the only thing standing between a one-file drop
|
||||
// and a path attached to a host that no longer owns it.
|
||||
it('reports a one-file drop whose owner changes during its authorization', async () => {
|
||||
const authorization = deferred<void>()
|
||||
let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' }
|
||||
mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner)
|
||||
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const notices: (string | null)[] = []
|
||||
const probe = await renderProbe({
|
||||
attachResolvedPaths,
|
||||
setNotice: (notice) => notices.push(notice)
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/external/only.pdf']))
|
||||
owner = { kind: 'runtime' }
|
||||
await act(async () => authorization.resolve())
|
||||
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
expect(notices.at(-1)).toBe(
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
})
|
||||
|
||||
// Both workspaces answer `local`, so the owner alone cannot tell them apart:
|
||||
// only asking which workspace this composer serves now catches a tab that
|
||||
// moved while the authorization was still in flight.
|
||||
it('does not attach when the pane changes workspace during authorization', async () => {
|
||||
const authorization = deferred<void>()
|
||||
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const notices: (string | null)[] = []
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'worktree-1',
|
||||
attachResolvedPaths,
|
||||
setNotice: (notice) => notices.push(notice)
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/external/only.pdf']))
|
||||
await probe.setStructuredWorktreeId('worktree-2')
|
||||
await act(async () => authorization.resolve())
|
||||
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
expect(notices.at(-1)).toBe(
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
})
|
||||
|
||||
// The upload window is the long one: the paths go to the remote worktree the
|
||||
// attach captured, so a pane that moved workspaces meanwhile must not receive
|
||||
// remote paths that live under the workspace it left.
|
||||
it('does not attach uploaded paths when the pane changes workspace during upload', async () => {
|
||||
const sshOwner = {
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1',
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
} as const
|
||||
mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReturnValue(sshOwner)
|
||||
const upload = deferred<string[]>()
|
||||
mocks.uploadNativeChatAttachmentPaths.mockReturnValueOnce(upload.promise)
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const notices: (string | null)[] = []
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'worktree-1',
|
||||
attachResolvedPaths,
|
||||
setNotice: (notice) => notices.push(notice)
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
|
||||
await probe.setStructuredWorktreeId('worktree-2')
|
||||
await act(async () => upload.resolve(['/remote/wt/.orca/drops/a.txt']))
|
||||
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
expect(notices.at(-1)).toBe(
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads SSH worktree paths and attaches the remote results', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
|
||||
kind: 'ssh',
|
||||
@@ -278,4 +405,34 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
})
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops an upload that resolves after the SSH owner generation changes', async () => {
|
||||
const initialOwner = {
|
||||
kind: 'ssh' as const,
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/remote/wt',
|
||||
expectedExecutionHostId: 'ssh:conn-1' as const,
|
||||
expectedSshTargetId: 'conn-1',
|
||||
expectedSshConnectionGeneration: 4
|
||||
}
|
||||
mocks.resolveNativeChatAttachmentOwner
|
||||
.mockReturnValueOnce(initialOwner)
|
||||
.mockReturnValue({ ...initialOwner, expectedSshConnectionGeneration: 5 })
|
||||
const upload = deferred<string[]>()
|
||||
mocks.uploadNativeChatAttachmentPaths.mockReturnValue(upload.promise)
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const notices: (string | null)[] = []
|
||||
const probe = await renderProbe({
|
||||
attachResolvedPaths,
|
||||
setNotice: (notice) => notices.push(notice)
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
|
||||
await act(async () => upload.resolve(['/remote/wt/.orca/drops/a.txt']))
|
||||
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
expect(notices.at(-1)).toBe(
|
||||
'This workspace changed hosts while attaching — drop the files again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
|
||||
import {
|
||||
nativeChatAttachmentOwnerChangedNotice,
|
||||
nativeChatAttachmentUnreadableNotice,
|
||||
nativeChatLocalAttachmentUnsupportedNotice,
|
||||
nativeChatWorktreeNotReadyNotice,
|
||||
resolveNativeChatAttachmentOwner,
|
||||
@@ -19,6 +22,15 @@ export type UseNativeChatExternalAttachmentsArgs = {
|
||||
setNotice: (notice: string | null) => void
|
||||
}
|
||||
|
||||
type ComposerWorkspace = { structuredWorktreeId?: string; terminalTabId: string }
|
||||
|
||||
function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerWorkspace): boolean {
|
||||
return (
|
||||
captured.structuredWorktreeId === current.structuredWorktreeId &&
|
||||
captured.terminalTabId === current.terminalTabId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach paths that arrived client-local (composer drop / file picker). SSH
|
||||
* worktrees upload into the worktree's `.orca/drops` first so the remote agent
|
||||
@@ -39,13 +51,23 @@ export function useNativeChatExternalAttachments({
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
const resolveAttachmentOwner = useCallback(
|
||||
() =>
|
||||
structuredWorktreeId
|
||||
? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId)
|
||||
: resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId),
|
||||
[structuredWorktreeId, terminalTabId]
|
||||
)
|
||||
// The post-await gate asks which workspace this composer serves now, so it
|
||||
// reads the pane through a ref. Resolving through the render closure would
|
||||
// re-ask the workspace the upload started in — a comparison with itself.
|
||||
const workspaceRef = useRef<ComposerWorkspace>({ structuredWorktreeId, terminalTabId })
|
||||
useLayoutEffect(() => {
|
||||
workspaceRef.current = { structuredWorktreeId, terminalTabId }
|
||||
}, [structuredWorktreeId, terminalTabId])
|
||||
|
||||
const resolveAttachmentOwner = useCallback(() => {
|
||||
const workspace = workspaceRef.current
|
||||
return workspace.structuredWorktreeId
|
||||
? resolveNativeChatAttachmentOwnerForWorktree(
|
||||
useAppStore.getState(),
|
||||
workspace.structuredWorktreeId
|
||||
)
|
||||
: resolveNativeChatAttachmentOwner(useAppStore.getState(), workspace.terminalTabId)
|
||||
}, [])
|
||||
|
||||
const attachExternalPaths = useCallback(
|
||||
(paths: string[]) => {
|
||||
@@ -61,6 +83,15 @@ export function useNativeChatExternalAttachments({
|
||||
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
|
||||
return
|
||||
}
|
||||
// Why every exit reports: a drop that reaches here and produces nothing is
|
||||
// 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
|
||||
// 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) &&
|
||||
nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner())
|
||||
if (owner.kind !== 'ssh') {
|
||||
void (async () => {
|
||||
const authorizedPaths: string[] = []
|
||||
@@ -68,6 +99,10 @@ export function useNativeChatExternalAttachments({
|
||||
if (disabledRef.current) {
|
||||
return
|
||||
}
|
||||
if (!ownerStillCurrent()) {
|
||||
setNotice(nativeChatAttachmentOwnerChangedNotice())
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.api.fs.authorizeExternalPath({ targetPath })
|
||||
authorizedPaths.push(targetPath)
|
||||
@@ -75,15 +110,34 @@ export function useNativeChatExternalAttachments({
|
||||
// Skip unreadable paths, matching workspace composer drops.
|
||||
}
|
||||
}
|
||||
if (authorizedPaths.length > 0 && !disabledRef.current) {
|
||||
attachResolvedPaths(authorizedPaths)
|
||||
if (disabledRef.current) {
|
||||
return
|
||||
}
|
||||
if (!ownerStillCurrent()) {
|
||||
setNotice(nativeChatAttachmentOwnerChangedNotice())
|
||||
return
|
||||
}
|
||||
if (authorizedPaths.length === 0) {
|
||||
setNotice(nativeChatAttachmentUnreadableNotice())
|
||||
return
|
||||
}
|
||||
attachResolvedPaths(authorizedPaths)
|
||||
})()
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const remotePaths = await uploadNativeChatAttachmentPaths(paths, owner)
|
||||
if (!remotePaths || remotePaths.length === 0 || disabledRef.current) {
|
||||
if (disabledRef.current) {
|
||||
return
|
||||
}
|
||||
if (!remotePaths || remotePaths.length === 0) {
|
||||
// uploadNativeChatAttachmentPaths already toasted the IPC failure;
|
||||
// an empty result with no failure means nothing was readable.
|
||||
setNotice(nativeChatAttachmentUnreadableNotice())
|
||||
return
|
||||
}
|
||||
if (!ownerStillCurrent()) {
|
||||
setNotice(nativeChatAttachmentOwnerChangedNotice())
|
||||
return
|
||||
}
|
||||
attachResolvedPaths(remotePaths, owner.connectionId)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
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 {
|
||||
nativeChatWorkspaceAttachmentMismatchNotice,
|
||||
type NativeChatResolvedPathOptions
|
||||
} from './native-chat-resolved-path-ownership'
|
||||
|
||||
type ResolvedAttachmentPath = {
|
||||
path: string
|
||||
connectionId?: string | null
|
||||
targetOwnerIsCurrent?: () => boolean
|
||||
}
|
||||
|
||||
type Args = {
|
||||
appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void
|
||||
attachmentTargetBlocked: (targetOwned?: boolean) => boolean
|
||||
caret: number
|
||||
disabled: boolean
|
||||
isComposing: () => boolean
|
||||
noteAttachmentTargetBlocked: () => void
|
||||
setCaret: (caret: number) => void
|
||||
setDraft: (updater: (previous: string) => string) => void
|
||||
setNotice: (notice: string | null) => void
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
}
|
||||
|
||||
export function useNativeChatResolvedPathAttachments({
|
||||
appendImageAttachments,
|
||||
attachmentTargetBlocked,
|
||||
caret,
|
||||
disabled,
|
||||
isComposing,
|
||||
noteAttachmentTargetBlocked,
|
||||
setCaret,
|
||||
setDraft,
|
||||
setNotice,
|
||||
textareaRef
|
||||
}: Args): {
|
||||
attachResolvedPaths: (
|
||||
paths: string[],
|
||||
connectionId?: string | null,
|
||||
options?: NativeChatResolvedPathOptions
|
||||
) => void
|
||||
disabledRef: RefObject<boolean>
|
||||
flushPendingAttachments: () => void
|
||||
} {
|
||||
const pendingResolvedPathsRef = useRef<ResolvedAttachmentPath[]>([])
|
||||
const pendingPathLimitRejectedRef = useRef(false)
|
||||
const disabledRef = useRef(disabled)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
if (disabled) {
|
||||
pendingResolvedPathsRef.current = []
|
||||
pendingPathLimitRejectedRef.current = false
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
const insertFileReferences = useCallback(
|
||||
(paths: string[]) => {
|
||||
const references = paths.map(formatNativeChatFileReference).join(' ')
|
||||
if (references.length === 0) {
|
||||
return
|
||||
}
|
||||
const insertion = `${references} `
|
||||
const caretAtInsert = textareaRef.current?.selectionStart ?? caret
|
||||
setDraft((prev) => {
|
||||
const before = prev.slice(0, caretAtInsert)
|
||||
const after = prev.slice(caretAtInsert)
|
||||
setCaret(before.length + insertion.length)
|
||||
return before + insertion + after
|
||||
})
|
||||
},
|
||||
[caret, setCaret, setDraft, textareaRef]
|
||||
)
|
||||
|
||||
const applyResolvedPaths = useCallback(
|
||||
(resolvedPaths: ResolvedAttachmentPath[], focus: boolean, preserveNotice = false) => {
|
||||
if (resolvedPaths.length === 0) {
|
||||
return
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// Ownership is per path, so the verdict is too: a queued batch can mix a
|
||||
// workspace drop the target owns with a client-local paste it does not,
|
||||
// and one verdict for the batch would refuse the drop the user can make.
|
||||
const ownedBlocked =
|
||||
resolvedPaths.some(({ targetOwnerIsCurrent }) => targetOwnerIsCurrent) &&
|
||||
attachmentTargetBlocked(true)
|
||||
const clientLocalBlocked =
|
||||
resolvedPaths.some(({ targetOwnerIsCurrent }) => !targetOwnerIsCurrent) &&
|
||||
attachmentTargetBlocked(false)
|
||||
// Filter rather than partition: the two halves are interleaved, and these
|
||||
// references are inserted in the order the user attached them.
|
||||
const attachable = resolvedPaths.filter(({ targetOwnerIsCurrent }) =>
|
||||
targetOwnerIsCurrent ? !ownedBlocked : !clientLocalBlocked
|
||||
)
|
||||
if (attachable.length === 0) {
|
||||
noteAttachmentTargetBlocked()
|
||||
return
|
||||
}
|
||||
const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path))
|
||||
const filePaths = attachable
|
||||
.filter(({ path }) => !isNativeChatImageAttachmentPath(path))
|
||||
.map(({ path }) => path)
|
||||
// Images ride along on submit so chips and the TUI input cannot diverge.
|
||||
appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId })))
|
||||
insertFileReferences(filePaths)
|
||||
if (ownedBlocked || clientLocalBlocked) {
|
||||
noteAttachmentTargetBlocked()
|
||||
} else if (!preserveNotice) {
|
||||
setNotice(null)
|
||||
}
|
||||
if (focus) {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
}
|
||||
},
|
||||
[
|
||||
appendImageAttachments,
|
||||
attachmentTargetBlocked,
|
||||
insertFileReferences,
|
||||
noteAttachmentTargetBlocked,
|
||||
setNotice,
|
||||
textareaRef
|
||||
]
|
||||
)
|
||||
|
||||
const attachResolvedPaths = useCallback(
|
||||
(
|
||||
paths: string[],
|
||||
connectionId?: string | null,
|
||||
options: NativeChatResolvedPathOptions = {}
|
||||
) => {
|
||||
if (paths.length === 0 || disabledRef.current) {
|
||||
return
|
||||
}
|
||||
const targetOwnerIsCurrent = options.targetOwnerIsCurrent?.()
|
||||
if (targetOwnerIsCurrent === false) {
|
||||
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
|
||||
return
|
||||
}
|
||||
if (attachmentTargetBlocked(targetOwnerIsCurrent === true)) {
|
||||
noteAttachmentTargetBlocked()
|
||||
return
|
||||
}
|
||||
if (isComposing()) {
|
||||
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS - pendingResolvedPathsRef.current.length) {
|
||||
// Reject the whole completion so ordered path batches are never partially applied.
|
||||
pendingPathLimitRejectedRef.current = true
|
||||
setNotice(
|
||||
translate(
|
||||
'components.native-chat.composer.pendingAttachmentLimit',
|
||||
'Too many attachments are waiting. Finish composing before attaching more.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingResolvedPathsRef.current.push(
|
||||
...paths.map((path) => ({
|
||||
path,
|
||||
connectionId,
|
||||
targetOwnerIsCurrent: options.targetOwnerIsCurrent
|
||||
}))
|
||||
)
|
||||
return
|
||||
}
|
||||
applyResolvedPaths(
|
||||
paths.map((path) => ({
|
||||
path,
|
||||
connectionId,
|
||||
targetOwnerIsCurrent: options.targetOwnerIsCurrent
|
||||
})),
|
||||
true
|
||||
)
|
||||
},
|
||||
[
|
||||
applyResolvedPaths,
|
||||
attachmentTargetBlocked,
|
||||
isComposing,
|
||||
noteAttachmentTargetBlocked,
|
||||
setNotice
|
||||
]
|
||||
)
|
||||
|
||||
const flushPendingAttachments = useCallback(() => {
|
||||
const paths = pendingResolvedPathsRef.current
|
||||
const preserveNotice = pendingPathLimitRejectedRef.current
|
||||
pendingResolvedPathsRef.current = []
|
||||
pendingPathLimitRejectedRef.current = false
|
||||
if (paths.length === 0 || disabledRef.current) {
|
||||
return
|
||||
}
|
||||
applyResolvedPaths(paths, false, preserveNotice)
|
||||
}, [applyResolvedPaths])
|
||||
|
||||
return { attachResolvedPaths, disabledRef, flushPendingAttachments }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type DragEventHandler } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
getWorkspaceFileDragRejectionMessage,
|
||||
hasWorkspaceFileDragType,
|
||||
isResolvedWorkspaceFileDragExecutionHost,
|
||||
readWorkspaceFileDragPaths,
|
||||
readWorkspaceFileDragSource
|
||||
} from '@/lib/workspace-file-drag'
|
||||
import {
|
||||
resolveNativeChatAttachmentOwnerForWorktree,
|
||||
nativeChatWorktreeNotReadyNotice
|
||||
} from './native-chat-attachment-upload'
|
||||
import { findTerminalTabWorktreeId } from './native-chat-file-link'
|
||||
import {
|
||||
nativeChatAttachmentOwnerUnchanged,
|
||||
nativeChatWorkspaceAttachmentMismatchNotice,
|
||||
type NativeChatResolvedPathOptions
|
||||
} from './native-chat-resolved-path-ownership'
|
||||
|
||||
type WorkspaceFileDropHandlers = {
|
||||
onDragOverCapture: DragEventHandler<HTMLDivElement>
|
||||
onDropCapture: DragEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
type Args = {
|
||||
attachResolvedPaths: (
|
||||
paths: string[],
|
||||
connectionId?: string | null,
|
||||
options?: NativeChatResolvedPathOptions
|
||||
) => void
|
||||
disabled: boolean
|
||||
setNotice: (notice: string | null) => void
|
||||
structuredWorktreeId?: string
|
||||
terminalTabId: string
|
||||
}
|
||||
|
||||
// The composer sits inside the terminal surface, which accepts the same drag and
|
||||
// pastes it into the shell. Claiming the event here is what keeps a drop aimed at
|
||||
// the composer out of the terminal behind it — including when we refuse it.
|
||||
function claimWorkspaceFileDrag(event: React.DragEvent<HTMLDivElement>): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function setDropEffect(dataTransfer: DataTransfer, effect: 'copy' | 'none'): void {
|
||||
if (effect === 'none') {
|
||||
dataTransfer.dropEffect = 'none'
|
||||
return
|
||||
}
|
||||
if (
|
||||
dataTransfer.effectAllowed === 'all' ||
|
||||
dataTransfer.effectAllowed === 'copy' ||
|
||||
dataTransfer.effectAllowed === 'copyLink' ||
|
||||
dataTransfer.effectAllowed === 'copyMove' ||
|
||||
dataTransfer.effectAllowed === 'uninitialized'
|
||||
) {
|
||||
dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}
|
||||
|
||||
export function useNativeChatWorkspaceFileDrop({
|
||||
attachResolvedPaths,
|
||||
disabled,
|
||||
setNotice,
|
||||
structuredWorktreeId,
|
||||
terminalTabId
|
||||
}: Args): WorkspaceFileDropHandlers {
|
||||
// The IME-flush check runs against a closure captured at drop time. Reading
|
||||
// the prop through a ref keeps "is this still my workspace?" a real question
|
||||
// rather than a comparison of one captured value against itself.
|
||||
const structuredWorktreeIdRef = useRef(structuredWorktreeId)
|
||||
useLayoutEffect(() => {
|
||||
structuredWorktreeIdRef.current = structuredWorktreeId
|
||||
}, [structuredWorktreeId])
|
||||
|
||||
const onDragOverCapture = useCallback<DragEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
if (!hasWorkspaceFileDragType(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
claimWorkspaceFileDrag(event)
|
||||
// A guarded composer answers `none` rather than promising a copy it will
|
||||
// then drop on the floor: the cursor refuses, and no drop event follows.
|
||||
setDropEffect(event.dataTransfer, disabled ? 'none' : 'copy')
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
const onDropCapture = useCallback<DragEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
if (!hasWorkspaceFileDragType(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
claimWorkspaceFileDrag(event)
|
||||
if (disabled) {
|
||||
setDropEffect(event.dataTransfer, 'none')
|
||||
return
|
||||
}
|
||||
setDropEffect(event.dataTransfer, 'copy')
|
||||
|
||||
const dragPaths = readWorkspaceFileDragPaths(event.dataTransfer)
|
||||
if (dragPaths.status === 'rejected') {
|
||||
setNotice(getWorkspaceFileDragRejectionMessage(dragPaths.reason))
|
||||
return
|
||||
}
|
||||
if (dragPaths.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const workspaceId =
|
||||
structuredWorktreeId ?? findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId)
|
||||
const source = readWorkspaceFileDragSource(event.dataTransfer)
|
||||
if (!workspaceId || !source || source.workspaceId !== workspaceId) {
|
||||
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
|
||||
return
|
||||
}
|
||||
const owner = resolveNativeChatAttachmentOwnerForWorktree(
|
||||
state,
|
||||
workspaceId,
|
||||
structuredWorktreeId ? undefined : terminalTabId
|
||||
)
|
||||
if (owner.kind === 'not-ready') {
|
||||
setNotice(nativeChatWorktreeNotReadyNotice())
|
||||
return
|
||||
}
|
||||
const targetExecutionHostId = getExecutionHostIdForWorktree(state, workspaceId)
|
||||
if (
|
||||
!isResolvedWorkspaceFileDragExecutionHost(targetExecutionHostId) ||
|
||||
source.executionHostId !== targetExecutionHostId
|
||||
) {
|
||||
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
|
||||
return
|
||||
}
|
||||
|
||||
const targetOwnerIsCurrent = (): boolean => {
|
||||
const currentState = useAppStore.getState()
|
||||
const currentWorkspaceId =
|
||||
structuredWorktreeIdRef.current ??
|
||||
findTerminalTabWorktreeId(currentState.tabsByWorktree, terminalTabId)
|
||||
if (currentWorkspaceId !== source.workspaceId) {
|
||||
return false
|
||||
}
|
||||
const currentHostId = getExecutionHostIdForWorktree(currentState, currentWorkspaceId)
|
||||
const currentOwner = resolveNativeChatAttachmentOwnerForWorktree(
|
||||
currentState,
|
||||
currentWorkspaceId,
|
||||
structuredWorktreeIdRef.current ? undefined : terminalTabId
|
||||
)
|
||||
return (
|
||||
isResolvedWorkspaceFileDragExecutionHost(currentHostId) &&
|
||||
currentHostId === source.executionHostId &&
|
||||
nativeChatAttachmentOwnerUnchanged(owner, currentOwner)
|
||||
)
|
||||
}
|
||||
|
||||
attachResolvedPaths(dragPaths.paths, owner.kind === 'ssh' ? owner.connectionId : undefined, {
|
||||
targetOwnerIsCurrent
|
||||
})
|
||||
},
|
||||
[attachResolvedPaths, disabled, setNotice, structuredWorktreeId, terminalTabId]
|
||||
)
|
||||
|
||||
return { onDragOverCapture, onDropCapture }
|
||||
}
|
||||
@@ -183,6 +183,8 @@ export function FileExplorerFilesTreePane({
|
||||
flashingPath={flashingPath}
|
||||
deleteShortcutLabel={deletion.deleteShortcutLabel}
|
||||
connectionId={activeRepo?.connectionId ?? null}
|
||||
sourceWorkspaceId={tree.sourceWorkspaceId}
|
||||
dirCache={tree.dirCache}
|
||||
runtimeDownloadContext={runtimeDownloadContext}
|
||||
supportsFolderDownload={supportsFolderDownload}
|
||||
canOpenInOrcaBrowser={canOpenWorkspaceFileBrowserForPath}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import {
|
||||
encodeWorkspaceFilePaths,
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
WORKSPACE_FILE_PATHS_MIME
|
||||
WORKSPACE_FILE_PATHS_MIME,
|
||||
writeWorkspaceFileDragSourceIfResolved
|
||||
} from '@/lib/workspace-file-drag'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { GitFileStatus } from '../../../../shared/git-status-types'
|
||||
import { STATUS_LABELS } from './status-display'
|
||||
import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing'
|
||||
@@ -33,6 +35,9 @@ export type FileExplorerRowProps = {
|
||||
isIgnored: boolean
|
||||
deleteShortcutLabel: string
|
||||
connectionId?: string | null
|
||||
sourceWorkspaceId?: string | null
|
||||
/** Resolved at dragstart so the virtualized list pays nothing per render. */
|
||||
resolveDragSourceHostId?: (paths: readonly string[]) => ExecutionHostId | null
|
||||
runtimeDownloadContext?: RuntimeFileOperationArgs | null
|
||||
supportsFolderDownload?: boolean
|
||||
canOpenInOrcaBrowser: boolean
|
||||
@@ -74,6 +79,8 @@ export function FileExplorerRow({
|
||||
isIgnored,
|
||||
deleteShortcutLabel,
|
||||
connectionId,
|
||||
sourceWorkspaceId,
|
||||
resolveDragSourceHostId,
|
||||
runtimeDownloadContext,
|
||||
supportsFolderDownload = false,
|
||||
canOpenInOrcaBrowser,
|
||||
@@ -153,6 +160,11 @@ export function FileExplorerRow({
|
||||
if (paths.length > 1) {
|
||||
event.dataTransfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths))
|
||||
}
|
||||
writeWorkspaceFileDragSourceIfResolved(
|
||||
event.dataTransfer,
|
||||
sourceWorkspaceId,
|
||||
resolveDragSourceHostId?.(paths)
|
||||
)
|
||||
event.dataTransfer.effectAllowed = 'copyMove'
|
||||
onDragSourceChange(node.path)
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import type { GitFileStatus } from '../../../../shared/git-status-types'
|
||||
import { FileExplorerRow } from './FileExplorerRow'
|
||||
import { InlineInputRow, type InlineInput } from './file-explorer-inline-input-row'
|
||||
import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import type { DirCache, FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
|
||||
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import { getFileExplorerOperationExecutionHostId } from './file-explorer-operation-owner'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
type FileExplorerVirtualRowsProps = {
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
@@ -28,6 +30,10 @@ type FileExplorerVirtualRowsProps = {
|
||||
flashingPath: string | null
|
||||
deleteShortcutLabel: string
|
||||
connectionId?: string | null
|
||||
sourceWorkspaceId?: string | null
|
||||
/** Listings behind the projection, so a drag can name the owner of a selected
|
||||
* path whose row is currently hidden. */
|
||||
dirCache?: Record<string, DirCache>
|
||||
runtimeDownloadContext?: RuntimeFileOperationArgs | null
|
||||
supportsFolderDownload?: boolean
|
||||
canOpenInOrcaBrowser?: (filePath: string) => boolean
|
||||
@@ -56,6 +62,44 @@ type FileExplorerVirtualRowsProps = {
|
||||
nativeDropTargetDir: string | null
|
||||
}
|
||||
|
||||
/** The owner of a dragged path, from the visible row when there is one and from
|
||||
* the cached listing when there is not. A selection survives collapsing a
|
||||
* directory, a name filter and the dotfile toggle, and the drag still carries
|
||||
* those paths — the projection only stopped indexing them, the cache still
|
||||
* records which host listed them. */
|
||||
function getDraggedPathOperationOwner(
|
||||
rowProjection: FileExplorerRowProjection,
|
||||
dirCache: Record<string, DirCache> | undefined,
|
||||
path: string
|
||||
): FileExplorerOperationOwner | undefined {
|
||||
const visibleOwner = rowProjection.getRowByPath(path)?.operationOwner
|
||||
if (visibleOwner || !dirCache) {
|
||||
return visibleOwner
|
||||
}
|
||||
const parent = dirCache[dirname(path)]
|
||||
return parent?.children.find((child) => child.path === path)?.operationOwner
|
||||
}
|
||||
|
||||
/** Null unless every dragged row came from one host: a mixed-owner drag has no
|
||||
* single source to stamp, so it must fail closed at the drop target. */
|
||||
function resolveDragSourceExecutionHostId(
|
||||
rowProjection: FileExplorerRowProjection,
|
||||
dirCache: Record<string, DirCache> | undefined,
|
||||
paths: readonly string[]
|
||||
): ExecutionHostId | null {
|
||||
let sourceExecutionHostId: ExecutionHostId | null = null
|
||||
for (const path of paths) {
|
||||
const executionHostId = getFileExplorerOperationExecutionHostId(
|
||||
getDraggedPathOperationOwner(rowProjection, dirCache, path)
|
||||
)
|
||||
if (!executionHostId || (sourceExecutionHostId && executionHostId !== sourceExecutionHostId)) {
|
||||
return null
|
||||
}
|
||||
sourceExecutionHostId = executionHostId
|
||||
}
|
||||
return sourceExecutionHostId
|
||||
}
|
||||
|
||||
export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): React.JSX.Element {
|
||||
const {
|
||||
virtualizer,
|
||||
@@ -75,6 +119,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
|
||||
flashingPath,
|
||||
deleteShortcutLabel,
|
||||
connectionId,
|
||||
sourceWorkspaceId,
|
||||
dirCache,
|
||||
runtimeDownloadContext,
|
||||
supportsFolderDownload = false,
|
||||
canOpenInOrcaBrowser = () => false,
|
||||
@@ -104,6 +150,10 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
|
||||
} = props
|
||||
|
||||
const visibleSelectionCount = rowProjection.countVisiblePaths(selectedPaths)
|
||||
// Resolved at dragstart, not per render: the virtualizer re-renders on every
|
||||
// scroll frame and only a drag ever reads this.
|
||||
const resolveDragSourceHostId = (paths: readonly string[]): ExecutionHostId | null =>
|
||||
resolveDragSourceExecutionHostId(rowProjection, dirCache, paths)
|
||||
|
||||
return (
|
||||
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
@@ -181,6 +231,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
|
||||
isIgnored={isIgnored}
|
||||
deleteShortcutLabel={deleteShortcutLabel}
|
||||
connectionId={connectionId}
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
resolveDragSourceHostId={resolveDragSourceHostId}
|
||||
runtimeDownloadContext={runtimeDownloadContext}
|
||||
supportsFolderDownload={supportsFolderDownload}
|
||||
canOpenInOrcaBrowser={canOpenInOrcaBrowser(n.path)}
|
||||
|
||||
+151
-4
@@ -6,8 +6,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
|
||||
import { useFileExplorerHandlers } from './useFileExplorerHandlers'
|
||||
import { createFileExplorerRowProjection } from './file-explorer-row-projection'
|
||||
import { createVisibleFileExplorerRowProjection } from './useFileExplorerVisibleRowProjection'
|
||||
import { FILE_EXPLORER_DRAGGABLE_SELECTOR } from './file-explorer-drag-scroll-marker'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import type { DirCache, TreeNode } from './file-explorer-types'
|
||||
import { readWorkspaceFileDragSource } from '@/lib/workspace-file-drag'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
@@ -47,7 +49,15 @@ const directoryNode: TreeNode = {
|
||||
depth: 0
|
||||
}
|
||||
|
||||
function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element {
|
||||
function virtualRowsElement(
|
||||
nodes: TreeNode[],
|
||||
options: {
|
||||
dirCache?: Record<string, DirCache>
|
||||
selectedPaths?: Set<string>
|
||||
sourceWorkspaceId?: string
|
||||
rowProjection?: ReturnType<typeof createFileExplorerRowProjection>
|
||||
} = {}
|
||||
): React.JSX.Element {
|
||||
return FileExplorerVirtualRows({
|
||||
virtualizer: {
|
||||
getTotalSize: () => nodes.length * 26,
|
||||
@@ -56,7 +66,7 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element {
|
||||
measureElement: vi.fn()
|
||||
} as never,
|
||||
inlineInputIndex: -1,
|
||||
rowProjection: createFileExplorerRowProjection(nodes),
|
||||
rowProjection: options.rowProjection ?? createFileExplorerRowProjection(nodes),
|
||||
inlineInput: null,
|
||||
handleInlineSubmit: vi.fn(),
|
||||
dismissInlineInput: vi.fn(),
|
||||
@@ -65,10 +75,12 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element {
|
||||
ignoredByRelativePath: new Set(),
|
||||
expanded: new Set(),
|
||||
loadingDirPaths: new Set<string>(),
|
||||
selectedPaths: new Set(),
|
||||
selectedPaths: options.selectedPaths ?? new Set(),
|
||||
activeFileId: null,
|
||||
flashingPath: null,
|
||||
deleteShortcutLabel: 'Del',
|
||||
sourceWorkspaceId: options.sourceWorkspaceId,
|
||||
dirCache: options.dirCache,
|
||||
onClick: vi.fn(),
|
||||
onDoubleClick: vi.fn(),
|
||||
onContextMenuSelect: vi.fn(),
|
||||
@@ -107,6 +119,141 @@ describe('file explorer draggable rows carry the wheel-scroll marker', () => {
|
||||
expect(button.matches(FILE_EXPLORER_DRAGGABLE_SELECTOR)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps a row with the workspace and host that produced its cached node', async () => {
|
||||
const cachedNode: TreeNode = {
|
||||
...fileNode,
|
||||
operationOwner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'old-env',
|
||||
executionHostId: 'runtime:old-env'
|
||||
}
|
||||
}
|
||||
const container = await renderToBody(
|
||||
virtualRowsElement([cachedNode], { sourceWorkspaceId: 'old-workspace' })
|
||||
)
|
||||
const transfer = new DataTransfer()
|
||||
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer })
|
||||
|
||||
container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event)
|
||||
|
||||
expect(readWorkspaceFileDragSource(transfer)).toEqual({
|
||||
executionHostId: 'runtime:old-env',
|
||||
workspaceId: 'old-workspace'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits ownership when selected cached rows came from different hosts', async () => {
|
||||
const localNode: TreeNode = { ...fileNode, operationOwner: { kind: 'local' } }
|
||||
const sshNode: TreeNode = {
|
||||
...directoryNode,
|
||||
operationOwner: { kind: 'ssh', connectionId: 'remote-1' }
|
||||
}
|
||||
const container = await renderToBody(
|
||||
virtualRowsElement([localNode, sshNode], {
|
||||
selectedPaths: new Set([localNode.path, sshNode.path]),
|
||||
sourceWorkspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
const transfer = new DataTransfer()
|
||||
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer })
|
||||
|
||||
container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event)
|
||||
|
||||
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
|
||||
})
|
||||
|
||||
// A selection outlives the rows that showed it: nothing prunes selectedPaths
|
||||
// when a directory collapses, and the drag still carries every selected path.
|
||||
// Resolving only against visible rows refused a drag whose owner the cache
|
||||
// knows perfectly well.
|
||||
it('stamps a selection that reaches under a collapsed directory', async () => {
|
||||
const owner = { kind: 'local' } as const
|
||||
const collapsedChild: TreeNode = {
|
||||
name: 'a.ts',
|
||||
path: '/repo/src/a.ts',
|
||||
relativePath: 'src/a.ts',
|
||||
isDirectory: false,
|
||||
depth: 1,
|
||||
operationOwner: owner
|
||||
}
|
||||
const readme: TreeNode = {
|
||||
name: 'README.md',
|
||||
path: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
isDirectory: false,
|
||||
depth: 0,
|
||||
operationOwner: owner
|
||||
}
|
||||
const dirCache = {
|
||||
'/repo': {
|
||||
children: [{ ...directoryNode, operationOwner: owner }, readme],
|
||||
operationOwner: owner
|
||||
},
|
||||
'/repo/src': { children: [collapsedChild], operationOwner: owner }
|
||||
}
|
||||
// `expanded` is empty, so /repo/src is collapsed and a.ts is not a row.
|
||||
const projection = createVisibleFileExplorerRowProjection(
|
||||
{ dirCache, expanded: new Set<string>(), worktreePath: '/repo' },
|
||||
{
|
||||
ignoredSet: new Set<string>(),
|
||||
nameFilter: null,
|
||||
showDotfiles: true,
|
||||
showGitIgnoredFiles: true
|
||||
}
|
||||
)
|
||||
expect(projection.getRowByPath(collapsedChild.path)).toBeNull()
|
||||
|
||||
const container = await renderToBody(
|
||||
virtualRowsElement([directoryNode, readme], {
|
||||
dirCache,
|
||||
rowProjection: projection,
|
||||
selectedPaths: new Set([readme.path, collapsedChild.path]),
|
||||
sourceWorkspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
const transfer = new DataTransfer()
|
||||
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer })
|
||||
|
||||
container.querySelectorAll('[data-file-explorer-row]')[1]?.dispatchEvent(event)
|
||||
|
||||
expect(readWorkspaceFileDragSource(transfer)).toEqual({
|
||||
executionHostId: 'local',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
})
|
||||
|
||||
// The virtualizer re-renders on every scroll frame; a per-render owner scan
|
||||
// over the whole selection would be paid on each of them.
|
||||
it('resolves drag ownership at dragstart, not while rendering rows', async () => {
|
||||
const localNode: TreeNode = { ...fileNode, operationOwner: { kind: 'local' } }
|
||||
const otherNode: TreeNode = { ...directoryNode, operationOwner: { kind: 'local' } }
|
||||
const projection = createFileExplorerRowProjection([localNode, otherNode])
|
||||
const getRowByPath = vi.fn(projection.getRowByPath)
|
||||
const container = await renderToBody(
|
||||
virtualRowsElement([localNode, otherNode], {
|
||||
rowProjection: { ...projection, getRowByPath },
|
||||
selectedPaths: new Set([localNode.path, otherNode.path]),
|
||||
sourceWorkspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(getRowByPath).not.toHaveBeenCalled()
|
||||
|
||||
const transfer = new DataTransfer()
|
||||
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer })
|
||||
container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event)
|
||||
|
||||
expect(getRowByPath.mock.calls.map(([path]) => path)).toEqual([localNode.path, otherNode.path])
|
||||
expect(readWorkspaceFileDragSource(transfer)).toEqual({
|
||||
executionHostId: 'local',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
let capturedHandlers: ReturnType<typeof useFileExplorerHandlers> | null = null
|
||||
|
||||
@@ -234,6 +234,12 @@ function getFileExplorerGenerationRoute(
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExplorerOperationExecutionHostId(
|
||||
owner: FileExplorerOperationOwner | undefined
|
||||
): ExecutionHostId | null {
|
||||
return getFileExplorerGenerationRoute(owner)?.executionHostId ?? null
|
||||
}
|
||||
|
||||
export function getFileExplorerOwnerUnresolvedMessage(): string {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.fileExplorerOperationOwner.unresolved',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MessageSquare } from 'lucide-react'
|
||||
import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import { DiffLineCounts } from './diff-line-counts'
|
||||
@@ -55,6 +56,7 @@ export function BranchEntryRow({
|
||||
onDragStart={(e) => {
|
||||
const absolutePath = joinPath(worktreePath, entry.path)
|
||||
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
|
||||
writeWorkspaceFileDragSourceForWorkspace(e.dataTransfer, currentWorktreeId)
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
onClick={(e) => onOpen(e)}
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import { ActionButton } from './action-button'
|
||||
@@ -122,6 +123,7 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
}
|
||||
const absolutePath = joinPath(worktreePath, entry.path)
|
||||
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
|
||||
writeWorkspaceFileDragSourceForWorkspace(e.dataTransfer, currentWorktreeId)
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -152,4 +152,26 @@ describe('useFileExplorerTree stale collapsed dirs', () => {
|
||||
})
|
||||
expect(result.current.isDirStale('/repo/src')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the rendered cache bound to its loaded workspace until reset', async () => {
|
||||
const props = { path: '/repo', worktreeId: 'wt-1' }
|
||||
const { result, rerender } = renderHook(
|
||||
({ path, worktreeId }: typeof props) => useFileExplorerTree(path, new Set(), worktreeId),
|
||||
{ initialProps: props }
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadDir('/repo', -1)
|
||||
})
|
||||
expect(result.current.sourceWorkspaceId).toBe('wt-1')
|
||||
|
||||
rerender({ path: '/repo', worktreeId: 'wt-2' })
|
||||
expect(result.current.sourceWorkspaceId).toBe('wt-1')
|
||||
|
||||
await act(async () => {
|
||||
result.current.resetAndLoad()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(result.current.sourceWorkspaceId).toBe('wt-2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
type UseFileExplorerTreeResult = {
|
||||
dirCache: Record<string, DirCache>
|
||||
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
|
||||
/** Workspace whose committed root listing owns the rendered cache. */
|
||||
sourceWorkspaceId: string | null
|
||||
/** Dirs with a read in flight — kept out of dirCache so the row projection does not rebuild. */
|
||||
loadingDirPaths: ReadonlySet<string>
|
||||
rootCache: DirCache | undefined
|
||||
@@ -54,6 +56,7 @@ export function useFileExplorerTree(
|
||||
EMPTY_FILE_EXPLORER_LOADING_DIRS
|
||||
)
|
||||
const [rootError, setRootError] = useState<string | null>(null)
|
||||
const [sourceWorkspaceId, setSourceWorkspaceId] = useState<string | null>(null)
|
||||
const dirCacheRef = useRef(dirCache)
|
||||
dirCacheRef.current = dirCache
|
||||
// Why the ref is authoritative rather than a render mirror: writing it during render is unsafe
|
||||
@@ -108,6 +111,7 @@ export function useFileExplorerTree(
|
||||
}
|
||||
if (depth === -1) {
|
||||
setRootError(null)
|
||||
setSourceWorkspaceId(activeWorktreeId?.trim() || null)
|
||||
}
|
||||
const children = fileExplorerEntriesToTreeNodes(
|
||||
listing.entries,
|
||||
@@ -132,6 +136,7 @@ export function useFileExplorerTree(
|
||||
// empty worktree. Preserve the message so the UI can distinguish
|
||||
// "no files" from "could not read this worktree".
|
||||
setRootError(error instanceof Error ? error.message : String(error))
|
||||
setSourceWorkspaceId(null)
|
||||
rootReadFailedRef.current = true
|
||||
}
|
||||
setDirCache((prev) => ({ ...prev, [dirPath]: { children: [] } }))
|
||||
@@ -267,6 +272,7 @@ export function useFileExplorerTree(
|
||||
dirLoadTrackerRef.current.reset()
|
||||
staleDirsRef.current.clear()
|
||||
setDirCache({})
|
||||
setSourceWorkspaceId(null)
|
||||
updateLoadingDirPaths(() => EMPTY_FILE_EXPLORER_LOADING_DIRS)
|
||||
setRootError(null)
|
||||
if (worktreePath) {
|
||||
@@ -277,6 +283,7 @@ export function useFileExplorerTree(
|
||||
return {
|
||||
dirCache,
|
||||
setDirCache,
|
||||
sourceWorkspaceId,
|
||||
loadingDirPaths,
|
||||
rootCache,
|
||||
rootError,
|
||||
|
||||
@@ -114,4 +114,18 @@ describe('shouldUploadRemoteEditorFileDrop', () => {
|
||||
})
|
||||
expect(JSON.stringify(message)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('names the drop whose file items carried no readable path (#15782)', () => {
|
||||
expect(
|
||||
getNativeFileDropRejectionMessage({
|
||||
byteLength: 0,
|
||||
pathCount: 2,
|
||||
reason: 'unresolved-paths',
|
||||
target: 'rejected'
|
||||
})
|
||||
).toEqual({
|
||||
description: 'Save them to disk first, then drop the saved files.',
|
||||
title: "Orca couldn't read a path for the dropped files."
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -207,6 +207,19 @@ export function getNativeFileDropRejectionMessage(data: NativeFileDropRejectedPa
|
||||
description: string
|
||||
title: string
|
||||
} {
|
||||
if (data.reason === 'unresolved-paths') {
|
||||
return {
|
||||
description: translate(
|
||||
'auto.hooks.useGlobalFileDrop.nativeDropUnresolvedPathsDescription',
|
||||
'Save them to disk first, then drop the saved files.'
|
||||
),
|
||||
title: translate(
|
||||
'auto.hooks.useGlobalFileDrop.nativeDropUnresolvedPaths',
|
||||
"Orca couldn't read a path for the dropped files."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reason === 'too-many-paths') {
|
||||
return {
|
||||
description: translate(
|
||||
|
||||
@@ -1007,7 +1007,9 @@
|
||||
"nativeDropTooManyPaths": "Drop contains too many files.",
|
||||
"nativeDropPathsTooLargeDescription": "Drop fewer files or use a shorter path list.",
|
||||
"nativeDropPathsTooLarge": "Drop path list is too large.",
|
||||
"ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects."
|
||||
"ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects.",
|
||||
"nativeDropUnresolvedPathsDescription": "Save them to disk first, then drop the saved files.",
|
||||
"nativeDropUnresolvedPaths": "Orca couldn't read a path for the dropped files."
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "Browser tab {{value0}} not found",
|
||||
@@ -17112,7 +17114,10 @@
|
||||
"pendingAttachmentLimit": "Too many attachments are waiting. Finish composing before attaching more.",
|
||||
"viewAttachment": "View image",
|
||||
"imagePreview": "Full-size image preview",
|
||||
"imagePreviewUnavailable": "Preview unavailable"
|
||||
"imagePreviewUnavailable": "Preview unavailable",
|
||||
"workspaceAttachmentMismatch": "Files can only be attached to their source workspace.",
|
||||
"attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.",
|
||||
"attachmentUnreadable": "Couldn't read the dropped files."
|
||||
},
|
||||
"tool": {
|
||||
"exitCode": "exit {{value0}}",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
|
||||
import { writeWorkspaceFileDragSourceIfResolved } from './workspace-file-drag'
|
||||
|
||||
/** Source-control rows list the live workspace, so the owner is resolved now
|
||||
* rather than captured with the listing (unlike the explorer's cached tree). */
|
||||
export function writeWorkspaceFileDragSourceForWorkspace(
|
||||
dataTransfer: Pick<DataTransfer, 'setData'>,
|
||||
workspaceId: string
|
||||
): void {
|
||||
writeWorkspaceFileDragSourceIfResolved(
|
||||
dataTransfer,
|
||||
workspaceId,
|
||||
getExecutionHostIdForWorktree(useAppStore.getState(), workspaceId)
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
WORKSPACE_FILE_PATHS_MIME,
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
encodeWorkspaceFilePaths,
|
||||
readWorkspaceFileDragPaths
|
||||
readWorkspaceFileDragPaths,
|
||||
readWorkspaceFileDragSource,
|
||||
isResolvedWorkspaceFileDragExecutionHost,
|
||||
writeWorkspaceFileDragSource
|
||||
} from './workspace-file-drag'
|
||||
|
||||
vi.mock('../../../shared/cross-platform-path', async (importOriginal) => {
|
||||
@@ -29,6 +32,31 @@ class FakeDataTransfer {
|
||||
}
|
||||
|
||||
describe('workspace file drag payloads', () => {
|
||||
it('round-trips explicit workspace and execution-host ownership', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
writeWorkspaceFileDragSource(transfer, {
|
||||
executionHostId: 'ssh:remote-1',
|
||||
workspaceId: 'folder:docs'
|
||||
})
|
||||
|
||||
expect(readWorkspaceFileDragSource(transfer)).toEqual({
|
||||
executionHostId: 'ssh:remote-1',
|
||||
workspaceId: 'folder:docs'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing and malformed workspace ownership', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
|
||||
transfer.setData('application/x-orca-workspace-file-source', '{"workspaceId":"worktree-1"}')
|
||||
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects only the exact unresolved execution-host sentinel', () => {
|
||||
expect(isResolvedWorkspaceFileDragExecutionHost('runtime:unresolved-owner')).toBe(false)
|
||||
expect(isResolvedWorkspaceFileDragExecutionHost('runtime:my-unresolved-owner-env')).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips bounded multi-path payloads and removes nested duplicates', () => {
|
||||
const transfer = new FakeDataTransfer()
|
||||
transfer.setData(
|
||||
|
||||
@@ -5,9 +5,24 @@ import {
|
||||
validateNativeFileDropPaths
|
||||
} from '../../../shared/native-file-drop'
|
||||
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
|
||||
import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
export const WORKSPACE_FILE_PATH_MIME = 'text/x-orca-file-path'
|
||||
export const WORKSPACE_FILE_PATHS_MIME = 'text/x-orca-file-paths'
|
||||
export const WORKSPACE_FILE_DRAG_SOURCE_MIME = 'application/x-orca-workspace-file-source'
|
||||
|
||||
const WORKSPACE_FILE_DRAG_SOURCE_MAX_BYTES = 4096
|
||||
|
||||
export type WorkspaceFileDragSource = {
|
||||
executionHostId: ExecutionHostId
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function isResolvedWorkspaceFileDragExecutionHost(
|
||||
executionHostId: ExecutionHostId
|
||||
): boolean {
|
||||
return executionHostId !== 'runtime:unresolved-owner'
|
||||
}
|
||||
|
||||
export type WorkspaceFileDragRejectionReason = 'paths-too-large' | 'too-many-paths'
|
||||
|
||||
@@ -33,6 +48,71 @@ export function encodeWorkspaceFilePaths(paths: readonly string[]): string {
|
||||
return paths.length === 1 ? paths[0] : JSON.stringify(paths)
|
||||
}
|
||||
|
||||
export function writeWorkspaceFileDragSource(
|
||||
dataTransfer: Pick<DataTransfer, 'setData'>,
|
||||
source: WorkspaceFileDragSource
|
||||
): void {
|
||||
dataTransfer.setData(WORKSPACE_FILE_DRAG_SOURCE_MIME, JSON.stringify({ ...source, version: 1 }))
|
||||
}
|
||||
|
||||
/** Stamp only when both halves resolve: an unstamped drag fails closed at the
|
||||
* composer, which is the right answer for an owner we could not name. */
|
||||
export function writeWorkspaceFileDragSourceIfResolved(
|
||||
dataTransfer: Pick<DataTransfer, 'setData'>,
|
||||
workspaceId: string | null | undefined,
|
||||
executionHostId: ExecutionHostId | null | undefined
|
||||
): void {
|
||||
if (
|
||||
!workspaceId ||
|
||||
!executionHostId ||
|
||||
!isResolvedWorkspaceFileDragExecutionHost(executionHostId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
writeWorkspaceFileDragSource(dataTransfer, { executionHostId, workspaceId })
|
||||
}
|
||||
|
||||
export function readWorkspaceFileDragSource(
|
||||
dataTransfer: Pick<DataTransfer, 'getData'>
|
||||
): WorkspaceFileDragSource | null {
|
||||
const data = dataTransfer.getData(WORKSPACE_FILE_DRAG_SOURCE_MIME)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
measureClipboardTextByteLength(data, {
|
||||
stopAfterBytes: WORKSPACE_FILE_DRAG_SOURCE_MAX_BYTES
|
||||
}).exceededLimit
|
||||
) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data)
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null
|
||||
}
|
||||
const executionHostValue = 'executionHostId' in parsed ? parsed.executionHostId : null
|
||||
const executionHostId =
|
||||
typeof executionHostValue === 'string' ? normalizeExecutionHostId(executionHostValue) : null
|
||||
const workspaceValue = 'workspaceId' in parsed ? parsed.workspaceId : null
|
||||
const workspaceId = typeof workspaceValue === 'string' ? workspaceValue.trim() : ''
|
||||
const version = 'version' in parsed ? parsed.version : null
|
||||
if (version !== 1 || !executionHostId || !workspaceId) {
|
||||
return null
|
||||
}
|
||||
return { executionHostId, workspaceId }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWorkspaceFileDragType(dataTransfer: Pick<DataTransfer, 'types'>): boolean {
|
||||
return (
|
||||
dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
|
||||
dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
|
||||
)
|
||||
}
|
||||
|
||||
export function decodeWorkspaceFilePaths(data: string): string[] {
|
||||
const result = decodeWorkspaceFilePathPayload(data)
|
||||
return result.status === 'accepted' ? result.paths : []
|
||||
|
||||
@@ -41,10 +41,17 @@ export type NativeFileDropPayload =
|
||||
export type NativeFileDropRejectedPayload = {
|
||||
byteLength: number
|
||||
pathCount: number
|
||||
reason: 'paths-too-large' | 'too-many-paths'
|
||||
reason: NativeFileDropRejectionReason
|
||||
target: 'rejected'
|
||||
}
|
||||
|
||||
/** What path validation alone can reject a drop for. */
|
||||
export type NativeFileDropSizeRejectionReason = 'paths-too-large' | 'too-many-paths'
|
||||
|
||||
/** `unresolved-paths`: the OS handed us file items no path could be read from
|
||||
* (promised/virtual files), which used to be swallowed with no feedback. */
|
||||
export type NativeFileDropRejectionReason = NativeFileDropSizeRejectionReason | 'unresolved-paths'
|
||||
|
||||
export type NativeFileDropPathEntry = {
|
||||
nativeFileDropTarget?: string
|
||||
nativeFileDropDir?: string
|
||||
@@ -58,14 +65,16 @@ export type NativeFileDropPathValidation =
|
||||
| {
|
||||
byteLength: number
|
||||
pathCount: number
|
||||
reason: NativeFileDropRejectedPayload['reason']
|
||||
reason: NativeFileDropSizeRejectionReason
|
||||
status: 'rejected'
|
||||
}
|
||||
|
||||
function isNativeFileDropRejectedReason(
|
||||
reason: unknown
|
||||
): reason is NativeFileDropRejectedPayload['reason'] {
|
||||
return reason === 'paths-too-large' || reason === 'too-many-paths'
|
||||
return (
|
||||
reason === 'paths-too-large' || reason === 'too-many-paths' || reason === 'unresolved-paths'
|
||||
)
|
||||
}
|
||||
|
||||
function isNativeFileDropTarget(target: unknown): target is NativeFileDropPayload['target'] {
|
||||
|
||||
Reference in New Issue
Block a user