Add sidebar folder drop for projects (#3207)

This commit is contained in:
Neil 2026-05-30 00:40:21 -07:00 committed by GitHub
parent 861e519113
commit 7286a6553b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 533 additions and 119 deletions

View File

@ -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 {

View File

@ -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<void>
writeSelectionClipboardText: (text: string) => Promise<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
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

View File

@ -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 }
: {})
})

View File

@ -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<string | null>(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') => {

View File

@ -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({
<TooltipProvider delayDuration={400}>
<div
ref={containerRef}
data-native-file-drop-target={sidebarOpen ? nativeDropTarget : undefined}
className="relative min-h-0 flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent"
{...dropHandlers}
>
{sidebarOpen && (
<>
@ -84,6 +90,23 @@ function Sidebar({
</>
)}
{sidebarOpen && affordance.visible ? (
<div
className={cn(
'pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-1.5 rounded-md border bg-sidebar-accent/95 px-4 text-center text-sidebar-accent-foreground shadow-xs',
affordance.tone === 'blocked' ? 'border-destructive/70' : 'border-sidebar-ring/70'
)}
>
{affordance.tone === 'busy' ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : (
<FolderPlus className="size-5 text-muted-foreground" />
)}
<div className="text-sm font-medium">{affordance.label}</div>
<div className="text-xs text-muted-foreground">{affordance.description}</div>
</div>
) : null}
{/* Resize handle */}
{sidebarOpen && (
<div

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
getSidebarProjectDropAffordance,
isRemoteRuntimeActive,
resolveSidebarProjectDropPath
} from './sidebar-project-drop'
describe('resolveSidebarProjectDropPath', () => {
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' })
})
})

View File

@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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'
}
}

View File

@ -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<HTMLElement>) => void
onDragOver: (event: React.DragEvent<HTMLElement>) => void
onDragLeave: (event: React.DragEvent<HTMLElement>) => void
}
export function useSidebarProjectDrop(): {
nativeDropTarget: typeof NATIVE_FILE_DROP_TARGET.projectSidebar
dropHandlers: SidebarProjectDropHandlers
affordance: ReturnType<typeof getSidebarProjectDropAffordance>
} {
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<SidebarProjectDropHandlers>(
() => ({
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
})
}
}

View File

@ -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' })
})
})

View File

@ -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<string> | ArrayLike<string> | null | undefined
): string[] {
return types ? Array.from(types) : []
}
export function hasNativeFileDragTypes(
types: Iterable<string> | ArrayLike<string> | 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
}