diff --git a/src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts similarity index 72% rename from src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx rename to src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts index c03f7a441465..02198a89201a 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx +++ b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts @@ -1,8 +1,4 @@ -// @vitest-environment happy-dom - -import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { NativeFileDropPayload } from '../../../shared/native-file-drop' +import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ statUserOpenedPath: vi.fn(), @@ -31,33 +27,15 @@ vi.mock('@/store', () => ({ } })) -import { useGlobalFileDrop } from './useGlobalFileDrop' - -let dropListener: ((data: NativeFileDropPayload) => void) | null = null +import { captureEditorFileDropOpen } from './editor-dropped-file-open' beforeEach(() => { mocks.statUserOpenedPath.mockReset() mocks.openFile.mockReset() - vi.stubGlobal('api', { - ui: { - onFileDrop: (listener: (data: NativeFileDropPayload) => void) => { - dropListener = listener - return () => { - dropListener = null - } - } - } - }) -}) - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() }) async function dropOnTabStrip(path: string): Promise { - renderHook(() => useGlobalFileDrop()) - dropListener?.({ target: 'editor', paths: [path] }) + await captureEditorFileDropOpen({ worktreeId: 'wt-1' })([path]) await vi.waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) } diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts new file mode 100644 index 000000000000..5409651b6669 --- /dev/null +++ b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + getEditorFileDropOperationContext, + getEditorFileDropSettingsForWorktree, + shouldUploadRemoteEditorFileDrop +} from './editor-dropped-file-open' + +describe('shouldUploadRemoteEditorFileDrop', () => { + it('does not upload editor drops for local workspaces', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false) + }) + + it('uploads editor drops while a runtime environment is active', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe( + true + ) + }) + + it('uploads editor drops for SSH workspaces', () => { + expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe( + true + ) + }) + + it('uses the worktree owner runtime instead of the focused runtime', () => { + expect( + getEditorFileDropSettingsForWorktree( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: 'owner-runtime' }) + }) + + it('keeps explicit local worktree editor drops local while a runtime is focused', () => { + expect( + getEditorFileDropSettingsForWorktree( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) + + it('builds file operation context from the worktree owner instead of global focus', () => { + expect( + getEditorFileDropOperationContext( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1', + '/repos/repo-1', + undefined + ) + ).toEqual({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repos/repo-1', + connectionId: undefined + }) + }) + + it('preserves SSH ownership in editor drop operation context', () => { + expect( + getEditorFileDropOperationContext( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1', + '/home/orca/repo-1', + 'ssh-1' + ) + ).toEqual({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/home/orca/repo-1', + connectionId: 'ssh-1' + }) + }) +}) diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.ts b/src/renderer/src/components/editor/editor-dropped-file-open.ts new file mode 100644 index 000000000000..a19ec6ee9398 --- /dev/null +++ b/src/renderer/src/components/editor/editor-dropped-file-open.ts @@ -0,0 +1,258 @@ +import { toast } from 'sonner' +import { detectLanguage } from '@/lib/language-detect' +import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { joinPath } from '@/lib/path' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' +import { + importExternalPathsToRuntime, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace' + +export function getEditorFileDropSettingsForWorktree( + store: WorktreeRuntimeOwnerState, + worktreeId: string +): Pick { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) + // Why: OS drops target the selected worktree. Use that worktree's host owner + // so a focused runtime cannot hijack local/SSH editor drops. + return { + ...store.settings, + activeRuntimeEnvironmentId: runtimeEnvironmentId + } +} + +export function shouldUploadRemoteEditorFileDrop( + settings: Pick | null | undefined, + connectionId: string | null | undefined +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) +} + +export function getEditorFileDropOperationContext( + store: WorktreeRuntimeOwnerState, + worktreeId: string, + worktreePath: string | null | undefined, + connectionId: string | undefined +): RuntimeFileOperationArgs { + return { + settings: getEditorFileDropSettingsForWorktree(store, worktreeId), + worktreeId, + worktreePath, + connectionId + } +} + +export type EditorFileDropDestination = { + worktreeId: string + /** Omitted only by the legacy route, which lets the store pick the worktree's active group. */ + groupId?: string +} + +type EditorFileDropContext = { + fileContext: RuntimeFileOperationArgs + worktreePath: string | undefined + connectionId: string | undefined +} + +function captureEditorFileDropContext(worktreeId: string): EditorFileDropContext | null { + const store = useAppStore.getState() + const worktreePath = store.getKnownWorktreeById(worktreeId)?.path + const connectionId = getConnectionId(worktreeId) ?? undefined + try { + return { + fileContext: { + ...getEditorFileDropOperationContext(store, worktreeId, worktreePath, connectionId), + ...captureWorktreeSshMutationExpectation(store, worktreeId) + }, + worktreePath, + connectionId + } + } catch { + return null + } +} + +function sameEditorFileDropOwner(a: EditorFileDropContext, b: EditorFileDropContext): boolean { + const left = a.fileContext + const right = b.fileContext + return ( + a.worktreePath === b.worktreePath && + a.connectionId === b.connectionId && + left.settings?.activeRuntimeEnvironmentId === right.settings?.activeRuntimeEnvironmentId && + left.expectedExecutionHostId === right.expectedExecutionHostId && + left.expectedSshTargetId === right.expectedSshTargetId && + left.expectedSshConnectionGeneration === right.expectedSshConnectionGeneration + ) +} + +function showOwnerChangedError(): void { + toast.error( + translate( + 'auto.hooks.useGlobalFileDrop.ownerChanged', + "Couldn't verify which host owns this workspace. Try again after it reconnects." + ) + ) +} + +export function editorGroupStillExists({ + worktreeId, + groupId +}: EditorFileDropDestination): boolean { + if (!groupId) { + return true + } + const groups = useAppStore.getState().groupsByWorktree[worktreeId] ?? [] + return groups.some((group) => group.id === groupId) +} + +/** + * Captures the destination workspace's host at drop time and returns the open + * step; a host change before preparation finishes refuses instead of re-routing. + */ +export function captureEditorFileDropOpen( + destination: EditorFileDropDestination +): (paths: readonly string[]) => Promise { + const captured = captureEditorFileDropContext(destination.worktreeId) + if (!captured) { + showOwnerChangedError() + return async () => undefined + } + return async (paths) => { + if (paths.length === 0 || !editorGroupStillExists(destination)) { + return + } + const current = captureEditorFileDropContext(destination.worktreeId) + if (!current || !sameEditorFileDropOwner(captured, current)) { + showOwnerChangedError() + return + } + await openEditorFileDropPaths(captured, destination, paths) + } +} + +async function openEditorFileDropPaths( + { fileContext, worktreePath, connectionId }: EditorFileDropContext, + destination: EditorFileDropDestination, + paths: readonly string[] +): Promise { + const store = useAppStore.getState() + const { worktreeId, groupId } = destination + const groupOptions = groupId ? { targetGroupId: groupId } : null + const dropSettings = fileContext.settings + const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId ?? null + if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { + if (!worktreePath) { + toast.error( + translate( + 'auto.hooks.useGlobalFileDrop.245faa95b9', + 'No remote workspace path is available for dropped files.' + ) + ) + return + } + try { + // Why: OS file drops provide client-local paths. Remote runtime and + // SSH editors must upload into the server worktree before opening. + const destinationDir = joinPath(worktreePath, '.orca/drops') + const { results } = await importExternalPathsToRuntime( + fileContext, + [...paths], + destinationDir, + { ensureDestinationDir: true } + ) + if (!editorGroupStillExists(destination)) { + return + } + for (const result of results) { + if (!editorGroupStillExists(destination)) { + return + } + if (result.status !== 'imported' || result.kind === 'directory') { + continue + } + const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath) + store.setActiveTabType('editor', worktreeId) + store.openFile( + { + filePath: result.destPath, + relativePath: maybeRelative ?? result.destPath, + worktreeId, + runtimeEnvironmentId: runtimeEnvironmentId ?? undefined, + language: detectLanguage(result.destPath), + mode: 'edit' + }, + { suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions } + ) + } + if (results.some((result) => result.status !== 'imported')) { + toast.error( + translate( + 'auto.hooks.useGlobalFileDrop.d720e2f855', + 'Some dropped files could not be uploaded.' + ) + ) + } + } catch { + toast.error( + translate('auto.hooks.useGlobalFileDrop.38c9f034ff', 'Failed to upload dropped files.') + ) + } + return + } + + // Sequential, so a multi-file drop opens its tabs in drop order. + for (const filePath of paths) { + try { + const stat = await statUserOpenedPath(fileContext, filePath) + if (!editorGroupStillExists(destination)) { + return + } + if (stat.isDirectory) { + continue + } + let relativePath = filePath + // Why: a project link out of the project keeps its absolute path, so it reads as + // user-named instead of being refused as a project file. + if (worktreePath && !stat.escapesWorktree && isPathInsideWorktree(filePath, worktreePath)) { + const maybeRelative = toWorktreeRelativePath(filePath, worktreePath) + if (maybeRelative !== null && maybeRelative.length > 0) { + relativePath = maybeRelative + } + } + store.setActiveTabType('editor', worktreeId) + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + openDocumentInFloatingWorkspace( + store.openFile, + { filePath, relativePath }, + groupOptions ?? {} + ) + continue + } + const file = { + filePath, + relativePath, + worktreeId, + language: detectLanguage(filePath), + mode: 'edit' as const + } + if (groupOptions) { + store.openFile(file, groupOptions) + } else { + store.openFile(file) + } + } catch { + // Ignore files that cannot be stat'd. + } + } +} diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx new file mode 100644 index 000000000000..9d13093bb61a --- /dev/null +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreparedDroppedPaths } from '../../../../shared/native-file-drop-preparation' +import { useAppStore } from '@/store' +import { + RUNTIME_WORKTREE_PATH as RUNTIME_PATH, + seedRemoteDropWorkspaces, + sshConnectionStatesAt, + SSH_WORKTREE_PATH as SSH_PATH +} from '@/lib/remote-workspace-drop-test-fixtures' +import { joinPath } from '@/lib/path' +import { useEditorGroupFileDropOwner } from './use-editor-group-file-drop-owner' + +const mocks = vi.hoisted(() => ({ + importPaths: vi.fn(), + prepare: vi.fn(), + openFile: vi.fn(), + setActiveTabType: vi.fn(), + toastError: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: mocks.importPaths +})) + +function EditorStrip({ worktreeId, groupId }: { worktreeId: string; groupId: string }) { + const attach = useEditorGroupFileDropOwner({ worktreeId, groupId }) + return
+} + +function dropFile(target: Element): void { + const transfer = { types: ['Files'], files: [new File(['x'], 'shot.png')], dropEffect: 'move' } + const event = new Event('drop', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) +} + +beforeEach(() => { + seedRemoteDropWorkspaces() + useAppStore.setState({ openFile: mocks.openFile, setActiveTabType: mocks.setActiveTabType }) + mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ + paths, + failures: [] + })) + mocks.importPaths.mockImplementation(async (_context, paths: string[], dir: string) => ({ + results: paths.map((path) => ({ + status: 'imported', + kind: 'file', + destPath: joinPath(dir, path.split('/').pop() ?? path) + })) + })) + vi.stubGlobal('api', { + fs: { + getPathForFile: (file: File) => `/Users/me/Desktop/${file.name}`, + prepareDroppedPaths: mocks.prepare + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('editor group OS file drops on remote workspaces', () => { + it("uploads an SSH group's drop into that workspace and opens it in that group", async () => { + const view = render() + dropFile(view.getByTestId('strip')) + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + const [context, paths, dir] = mocks.importPaths.mock.calls[0] + expect(context).toMatchObject({ + worktreeId: 'wt-ssh', + worktreePath: SSH_PATH, + connectionId: 'ssh-1', + settings: { activeRuntimeEnvironmentId: null }, + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 3 + }) + expect(paths).toEqual(['/Users/me/Desktop/shot.png']) + expect(dir).toBe(joinPath(SSH_PATH, '.orca/drops')) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: joinPath(SSH_PATH, '.orca/drops/shot.png'), + worktreeId: 'wt-ssh' + }), + { suppressActiveRuntimeFallback: true, targetGroupId: 'group-ssh' } + ) + }) + + it("uploads through the workspace's own runtime, not the focused one", async () => { + const view = render() + dropFile(view.getByTestId('strip')) + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ + worktreeId: 'wt-runtime', + settings: { activeRuntimeEnvironmentId: 'owner-runtime' } + }) + expect(mocks.importPaths.mock.calls[0][2]).toBe(joinPath(RUNTIME_PATH, '.orca/drops')) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-runtime', runtimeEnvironmentId: 'owner-runtime' }), + { suppressActiveRuntimeFallback: false, targetGroupId: 'group-runtime' } + ) + }) + + it.each(['wt-ssh', 'wt-runtime'])( + 'opens nothing when the group closes during upload in %s and keeps preparation feedback', + async (worktreeId) => { + let finish: (result: { + results: { status: 'imported'; kind: 'file'; destPath: string }[] + }) => void = () => undefined + mocks.importPaths.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))) + mocks.prepare.mockResolvedValueOnce({ + paths: ['/Users/me/Desktop/shot.png'], + failures: [{ target: 'rejected', reason: 'unresolved-paths', pathCount: 1, byteLength: 0 }] + }) + const groupId = worktreeId === 'wt-ssh' ? 'group-ssh' : 'group-runtime' + const view = render() + dropFile(view.getByTestId('strip')) + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + view.unmount() + useAppStore.setState((state) => ({ + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: [{ id: 'group-other', worktreeId, activeTabId: null, tabOrder: [] }] + } + })) + await act(async () => + finish({ results: [{ status: 'imported', kind: 'file', destPath: '/uploaded/shot.png' }] }) + ) + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.setActiveTabType).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + } + ) + + it('refuses when the SSH connection changes before preparation finishes', async () => { + let finish: (prepared: PreparedDroppedPaths) => void = () => undefined + mocks.prepare.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + const view = render() + dropFile(view.getByTestId('strip')) + // A reconnect is a new connection generation: the captured host no longer holds. + useAppStore.setState({ sshConnectionStates: sshConnectionStatesAt(4) }) + await act(async () => finish({ paths: ['/Users/me/Desktop/shot.png'], failures: [] })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(mocks.toastError.mock.calls[0][0]).toMatch(/Couldn't verify which host owns/) + expect(mocks.importPaths).not.toHaveBeenCalled() + expect(mocks.openFile).not.toHaveBeenCalled() + }) + + it('refuses when the workspace runtime owner changes before preparation finishes', async () => { + let finish: (prepared: PreparedDroppedPaths) => void = () => undefined + mocks.prepare.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + const view = render() + dropFile(view.getByTestId('strip')) + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => + repo.id === 'repo-rt' ? { ...repo, executionHostId: 'runtime:new-owner' as const } : repo + ) + })) + await act(async () => finish({ paths: ['/Users/me/Desktop/shot.png'], failures: [] })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(mocks.toastError.mock.calls[0][0]).toMatch(/Couldn't verify which host owns/) + expect(mocks.importPaths).not.toHaveBeenCalled() + expect(mocks.openFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx new file mode 100644 index 000000000000..a33abd776bfe --- /dev/null +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx @@ -0,0 +1,275 @@ +// @vitest-environment happy-dom +import { Suspense, use } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreparedDroppedPaths } from '../../../../shared/native-file-drop-preparation' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { createOsFileDropSequence } from '@/hooks/use-os-file-drop-owner' +import type * as OsFileDropOwnerModule from '@/hooks/use-os-file-drop-owner' +import { useEditorGroupFileDropOwner } from './use-editor-group-file-drop-owner' + +const mocks = vi.hoisted(() => { + const groupsByWorktree: Record = {} + return { + openFile: vi.fn(), + setActiveTabType: vi.fn(), + stat: vi.fn(), + prepare: vi.fn(), + toastError: vi.fn(), + groupsByWorktree + } +}) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/hooks/use-os-file-drop-owner', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createOsFileDropSequence: vi.fn(actual.createOsFileDropSequence) } +}) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/ssh-mutation-expectation', () => ({ + // Like the real lookup, a workspace with no host record fails closed. + captureWorktreeSshMutationExpectation: (_state: unknown, worktreeId: string) => { + if (!worktreeId.startsWith('wt-') && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + throw new Error('unresolved host') + } + return { expectedExecutionHostId: 'local' } + } +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) +vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.stat })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: {}, + activeWorktreeId: 'wt-active', + groupsByWorktree: mocks.groupsByWorktree, + getKnownWorktreeById: (id: string) => ({ id, path: `/repos/${id}` }), + setActiveTabType: mocks.setActiveTabType, + openFile: mocks.openFile + }) + } +})) + +function EditorArea({ worktreeId, groupId }: { worktreeId: string; groupId: string }) { + const attachArea = useEditorGroupFileDropOwner({ worktreeId, groupId }) + return
+} + +function EditorGroup({ + worktreeId, + groupId, + editorTabActive = true +}: { + worktreeId: string + groupId: string + editorTabActive?: boolean +}) { + const attachStrip = useEditorGroupFileDropOwner({ worktreeId, groupId }) + return ( + <> +
+ {/* Like TabGroupPanel, the editor area only renders while an editor tab is active. */} + {editorTabActive ? : null} + + ) +} + +function deferredPreparation(): (prepared: PreparedDroppedPaths) => void { + let finish: (prepared: PreparedDroppedPaths) => void = () => undefined + mocks.prepare.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + return (prepared) => finish(prepared) +} + +function dropFile(target: Element, name: string): void { + const transfer = { types: ['Files'], files: [new File(['x'], name)], dropEffect: 'move' } + const event = new Event('drop', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) +} + +const openedPaths = (): string[] => mocks.openFile.mock.calls.map(([file]) => file.filePath) + +beforeEach(() => { + mocks.groupsByWorktree = { + 'wt-b': [{ id: 'group-b' }], + [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'floating-group' }] + } + mocks.stat.mockResolvedValue({ isDirectory: false, escapesWorktree: false }) + mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ + paths, + failures: [] + })) + vi.stubGlobal('api', { + fs: { + getPathForFile: (file: File) => `/repos/wt-b/${file.name}`, + prepareDroppedPaths: mocks.prepare + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('editor group OS file drops', () => { + it.each(['strip', 'area'])( + 'opens a file dropped on the %s in that group and worktree, not the active one', + async (root) => { + const view = render() + dropFile(view.getByTestId(`group-b:${root}`), 'notes.md') + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + expect(mocks.prepare).toHaveBeenCalledWith({ + paths: ['/repos/wt-b/notes.md'], + consumer: 'main-reader' + }) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repos/wt-b/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-b' + }), + { targetGroupId: 'group-b' } + ) + expect(mocks.setActiveTabType).toHaveBeenCalledWith('editor', 'wt-b') + } + ) + + it('opens a file dropped on the floating panel as a local floating tab', async () => { + const view = render( + + ) + dropFile(view.getByTestId('floating-group:strip'), 'scratch.md') + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repos/wt-b/scratch.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + runtimeEnvironmentId: null + }), + { preview: false, suppressActiveRuntimeFallback: true, targetGroupId: 'floating-group' } + ) + }) + + it('applies a strip drop then an area drop in drop order when the first prepares slower', async () => { + const pending: (() => void)[] = [] + mocks.prepare.mockImplementation( + ({ paths }: { paths: string[] }) => + new Promise((resolve) => + pending.push(() => resolve({ paths, failures: [] })) + ) + ) + const view = render() + dropFile(view.getByTestId('group-b:strip'), 'first.ts') + dropFile(view.getByTestId('group-b:area'), 'second.ts') + expect(pending).toHaveLength(2) + await act(async () => pending[1]()) + expect(mocks.openFile).not.toHaveBeenCalled() + await act(async () => pending[0]()) + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(2)) + expect(openedPaths()).toEqual(['/repos/wt-b/first.ts', '/repos/wt-b/second.ts']) + }) + + it('still opens in the group when switching to a terminal tab unmounts the editor area', async () => { + const finish = deferredPreparation() + const view = render() + dropFile(view.getByTestId('group-b:area'), 'shot.png') + view.rerender() + await act(async () => finish({ paths: ['/repos/wt-b/shot.png'], failures: [] })) + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repos/wt-b/shot.png', worktreeId: 'wt-b' }), + { targetGroupId: 'group-b' } + ) + }) + + it('opens nothing once the group closes during preparation, but still reports failures', async () => { + const finish = deferredPreparation() + const view = render() + dropFile(view.getByTestId('group-b:area'), 'shot.png') + view.unmount() + mocks.groupsByWorktree['wt-b'] = [{ id: 'group-other' }] + await act(async () => + finish({ + paths: ['/repos/wt-b/shot.png'], + failures: [ + { + target: 'rejected', + reason: 'temp-copy-failed', + commonReason: 'copy-failed', + pathCount: 1, + byteLength: 1 + } + ] + }) + ) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(mocks.stat).not.toHaveBeenCalled() + expect(mocks.openFile).not.toHaveBeenCalled() + }) + + it('keeps drop order when both roots remount while preparation is pending', async () => { + const finish = deferredPreparation() + const first = render() + dropFile(first.getByTestId('group-b:area'), 'first.ts') + first.unmount() + const second = render() + dropFile(second.getByTestId('group-b:strip'), 'second.ts') + await act(async () => undefined) + expect(mocks.openFile).not.toHaveBeenCalled() + await act(async () => finish({ paths: ['/repos/wt-b/first.ts'], failures: [] })) + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(2)) + expect(openedPaths()).toEqual(['/repos/wt-b/first.ts', '/repos/wt-b/second.ts']) + }) + + it.each(['group', 'workspace'])( + 'opens nothing when the destination %s closes during stat and keeps preparation feedback', + async (closed) => { + let finish: (stat: { isDirectory: boolean; escapesWorktree: boolean }) => void = () => + undefined + mocks.stat.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))) + mocks.prepare.mockResolvedValueOnce({ + paths: ['/repos/wt-b/shot.png'], + failures: [{ target: 'rejected', reason: 'unresolved-paths', pathCount: 1, byteLength: 0 }] + }) + const view = render() + dropFile(view.getByTestId('group-b:area'), 'shot.png') + await waitFor(() => expect(mocks.stat).toHaveBeenCalledTimes(1)) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + view.unmount() + if (closed === 'workspace') { + delete mocks.groupsByWorktree['wt-b'] + } else { + mocks.groupsByWorktree['wt-b'] = [{ id: 'group-other' }] + } + await act(async () => finish({ isDirectory: false, escapesWorktree: false })) + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.setActiveTabType).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + } + ) + + it('creates no shared sequence for a render that never commits', async () => { + const never = new Promise(() => undefined) + function SuspendedGroup() { + useEditorGroupFileDropOwner({ worktreeId: 'wt-b', groupId: 'group-suspended' }) + use(never) + return null + } + await act(async () => { + render( + + + + ) + }) + expect(createOsFileDropSequence).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts new file mode 100644 index 000000000000..f26cd8c5e8a3 --- /dev/null +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts @@ -0,0 +1,79 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import { toast } from 'sonner' +import { + createOsFileDropSequence, + useOsFileDropOwner, + type OsFileDropSequence +} from '@/hooks/use-os-file-drop-owner' +import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop' +import { + captureEditorFileDropOpen, + editorGroupStillExists, + type EditorFileDropDestination +} from './editor-dropped-file-open' + +type GroupSequenceEntry = { sequence: OsFileDropSequence; owners: number } + +// The tab strip and editor area are sibling roots of one group and render in different trees. +const groupSequences = new Map() + +function groupSequenceEntry(key: string): GroupSequenceEntry { + const existing = groupSequences.get(key) + if (existing) { + return existing + } + const entry = { sequence: createOsFileDropSequence(), owners: 0 } + groupSequences.set(key, entry) + return entry +} + +function useEditorGroupFileDropSequence(key: string): OsFileDropSequence { + useLayoutEffect(() => { + const entry = groupSequenceEntry(key) + entry.owners += 1 + return () => { + entry.owners -= 1 + // Why: wait for queued drops, so a root remounting in the same commit keeps their order. + void entry.sequence.deliveryTail.then(() => { + if (entry.owners === 0 && groupSequences.get(key) === entry) { + groupSequences.delete(key) + } + }) + } + }, [key]) + // Resolved when a drop queues, never during render. + return useMemo( + () => ({ + get deliveryTail() { + return groupSequenceEntry(key).sequence.deliveryTail + }, + set deliveryTail(tail: Promise) { + groupSequenceEntry(key).sequence.deliveryTail = tail + } + }), + [key] + ) +} + +/** Opens OS files dropped on this group's tab strip or editor area in that group's worktree. */ +export function useEditorGroupFileDropOwner({ + worktreeId, + groupId +}: EditorFileDropDestination): (root: HTMLElement | null) => void { + const rootRef = useRef(null) + const sequence = useEditorGroupFileDropSequence(`${worktreeId}\0${groupId ?? ''}`) + return useOsFileDropOwner(rootRef, { + consumer: 'main-reader', + sequence, + captureDestination: () => captureEditorFileDropOpen({ worktreeId, groupId }), + // Why: switching to a terminal tab unmounts the editor area; the open step checks the group. + isDestinationLive: () => editorGroupStillExists({ worktreeId, groupId }), + onDrop: async (prepared, { destination }) => { + for (const failure of prepared.failures) { + const message = getNativeFileDropRejectionMessage(failure) + toast.error(message.title, { description: message.description }) + } + await destination?.(prepared.paths) + } + }) +} diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index c8800f4655fa..8a36297b9cfb 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -325,9 +325,7 @@ function FileExplorerFiles(): React.JSX.Element { displayRootPath={displayRootPath} activeRepo={activeRepo} worktreePath={worktreePath} - visibleFilesWorktreePath={visibleFilesWorktreePath} explorerView={explorerView} - isFilesViewActive={isFilesViewActive} activeFileId={activeFileId} hasNameFilter={hasNameFilter} nameFilterSource={nameFilterSource} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx b/src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx index a83b4e0a3709..7cbe51de593c 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx @@ -21,9 +21,7 @@ type FileExplorerFilesTreePaneProps = { activeRepo: Repo | null worktreePath: string | null displayRootPath: string | null - visibleFilesWorktreePath: string | null explorerView: RightSidebarExplorerView - isFilesViewActive: boolean activeFileId: string | null hasNameFilter: boolean nameFilterSource: FileExplorerNameFilterProjectionSource | null @@ -45,9 +43,7 @@ export function FileExplorerFilesTreePane({ activeRepo, worktreePath, displayRootPath, - visibleFilesWorktreePath, explorerView, - isFilesViewActive, activeFileId, hasNameFilter, nameFilterSource, @@ -78,7 +74,8 @@ export function FileExplorerFilesTreePane({ inlineInputState, rowScrolling, handlers, - nodeCommands + nodeCommands, + fileDropOwnerRef } = paneState const { inlineInput, inlineInputIndex, startNew, dismissInlineInput, handleInlineSubmit } = inlineInputState @@ -111,9 +108,8 @@ export function FileExplorerFilesTreePane({ } = nodeCommands // Why: the root explorer container must stay mounted for loading, error, - // and empty states so the data-native-file-drop-target marker is always - // present. Without this, external file drops would have no target surface - // when the tree is empty, still loading, or showing a read error. + // and empty states so it always owns OS file drops; otherwise an empty, + // loading or failed tree would have no drop target. const isEmptyState = visibleRowCount === 0 && !inlineInput const isNameFilterLoading = nameFilterSource?.relativePaths === null const isRootLoading = !rootCache || (!!displayRootPath && loadingDirPaths.has(displayRootPath)) @@ -146,14 +142,12 @@ export function FileExplorerFilesTreePane({ 'bg-border', isNativeDragOver && explorerView === 'files' && !nativeDropTargetDir && 'bg-border' )} + ref={fileDropOwnerRef} viewportRef={scrollRef} viewportTabIndex={-1} viewportClassName="h-full min-h-0 py-2" - data-native-file-drop-target={isFilesViewActive ? 'file-explorer' : undefined} - data-native-file-drop-dir={ - visibleFilesWorktreePath ? (displayRootPath ?? undefined) : undefined - } onWheelCapture={handleWheelCapture} + onDragOverCapture={rootDragHandlers.onDragOverCapture} onDragOver={rootDragHandlers.onDragOver} onDragEnter={rootDragHandlers.onDragEnter} onDragLeave={rootDragHandlers.onDragLeave} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index f2eb2ba11412..d71b02da7690 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -149,7 +149,8 @@ export function FileExplorerRow({ )} style={{ paddingLeft: `${(node.depth - displayDepthOffset) * 16 + 8}px` }} ref={setRowDragNode} - data-native-file-drop-dir={rowDropDir} + // Why: the explorer's OS-drop owner reads the target folder from this at drop time. + data-file-explorer-drop-dir={rowDropDir} // Why: marks this draggable row so the wheel-capture handler can rescue // scroll Chromium swallows over draggable nodes (file-explorer-drag-scroll-marker). data-explorer-draggable="true" diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index d5bb840feeb9..75b82277e66e 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -2,10 +2,11 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Worktree } from '../../../../shared/worktree/types' import { useAppStore } from '@/store' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { captureFileExplorerOperationGuard, - getFileExplorerOperationOwner + getFileExplorerOperationOwner, + getFileExplorerOperationOwnerFromState } from './file-explorer-operation-owner' const initialState = useAppStore.getInitialState() @@ -27,11 +28,20 @@ afterEach(() => { }) describe('file explorer operation generations', () => { - it('routes floating workspace file mutations to the local host', () => { + it('routes floating file ownership and mutations locally while a runtime is focused', () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'focused-env' }, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeWorkspaceExecutionHostId: 'runtime:focused-env' + }) const owner = getFileExplorerOperationOwner(FLOATING_TERMINAL_WORKTREE_ID) const guard = captureFileExplorerOperationGuard(FLOATING_TERMINAL_WORKTREE_ID, owner) expect(owner).toEqual({ kind: 'local' }) + expect( + getFileExplorerOperationOwnerFromState(useAppStore.getState(), FLOATING_TERMINAL_WORKTREE_ID) + ).toEqual(owner) + expect(guard.route.settings.activeRuntimeEnvironmentId).toBeNull() expect(guard.route.expectedExecutionHostId).toBe('local') expect(() => guard.assertCurrent()).not.toThrow() }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 6fa5a7fc97b0..2f6297764502 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -10,11 +10,11 @@ import { } from '@/lib/worktree-runtime-owner' import type { FileExplorerOperationOwner } from './file-explorer-types' import { + getFloatingWorkspaceOperationRoute, resolveWorktreeOperationRoute, type WorktreeOperationRoute } from '@/lib/worktree-operation-route' import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type FileExplorerOperationRoute = { settings: { activeRuntimeEnvironmentId: string | null } @@ -44,8 +44,9 @@ export function getFileExplorerOperationOwnerFromState( state: FileExplorerOwnerState, worktreeId: string | null | undefined ): FileExplorerOperationOwner { - if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - return { kind: 'local' } + const floatingRoute = worktreeId ? getFloatingWorkspaceOperationRoute(worktreeId) : null + if (floatingRoute?.executionHostId) { + return operationOwnerFromHostId(floatingRoute.executionHostId) } const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && parsedWorkspace?.type !== 'folder') { diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts index 1df290682de3..2a50566fc84b 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts @@ -56,6 +56,7 @@ type UseFileExplorerTreePaneStateResult = { rowScrolling: ReturnType handlers: ReturnType nodeCommands: ReturnType + fileDropOwnerRef: (root: HTMLElement | null) => void } /** @@ -217,10 +218,10 @@ export function useFileExplorerTreePaneState({ operationOwner: rootCache?.operationOwner }) - useFileExplorerImport({ + const fileDropOwnerRef = useFileExplorerImport({ displayRootPath, worktreePath: visibleFilesWorktreePath, - activeWorktreeId, + worktreeId: activeWorktreeId, refreshDir, clearNativeDragState: dragDrop.clearNativeDragState, setSelectedPath: setSingleSelectedPath, @@ -292,6 +293,7 @@ export function useFileExplorerTreePaneState({ inlineInputState, rowScrolling, handlers, - nodeCommands + nodeCommands, + fileDropOwnerRef } } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.os-file-drop.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.os-file-drop.test.tsx new file mode 100644 index 000000000000..5922b56bfbf4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.os-file-drop.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import { useRef, type ComponentProps } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useFileExplorerDragDrop } from './useFileExplorerDragDrop' +import { useFileExplorerImport } from './useFileExplorerImport' +import { FileExplorerFilesTreePane } from './FileExplorerFilesTreePane' + +const mocks = vi.hoisted(() => ({ startDragEdgeScroll: vi.fn(), stopDragEdgeScroll: vi.fn() })) +vi.mock('./useFileExplorerDragEdgeScroll', () => ({ + useFileExplorerDragEdgeScroll: () => ({ + startDragEdgeScroll: mocks.startDragEdgeScroll, + stopDragEdgeScroll: mocks.stopDragEdgeScroll + }) +})) + +const refreshDir = vi.fn().mockResolvedValue(undefined) + +function Explorer(): React.JSX.Element { + const scrollRef = useRef(null) + const dragDrop = useFileExplorerDragDrop({ + worktreePath: '/repo', + activeWorktreeId: 'wt-1', + expanded: new Set(), + toggleDir: vi.fn(), + refreshDir, + scrollRef, + getOperationOwnerForPath: () => undefined + }) + const ownerRef = useFileExplorerImport({ + worktreeId: 'wt-1', + worktreePath: '/repo', + refreshDir, + clearNativeDragState: dragDrop.clearNativeDragState, + setSelectedPath: vi.fn() + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The empty pane reads these fields only; row handlers and virtual rows are not rendered. + const props = { + worktreePath: '/repo', + displayRootPath: '/repo', + explorerView: 'files', + visibleRowCount: 0, + hasNameFilter: false, + tree: { loadingDirPaths: new Set(), rootError: null, dirCache: { '/repo': { children: [] } } }, + selection: {}, + paneState: { + scrollRef, + fileDropOwnerRef: ownerRef, + inlineInputState: {}, + rowScrolling: {}, + handlers: {}, + nodeCommands: {}, + dragDrop + } + } as unknown as ComponentProps + return +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('file explorer edge auto-scroll under the OS-drop owner', () => { + it('still auto-scrolls while an OS file drag hovers the tree', () => { + const view = render() + const ownerRoot = view.container.querySelector('[data-os-file-drop-owner]') + view.rerender() + expect(view.container.querySelector('[data-os-file-drop-owner]')).toBe(ownerRoot) + const transfer = { types: ['Files'], files: [], dropEffect: 'move' } + const event = new Event('dragover', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'clientY', { value: 4 }) + act(() => { + view.getByText('No files in this workspace').dispatchEvent(event) + }) + // The owner claimed the drag and stopped it, yet the capture handler saw it first. + expect(event.defaultPrevented).toBe(true) + expect(transfer.dropEffect).toBe('copy') + expect(mocks.startDragEdgeScroll).toHaveBeenCalledWith(4) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 16d9b8ca0add..f892163179d0 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -40,6 +40,7 @@ type UseFileExplorerDragDropResult = { // Stops the drag edge auto-scroll loop (call on drag end / unmount) stopDragEdgeScroll: () => void rootDragHandlers: { + onDragOverCapture: (e: React.DragEvent) => void onDragOver: (e: React.DragEvent) => void onDragEnter: (e: React.DragEvent) => void onDragLeave: (e: React.DragEvent) => void @@ -89,9 +90,9 @@ export function useFileExplorerDragDrop({ useEffect(() => { const handleGlobalDragFinish = (): void => { - // Why: native OS drops are consumed by preload in the capture phase, so - // React root onDrop may never run. A document-level capture listener keeps - // the edge-scroll loop from surviving rejected, cancelled, or row drops. + // Why: the OS-drop owner stops native drops before React's root onDrop, and + // drops can land elsewhere. A document-level capture listener keeps the + // edge-scroll loop from surviving rejected, cancelled, or row drops. stopAndClearDragState() } @@ -116,27 +117,31 @@ export function useFileExplorerDragDrop({ }) const clearNativeDragState = useCallback(() => { - // Why: for native OS file drops the preload intercepts the drop event and - // stops propagation, so React's onDrop (which calls stopDragEdgeScroll) - // never fires. Without this, the edge-scroll rAF loop keeps running with - // the last recorded cursor Y, continuously overriding the user's scroll. + // Why: the OS-drop owner stops native drops before React's onDrop (which + // calls stopDragEdgeScroll), so its drop path clears here. Otherwise the + // edge-scroll rAF loop keeps overriding the user's scroll. stopAndClearDragState() }, [stopAndClearDragState]) const rootDragHandlers = { - onDragOver: useCallback( + // Why: capture, because the OS-drop owner stops native dragover before it bubbles. + onDragOverCapture: useCallback( (e: React.DragEvent) => { - const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) - const isNative = e.dataTransfer.types.includes('Files') - if (!isInternal && !isNative) { - return + const types = e.dataTransfer.types + if (types.includes(WORKSPACE_FILE_PATH_MIME) || types.includes('Files')) { + startDragEdgeScroll(e.clientY) } - e.preventDefault() - e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy' - startDragEdgeScroll(e.clientY) }, [startDragEdgeScroll] ), + // OS file drags never reach here; the explorer's OS-drop owner sets their drop effect. + onDragOver: useCallback((e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { + return + } + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, []), onDragEnter: useCallback((e: React.DragEvent) => { const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) const isNative = !isInternal && e.dataTransfer.types.includes('Files') @@ -183,9 +188,6 @@ export function useFileExplorerDragDrop({ rootDragCounterRef.current = 0 setIsRootDragOver(false) setDropTargetDir(null) - // Why: native Files drops are handled by the preload-relayed IPC event, - // not the React drop handler. We only clear native drag visual state - // here; the actual import is triggered from onFileDrop. clearNativeDragState() if (displayRootPath) { const dragPaths = readWorkspaceFileDragPaths(e.dataTransfer) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.committed-scope.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerImport.committed-scope.test.tsx index efdd716f5b9f..095cd2043179 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.committed-scope.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.committed-scope.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { useFileExplorerImport } from './useFileExplorerImport' -const mocks = vi.hoisted(() => ({ importPaths: vi.fn(), subscribe: vi.fn() })) +const mocks = vi.hoisted(() => ({ importPaths: vi.fn(), prepare: vi.fn() })) vi.mock('@/runtime/runtime-file-client', () => ({ importExternalPathsToRuntime: mocks.importPaths })) @@ -18,20 +18,39 @@ afterEach(() => { vi.clearAllMocks() }) +function dropOn(target: Element): { dropEffect: string } { + const transfer = { types: ['Files'], files: [new File(['a'], 'file.ts')], dropEffect: 'move' } + for (const type of ['dragover', 'drop']) { + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + target.dispatchEvent(event) + } + return transfer +} + it('keeps native drops on committed scope when a new scope render suspends', async () => { - vi.stubGlobal('api', { ui: { onFileDrop: mocks.subscribe } }) + vi.stubGlobal('api', { + ui: { onFileDrop: () => () => undefined }, + fs: { + getPathForFile: () => '/source/file.ts', + prepareDroppedPaths: mocks.prepare.mockImplementation(async ({ paths }) => ({ + paths, + failures: [] + })) + } + }) const selected = vi.fn() const clearDrag = vi.fn() const refresh = vi.fn().mockResolvedValue(undefined) const suspended = new Promise(() => {}) - mocks.subscribe.mockReturnValue(vi.fn()) mocks.importPaths.mockResolvedValue({ results: [{ status: 'imported', destPath: '/repo/app/file.ts' }] }) function Probe({ scope, suspend = false }: { scope: string; suspend?: boolean }) { - useFileExplorerImport({ - activeWorktreeId: 'wt', + const ownerRef = useFileExplorerImport({ + worktreeId: 'wt', worktreePath: '/repo', displayRootPath: scope, refreshDir: refresh, @@ -41,7 +60,13 @@ it('keeps native drops on committed scope when a new scope render suspends', asy if (suspend) { throw suspended } - return {scope} + return ( +
+ + {scope} + +
+ ) } const view = render( @@ -58,9 +83,8 @@ it('keeps native drops on committed scope when a new scope render suspends', asy ) }) expect(view.getByText('/repo/app')).toBeTruthy() - const drop = mocks.subscribe.mock.calls[0][0] await act(async () => { - drop({ target: 'file-explorer', paths: ['/source/file.ts'], destinationDir: '/repo/app' }) + dropOn(view.getByTestId('row')) }) await waitFor(() => expect(selected).toHaveBeenCalledWith('/repo/app/file.ts')) expect(refresh).toHaveBeenCalledWith('/repo/app') @@ -71,12 +95,14 @@ it('keeps native drops on committed scope when a new scope render suspends', asy ) + let transfer: { dropEffect: string } | undefined await act(async () => { - drop({ target: 'file-explorer', paths: ['/source/file.ts'], destinationDir: '/repo/app' }) + transfer = dropOn(view.getByTestId('row')) }) - await waitFor(() => expect(clearDrag).toHaveBeenCalledTimes(2)) + // The row still names /repo/app, which is outside the committed /repo/api root. + expect(transfer?.dropEffect).toBe('none') expect(selected).not.toHaveBeenCalled() expect(mocks.importPaths).toHaveBeenCalledTimes(1) expect(refresh).toHaveBeenCalledTimes(1) - expect(mocks.subscribe).toHaveBeenCalledTimes(1) + expect(mocks.prepare).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx new file mode 100644 index 000000000000..8bb046856a31 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreparedDroppedPaths } from '../../../../shared/native-file-drop-preparation' +import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import { useFileExplorerImport } from './useFileExplorerImport' +import { useFileExplorerRowDrag } from './useFileExplorerRowDrag' + +const mocks = vi.hoisted(() => ({ + importPaths: vi.fn(), + captureGuard: vi.fn(), + prepare: vi.fn() +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: mocks.importPaths +})) +vi.mock('./file-explorer-operation-owner', () => ({ + captureFileExplorerOperationGuard: mocks.captureGuard +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +type Scope = { worktreeId: string; worktreePath: string; displayRootPath: string } +type Row = { path: string; dropDir: string } + +const refreshDir = vi.fn().mockResolvedValue(undefined) +const clearDrag = vi.fn() +const setSelected = vi.fn() + +function Explorer({ scope, rows }: { scope: Scope; rows: Row[] }): React.JSX.Element { + const ownerRef = useFileExplorerImport({ + ...scope, + refreshDir, + clearNativeDragState: clearDrag, + setSelectedPath: setSelected + }) + return ( +
+ {rows.map((row) => ( + + ))} +
+ ) +} + +function drag( + target: Element, + type: 'dragover' | 'drop', + types: string[] = ['Files'] +): { dropEffect: string } { + const transfer = { + types, + files: [new File(['a'], 'a.txt')], + dropEffect: 'move', + getData: (format: string) => (format === WORKSPACE_FILE_PATH_MIME ? '/repo/app/moved.ts' : '') + } + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) + return transfer +} + +function deferredPreparation(): (paths: string[]) => void { + let resolve: (prepared: PreparedDroppedPaths) => void = () => undefined + mocks.prepare.mockImplementationOnce( + () => new Promise((done) => (resolve = done)) + ) + return (paths) => resolve({ paths, failures: [] }) +} + +const SCOPE_B: Scope = { worktreeId: 'wt-b', worktreePath: '/repo', displayRootPath: '/repo/app' } +const ROWS: Row[] = [ + { path: '/repo/app/src', dropDir: '/repo/app/src' }, + { path: '/repo/app/README.md', dropDir: '/repo/app' } +] + +beforeEach(() => { + mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ + paths, + failures: [] + })) + mocks.captureGuard.mockImplementation(() => ({ + route: { settings: { activeRuntimeEnvironmentId: null }, expectedExecutionHostId: 'local' }, + assertCurrent: vi.fn() + })) + mocks.importPaths.mockResolvedValue({ + results: [{ status: 'imported', destPath: '/repo/app/src/a.txt' }] + }) + vi.stubGlobal('api', { + // The legacy broadcast stays available; only the element drop may import. + ui: { onFileDrop: () => () => undefined }, + fs: { + getPathForFile: (file: File) => `/source/${file.name}`, + prepareDroppedPaths: mocks.prepare + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('file explorer OS file drops', () => { + it('imports a drop on a folder row into that folder', async () => { + const view = render() + expect(drag(view.getByTestId('/repo/app/src:label'), 'dragover').dropEffect).toBe('copy') + drag(view.getByTestId('/repo/app/src:label'), 'drop') + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + expect(mocks.prepare).toHaveBeenCalledWith({ + paths: ['/source/a.txt'], + consumer: 'main-reader' + }) + expect(mocks.importPaths.mock.calls[0][1]).toEqual(['/source/a.txt']) + expect(mocks.importPaths.mock.calls[0][2]).toBe('/repo/app/src') + await waitFor(() => expect(setSelected).toHaveBeenCalledWith('/repo/app/src/a.txt')) + expect(refreshDir).toHaveBeenCalledWith('/repo/app/src') + expect(clearDrag).toHaveBeenCalled() + }) + + it('imports a drop on the empty tree area into the displayed root', async () => { + const view = render() + drag(view.getByTestId('tree'), 'drop') + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + expect(mocks.importPaths.mock.calls[0][2]).toBe('/repo/app') + }) + + it('captures the target folder at drop time even if rows re-render during preparation', async () => { + const finishPreparation = deferredPreparation() + const view = render() + drag(view.getByTestId('/repo/app/src:label'), 'drop') + // A virtualized list recycles the row under the cursor for another node. + view.rerender( + + ) + await act(async () => finishPreparation(['/source/a.txt'])) + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + expect(mocks.importPaths.mock.calls[0][2]).toBe('/repo/app/src') + }) + + it('imports into the workspace the drop landed on, not the one shown after preparation', async () => { + const finishPreparation = deferredPreparation() + const view = render() + drag(view.getByTestId('/repo/app/src:label'), 'drop') + const scopeA = { worktreeId: 'wt-a', worktreePath: '/other', displayRootPath: '/other' } + view.rerender() + await act(async () => finishPreparation(['/source/a.txt'])) + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + expect(mocks.captureGuard).toHaveBeenCalledTimes(1) + expect(mocks.captureGuard.mock.calls[0][0]).toBe('wt-b') + expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ + worktreeId: 'wt-b', + worktreePath: '/repo' + }) + expect(mocks.importPaths.mock.calls[0][2]).toBe('/repo/app/src') + // The explorer now shows workspace A, so the B result is not selected there. + await waitFor(() => expect(clearDrag).toHaveBeenCalled()) + expect(setSelected).not.toHaveBeenCalled() + }) + + it('refuses a drop on a row outside the displayed root', () => { + const view = render( + + ) + expect(drag(view.getByTestId('/repo/api/x'), 'dragover').dropEffect).toBe('none') + drag(view.getByTestId('/repo/api/x'), 'drop') + expect(mocks.prepare).not.toHaveBeenCalled() + expect(mocks.importPaths).not.toHaveBeenCalled() + }) + + it('refuses while the files view is hidden', () => { + const hidden = { worktreeId: 'wt-b', worktreePath: null, displayRootPath: '/repo/app' } + function HiddenExplorer(): React.JSX.Element { + const ownerRef = useFileExplorerImport({ + ...hidden, + refreshDir, + clearNativeDragState: clearDrag, + setSelectedPath: setSelected + }) + return
+ } + const view = render() + expect(drag(view.getByTestId('tree'), 'dragover').dropEffect).toBe('none') + drag(view.getByTestId('tree'), 'drop') + expect(mocks.prepare).not.toHaveBeenCalled() + }) +}) + +describe('internal explorer row drags under the OS-drop owner', () => { + function DraggableRow({ onMoveDrop }: { onMoveDrop: (source: string, dir: string) => void }) { + const handlers = useFileExplorerRowDrag({ + rowDropDir: '/repo/app/src', + isDirectory: true, + nodePath: '/repo/app/src', + isExpanded: true, + onDragTargetChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + onMoveDrop + }) + return ( +
+ ) +} + +function dropFile(target: Element): void { + const transfer = { types: ['Files'], files: [new File(['x'], 'shot.png')], dropEffect: 'move' } + const event = new Event('drop', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) +} + +beforeEach(() => { + seedRemoteDropWorkspaces() + mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ + paths, + failures: [] + })) + mocks.importPaths.mockImplementation(async (_context, _paths, dir: string) => ({ + results: [{ status: 'imported', destPath: joinPath(dir, 'shot.png') }] + })) + vi.stubGlobal('api', { + fs: { + getPathForFile: (file: File) => `/Users/me/Desktop/${file.name}`, + prepareDroppedPaths: mocks.prepare + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('file explorer OS file drops on remote workspaces', () => { + it('uploads into the SSH folder the drop landed on', async () => { + const view = render() + dropFile(view.getByTestId('src')) + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + const [context, paths, dir] = mocks.importPaths.mock.calls[0] + expect(context).toMatchObject({ + worktreeId: 'wt-ssh', + worktreePath: SSH_ROOT, + connectionId: 'ssh-1', + settings: { activeRuntimeEnvironmentId: null }, + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 3 + }) + expect(paths).toEqual(['/Users/me/Desktop/shot.png']) + expect(dir).toBe(joinPath(SSH_ROOT, 'src')) + }) + + it("uploads through the workspace's own runtime, not the focused one", async () => { + const view = render() + dropFile(view.getByTestId('src')) + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ + worktreeId: 'wt-runtime', + settings: { activeRuntimeEnvironmentId: 'owner-runtime' } + }) + expect(mocks.importPaths.mock.calls[0][2]).toBe(joinPath(RUNTIME_ROOT, 'src')) + }) + + it('refuses after preparation when the SSH connection changed', async () => { + let finish: (prepared: PreparedDroppedPaths) => void = () => undefined + mocks.prepare.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + const view = render() + dropFile(view.getByTestId('src')) + useAppStore.setState({ sshConnectionStates: sshConnectionStatesAt(4) }) + await act(async () => finish({ paths: ['/Users/me/Desktop/shot.png'], failures: [] })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(mocks.importPaths).not.toHaveBeenCalled() + expect(clearDrag).toHaveBeenCalled() + }) + + it('reports a host that could not be verified at drop time once preparation finishes', async () => { + let finish: (prepared: PreparedDroppedPaths) => void = () => undefined + mocks.prepare.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + // A disconnected SSH host has no connection generation to pin the upload to. + useAppStore.setState({ sshConnectionStates: new Map() }) + const view = render() + dropFile(view.getByTestId('src')) + expect(mocks.toastError).not.toHaveBeenCalled() + await act(async () => finish({ paths: ['/Users/me/Desktop/shot.png'], failures: [] })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(mocks.importPaths).not.toHaveBeenCalled() + expect(clearDrag).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index 8d7fffd64087..71a644ab311c 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -1,151 +1,163 @@ import { getRelativePathInsideRoot } from '@/lib/path' -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useLayoutEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { translate } from '@/i18n/i18n' +import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner' +import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop' import type { FileExplorerOperationOwner } from './file-explorer-types' -import { captureFileExplorerOperationGuard } from './file-explorer-operation-owner' +import { + captureFileExplorerOperationGuard, + type FileExplorerOperationGuard +} from './file-explorer-operation-owner' + +/** Row attribute naming the folder an OS file dropped on that row imports into. */ +export const FILE_EXPLORER_DROP_DIR_ATTRIBUTE = 'data-file-explorer-drop-dir' type UseFileExplorerImportParams = { + worktreeId: string | null worktreePath: string | null displayRootPath?: string | null - activeWorktreeId: string | null refreshDir: (dirPath: string) => Promise clearNativeDragState: () => void setSelectedPath: (path: string | null) => void operationOwner?: FileExplorerOperationOwner } +type FileExplorerDropDestination = + | { + worktreeId: string + worktreePath: string + destinationDir: string + guard: FileExplorerOperationGuard + } + | { error: unknown } + +function readDropDir(root: HTMLElement | null, target: EventTarget | null): string | null { + const row = + target instanceof Element ? target.closest(`[${FILE_EXPLORER_DROP_DIR_ATTRIBUTE}]`) : null + return row && root?.contains(row) ? row.getAttribute(FILE_EXPLORER_DROP_DIR_ATTRIBUTE) : null +} + /** - * Subscribes to native file-drop events targeted at the file explorer and - * runs the import pipeline: copy into worktree, refresh, reveal. - * - * Why this is a separate hook: the actual filesystem paths from native OS - * drops are only available through the preload-relayed IPC event, not the - * React drop handler. The drop handler manages visual state; this hook - * manages the import action. + * Makes the explorer tree root the owner of OS file drops: the target folder is + * read from the row under the cursor at drop time, then imported, refreshed and selected. */ export function useFileExplorerImport({ + worktreeId, worktreePath, displayRootPath = worktreePath, - activeWorktreeId, refreshDir, clearNativeDragState, setSelectedPath, operationOwner -}: UseFileExplorerImportParams): void { - // Refs to avoid re-subscribing IPC listener on every render - const displayRootRef = useRef(displayRootPath) - const worktreePathRef = useRef(worktreePath) - const activeWorktreeIdRef = useRef(activeWorktreeId) - const refreshDirRef = useRef(refreshDir) - const clearNativeDragStateRef = useRef(clearNativeDragState) - const setSelectedPathRef = useRef(setSelectedPath) - const operationOwnerRef = useRef(operationOwner) - - // Native drops must observe only committed workspace state. +}: UseFileExplorerImportParams): (root: HTMLElement | null) => void { + const rootRef = useRef(null) + const [sequence] = useState(createOsFileDropSequence) + // Committed scope, read after the import to decide whether to select the result. + const shownRef = useRef({ worktreeId, displayRootPath }) useLayoutEffect(() => { - displayRootRef.current = displayRootPath - worktreePathRef.current = worktreePath - activeWorktreeIdRef.current = activeWorktreeId - refreshDirRef.current = refreshDir - clearNativeDragStateRef.current = clearNativeDragState - setSelectedPathRef.current = setSelectedPath - operationOwnerRef.current = operationOwner - }, [ - displayRootPath, - worktreePath, - activeWorktreeId, - refreshDir, - clearNativeDragState, - setSelectedPath, - operationOwner - ]) + shownRef.current = { worktreeId, displayRootPath } + }, [worktreeId, displayRootPath]) + const resolveDropDir = (event: DragEvent): string | null => { + // Why: worktreePath is null while the files view is hidden behind search or a closed sidebar. + if (!worktreeId || !worktreePath || !displayRootPath) { + return null + } + const dir = readDropDir(rootRef.current, event.target) ?? displayRootPath + return getRelativePathInsideRoot(dir, displayRootPath) === null ? null : dir + } - useEffect(() => { - return window.api.ui.onFileDrop((data) => { - if (data.target !== 'file-explorer') { - return + return useOsFileDropOwner(rootRef, { + consumer: 'main-reader', + sequence, + canAccept: (event) => resolveDropDir(event) !== null, + captureDestination: (event) => { + const destinationDir = resolveDropDir(event) + if (!destinationDir || !worktreeId || !worktreePath) { + return null } - - const wtId = activeWorktreeIdRef.current - if (!wtId || !worktreePathRef.current) { - // Why: the preload stops propagation of the native drop event, so - // React onDrop handlers never fire. We must clear the drag highlight - // ourselves even when we bail out, otherwise the explorer stays stuck - // in its drag-over visual state. - clearNativeDragStateRef.current() + try { + const guard = captureFileExplorerOperationGuard(worktreeId, operationOwner) + return { worktreeId, worktreePath, destinationDir, guard } + } catch (error) { + return { error } + } + }, + onDrop: async (prepared, { destination }) => { + for (const failure of prepared.failures) { + const message = getNativeFileDropRejectionMessage(failure) + toast.error(message.title, { description: message.description }) + } + if (!destination || prepared.paths.length === 0) { + clearNativeDragState() return } + try { + if ('error' in destination) { + throw destination.error + } + const { destinationDir, guard } = destination + guard.assertCurrent() + const { results } = await importExternalPathsToRuntime( + { + settings: guard.route.settings, + worktreeId: destination.worktreeId, + worktreePath: destination.worktreePath, + connectionId: guard.route.connectionId, + expectedExecutionHostId: guard.route.expectedExecutionHostId, + expectedSshTargetId: guard.route.expectedSshTargetId, + expectedSshConnectionGeneration: guard.route.expectedSshConnectionGeneration + }, + prepared.paths, + destinationDir, + { assertCurrent: guard.assertCurrent } + ) - const { paths, destinationDir } = data - - void (async () => { - try { - if (getRelativePathInsideRoot(destinationDir, displayRootRef.current) === null) { - return - } - const operationGuard = captureFileExplorerOperationGuard(wtId, operationOwnerRef.current) - operationGuard.assertCurrent() - const { results } = await importExternalPathsToRuntime( - { - settings: operationGuard.route.settings, - worktreeId: wtId, - worktreePath: worktreePathRef.current, - connectionId: operationGuard.route.connectionId, - expectedExecutionHostId: operationGuard.route.expectedExecutionHostId, - expectedSshTargetId: operationGuard.route.expectedSshTargetId, - expectedSshConnectionGeneration: operationGuard.route.expectedSshConnectionGeneration - }, - paths, - destinationDir, - { assertCurrent: operationGuard.assertCurrent } - ) - - // Refresh the destination directory once per gesture - await refreshDirRef.current(destinationDir) + // Refresh the destination directory once per gesture + await refreshDir(destinationDir) - // Why: only select (highlight) the first imported file — don't trigger - // the full reveal machinery because watcher refreshes can otherwise - // snap the tree viewport away from the user's drop target. - const imported = results.filter((r) => r.status === 'imported') - const skipped = results.filter((r) => r.status === 'skipped') - const failed = results.filter((r) => r.status === 'failed') + // Why: only select (highlight) the first imported file — don't trigger + // the full reveal machinery because watcher refreshes can otherwise + // snap the tree viewport away from the user's drop target. + const imported = results.filter((r) => r.status === 'imported') + const skipped = results.filter((r) => r.status === 'skipped') + const failed = results.filter((r) => r.status === 'failed') - if ( - imported.length > 0 && - activeWorktreeIdRef.current === wtId && - getRelativePathInsideRoot(imported[0].destPath, displayRootRef.current) !== null - ) { - setSelectedPathRef.current(imported[0].destPath) - } + const shown = shownRef.current + if ( + imported.length > 0 && + shown.worktreeId === destination.worktreeId && + getRelativePathInsideRoot(imported[0].destPath, shown.displayRootPath) !== null + ) { + setSelectedPath(imported[0].destPath) + } - if (failed.length > 0) { - const noun = failed.length === 1 ? 'file' : 'files' - toast.error( - translate( - 'auto.components.right.sidebar.useFileExplorerImport.132fd0e1e9', - 'Failed to import {{value0}} {{value1}}.', - { value0: failed.length, value1: noun } - ) + if (failed.length > 0) { + const noun = failed.length === 1 ? 'file' : 'files' + toast.error( + translate( + 'auto.components.right.sidebar.useFileExplorerImport.132fd0e1e9', + 'Failed to import {{value0}} {{value1}}.', + { value0: failed.length, value1: noun } ) - } else if (skipped.length > 0 && imported.length === 0) { - const noun = skipped.length === 1 ? 'file' : 'files' - toast.error( - translate( - 'auto.components.right.sidebar.useFileExplorerImport.25919b2050', - 'Skipped {{value0}} {{value1}}.', - { value0: skipped.length, value1: noun } - ) + ) + } else if (skipped.length > 0 && imported.length === 0) { + const noun = skipped.length === 1 ? 'file' : 'files' + toast.error( + translate( + 'auto.components.right.sidebar.useFileExplorerImport.25919b2050', + 'Skipped {{value0}} {{value1}}.', + { value0: skipped.length, value1: noun } ) - } - } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to import files.')) - } finally { - clearNativeDragStateRef.current() + ) } - })() - }) - }, []) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to import files.')) + } finally { + clearNativeDragState() + } + } + }) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts b/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts index 9e6b70c9042a..ce372b118525 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts @@ -70,14 +70,13 @@ export function useFileExplorerRowDrag({ [clearExpandTimer, clearNativeExpandTimer] ) + // OS file dragover and drop are taken by the explorer root's owner before rows see them. const handleDragOver = useCallback((e: React.DragEvent) => { - const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) - const isNative = e.dataTransfer.types.includes('Files') - if (!isInternal && !isNative) { + if (!e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { return } e.preventDefault() - e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy' + e.dataTransfer.dropEffect = 'move' }, []) const handleDragEnter = useCallback( @@ -174,8 +173,6 @@ export function useFileExplorerRowDrag({ for (const sourcePath of dragPaths.paths) { onMoveDrop(sourcePath, rowDropDir) } - // Why: native Files drops are handled by the preload-relayed IPC event, - // not the React drop handler. We only clear visual state here. }, [ rowDropDir, diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index a260805cff11..8c0c869e1d97 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ state: {} as Record, // Stable callback identities so companion-board Effects only re-run on real state changes. closeWorkspaceBoard: vi.fn(), + dropOwnerRef: vi.fn(), panel: { workspaceBoardOpen: false, workspaceBoardRenderedOpen: true, @@ -80,7 +81,7 @@ vi.mock('./WorkspaceKanbanDrawer', () => ({ vi.mock('./useSidebarProjectDrop', () => ({ useSidebarProjectDrop: () => ({ - nativeDropTarget: undefined, + dropOwnerRef: mocks.dropOwnerRef, dropHandlers: {}, affordance: { visible: false } }) @@ -133,6 +134,7 @@ function sidebarElement(): ReactNode { beforeEach(() => { mocks.closeWorkspaceBoard.mockClear() + mocks.dropOwnerRef.mockClear() mocks.panel = { workspaceBoardOpen: false, workspaceBoardRenderedOpen: true, @@ -245,4 +247,14 @@ describe('Sidebar', () => { await waitFor(() => expect(setAgentDashboardDrawerOpen).toHaveBeenCalledWith(false)) }) + + it('detaches the project-drop owner while the sidebar is collapsed', () => { + setSidebarState(getDefaultSettings(tmpdir())) + const view = render(sidebarElement()) + expect(mocks.dropOwnerRef).toHaveBeenLastCalledWith(expect.any(HTMLDivElement)) + + mocks.state = { ...mocks.state, sidebarOpen: false } + view.rerender(sidebarElement()) + expect(mocks.dropOwnerRef).toHaveBeenLastCalledWith(null) + }) }) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 6a5b0f3dfa4e..057c494c9179 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -95,7 +95,7 @@ function Sidebar({ () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), [settings, systemPrefersDark] ) as React.CSSProperties | undefined - const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() + const { dropOwnerRef, dropHandlers, affordance } = useSidebarProjectDrop() const { workspaceBoardOpen, workspaceBoardRenderedOpen, @@ -148,12 +148,19 @@ function Sidebar({ }) useWorkspaceRevealBodyRedirect(sidebarOpen && sidebarBody === 'agents') + // A collapsed sidebar takes no drops. + const sidebarRef = React.useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node + dropOwnerRef(sidebarOpen ? node : null) + }, + [containerRef, dropOwnerRef, sidebarOpen] + ) return (
{ + const settings: { activeRuntimeEnvironmentId: string | null } = { + activeRuntimeEnvironmentId: null + } + return { state: { settings, openModal: vi.fn() }, stat: vi.fn(), prepare: vi.fn() } +}) +vi.mock('@/store', () => { + const useAppStore = (selector: (state: typeof mocks.state) => unknown): unknown => + selector(mocks.state) + useAppStore.getState = () => mocks.state + return { useAppStore } +}) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) + +function SidebarProbe(): React.JSX.Element { + const { dropOwnerRef, dropHandlers } = useSidebarProjectDrop() + return ( +
+
+
+ ) +} + +function drag( + target: Element, + type: 'dragover' | 'drop' +): { dropEffect: string; defaultPrevented: boolean } { + const transfer = { types: ['Files'], files: [new File([''], 'project')], dropEffect: 'move' } + const event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) + return { dropEffect: transfer.dropEffect, defaultPrevented: event.defaultPrevented } +} + +beforeEach(() => { + mocks.state.settings = { activeRuntimeEnvironmentId: null } + mocks.stat.mockResolvedValue({ isDirectory: true }) + mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ + paths, + failures: [] + })) + vi.stubGlobal('api', { + // The legacy broadcast stays available; only the element drop may add a project. + ui: { onFileDrop: () => () => undefined }, + fs: { + getPathForFile: () => '/Users/me/project', + prepareDroppedPaths: mocks.prepare, + stat: mocks.stat + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('project sidebar OS folder drops', () => { + it('offers to add a dropped folder as a project', async () => { + const view = render() + expect(drag(view.getByTestId('worktree-card'), 'dragover').dropEffect).toBe('copy') + drag(view.getByTestId('worktree-card'), 'drop') + await waitFor(() => + expect(mocks.state.openModal).toHaveBeenCalledWith('add-repo', { + droppedLocalPath: '/Users/me/project' + }) + ) + expect(mocks.prepare).toHaveBeenCalledWith({ + paths: ['/Users/me/project'], + consumer: 'main-reader' + }) + }) + + it('refuses the drop while a remote runtime is active', () => { + mocks.state.settings = { activeRuntimeEnvironmentId: 'env-1' } + const legacyRoute = vi.fn() + window.addEventListener('drop', legacyRoute) + const view = render() + expect(drag(view.getByTestId('worktree-card'), 'dragover').dropEffect).toBe('none') + // The sidebar claims the refused drop, so no window-level route can act on it. + expect(drag(view.getByTestId('worktree-card'), 'drop').defaultPrevented).toBe(true) + window.removeEventListener('drop', legacyRoute) + expect(legacyRoute).not.toHaveBeenCalled() + expect(mocks.prepare).not.toHaveBeenCalled() + expect(mocks.stat).not.toHaveBeenCalled() + expect(mocks.state.openModal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts index 90f40d0f8f28..69d4a036e2d4 100644 --- a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { - NATIVE_FILE_DROP_TARGET, - hasNativeFileDragTypes -} from '../../../../shared/native-file-drop' +import { hasNativeFileDragTypes } from '../../../../shared/native-file-drop' +import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner' +import { getNativeFileDropRejectionMessage } from '@/hooks/useGlobalFileDrop' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { @@ -16,12 +15,12 @@ import { userNamedFileAccess } from '@/lib/local-file-access' type SidebarProjectDropHandlers = { onDragEnter: (event: React.DragEvent) => void - onDragOver: (event: React.DragEvent) => void onDragLeave: (event: React.DragEvent) => void } +/** The sidebar owns OS folder drops and offers to add the dropped folder as a project. */ export function useSidebarProjectDrop(): { - nativeDropTarget: typeof NATIVE_FILE_DROP_TARGET.projectSidebar + dropOwnerRef: (root: HTMLElement | null) => void dropHandlers: SidebarProjectDropHandlers affordance: ReturnType } { @@ -62,7 +61,8 @@ export function useSidebarProjectDrop(): { ) return } - if (remoteRuntimeActive) { + // Why: re-read live settings; a runtime can be focused while the drop is prepared. + if (isRemoteRuntimeActive(useAppStore.getState().settings)) { toast.error( translate( 'auto.components.sidebar.useSidebarProjectDrop.849ef13dc0', @@ -115,17 +115,24 @@ export function useSidebarProjectDrop(): { } } }, - [mountedRef, openModal, remoteRuntimeActive] + [mountedRef, openModal] ) - useEffect(() => { - return window.api.ui.onFileDrop((data) => { - if (data.target !== NATIVE_FILE_DROP_TARGET.projectSidebar) { - return + const ownerRef = useRef(null) + const [sequence] = useState(createOsFileDropSequence) + const dropOwnerRef = useOsFileDropOwner(ownerRef, { + consumer: 'main-reader', + sequence, + // A remote runtime cannot add a local folder, so the drag shows "not allowed". + canAccept: !remoteRuntimeActive, + onDrop: async (prepared) => { + for (const failure of prepared.failures) { + const message = getNativeFileDropRejectionMessage(failure) + toast.error(message.title, { description: message.description }) } - void handleProjectDropPaths(data.paths) - }) - }, [handleProjectDropPaths]) + await handleProjectDropPaths(prepared.paths) + } + }) const dropHandlers = useMemo( () => ({ @@ -136,14 +143,6 @@ export function useSidebarProjectDrop(): { dragDepthRef.current += 1 setIsDragOver(true) }, - onDragOver: (event) => { - if (!hasNativeFileDragTypes(event.dataTransfer.types)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = remoteRuntimeActive ? 'none' : 'copy' - setIsDragOver(true) - }, onDragLeave: (event) => { if (!hasNativeFileDragTypes(event.dataTransfer.types)) { return @@ -154,11 +153,11 @@ export function useSidebarProjectDrop(): { } } }), - [remoteRuntimeActive] + [] ) return { - nativeDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar, + dropOwnerRef, dropHandlers, affordance: getSidebarProjectDropAffordance({ isDragOver, diff --git a/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx b/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx new file mode 100644 index 000000000000..3aaaedcc2ee3 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createTabBarProbeStore, + TAB_BAR_PROBE_PROPS, + tabBarRuntimeModelStubs, + tabBarShellStubs +} from './use-tab-bar-runtime-model-worktree-write-probe' +import type { TabBarProps } from './tab-bar-props' + +const mocks = vi.hoisted(() => ({ capture: vi.fn(), deliver: vi.fn() })) + +vi.mock('@/store', async () => ({ useAppStore: await createTabBarProbeStore() })) +vi.mock('../../store', async () => ({ useAppStore: await createTabBarProbeStore() })) +vi.mock('@/hooks/useShortcutLabel', () => tabBarRuntimeModelStubs().shortcutLabels()) +vi.mock('@/hooks/useDetectedAgents', () => tabBarRuntimeModelStubs().detectedAgents()) +vi.mock('@/hooks/useAgentDetectionTarget', () => tabBarRuntimeModelStubs().detectionTarget()) +vi.mock('@/lib/connection-context', () => tabBarRuntimeModelStubs().connectionContext()) +vi.mock('@/lib/worktree-runtime-owner', () => tabBarRuntimeModelStubs().runtimeOwner()) +vi.mock('@/runtime/runtime-rpc-client', () => tabBarRuntimeModelStubs().runtimeRpcClient()) +vi.mock('@/lib/native-chat-transcript-readability', () => + tabBarRuntimeModelStubs().nativeChatReadability() +) +vi.mock('@/lib/client-creation-action-policy', () => tabBarRuntimeModelStubs().creationPolicy()) +vi.mock('./tab-agent-types-by-tab-id', () => tabBarRuntimeModelStubs().agentProjections()) +vi.mock('@/lib/local-preflight-context', () => tabBarRuntimeModelStubs().localPreflight()) +vi.mock('@/lib/windows-terminal-capabilities', () => + tabBarRuntimeModelStubs().windowsCapabilities() +) +// The real surface needs the full menu model; this one only renders the strip root it is handed. +vi.mock('./tab-bar-surface', () => ({ + renderTabBarSurface: ({ surfaceRef }: { surfaceRef?: (node: HTMLDivElement | null) => void }) => ( +
+ +
+ ) +})) +vi.mock('./use-tab-bar-create-menu-controller', () => ({ + useTabBarCreateMenuController: () => ({ clearPendingNewTabMenuFocusOnUnmount: () => undefined }) +})) +vi.mock('./use-tab-bar-item-projection', () => tabBarShellStubs().itemProjection()) +vi.mock('./tab-strip-overflow-navigation', () => tabBarShellStubs().overflowNavigation()) +vi.mock('./tab-strip-drag-scroll', () => tabBarShellStubs().dragScroll()) +vi.mock('@/lib/pane-manager/client-hosted-browser-row-state', () => + tabBarShellStubs().clientHostedBrowserRows() +) +vi.mock('../editor/editor-dropped-file-open', () => ({ + captureEditorFileDropOpen: mocks.capture, + editorGroupStillExists: () => true +})) + +function dropFile(target: Element): void { + const transfer = { types: ['Files'], files: [new File(['x'], 'a.ts')], dropEffect: 'move' } + const event = new Event('drop', { bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + Object.defineProperty(event, 'isTrusted', { value: true }) + act(() => { + target.dispatchEvent(event) + }) +} + +beforeEach(async () => { + ;(await createTabBarProbeStore()).setState({ activeWorktreeId: 'wt-active' }) + mocks.capture.mockImplementation(() => mocks.deliver) + vi.stubGlobal('api', { + fs: { + getPathForFile: (file: File) => `/Users/me/${file.name}`, + prepareDroppedPaths: async ({ paths }: { paths: string[] }) => ({ paths, failures: [] }) + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('TabBar OS file drops', () => { + it("opens a file dropped on the strip in the strip's own worktree and group", async () => { + const { default: TabBar } = await import('./TabBar') + const props: TabBarProps = { ...TAB_BAR_PROBE_PROPS, tabs: [], groupId: 'group-target' } + const view = render() + dropFile(view.getByTestId('tab')) + expect(mocks.capture).toHaveBeenCalledWith({ worktreeId: 'wt-target', groupId: 'group-target' }) + await waitFor(() => expect(mocks.deliver).toHaveBeenCalledWith(['/Users/me/a.ts'])) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 99e22b3f29e2..198e4a08af20 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useCallback } from 'react' import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import type { TabBarProps } from './tab-bar-props' @@ -9,6 +9,7 @@ import { useTabBarItemProjection } from './use-tab-bar-item-projection' import { renderTabBarSurface } from './tab-bar-surface' import { useTabBarItemActions } from './use-tab-bar-item-actions' import { useActiveClientHostedBrowserRowId } from '@/lib/pane-manager/client-hosted-browser-row-state' +import { useEditorGroupFileDropOwner } from '../editor/use-editor-group-file-drop-owner' function TabBarInner(props: TabBarProps): React.JSX.Element { const { @@ -85,6 +86,15 @@ function TabBarInner(props: TabBarProps): React.JSX.Element { ].join('|'), worktreeId }) + const attachFileDropOwner = useEditorGroupFileDropOwner({ worktreeId, groupId }) + const { clearPendingNewTabMenuFocusOnUnmount } = createMenu + const surfaceRef = useCallback( + (node: HTMLDivElement | null) => { + clearPendingNewTabMenuFocusOnUnmount(node) + attachFileDropOwner(node) + }, + [attachFileDropOwner, clearPendingNewTabMenuFocusOnUnmount] + ) const tabStripDragScroll = useTabStripDragScrollHandlers(tabStripNavigation.scrollTabStrip, { start: tabStripNavigation.tabStripOverflowState.canScrollStart, end: tabStripNavigation.tabStripOverflowState.canScrollEnd @@ -98,7 +108,8 @@ function TabBarInner(props: TabBarProps): React.JSX.Element { tabStripNavigation, tabStripDragScroll, activeClientHostedBrowserRowId, - itemActions + itemActions, + surfaceRef }) } diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index 9e2cc2c5c970..123dedff1ff2 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -37,7 +37,8 @@ export function renderTabBarSurface({ tabStripNavigation, tabStripDragScroll, activeClientHostedBrowserRowId, - itemActions + itemActions, + surfaceRef }: { props: TabBarProps runtime: TabBarRuntimeModel @@ -47,6 +48,7 @@ export function renderTabBarSurface({ tabStripDragScroll: ReturnType activeClientHostedBrowserRowId: string | null itemActions: TabBarItemActions + surfaceRef: (node: HTMLDivElement | null) => void }): React.JSX.Element { const { worktreeId, @@ -81,7 +83,6 @@ export function renderTabBarSurface({ handleSelectCreateMenuOption, launchAgentFromNewTabEntry, runPendingNewTabMenuFocusAfterClose, - clearPendingNewTabMenuFocusOnUnmount, queueNewActiveTerminalFocusAfterNewTabMenuClose, queueTerminalTabFocusAfterNewTabMenuClose, queueFocusAfterNewTabMenuClose, @@ -108,12 +109,7 @@ export function renderTabBarSurface({ }) return ( -
+
{tabStripOverflowState.hasOverflow ? ( diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.os-file-drop.test.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.os-file-drop.test.tsx new file mode 100644 index 000000000000..627e1c228937 --- /dev/null +++ b/src/renderer/src/components/tab-group/TabGroupPanel.os-file-drop.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import { useEffect, useRef } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const state = { + activeWorktreeId: 'wt-active', + rightSidebarOpen: true, + sidebarOpen: true, + groupsByWorktree: {} + } + const useAppStore = (selector: (current: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = () => state + return { capture: vi.fn(), deliver: vi.fn(), monacoSawDrop: vi.fn(), useAppStore } +}) +vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('../../store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('../editor/editor-dropped-file-open', () => ({ + captureEditorFileDropOpen: mocks.capture, + editorGroupStillExists: () => true +})) +vi.mock('../tab-bar/TabBar', () => ({ default: () => null })) +vi.mock('../tab-bar/TabBarQuickCommandsButton', () => ({ TabBarQuickCommandsButton: () => null })) +vi.mock('@/lib/pane-manager/client-hosted-browser-row-state', () => ({ + useClientHostedBrowserRows: () => [] +})) +vi.mock('./useTabGroupWorkspaceModel', () => ({ + useTabGroupWorkspaceModel: () => ({ + activeTab: { id: 'tab-1', entityId: 'file-1', contentType: 'editor' }, + agentSessionItems: [], + browserItems: [], + commands: { focusGroup: () => undefined }, + editorItems: [], + tabBarOrder: [], + terminalTabs: [], + groupTabs: [], + expandedPaneByTabId: {} + }) +})) +// Stands in for Monaco, which listens for drops on its own DOM node. +vi.mock('../editor/EditorPanel', () => ({ + default: function FakeMonaco() { + const ref = useRef(null) + useEffect(() => { + const node = ref.current + node?.addEventListener('drop', mocks.monacoSawDrop) + node?.addEventListener('dragover', mocks.monacoSawDrop) + return () => { + node?.removeEventListener('drop', mocks.monacoSawDrop) + node?.removeEventListener('dragover', mocks.monacoSawDrop) + } + }, []) + return ( +
+