mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(source-control): expand file context menu and shell-based Open in apps (#5825)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCmdExePath } from './win32-utils'
|
||||
import { resolveExternalEditorLaunchSpec } from './external-editor-launch'
|
||||
|
||||
describe('resolveExternalEditorLaunchSpec', () => {
|
||||
it('keeps simple CLI commands on the executable launch path', () => {
|
||||
const spec = resolveExternalEditorLaunchSpec('cursor', '/tmp/workspace', {
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(spec).toEqual({
|
||||
kind: 'executable',
|
||||
spawnCmd: expect.any(String),
|
||||
spawnArgs: ['--new-window', '/tmp/workspace']
|
||||
})
|
||||
})
|
||||
|
||||
it('appends escaped paths to compound macOS open commands', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", {
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
spawnCmd: '/bin/sh',
|
||||
spawnArgs: ['-c', "open -a \"Typora\" '/tmp/note'\\''s.md'"]
|
||||
})
|
||||
})
|
||||
|
||||
it('runs compound Windows commands through cmd.exe', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\note.md', { platform: 'win32' })
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', 'start "" notepad C:\\note.md']
|
||||
})
|
||||
})
|
||||
|
||||
it('quotes Windows paths with spaces in compound commands', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' })
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', 'start "" notepad "C:\\my notes.md"']
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { basename, win32 } from 'node:path'
|
||||
import { resolveCliCommand } from './codex-cli/command'
|
||||
import { getCmdExePath } from './win32-utils'
|
||||
|
||||
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
|
||||
|
||||
export type ExternalEditorLaunchSpec =
|
||||
| {
|
||||
kind: 'executable'
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'shell'
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
|
||||
function escapePosixPathForShell(pathValue: string): string {
|
||||
if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) {
|
||||
return pathValue
|
||||
}
|
||||
return `'${pathValue.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
function escapeWindowsPathForShell(pathValue: string): string {
|
||||
return /^[a-zA-Z0-9_./@:\\-]+$/.test(pathValue) ? pathValue : `"${pathValue}"`
|
||||
}
|
||||
|
||||
function escapePathForShell(pathValue: string, platform: NodeJS.Platform): string {
|
||||
return platform === 'win32'
|
||||
? escapeWindowsPathForShell(pathValue)
|
||||
: escapePosixPathForShell(pathValue)
|
||||
}
|
||||
|
||||
function getLauncherBaseName(command: string): string {
|
||||
const name = command.includes('\\') ? win32.basename(command) : basename(command)
|
||||
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
function buildExecutableArgs(editorCommand: string, pathValue: string): string[] {
|
||||
if (getLauncherBaseName(editorCommand) === 'cursor') {
|
||||
// Why: Cursor can route bare folder launches through the last active
|
||||
// workbench. A new window keeps "Open in Cursor" scoped to this worktree.
|
||||
return ['--new-window', pathValue]
|
||||
}
|
||||
return [pathValue]
|
||||
}
|
||||
|
||||
function isCompoundShellCommand(command: string): boolean {
|
||||
return /\s/.test(command)
|
||||
}
|
||||
|
||||
function buildShellLaunchSpec(
|
||||
command: string,
|
||||
pathValue: string,
|
||||
platform: NodeJS.Platform
|
||||
): ExternalEditorLaunchSpec {
|
||||
const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}`
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
kind: 'shell',
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', shellCommand]
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'shell',
|
||||
spawnCmd: '/bin/sh',
|
||||
spawnArgs: ['-c', shellCommand]
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExternalEditorLaunchSpec(
|
||||
command: string | undefined,
|
||||
pathValue: string,
|
||||
options: { platform?: NodeJS.Platform } = {}
|
||||
): ExternalEditorLaunchSpec {
|
||||
const platform = options.platform ?? process.platform
|
||||
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
|
||||
|
||||
if (isCompoundShellCommand(trimmed)) {
|
||||
return buildShellLaunchSpec(trimmed, pathValue, platform)
|
||||
}
|
||||
|
||||
const editorCommand = resolveCliCommand(trimmed, { platform })
|
||||
return {
|
||||
kind: 'executable',
|
||||
spawnCmd: editorCommand,
|
||||
spawnArgs: buildExecutableArgs(editorCommand, pathValue)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ vi.mock('../win32-utils', () => ({
|
||||
}))
|
||||
|
||||
import { EXTERNAL_EDITOR_CLI_COMMAND, registerShellHandlers } from './shell'
|
||||
import { resolveExternalEditorLaunchSpec } from '../external-editor-launch'
|
||||
|
||||
function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): {
|
||||
once: ReturnType<typeof vi.fn>
|
||||
@@ -215,7 +216,9 @@ describe('registerShellHandlers', () => {
|
||||
ok: false,
|
||||
reason: 'launch-failed'
|
||||
})
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND)
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
||||
platform: process.platform
|
||||
})
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
|
||||
normalize(workspacePath)
|
||||
])
|
||||
@@ -236,7 +239,9 @@ describe('registerShellHandlers', () => {
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
|
||||
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND)
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
||||
platform: process.platform
|
||||
})
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
|
||||
normalize(workspacePath)
|
||||
])
|
||||
@@ -255,7 +260,7 @@ describe('registerShellHandlers', () => {
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
|
||||
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor')
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
|
||||
normalize(workspacePath)
|
||||
])
|
||||
@@ -284,7 +289,9 @@ describe('registerShellHandlers', () => {
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
|
||||
await expect(handler({}, workspacePath, ' ')).resolves.toEqual({ ok: true })
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND)
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
||||
platform: process.platform
|
||||
})
|
||||
})
|
||||
|
||||
it('uses platform-safe launcher command arguments', async () => {
|
||||
@@ -296,7 +303,9 @@ describe('registerShellHandlers', () => {
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
|
||||
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND)
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
|
||||
platform: process.platform
|
||||
})
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
|
||||
normalize(workspacePath)
|
||||
])
|
||||
@@ -307,6 +316,22 @@ describe('registerShellHandlers', () => {
|
||||
})
|
||||
expect(openPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs compound shell commands through the platform shell', async () => {
|
||||
const filePath = normalize(resolve('note.md'))
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
const launchSpec = resolveExternalEditorLaunchSpec('open -a "Typora"', filePath)
|
||||
|
||||
await expect(handler({}, filePath, 'open -a "Typora"')).resolves.toEqual({ ok: true })
|
||||
expect(resolveCliCommandMock).not.toHaveBeenCalled()
|
||||
expect(getSpawnArgsForWindowsMock).not.toHaveBeenCalled()
|
||||
expect(launchSpec.kind).toBe('shell')
|
||||
expect(spawnMock).toHaveBeenCalledWith(launchSpec.spawnCmd, launchSpec.spawnArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy file open handlers', () => {
|
||||
|
||||
+11
-27
@@ -1,14 +1,17 @@
|
||||
import { ipcMain, shell, dialog } from 'electron'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
|
||||
import { basename, extname, isAbsolute, normalize, win32 } from 'node:path'
|
||||
import { basename, extname, isAbsolute, normalize } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { ShellOpenLocalPathResult } from '../../shared/shell-open-types'
|
||||
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
|
||||
import { resolveCliCommand } from '../codex-cli/command'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import {
|
||||
EXTERNAL_EDITOR_CLI_COMMAND,
|
||||
resolveExternalEditorLaunchSpec
|
||||
} from '../external-editor-launch'
|
||||
|
||||
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
|
||||
export { EXTERNAL_EDITOR_CLI_COMMAND }
|
||||
|
||||
const REPO_ICON_IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png'
|
||||
@@ -51,31 +54,12 @@ async function openInFileManager(pathValue: string): Promise<ShellOpenLocalPathR
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExternalEditorCommand(command?: string): string {
|
||||
const trimmed = command?.trim()
|
||||
return resolveCliCommand(trimmed || EXTERNAL_EDITOR_CLI_COMMAND)
|
||||
}
|
||||
|
||||
function getLauncherBaseName(command: string): string {
|
||||
const name = command.includes('\\') ? win32.basename(command) : basename(command)
|
||||
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
function buildExternalEditorArgs(editorCommand: string, pathValue: string): string[] {
|
||||
if (getLauncherBaseName(editorCommand) === 'cursor') {
|
||||
// Why: Cursor can route bare folder launches through the last active
|
||||
// workbench. A new window keeps "Open in Cursor" scoped to this worktree.
|
||||
return ['--new-window', pathValue]
|
||||
}
|
||||
return [pathValue]
|
||||
}
|
||||
|
||||
async function launchExternalEditor(pathValue: string, command?: string): Promise<void> {
|
||||
const editorCommand = resolveExternalEditorCommand(command)
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
|
||||
editorCommand,
|
||||
buildExternalEditorArgs(editorCommand, pathValue)
|
||||
)
|
||||
const launchSpec = resolveExternalEditorLaunchSpec(command, pathValue)
|
||||
const { spawnCmd, spawnArgs } =
|
||||
launchSpec.kind === 'executable'
|
||||
? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs)
|
||||
: { spawnCmd: launchSpec.spawnCmd, spawnArgs: launchSpec.spawnArgs }
|
||||
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
|
||||
@@ -96,12 +96,7 @@ import {
|
||||
} from './git-status-refresh'
|
||||
import { describeForkPushTarget } from './fork-push-target-label'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { SourceControlEntryContextMenu } from './source-control-entry-context-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -5573,6 +5568,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
onSelect={handleSelect}
|
||||
onContextMenu={handleContextMenu}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={handleOpenDiff}
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
@@ -5596,6 +5592,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
onSelect={handleSelect}
|
||||
onContextMenu={handleContextMenu}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={handleOpenDiff}
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
@@ -5674,6 +5671,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
worktreePath={worktreePath}
|
||||
depth={node.depth}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={(event) => openCommittedDiff(node.entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0}
|
||||
showPathHint={false}
|
||||
@@ -5687,6 +5685,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
currentWorktreeId={currentWorktreeId}
|
||||
worktreePath={worktreePath}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={(event) => openCommittedDiff(entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
|
||||
/>
|
||||
@@ -7485,6 +7484,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onRevealInExplorer,
|
||||
connectionId,
|
||||
onOpen,
|
||||
onStage,
|
||||
onUnstage,
|
||||
@@ -7502,6 +7502,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
onSelect?: (e: React.MouseEvent, key: string, entry: GitStatusEntry) => void
|
||||
onContextMenu?: (key: string) => void
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
connectionId?: string | null
|
||||
onOpen: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
onStage: (filePath: string) => Promise<void>
|
||||
onUnstage: (filePath: string) => Promise<void>
|
||||
@@ -7542,6 +7543,8 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
<SourceControlEntryContextMenu
|
||||
currentWorktreeId={currentWorktreeId}
|
||||
absolutePath={joinPath(worktreePath, entry.path)}
|
||||
connectionId={connectionId}
|
||||
onView={() => onOpen(entry)}
|
||||
onRevealInExplorer={onRevealInExplorer}
|
||||
onOpenChange={(open) => {
|
||||
if (open && onContextMenu) {
|
||||
@@ -7748,6 +7751,7 @@ function BranchEntryRow({
|
||||
worktreePath,
|
||||
depth = 0,
|
||||
onRevealInExplorer,
|
||||
connectionId,
|
||||
onOpen,
|
||||
commentCount,
|
||||
showPathHint = true
|
||||
@@ -7757,7 +7761,8 @@ function BranchEntryRow({
|
||||
worktreePath: string
|
||||
depth?: number
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
onOpen: (event: SourceControlRowOpenEvent) => void
|
||||
connectionId?: string | null
|
||||
onOpen: (event?: SourceControlRowOpenEvent) => void
|
||||
commentCount: number
|
||||
showPathHint?: boolean
|
||||
}): React.JSX.Element {
|
||||
@@ -7770,6 +7775,8 @@ function BranchEntryRow({
|
||||
<SourceControlEntryContextMenu
|
||||
currentWorktreeId={currentWorktreeId}
|
||||
absolutePath={joinPath(worktreePath, entry.path)}
|
||||
connectionId={connectionId}
|
||||
onView={() => onOpen()}
|
||||
onRevealInExplorer={onRevealInExplorer}
|
||||
>
|
||||
<div
|
||||
@@ -7818,42 +7825,6 @@ function BranchEntryRow({
|
||||
)
|
||||
}
|
||||
|
||||
function SourceControlEntryContextMenu({
|
||||
currentWorktreeId,
|
||||
absolutePath,
|
||||
onRevealInExplorer,
|
||||
onOpenChange,
|
||||
children
|
||||
}: {
|
||||
currentWorktreeId: string
|
||||
absolutePath?: string
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const handleOpenInFileExplorer = useCallback(() => {
|
||||
if (!absolutePath) {
|
||||
return
|
||||
}
|
||||
onRevealInExplorer(currentWorktreeId, absolutePath)
|
||||
}, [absolutePath, currentWorktreeId, onRevealInExplorer])
|
||||
|
||||
return (
|
||||
<ContextMenu onOpenChange={onOpenChange}>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuItem onSelect={handleOpenInFileExplorer} disabled={!absolutePath}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.cc05b2d088',
|
||||
'Open in File Explorer'
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
heading,
|
||||
supportingText
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Copy, ExternalLink, Eye, FolderOpen } from 'lucide-react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { useAppStore } from '@/store'
|
||||
import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label'
|
||||
import {
|
||||
getWorktreeOpenInEntries,
|
||||
openOpenInAppsSettings,
|
||||
openWorktreePath
|
||||
} from '@/components/sidebar/WorktreeOpenInMenu'
|
||||
|
||||
type SourceControlEntryContextMenuProps = {
|
||||
currentWorktreeId: string
|
||||
absolutePath?: string
|
||||
connectionId?: string | null
|
||||
onView?: () => void
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SourceControlEntryContextMenu({
|
||||
currentWorktreeId,
|
||||
absolutePath,
|
||||
connectionId,
|
||||
onView,
|
||||
onRevealInExplorer,
|
||||
onOpenChange,
|
||||
children
|
||||
}: SourceControlEntryContextMenuProps): React.JSX.Element {
|
||||
const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? [])
|
||||
const fileManagerLabel = getLocalFileManagerLabel()
|
||||
const openInEntries = React.useMemo(
|
||||
() => getWorktreeOpenInEntries(openInApplications, fileManagerLabel),
|
||||
[fileManagerLabel, openInApplications]
|
||||
)
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
if (!absolutePath) {
|
||||
return
|
||||
}
|
||||
void window.api.ui.writeClipboardText(absolutePath)
|
||||
}, [absolutePath])
|
||||
|
||||
const handleRevealInOrcaExplorer = useCallback(() => {
|
||||
if (!absolutePath) {
|
||||
return
|
||||
}
|
||||
onRevealInExplorer(currentWorktreeId, absolutePath)
|
||||
}, [absolutePath, currentWorktreeId, onRevealInExplorer])
|
||||
|
||||
const handleOpenInExternal = useCallback(
|
||||
(target: 'file-manager' | 'external-editor', command?: string) => {
|
||||
if (!absolutePath) {
|
||||
return
|
||||
}
|
||||
void openWorktreePath({
|
||||
target,
|
||||
worktreePath: absolutePath,
|
||||
connectionId,
|
||||
command
|
||||
})
|
||||
},
|
||||
[absolutePath, connectionId]
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu onOpenChange={onOpenChange}>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuItem onSelect={onView} disabled={!onView}>
|
||||
<Eye className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControlEntryContextMenu.a1f2c8d901',
|
||||
'View'
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={handleCopyPath} disabled={!absolutePath}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.FileExplorerRow.b5d436aa30', 'Copy Path')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={!absolutePath}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeOpenInMenu.8009ab69a6', 'Open in')}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-52">
|
||||
{openInEntries.map((entry) => (
|
||||
<ContextMenuItem
|
||||
key={entry.id}
|
||||
onSelect={() => handleOpenInExternal(entry.target, entry.command)}
|
||||
disabled={!absolutePath}
|
||||
>
|
||||
{entry.target === 'file-manager' ? (
|
||||
<FolderOpen className="size-3.5" />
|
||||
) : entry.command ? (
|
||||
<OpenInApplicationIcon application={{ command: entry.command }} size={14} />
|
||||
) : (
|
||||
<ExternalLink className="size-3.5" />
|
||||
)}
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={openOpenInAppsSettings}>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeOpenInMenu.1417fd8380',
|
||||
'Customize apps...'
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={handleRevealInOrcaExplorer} disabled={!absolutePath}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.cc05b2d088',
|
||||
'Open in File Explorer'
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function ContextMenuSubTrigger({
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-[7px] px-2 py-1 text-[12px] leading-5 font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"flex cursor-default items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-black/8 dark:data-[state=open]:bg-white/14 data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -58,17 +58,23 @@ function ContextMenuSubTrigger({
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'z-[70] min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.10)] p-1 text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.12)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'z-[70] min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.10)] p-1 text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.12)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Why: submenu content must portal out of the scrollable parent menu so
|
||||
// overflow clipping does not hide the cascade on click/hover.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user