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:
Trevin Chow
2026-06-19 12:54:21 -07:00
committed by GitHub
co-authored by Cursor
parent ef45305aa4
commit 8e524dfd33
7 changed files with 347 additions and 84 deletions
+48
View File
@@ -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"']
})
})
})
+92
View File
@@ -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)
}
}
+30 -5
View File
@@ -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
View File
@@ -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>
)
}