diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 558e9108eaf..a21a5b2023d 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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): 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 diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 2c7871d367d..e02cbcffc70 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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) => { + if (!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { + return + } + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + }, []) + + const handleInternalFileDrop = useCallback( + (event: DragEvent) => { + 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 (
setFindOpen(false)} webviewRef={webviewRef} /> {showFailureOverlay ? ( diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index d41fddfbc49..5a330da84f6 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -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 = { 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)} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx b/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx index fa1cc065780..4ce77d79cd6 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx @@ -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()} > onStartNew('file', worktreePath, 0)}> diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index c58e7015df4..5c119f3fa18 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -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 ( @@ -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({ e.preventDefault()} > onStartNew('file', targetDir, targetDepth)}> @@ -390,6 +412,12 @@ export function FileExplorerRow({ Duplicate )} + {!node.isDirectory && activeWorktreeId && ( + + + Open in Orca Browser + + )} {!node.isDirectory && activeWorktreeId && detectLanguage(node.path) === 'markdown' && ( diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 790309120f0..864cd834b7a 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 32716165c12..beaab02b322 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -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) } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts b/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts index aace7518841..9988ef7a9f6 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.ts @@ -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) } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 1fa5ea61e12..85bec2a914f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 } diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts new file mode 100644 index 00000000000..bea6e40a3be --- /dev/null +++ b/src/renderer/src/lib/file-preview.test.ts @@ -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' + }) + }) +}) diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 75eba9bd2f6..845787b6cfb 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -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 }) diff --git a/src/renderer/src/lib/workspace-file-drag.ts b/src/renderer/src/lib/workspace-file-drag.ts new file mode 100644 index 00000000000..2353a697a1e --- /dev/null +++ b/src/renderer/src/lib/workspace-file-drag.ts @@ -0,0 +1 @@ +export const WORKSPACE_FILE_PATH_MIME = 'text/x-orca-file-path'