feat: Add file explorer open in browser actions (#1843)

This commit is contained in:
elashera
2026-05-18 00:47:23 -04:00
committed by GitHub
parent 7a126b192a
commit 55ab246556
12 changed files with 250 additions and 28 deletions
@@ -18,6 +18,7 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import type {
GitHubWorkItem,
GitLabWorkItem,
@@ -144,12 +145,12 @@ function useComposerFileDragOver(): {
const onDragEnter = React.useCallback((event: React.DragEvent<HTMLDivElement>): void => {
// Why: "Files" is the DataTransfer type the OS adds for native file drags;
// internal in-app drags (text/x-orca-file-path) must not trigger the
// internal in-app drags must not trigger the
// attachment-drop highlight so they still route to their own handlers.
if (!event.dataTransfer.types.includes('Files')) {
return
}
if (event.dataTransfer.types.includes('text/x-orca-file-path')) {
if (event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) {
return
}
dragCounterRef.current += 1
@@ -162,10 +163,10 @@ function useComposerFileDragOver(): {
return
}
// Why: mirror the onDragEnter guard so internal in-app drags (which may
// carry both 'Files' and 'text/x-orca-file-path' types) don't decrement
// carry both "Files" and the workspace path MIME type) don't decrement
// the counter when enter skipped incrementing it — otherwise the counter
// goes negative and the native-drag highlight state desyncs.
if (event.dataTransfer.types.includes('text/x-orca-file-path')) {
if (event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) {
return
}
dragCounterRef.current -= 1
@@ -1,10 +1,12 @@
/* eslint-disable max-lines */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type DragEvent } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import {
ArrowLeft,
ArrowRight,
@@ -2674,6 +2676,48 @@ function BrowserPagePane({
webview.style.display = showFailureOverlay ? 'none' : 'flex'
}, [showFailureOverlay])
const handleInternalFileDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) {
return
}
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}, [])
const handleInternalFileDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
const filePath = event.dataTransfer.getData(WORKSPACE_FILE_PATH_MIME)
if (!filePath) {
return
}
event.preventDefault()
event.stopPropagation()
const target = getWorkspaceFileBrowserOpenTarget({ filePath, worktreeId })
if (target.status === 'unsupported') {
setResourceNotice(target.message)
return
}
const webview = webviewRef.current
const rect = webview?.getBoundingClientRect()
if (!webview || !rect) {
setResourceNotice('Browser page is not ready for file drops.')
return
}
const pageX = event.clientX - rect.left
const pageY = event.clientY - rect.top
if (pageX < 0 || pageY < 0 || pageX > rect.width || pageY > rect.height) {
setResourceNotice('Drop files over the browser page, not the toolbar.')
return
}
navigateToUrl(target.url)
},
[navigateToUrl, worktreeId]
)
return (
<div
className={cn(
@@ -3076,6 +3120,8 @@ function BrowserPagePane({
<div
ref={containerRef}
className="relative flex min-h-0 flex-1 overflow-hidden bg-background"
onDragOver={handleInternalFileDragOver}
onDrop={handleInternalFileDrop}
>
<BrowserFind isOpen={findOpen} onClose={() => setFindOpen(false)} webviewRef={webviewRef} />
{showFailureOverlay ? (
@@ -14,6 +14,7 @@ import {
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -56,7 +57,6 @@ type CombinedDiffTreeNode = SourceControlTreeNode<
const COMBINED_DIFF_TREE_INDENT_PX = 12
const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8
const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20
const ORCA_PATH_MIME = 'text/x-orca-file-path'
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
unstaged: 'Changes',
@@ -350,7 +350,7 @@ function CombinedDiffFileTreeRow({
}}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(ORCA_PATH_MIME, joinPath(worktreePath, node.path))
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
event.dataTransfer.effectAllowed = 'copy'
}}
>
@@ -402,7 +402,10 @@ function CombinedDiffFileTreeRow({
event.preventDefault()
return
}
event.dataTransfer.setData(ORCA_PATH_MIME, joinPath(worktreePath, node.entry.path))
event.dataTransfer.setData(
WORKSPACE_FILE_PATH_MIME,
joinPath(worktreePath, node.entry.path)
)
event.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => onNavigate(node.entry)}
@@ -7,6 +7,16 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
function stopRightButtonMenuSelection(event: React.PointerEvent): void {
if (event.button !== 2) {
return
}
// Why: the synthetic trigger sits at the cursor; the right-button release
// can otherwise land on "New File" and select it immediately.
event.preventDefault()
event.stopPropagation()
}
export function FileExplorerBackgroundMenu({
open,
onOpenChange,
@@ -34,6 +44,7 @@ export function FileExplorerBackgroundMenu({
className="w-48"
sideOffset={0}
align="start"
onPointerUpCapture={stopRightButtonMenuSelection}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={() => onStartNew('file', worktreePath, 0)}>
@@ -12,11 +12,13 @@ import {
Folder,
FolderOpen,
FolderPlus,
Globe,
ListCollapse,
Loader2,
Pencil,
Trash2
} from 'lucide-react'
import { toast } from 'sonner'
import {
ContextMenu,
ContextMenuContent,
@@ -29,14 +31,14 @@ import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { openFileInBrowserTab } from '@/lib/file-preview'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import type { GitFileStatus } from '../../../../shared/types'
import { STATUS_LABELS } from './status-display'
import type { TreeNode } from './file-explorer-types'
import { useFileExplorerRowDrag } from './useFileExplorerRowDrag'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
const ORCA_PATH_MIME = 'text/x-orca-file-path'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@@ -47,6 +49,16 @@ const revealLabel = isMac
? 'Open Containing Folder'
: 'Reveal in File Explorer'
function stopRightButtonMenuSelection(event: React.PointerEvent): void {
if (event.button !== 2) {
return
}
// Why: Radix opens context menus under the pointer; on some macOS/Electron
// paths the right-button release lands on the first item and selects it.
event.preventDefault()
event.stopPropagation()
}
export type InlineInput = {
parentPath: string
type: 'file' | 'folder' | 'rename'
@@ -271,6 +283,15 @@ export function FileExplorerRow({
onNativeDragExpandDir,
onMoveDrop
})
const handleOpenInOrcaBrowser = useCallback(() => {
if (!activeWorktreeId) {
return
}
const result = openFileInBrowserTab({ filePath: node.path, worktreeId: activeWorktreeId })
if (result.status === 'unsupported') {
toast.error(result.message)
}
}, [activeWorktreeId, node.path])
return (
<ContextMenu>
@@ -285,7 +306,7 @@ export function FileExplorerRow({
data-native-file-drop-dir={rowDropDir}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(ORCA_PATH_MIME, node.path)
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, node.path)
// Allow both file explorer moving and copying to terminal
event.dataTransfer.effectAllowed = 'copyMove'
onDragSourceChange(node.path)
@@ -363,6 +384,7 @@ export function FileExplorerRow({
</ContextMenuTrigger>
<ContextMenuContent
className="w-64 bg-[rgba(255,255,255,0.82)] dark:bg-[rgba(0,0,0,0.72)]"
onPointerUpCapture={stopRightButtonMenuSelection}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ContextMenuItem onSelect={() => onStartNew('file', targetDir, targetDepth)}>
@@ -390,6 +412,12 @@ export function FileExplorerRow({
Duplicate
</ContextMenuItem>
)}
{!node.isDirectory && activeWorktreeId && (
<ContextMenuItem onSelect={handleOpenInOrcaBrowser}>
<Globe />
Open in Orca Browser
</ContextMenuItem>
)}
{!node.isDirectory && activeWorktreeId && detectLanguage(node.path) === 'markdown' && (
<ContextMenuItem
onSelect={() =>
@@ -38,6 +38,7 @@ import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
@@ -4175,7 +4176,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
return
}
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData('text/x-orca-file-path', absolutePath)
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={(e) => {
@@ -4337,7 +4338,7 @@ function BranchEntryRow({
draggable
onDragStart={(e) => {
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData('text/x-orca-file-path', absolutePath)
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={onOpen}
@@ -8,6 +8,7 @@ import { useAppStore } from '@/store'
import { basename, dirname, joinPath } from '@/lib/path'
import { detectLanguage } from '@/lib/language-detect'
import { getConnectionId } from '@/lib/connection-context'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { renameRuntimePath } from '@/runtime/runtime-file-client'
@@ -56,8 +57,6 @@ type UseFileExplorerDragDropResult = {
clearNativeDragState: () => void
}
const ORCA_PATH_MIME = 'text/x-orca-file-path'
// Native drag auto-scroll uses a very thin band; a wider zone matches IDE-style
// tree dragging so users need not hug the scrollbar.
const DRAG_EDGE_ZONE_PX = 48
@@ -325,7 +324,7 @@ export function useFileExplorerDragDrop({
const rootDragHandlers = {
onDragOver: useCallback(
(e: React.DragEvent) => {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)
const isNative = e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
@@ -340,7 +339,7 @@ export function useFileExplorerDragDrop({
[tickDragEdgeScroll]
),
onDragEnter: useCallback((e: React.DragEvent) => {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)
const isNative = !isInternal && e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
@@ -389,7 +388,7 @@ export function useFileExplorerDragDrop({
// not the React drop handler. We only clear native drag visual state
// here; the actual import is triggered from onFileDrop.
clearNativeDragState()
const sourcePath = e.dataTransfer.getData(ORCA_PATH_MIME)
const sourcePath = e.dataTransfer.getData(WORKSPACE_FILE_PATH_MIME)
if (sourcePath && worktreePath) {
handleMoveDrop(sourcePath, worktreePath)
}
@@ -1,6 +1,6 @@
import React, { useCallback, useRef } from 'react'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
const ORCA_PATH_MIME = 'text/x-orca-file-path'
const DRAG_EXPAND_DELAY_MS = 500
type UseFileExplorerRowDragParams = {
@@ -53,7 +53,7 @@ export function useFileExplorerRowDrag({
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)
const isNative = e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
@@ -64,7 +64,7 @@ export function useFileExplorerRowDrag({
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
const isInternal = e.dataTransfer.types.includes(ORCA_PATH_MIME)
const isInternal = e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)
const isNative = !isInternal && e.dataTransfer.types.includes('Files')
if (!isInternal && !isNative) {
return
@@ -148,7 +148,7 @@ export function useFileExplorerRowDrag({
clearNativeExpandTimer()
onDragTargetChange(null)
onNativeDragTargetChange(null)
const sourcePath = e.dataTransfer.getData(ORCA_PATH_MIME)
const sourcePath = e.dataTransfer.getData(WORKSPACE_FILE_PATH_MIME)
if (sourcePath) {
onMoveDrop(sourcePath, rowDropDir)
}
@@ -54,6 +54,7 @@ import {
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
// Why: registry lives in a leaf module so the store slice can import it
@@ -1232,13 +1233,13 @@ export default function TerminalPane({
onMouseDownCapture={handlePrimarySelectionMiddleMouseDown}
onAuxClickCapture={handlePrimarySelectionAuxClick}
onDragOver={(e) => {
if (e.dataTransfer.types.includes('text/x-orca-file-path')) {
if (e.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
}}
onDrop={(e) => {
const filePath = e.dataTransfer.getData('text/x-orca-file-path')
const filePath = e.dataTransfer.getData(WORKSPACE_FILE_PATH_MIME)
if (!filePath) {
return
}
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE,
getWorkspaceFileBrowserOpenTarget,
openFileInBrowserTab
} from './file-preview'
const mocks = vi.hoisted(() => ({
createBrowserTab: vi.fn(),
connectionId: null as string | null
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
createBrowserTab: mocks.createBrowserTab,
repos: [{ id: 'repo-1', connectionId: mocks.connectionId }],
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }]
}
})
}
}))
beforeEach(() => {
vi.clearAllMocks()
mocks.connectionId = null
})
describe('openFileInBrowserTab', () => {
it('opens a local file URL in the Orca browser with the filename as title', () => {
openFileInBrowserTab({
filePath: '/tmp/example file.html',
worktreeId: 'wt-1'
})
expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'file:///tmp/example%20file.html', {
title: 'example file.html',
activate: true
})
})
it('returns unsupported for SSH worktrees without creating a local file URL tab', () => {
mocks.connectionId = 'ssh-1'
const result = openFileInBrowserTab({
filePath: '/home/alice/report.html',
worktreeId: 'wt-1'
})
expect(result).toEqual({
status: 'unsupported',
reason: 'remote-worktree',
message: REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE
})
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
})
})
describe('getWorkspaceFileBrowserOpenTarget', () => {
it('returns a reusable browser navigation target for local files', () => {
expect(
getWorkspaceFileBrowserOpenTarget({
filePath: 'C:\\repo\\demo page.html',
worktreeId: 'wt-1'
})
).toEqual({
status: 'ready',
url: 'file:///C:/repo/demo%20page.html',
title: 'demo page.html'
})
})
})
+62 -4
View File
@@ -1,8 +1,61 @@
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
import { getConnectionId } from '@/lib/connection-context'
import { useAppStore } from '@/store'
import { findSiblingGroupId } from '@/store/slices/tabs'
export type PreviewableLanguage = 'html'
export const REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE =
'Open in Orca Browser is only available for local files.'
export type WorkspaceFileBrowserOpenTarget =
| {
status: 'ready'
url: string
title: string
}
| {
status: 'unsupported'
message: string
reason: 'remote-worktree'
}
export function getWorkspaceFileBrowserOpenTarget(params: {
filePath: string
worktreeId: string
}): WorkspaceFileBrowserOpenTarget {
if (getConnectionId(params.worktreeId)) {
// Why: Chromium resolves file:// URLs on the local machine. Remote files
// need an Orca-served URL before the browser can render them correctly.
return {
status: 'unsupported',
reason: 'remote-worktree',
message: REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE
}
}
return {
status: 'ready',
url: absolutePathToFileUri(params.filePath),
title: params.filePath.split(/[/\\]/).pop() ?? params.filePath
}
}
export function openFileInBrowserTab(
params: { filePath: string; worktreeId: string }
): WorkspaceFileBrowserOpenTarget {
const target = getWorkspaceFileBrowserOpenTarget(params)
if (target.status === 'unsupported') {
return target
}
const state = useAppStore.getState()
state.createBrowserTab(params.worktreeId, target.url, {
title: target.title,
activate: true
})
return target
}
export function canPreviewLanguage(language: string): language is PreviewableLanguage {
return language === 'html'
@@ -50,11 +103,16 @@ export function openFilePreviewToSide(params: {
return
}
const fileUrl = absolutePathToFileUri(params.filePath)
const title = params.filePath.split(/[/\\]/).pop() ?? params.filePath
const target = getWorkspaceFileBrowserOpenTarget({
filePath: params.filePath,
worktreeId
})
if (target.status === 'unsupported') {
return
}
state.createBrowserTab(worktreeId, fileUrl, {
title,
state.createBrowserTab(worktreeId, target.url, {
title: target.title,
targetGroupId,
activate: true
})
@@ -0,0 +1 @@
export const WORKSPACE_FILE_PATH_MIME = 'text/x-orca-file-path'