mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
cb32f017aa
commit
653575e506
11 changed files with 240 additions and 76 deletions
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
1
src/preload/index.d.ts
vendored
1
src/preload/index.d.ts
vendored
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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 }> =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,6 @@ export default function TerminalPane({
|
|||
managerRef,
|
||||
containerRef,
|
||||
pendingWritesRef,
|
||||
paneTransportsRef,
|
||||
isActiveRef,
|
||||
toggleExpandPane
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
52
src/renderer/src/hooks/useGlobalFileDrop.ts
Normal file
52
src/renderer/src/hooks/useGlobalFileDrop.ts
Normal 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.
|
||||
}
|
||||
})()
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
33
src/renderer/src/lib/terminal-links.test.ts
Normal file
33
src/renderer/src/lib/terminal-links.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue