fix: open external files in Orca editor (#276)

* feat: allow opening files outside the worktree in the editor

Fixes an issue where prompting Claude Code to open a file outside the worktree
would fall back to opening the default system viewer, and dragging external files
into Orca wouldn't work.

- Replaced `shell.openFilePath` fallback with internal `store.openFile` for
  external files clicked in the terminal.
- Added a global drag-and-drop handler (`useGlobalFileDrop`) that resolves
  external file paths and opens them natively in Orca.
- Used `window.api.fs.stat` to prevent opening directories directly in the editor,
  falling back to the OS system viewer.

Closes stablyai/orca#271

* fix: authorize external editor file opens
This commit is contained in:
Jinjing 2026-04-03 18:56:37 -07:00 committed by GitHub
parent cb32f017aa
commit 653575e506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 240 additions and 76 deletions

View file

@ -6,6 +6,12 @@ import { listWorktrees } from '../git/worktree'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
const authorizedExternalPaths = new Set<string>()
export function authorizeExternalPath(targetPath: string): void {
authorizedExternalPaths.add(resolve(targetPath))
}
/**
* Check whether resolvedTarget is equal to or a descendant of resolvedBase.
* Uses relative() so it works with both `/` (Unix) and `\` (Windows) separators.
@ -37,6 +43,9 @@ export function getAllowedRoots(store: Store): string[] {
export function isPathAllowed(targetPath: string, store: Store): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
}
return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root))
}

View file

@ -31,7 +31,8 @@ import {
resolveAuthorizedPath,
resolveRegisteredWorktreePath,
validateGitRelativeFilePath,
isENOENT
isENOENT,
authorizeExternalPath
} from './filesystem-auth'
import { listQuickOpenFiles } from './filesystem-list-files'
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
@ -157,6 +158,10 @@ export function registerFilesystemHandlers(store: Store): void {
registerFilesystemMutationHandlers(store)
ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => {
authorizeExternalPath(args.targetPath)
})
ipcMain.handle(
'fs:stat',
async (

View file

@ -155,6 +155,7 @@ type FsApi = {
createDir: (args: { dirPath: string }) => Promise<void>
rename: (args: { oldPath: string; newPath: string }) => Promise<void>
deletePath: (args: { targetPath: string }) => Promise<void>
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
stat: (args: {
filePath: string
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>

View file

@ -245,6 +245,8 @@ const api = {
ipcRenderer.invoke('fs:rename', args),
deletePath: (args: { targetPath: string }): Promise<void> =>
ipcRenderer.invoke('fs:deletePath', args),
authorizeExternalPath: (args: { targetPath: string }): Promise<void> =>
ipcRenderer.invoke('fs:authorizeExternalPath', args),
stat: (args: {
filePath: string
}): Promise<{ size: number; isDirectory: boolean; mtime: number }> =>

View file

@ -20,6 +20,7 @@ import {
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
} from './runtime/sync-runtime-graph'
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@ -84,6 +85,7 @@ function App(): React.JSX.Element {
// sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind
// until some unrelated view remount happens to refresh them.
useGitStatusPolling()
useGlobalFileDrop()
const settings = useAppStore((s) => s.settings)

View file

@ -160,7 +160,6 @@ export default function TerminalPane({
managerRef,
containerRef,
pendingWritesRef,
paneTransportsRef,
isActiveRef,
toggleExpandPane
})

View file

@ -27,42 +27,51 @@ export function openDetectedFilePath(
const { worktreeId, worktreePath } = deps
void (async () => {
const pathExists = await window.api.shell.pathExists(filePath)
if (!pathExists) {
let statResult
try {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
statResult = await window.api.fs.stat({ filePath })
} catch {
return
}
if (statResult.isDirectory) {
await window.api.shell.openFilePath(filePath)
return
}
let relativePath = filePath
if (worktreePath && isPathInsideWorktree(filePath, worktreePath)) {
const relativePath = toWorktreeRelativePath(filePath, worktreePath)
if (relativePath === null || relativePath.length === 0) {
return
const maybeRelative = toWorktreeRelativePath(filePath, worktreePath)
if (maybeRelative !== null && maybeRelative.length > 0) {
relativePath = maybeRelative
}
const store = useAppStore.getState()
store.setActiveWorktree(worktreeId)
store.openFile({
filePath,
relativePath,
worktreeId,
language: detectLanguage(filePath),
mode: 'edit'
})
if (line !== null) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('orca:editor-reveal-location', {
detail: { filePath, line, column }
})
)
})
})
}
return
}
await window.api.shell.openFilePath(filePath)
const store = useAppStore.getState()
if (worktreeId) {
store.setActiveWorktree(worktreeId)
}
store.openFile({
filePath,
relativePath,
worktreeId: worktreeId || '',
language: detectLanguage(filePath),
mode: 'edit'
})
if (line !== null) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('orca:editor-reveal-location', {
detail: { filePath, line, column }
})
)
})
})
}
})()
}

View file

@ -1,8 +1,7 @@
import { useEffect, useRef } from 'react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { fitAndFocusPanes, fitPanes, shellEscapePath } from './pane-helpers'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
@ -10,7 +9,6 @@ type UseTerminalPaneGlobalEffectsArgs = {
managerRef: React.RefObject<PaneManager | null>
containerRef: React.RefObject<HTMLDivElement | null>
pendingWritesRef: React.RefObject<Map<number, string>>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
isActiveRef: React.RefObject<boolean>
toggleExpandPane: (paneId: number) => void
}
@ -21,7 +19,6 @@ export function useTerminalPaneGlobalEffects({
managerRef,
containerRef,
pendingWritesRef,
paneTransportsRef,
isActiveRef,
toggleExpandPane
}: UseTerminalPaneGlobalEffectsArgs): void {
@ -96,26 +93,4 @@ export function useTerminalPaneGlobalEffects({
return () => resizeObserver.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive])
useEffect(() => {
if (!isActive) {
return
}
return window.api.ui.onFileDrop(({ path: filePath }) => {
const manager = managerRef.current
if (!manager) {
return
}
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
if (!transport) {
return
}
transport.sendInput(shellEscapePath(filePath))
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive])
}

View file

@ -0,0 +1,52 @@
import { useEffect } from 'react'
import { detectLanguage } from '@/lib/language-detect'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import { useAppStore } from '@/store'
export function useGlobalFileDrop(): void {
useEffect(() => {
return window.api.ui.onFileDrop(({ path: filePath }) => {
const store = useAppStore.getState()
const activeWorktreeId = store.activeWorktreeId
if (!activeWorktreeId) {
return
}
const activeWorktree = store.allWorktrees().find((w) => w.id === activeWorktreeId)
const worktreePath = activeWorktree?.path
void (async () => {
try {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
const stat = await window.api.fs.stat({ filePath })
if (stat.isDirectory) {
return
}
let relativePath = filePath
if (worktreePath && isPathInsideWorktree(filePath, worktreePath)) {
const maybeRelative = toWorktreeRelativePath(filePath, worktreePath)
if (maybeRelative !== null && maybeRelative.length > 0) {
relativePath = maybeRelative
}
}
// Why: native OS file drops are resolved in preload because the
// isolated renderer cannot read filesystem paths from File objects.
// App owns those external drops so they consistently open in the
// editor instead of being misrouted to whichever terminal is active.
store.setActiveTabType('editor')
store.openFile({
filePath,
relativePath,
worktreeId: activeWorktreeId,
language: detectLanguage(filePath),
mode: 'edit'
})
} catch {
// Ignore files that cannot be authorized or stat'd.
}
})()
})
}, [])
}

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
isPathInsideWorktree,
resolveTerminalFileLink,
toWorktreeRelativePath
} from './terminal-links'
describe('terminal path helpers', () => {
it('keeps worktree-relative paths on Windows external files', () => {
expect(isPathInsideWorktree('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe(true)
expect(toWorktreeRelativePath('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe('src/file.ts')
})
it('supports Windows cwd resolution for terminal file links', () => {
expect(
resolveTerminalFileLink(
{
pathText: '.\\src\\file.ts',
line: 12,
column: 3,
startIndex: 0,
endIndex: 13,
displayText: '.\\src\\file.ts:12:3'
},
'C:\\repo'
)
).toEqual({
absolutePath: 'C:/repo/src/file.ts',
line: 12,
column: 3
})
})
})

View file

@ -67,8 +67,14 @@ function parsePathWithOptionalLineColumn(value: string): {
return { pathText, line, column }
}
function normalizeAbsolutePosixPath(pathValue: string): string {
const segments = pathValue.split('/')
type NormalizedAbsolutePath = {
normalized: string
comparisonKey: string
rootKind: 'posix' | 'windows' | 'unc'
}
function normalizeSegments(pathValue: string): string[] {
const segments = pathValue.split(/[\\/]+/)
const stack: string[] = []
for (const segment of segments) {
if (!segment || segment === '.') {
@ -82,7 +88,74 @@ function normalizeAbsolutePosixPath(pathValue: string): string {
}
stack.push(segment)
}
return `/${stack.join('/')}`
return stack
}
function normalizeAbsolutePath(pathValue: string): NormalizedAbsolutePath | null {
const windowsDriveMatch = /^([A-Za-z]):[\\/]*(.*)$/.exec(pathValue)
if (windowsDriveMatch) {
const driveLetter = windowsDriveMatch[1].toUpperCase()
const suffix = normalizeSegments(windowsDriveMatch[2]).join('/')
const normalized = suffix ? `${driveLetter}:/${suffix}` : `${driveLetter}:/`
return {
normalized,
comparisonKey: normalized.toLowerCase(),
rootKind: 'windows'
}
}
const uncMatch = /^\\\\([^\\/]+)[\\/]+([^\\/]+)(?:[\\/]*(.*))?$/.exec(pathValue)
if (uncMatch) {
const server = uncMatch[1]
const share = uncMatch[2]
const suffix = normalizeSegments(uncMatch[3] ?? '').join('/')
const normalizedRoot = `//${server}/${share}`
const normalized = suffix ? `${normalizedRoot}/${suffix}` : normalizedRoot
return {
normalized,
comparisonKey: normalized.toLowerCase(),
rootKind: 'unc'
}
}
if (pathValue.startsWith('/')) {
const normalized = `/${normalizeSegments(pathValue).join('/')}`.replace(/\/+$/, '') || '/'
return {
normalized,
comparisonKey: normalized,
rootKind: 'posix'
}
}
return null
}
function joinAbsolutePath(basePath: string, relativePath: string): string | null {
const normalizedBase = normalizeAbsolutePath(basePath)
if (!normalizedBase) {
return null
}
return normalizeJoinedPath(normalizedBase, relativePath)
}
function normalizeJoinedPath(basePath: NormalizedAbsolutePath, relativePath: string): string {
const normalizedBaseSegments = normalizeSegments(basePath.normalized)
const relativeSegments = normalizeSegments(relativePath)
const joinedSegments = [...normalizedBaseSegments, ...relativeSegments]
if (basePath.rootKind === 'unc') {
const [server, share, ...rest] = joinedSegments
return rest.length > 0 ? `//${server}/${share}/${rest.join('/')}` : `//${server}/${share}`
}
if (basePath.rootKind === 'windows') {
const [drive, ...rest] = joinedSegments
return rest.length > 0 ? `${drive}/${rest.join('/')}` : drive
}
return `/${joinedSegments.join('/')}`.replace(/\/+$/, '') || '/'
}
export function extractTerminalFileLinks(lineText: string): ParsedTerminalFileLink[] {
@ -131,14 +204,12 @@ export function resolveTerminalFileLink(
parsed: ParsedTerminalFileLink,
cwd: string
): ResolvedTerminalFileLink | null {
if (!cwd.startsWith('/')) {
const absolutePath =
normalizeAbsolutePath(parsed.pathText)?.normalized ?? joinAbsolutePath(cwd, parsed.pathText)
if (!absolutePath) {
return null
}
const absolutePath = parsed.pathText.startsWith('/')
? normalizeAbsolutePosixPath(parsed.pathText)
: normalizeAbsolutePosixPath(`${cwd.replace(/\/+$/, '')}/${parsed.pathText}`)
return {
absolutePath,
line: parsed.line,
@ -147,22 +218,28 @@ export function resolveTerminalFileLink(
}
export function isPathInsideWorktree(filePath: string, worktreePath: string): boolean {
const normalizedFile = normalizeAbsolutePosixPath(filePath)
const normalizedWorktree = normalizeAbsolutePosixPath(worktreePath)
if (normalizedFile === normalizedWorktree) {
const normalizedFile = normalizeAbsolutePath(filePath)
const normalizedWorktree = normalizeAbsolutePath(worktreePath)
if (!normalizedFile || !normalizedWorktree || normalizedFile.rootKind !== normalizedWorktree.rootKind) {
return false
}
if (normalizedFile.comparisonKey === normalizedWorktree.comparisonKey) {
return true
}
return normalizedFile.startsWith(`${normalizedWorktree}/`)
return normalizedFile.comparisonKey.startsWith(`${normalizedWorktree.comparisonKey}/`)
}
export function toWorktreeRelativePath(filePath: string, worktreePath: string): string | null {
const normalizedFile = normalizeAbsolutePosixPath(filePath)
const normalizedWorktree = normalizeAbsolutePosixPath(worktreePath)
if (normalizedFile === normalizedWorktree) {
return ''
}
if (!normalizedFile.startsWith(`${normalizedWorktree}/`)) {
const normalizedFile = normalizeAbsolutePath(filePath)
const normalizedWorktree = normalizeAbsolutePath(worktreePath)
if (!normalizedFile || !normalizedWorktree || normalizedFile.rootKind !== normalizedWorktree.rootKind) {
return null
}
return normalizedFile.slice(normalizedWorktree.length + 1)
if (normalizedFile.comparisonKey === normalizedWorktree.comparisonKey) {
return ''
}
if (!normalizedFile.comparisonKey.startsWith(`${normalizedWorktree.comparisonKey}/`)) {
return null
}
return normalizedFile.normalized.slice(normalizedWorktree.normalized.length + 1)
}