Add sidebar folder drop for projects (#3207)
This commit is contained in:
parent
861e519113
commit
7286a6553b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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') => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue