mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat: Add file explorer open in browser actions (#1843)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user