diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 27ce7d77bc2..6a9462473c1 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -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) diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 82e7ece6cfb..449f89cb70f 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -348,6 +348,7 @@ export default function CombinedDiffViewer({ ({ + 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( + {}} + 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() + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index dfb417e2518..fe6d5644db1 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -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 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)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx index 229f9561ac0..3b5abc6113c 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx @@ -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 collapsedDirectoryKeys: ReadonlySet @@ -50,6 +52,7 @@ export function CombinedDiffFileTreeRows({ node={node} mode={mode} worktreePath={worktreePath} + sourceWorkspaceId={sourceWorkspaceId} activeSectionKey={activeSectionKey} sectionIndexByKey={sectionIndexByKey} isCollapsed={collapsedDirectoryKeys.has(node.key)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx index 304aca3d19c..6c19b55d527 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx @@ -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 activeSectionKey: string | null @@ -200,6 +202,7 @@ export function CombinedDiffFileTree({ const sharedRowProps = { mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 4833f89fc94..2f0688dfede 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -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 attachment.pending) @@ -413,6 +421,7 @@ const NativeChatComposerPane = forwardRef removeImageAttachment(id)} onAttach={pickAttachment} + workspaceFileDropHandlers={workspaceFileDropHandlers} onDictationToggle={toggleDictation} onDictationHoldStart={startHoldDictation} onDictationHoldEnd={stopHoldDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..5ea20e3d0cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -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 + onDropCapture: DragEventHandler + } 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({ ) : null}
{ }) }) + 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( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 8157a5190ff..b7564ba7d9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -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', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index e535a59fad0..c7c7dec050c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -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()), 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(null) + const textareaRef = useRef(null) + const [notice, setNotice] = useState(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 (