From 7286a6553bc1b7db40dede7b2a252213405d7d9b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:40:21 -0700 Subject: [PATCH] Add sidebar folder drop for projects (#3207) --- .../window/attach-main-window-services.ts | 27 ++-- src/preload/api-types.ts | 11 +- src/preload/index.ts | 85 ++++-------- .../src/components/sidebar/AddRepoDialog.tsx | 101 +++++++++----- src/renderer/src/components/sidebar/index.tsx | 23 +++ .../sidebar/sidebar-project-drop.test.ts | 69 +++++++++ .../sidebar/sidebar-project-drop.ts | 61 ++++++++ .../sidebar/useSidebarProjectDrop.ts | 131 ++++++++++++++++++ src/shared/native-file-drop.test.ts | 57 ++++++++ src/shared/native-file-drop.ts | 87 ++++++++++++ 10 files changed, 533 insertions(+), 119 deletions(-) create mode 100644 src/renderer/src/components/sidebar/sidebar-project-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-project-drop.ts create mode 100644 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts create mode 100644 src/shared/native-file-drop.test.ts create mode 100644 src/shared/native-file-drop.ts diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4f8f981d8..8bc36c334 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -33,6 +33,7 @@ import type { RuntimeMarkdownSaveTabResult } from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' @@ -373,25 +374,15 @@ function registerRuntimeWindowLifecycle( function registerFileDropRelay(mainWindow: BrowserWindow): void { ipcMain.removeAllListeners('terminal:file-dropped-from-preload') - ipcMain.on( - 'terminal:file-dropped-from-preload', - ( - _event, - args: - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - ) => { - if (mainWindow.isDestroyed()) { - return - } - - // Why: relay exactly one IPC event per drop gesture so the renderer - // receives the full batch of paths without timer-based reconstruction. - mainWindow.webContents.send('terminal:file-drop', args) + ipcMain.on('terminal:file-dropped-from-preload', (_event, args: NativeFileDropPayload) => { + if (mainWindow.isDestroyed()) { + return } - ) + + // Why: relay exactly one IPC event per drop gesture so the renderer + // receives the full batch of paths without timer-based reconstruction. + mainWindow.webContents.send('terminal:file-drop', args) + }) } export function registerUpdaterHandlers(_store: Store): void { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fce1dc1d6..89a1fa759 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -7,6 +7,7 @@ import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' +import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { AppIdentity } from '../shared/app-identity' import type { BaseRefDefaultResult, @@ -1951,15 +1952,7 @@ export type PreloadApi = { writeClipboardText: (text: string) => Promise writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise - onFileDrop: ( - callback: ( - data: - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - ) => void - ) => () => void + onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void syncTrafficLights: (zoomFactor: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 26e016048..580efb1a1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -146,27 +146,20 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { + NATIVE_FILE_DROP_TARGET, + ORCA_INTERNAL_FILE_DRAG_TYPE, + hasNativeFileDragTypes, + resolveNativeFileDropPath, + type NativeDropResolution, + type NativeFileDropPayload, + type NativeFileDropPathEntry +} from '../shared/native-file-drop' import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' import type { CrashReportSubmitArgs, CrashReportSubmitResult } from '../shared/crash-reporting' -type NativeDropResolution = - | { target: 'editor' } - | { target: 'terminal'; tabId?: string } - | { target: 'composer' } - | { target: 'file-explorer'; destinationDir: string } - // Why: returned when the explorer marker was found but no destinationDir - // could be resolved. The caller must suppress the drop entirely instead of - // falling back to 'editor' — fail-closed behavior per design §7.1. - | { target: 'rejected' } - -type NativeFileDropPayload = - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] @@ -298,43 +291,17 @@ function disposeCachedNotificationSound(): void { * "inside this folder". */ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const path = event.composedPath() - let foundExplorer = false - let destinationDir: string | undefined - - for (const entry of path) { - if (!(entry instanceof HTMLElement)) { - continue - } - - const target = entry.dataset.nativeFileDropTarget - if (target === 'terminal') { - return { target, tabId: entry.dataset.terminalTabId } - } - if (target === 'editor' || target === 'composer') { - return { target } - } - if (target === 'file-explorer') { - foundExplorer = true - } - - // Pick the nearest (innermost) destination directory marker - if (destinationDir === undefined && entry.dataset.nativeFileDropDir) { - destinationDir = entry.dataset.nativeFileDropDir + const pathEntries: NativeFileDropPathEntry[] = [] + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement) { + pathEntries.push({ + nativeFileDropTarget: entry.dataset.nativeFileDropTarget, + nativeFileDropDir: entry.dataset.nativeFileDropDir, + terminalTabId: entry.dataset.terminalTabId + }) } } - - if (foundExplorer) { - // Why: routing must fail closed for explorer drops. If preload sees the - // explorer target marker but cannot resolve a destinationDir, it rejects - // the gesture and emits no fallback editor drop event. - if (!destinationDir) { - return { target: 'rejected' } - } - return { target: 'file-explorer', destinationDir } - } - - return null + return resolveNativeFileDropPath(pathEntries) } // --------------------------------------------------------------------------- @@ -347,7 +314,7 @@ document.addEventListener( (e) => { // Let in-app drags (e.g. file explorer drag-to-move) through to React handlers // so they can set their own dropEffect. Only override for native OS file drops. - if (e.dataTransfer?.types.includes('text/x-orca-file-path')) { + if (e.dataTransfer && !hasNativeFileDragTypes(e.dataTransfer.types)) { return } e.preventDefault() @@ -362,7 +329,7 @@ document.addEventListener( 'drop', (e) => { // Let in-app drags (e.g. file explorer → terminal) through to React handlers - if (e.dataTransfer?.types.includes('text/x-orca-file-path')) { + if (e.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { return } @@ -398,20 +365,20 @@ document.addEventListener( // The preload layer already has the full FileList. Re-emitting one IPC // message per path and asking the renderer to reconstruct the gesture via // timing would be both fragile and slower under large drops. - if (resolution?.target === 'file-explorer') { + if (resolution?.target === NATIVE_FILE_DROP_TARGET.fileExplorer) { ipcRenderer.send('terminal:file-dropped-from-preload', { paths, - target: 'file-explorer', + target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir: resolution.destinationDir }) } else { // Why: falls back to 'editor' so drops on surfaces without an explicit - // marker (sidebar, editor body, etc.) preserve the prior open-in-editor - // behavior instead of being silently discarded. + // marker preserve the prior open-in-editor behavior instead of being + // silently discarded. ipcRenderer.send('terminal:file-dropped-from-preload', { paths, - target: resolution?.target ?? 'editor', - ...(resolution?.target === 'terminal' && resolution.tabId + target: resolution?.target ?? NATIVE_FILE_DROP_TARGET.editor, + ...(resolution?.target === NATIVE_FILE_DROP_TARGET.terminal && resolution.tabId ? { tabId: resolution.tabId } : {}) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index a4ca6fecd..7ec5bdc29 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -56,6 +56,7 @@ function defaultProjectGroupNameForPath(path: string): string { const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) + const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const addRepoPath = useAppStore((s) => s.addRepoPath) const scanNestedRepos = useAppStore((s) => s.scanNestedRepos) @@ -108,6 +109,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) + // Why: a dropped path is modal data, so ordinary state updates must not + // re-run the import while the Add Project dialog advances through steps. + const droppedLocalPathHandledRef = useRef(null) // Why: track whether we've already auto-filled for this entry into the clone step, // so a late settings hydration still gets a chance to set the default. const cloneStepAutoFilledRef = useRef(false) @@ -197,6 +201,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { }, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir]) const isOpen = activeModal === 'add-repo' + const droppedLocalPath = + typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' const projectId = addedRepo?.id ?? '' const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) @@ -261,6 +267,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // Why: reset state on close so reopening doesn't show stale step/repo. useEffect(() => { if (!isOpen) { + droppedLocalPathHandledRef.current = null resetState() } }, [isOpen, resetState]) @@ -272,6 +279,65 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { step === 'create' || step === 'nested' + const handleAddLocalPath = useCallback( + async (path: string, source: AddRepoExistingWorkspaceSource) => { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + toast.error('Use a server path to add projects from a remote runtime.') + closeModal() + return + } + setIsAdding(true) + try { + const attemptId = createNestedRepoTelemetryAttemptId() + const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan + }) + ) + if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { + setNestedScan(scan) + setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) + setNestedGroupName(defaultProjectGroupNameForPath(path)) + setNestedConnectionId(null) + setNestedAttemptId(attemptId) + setNestedRuntimeKind('local') + setStep('nested') + return + } + const repo = await addRepoPath(path) + if (repo && isGitRepoKind(repo)) { + setAddedRepo(repo) + setExistingWorkspaceSource(source) + await fetchWorktrees(repo.id) + setStep('setup') + } else if (repo) { + // Why: folder repos skip the Git worktree setup step and activate + // their synthetic root workspace in the folder add flow. + closeModal() + } + } finally { + setIsAdding(false) + } + }, + [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos, settings?.activeRuntimeEnvironmentId] + ) + + useEffect(() => { + if (!isOpen || !droppedLocalPath) { + return + } + if (droppedLocalPathHandledRef.current === droppedLocalPath) { + return + } + droppedLocalPathHandledRef.current = droppedLocalPath + void handleAddLocalPath(droppedLocalPath, 'local_folder_picker') + }, [droppedLocalPath, handleAddLocalPath, isOpen]) + const handleBrowse = useCallback(async () => { setIsAdding(true) try { @@ -279,42 +345,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { if (!path) { return } - const attemptId = createNestedRepoTelemetryAttemptId() - const scan = await scanNestedRepos(path) - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'sidebar', - runtimeKind: 'local', - scan - }) - ) - if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - setNestedScan(scan) - setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) - setNestedGroupName(defaultProjectGroupNameForPath(path)) - setNestedConnectionId(null) - setNestedAttemptId(attemptId) - setNestedRuntimeKind('local') - setStep('nested') - return - } - const repo = await addRepoPath(path) - if (repo && isGitRepoKind(repo)) { - setAddedRepo(repo) - setExistingWorkspaceSource('local_folder_picker') - await fetchWorktrees(repo.id) - setStep('setup') - } else if (repo) { - // Why: folder repos skip the Git worktree setup step and activate - // their synthetic root workspace in the folder add flow. - closeModal() - } + await handleAddLocalPath(path, 'local_folder_picker') } finally { setIsAdding(false) } - }, [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos]) + }, [handleAddLocalPath]) const handleImportNestedRepos = useCallback( async (mode: 'group' | 'separate') => { diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index f712e820b..d42737a0f 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -16,6 +16,9 @@ import ProjectAddedDialog from './ProjectAddedDialog' import WorktreeVisibilityDialog from './WorktreeVisibilityDialog' import OrcaYamlTrustDialog from './OrcaYamlTrustDialog' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import { cn } from '@/lib/utils' +import { FolderPlus, Loader2 } from 'lucide-react' +import { useSidebarProjectDrop } from './useSidebarProjectDrop' const MIN_WIDTH = 220 const MAX_WIDTH = 500 @@ -37,6 +40,7 @@ function Sidebar({ const setSidebarWidth = useAppStore((s) => s.setSidebarWidth) const repos = useAppStore((s) => s.repos) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) + const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() const setLiveSidebarWidth = React.useCallback((width: number) => { document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`) @@ -64,7 +68,9 @@ function Sidebar({
{sidebarOpen && ( <> @@ -84,6 +90,23 @@ function Sidebar({ )} + {sidebarOpen && affordance.visible ? ( +
+ {affordance.tone === 'busy' ? ( + + ) : ( + + )} +
{affordance.label}
+
{affordance.description}
+
+ ) : null} + {/* Resize handle */} {sidebarOpen && (
{ + it('accepts exactly one dropped path', () => { + expect(resolveSidebarProjectDropPath(['/Users/alice/repo'])).toEqual({ + status: 'ready', + path: '/Users/alice/repo' + }) + }) + + it('rejects empty and multi-path drops before routing', () => { + expect(resolveSidebarProjectDropPath([])).toEqual({ status: 'empty' }) + expect(resolveSidebarProjectDropPath(['/repo/a', '/repo/b'])).toEqual({ + status: 'multiple', + count: 2 + }) + }) +}) + +describe('isRemoteRuntimeActive', () => { + it('distinguishes local runtime from active server runtime', () => { + expect(isRemoteRuntimeActive(null)).toBe(false) + expect(isRemoteRuntimeActive({ activeRuntimeEnvironmentId: ' ' })).toBe(false) + expect(isRemoteRuntimeActive({ activeRuntimeEnvironmentId: 'server-1' })).toBe(true) + }) +}) + +describe('getSidebarProjectDropAffordance', () => { + it('hides when the sidebar is not in a drop interaction', () => { + expect( + getSidebarProjectDropAffordance({ + isDragOver: false, + isHandlingDrop: false, + remoteRuntimeActive: false + }) + ).toEqual({ visible: false }) + }) + + it('shows ready, busy, and blocked states', () => { + expect( + getSidebarProjectDropAffordance({ + isDragOver: true, + isHandlingDrop: false, + remoteRuntimeActive: false + }) + ).toMatchObject({ visible: true, tone: 'ready' }) + + expect( + getSidebarProjectDropAffordance({ + isDragOver: false, + isHandlingDrop: true, + remoteRuntimeActive: false + }) + ).toMatchObject({ visible: true, tone: 'busy' }) + + expect( + getSidebarProjectDropAffordance({ + isDragOver: true, + isHandlingDrop: false, + remoteRuntimeActive: true + }) + ).toMatchObject({ visible: true, tone: 'blocked' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-project-drop.ts b/src/renderer/src/components/sidebar/sidebar-project-drop.ts new file mode 100644 index 000000000..d76c05b54 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-project-drop.ts @@ -0,0 +1,61 @@ +import type { GlobalSettings } from '../../../../shared/types' + +export type SidebarProjectDropPathResolution = + | { status: 'ready'; path: string } + | { status: 'empty' } + | { status: 'multiple'; count: number } + +export type SidebarProjectDropAffordance = + | { visible: false } + | { visible: true; tone: 'ready' | 'blocked' | 'busy'; label: string; description: string } + +export function resolveSidebarProjectDropPath( + paths: readonly string[] +): SidebarProjectDropPathResolution { + const usablePaths = paths.filter((path) => path.length > 0) + if (usablePaths.length === 0) { + return { status: 'empty' } + } + if (usablePaths.length > 1) { + return { status: 'multiple', count: usablePaths.length } + } + return { status: 'ready', path: usablePaths[0] } +} + +export function isRemoteRuntimeActive( + settings: Pick | null | undefined +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim()) +} + +export function getSidebarProjectDropAffordance(args: { + isDragOver: boolean + isHandlingDrop: boolean + remoteRuntimeActive: boolean +}): SidebarProjectDropAffordance { + if (!args.isDragOver && !args.isHandlingDrop) { + return { visible: false } + } + if (args.isHandlingDrop) { + return { + visible: true, + tone: 'busy', + label: 'Checking folder', + description: 'Preparing the project add flow' + } + } + if (args.remoteRuntimeActive) { + return { + visible: true, + tone: 'blocked', + label: 'Server runtime active', + description: 'Use Add Project for server paths' + } + } + return { + visible: true, + tone: 'ready', + label: 'Drop folder to add project', + description: 'Local folders and Git repositories' + } +} diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts new file mode 100644 index 000000000..73c97f59e --- /dev/null +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -0,0 +1,131 @@ +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 { useAppStore } from '@/store' +import { + getSidebarProjectDropAffordance, + isRemoteRuntimeActive, + resolveSidebarProjectDropPath +} from './sidebar-project-drop' + +type SidebarProjectDropHandlers = { + onDragEnter: (event: React.DragEvent) => void + onDragOver: (event: React.DragEvent) => void + onDragLeave: (event: React.DragEvent) => void +} + +export function useSidebarProjectDrop(): { + nativeDropTarget: typeof NATIVE_FILE_DROP_TARGET.projectSidebar + dropHandlers: SidebarProjectDropHandlers + affordance: ReturnType +} { + const openModal = useAppStore((s) => s.openModal) + const settings = useAppStore((s) => s.settings) + const [isDragOver, setIsDragOver] = useState(false) + const [isHandlingDrop, setIsHandlingDrop] = useState(false) + const dragDepthRef = useRef(0) + const remoteRuntimeActive = isRemoteRuntimeActive(settings) + + const clearDragState = useCallback(() => { + dragDepthRef.current = 0 + setIsDragOver(false) + }, []) + + useEffect(() => { + document.addEventListener('drop', clearDragState, true) + document.addEventListener('dragend', clearDragState, true) + return () => { + document.removeEventListener('drop', clearDragState, true) + document.removeEventListener('dragend', clearDragState, true) + } + }, [clearDragState]) + + const handleProjectDropPaths = useCallback( + async (paths: readonly string[]) => { + const pathResolution = resolveSidebarProjectDropPath(paths) + if (pathResolution.status === 'empty') { + return + } + if (pathResolution.status === 'multiple') { + toast.warning('Drop one folder at a time.') + return + } + if (remoteRuntimeActive) { + toast.error('Local folder drops are unavailable for server runtimes.', { + description: 'Use Add Project to enter a server path.' + }) + return + } + + setIsHandlingDrop(true) + try { + await window.api.fs.authorizeExternalPath({ targetPath: pathResolution.path }) + const stat = await window.api.fs.stat({ filePath: pathResolution.path }) + if (!stat.isDirectory) { + toast.error('Drop a folder to add it as a project.') + return + } + openModal('add-repo', { droppedLocalPath: pathResolution.path }) + } catch (error) { + toast.error('Could not add dropped folder.', { + description: error instanceof Error ? error.message : String(error) + }) + } finally { + setIsHandlingDrop(false) + } + }, + [openModal, remoteRuntimeActive] + ) + + useEffect(() => { + return window.api.ui.onFileDrop((data) => { + if (data.target !== NATIVE_FILE_DROP_TARGET.projectSidebar) { + return + } + void handleProjectDropPaths(data.paths) + }) + }, [handleProjectDropPaths]) + + const dropHandlers = useMemo( + () => ({ + onDragEnter: (event) => { + if (!hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + 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 + } + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) { + setIsDragOver(false) + } + } + }), + [remoteRuntimeActive] + ) + + return { + nativeDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar, + dropHandlers, + affordance: getSidebarProjectDropAffordance({ + isDragOver, + isHandlingDrop, + remoteRuntimeActive + }) + } +} diff --git a/src/shared/native-file-drop.test.ts b/src/shared/native-file-drop.test.ts new file mode 100644 index 000000000..53ee4bf04 --- /dev/null +++ b/src/shared/native-file-drop.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + NATIVE_FILE_DROP_TARGET, + ORCA_INTERNAL_FILE_DRAG_TYPE, + hasNativeFileDragTypes, + resolveNativeFileDropPath +} from './native-file-drop' + +describe('hasNativeFileDragTypes', () => { + it('accepts native OS file drags', () => { + expect(hasNativeFileDragTypes(['Files'])).toBe(true) + }) + + it('rejects internal Orca file moves and URL/text drags', () => { + expect(hasNativeFileDragTypes(['Files', ORCA_INTERNAL_FILE_DRAG_TYPE])).toBe(false) + expect(hasNativeFileDragTypes(['text/uri-list'])).toBe(false) + expect(hasNativeFileDragTypes(['text/plain'])).toBe(false) + }) +}) + +describe('resolveNativeFileDropPath', () => { + it('routes drops on the project sidebar to the add-project surface', () => { + expect( + resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar }]) + ).toEqual({ target: NATIVE_FILE_DROP_TARGET.projectSidebar }) + }) + + it('preserves terminal tab routing for native file drops', () => { + expect( + resolveNativeFileDropPath([ + { + nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.terminal, + terminalTabId: 'tab-1' + } + ]) + ).toEqual({ target: NATIVE_FILE_DROP_TARGET.terminal, tabId: 'tab-1' }) + }) + + it('uses the nearest file-explorer destination and fails closed without one', () => { + expect( + resolveNativeFileDropPath([ + { nativeFileDropDir: '/repo/src' }, + { + nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.fileExplorer, + nativeFileDropDir: '/repo' + } + ]) + ).toEqual({ + target: NATIVE_FILE_DROP_TARGET.fileExplorer, + destinationDir: '/repo/src' + }) + + expect( + resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.fileExplorer }]) + ).toEqual({ target: 'rejected' }) + }) +}) diff --git a/src/shared/native-file-drop.ts b/src/shared/native-file-drop.ts new file mode 100644 index 000000000..4c5ef2aa9 --- /dev/null +++ b/src/shared/native-file-drop.ts @@ -0,0 +1,87 @@ +export const ORCA_INTERNAL_FILE_DRAG_TYPE = 'text/x-orca-file-path' + +export const NATIVE_FILE_DROP_TARGET = { + editor: 'editor', + terminal: 'terminal', + composer: 'composer', + fileExplorer: 'file-explorer', + projectSidebar: 'project-sidebar' +} as const + +export type NativeFileDropTarget = + (typeof NATIVE_FILE_DROP_TARGET)[keyof typeof NATIVE_FILE_DROP_TARGET] + +export type NativeDropResolution = + | { target: typeof NATIVE_FILE_DROP_TARGET.editor } + | { target: typeof NATIVE_FILE_DROP_TARGET.terminal; tabId?: string } + | { target: typeof NATIVE_FILE_DROP_TARGET.composer } + | { target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer; destinationDir: string } + | { target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar } + | { target: 'rejected' } + +export type NativeFileDropPayload = + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.editor } + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.terminal; tabId?: string } + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.composer } + | { + paths: string[] + target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer + destinationDir: string + } + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar } + +export type NativeFileDropPathEntry = { + nativeFileDropTarget?: string + nativeFileDropDir?: string + terminalTabId?: string +} + +export function getDataTransferTypes( + types: Iterable | ArrayLike | null | undefined +): string[] { + return types ? Array.from(types) : [] +} + +export function hasNativeFileDragTypes( + types: Iterable | ArrayLike | null | undefined +): boolean { + const values = getDataTransferTypes(types) + return values.includes('Files') && !values.includes(ORCA_INTERNAL_FILE_DRAG_TYPE) +} + +export function resolveNativeFileDropPath( + path: readonly NativeFileDropPathEntry[] +): NativeDropResolution | null { + let foundExplorer = false + let destinationDir: string | undefined + + for (const entry of path) { + const target = entry.nativeFileDropTarget + if (target === NATIVE_FILE_DROP_TARGET.terminal) { + return { target, tabId: entry.terminalTabId } + } + if (target === NATIVE_FILE_DROP_TARGET.editor || target === NATIVE_FILE_DROP_TARGET.composer) { + return { target } + } + if (target === NATIVE_FILE_DROP_TARGET.projectSidebar) { + return { target } + } + if (target === NATIVE_FILE_DROP_TARGET.fileExplorer) { + foundExplorer = true + } + + // Pick the nearest (innermost) destination directory marker. + if (destinationDir === undefined && entry.nativeFileDropDir) { + destinationDir = entry.nativeFileDropDir + } + } + + if (foundExplorer) { + if (!destinationDir) { + return { target: 'rejected' } + } + return { target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir } + } + + return null +}