From 8e524dfd3346e23a6583916cd29858e6d74e7ba3 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 19 Jun 2026 12:54:21 -0700 Subject: [PATCH] fix(source-control): expand file context menu and shell-based Open in apps (#5825) Co-authored-by: Cursor --- src/main/external-editor-launch.test.ts | 48 ++++++ src/main/external-editor-launch.ts | 92 ++++++++++++ src/main/ipc/shell.test.ts | 35 ++++- src/main/ipc/shell.ts | 38 ++--- .../right-sidebar/SourceControl.tsx | 57 ++------ .../source-control-entry-context-menu.tsx | 137 ++++++++++++++++++ .../src/components/ui/context-menu.tsx | 24 +-- 7 files changed, 347 insertions(+), 84 deletions(-) create mode 100644 src/main/external-editor-launch.test.ts create mode 100644 src/main/external-editor-launch.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts new file mode 100644 index 00000000000..fc33d182c3a --- /dev/null +++ b/src/main/external-editor-launch.test.ts @@ -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"'] + }) + }) +}) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts new file mode 100644 index 00000000000..6d4f51695a4 --- /dev/null +++ b/src/main/external-editor-launch.ts @@ -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) + } +} diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 69004644ba0..a72074b043e 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -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 @@ -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', () => { diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index b261426a880..1b38ecb9b47 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -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 = { '.png': 'image/png' @@ -51,31 +54,12 @@ async function openInFileManager(pathValue: string): Promise { - 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((resolvePromise, rejectPromise) => { const child = spawn(spawnCmd, spawnArgs, { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 2bd56d350af..47930bf457f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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 onUnstage: (filePath: string) => Promise @@ -7542,6 +7543,8 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ 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({ onOpen()} onRevealInExplorer={onRevealInExplorer} >
void - onOpenChange?: (open: boolean) => void - children: React.ReactNode -}): React.JSX.Element { - const handleOpenInFileExplorer = useCallback(() => { - if (!absolutePath) { - return - } - onRevealInExplorer(currentWorktreeId, absolutePath) - }, [absolutePath, currentWorktreeId, onRevealInExplorer]) - - return ( - - {children} - - - - {translate( - 'auto.components.right.sidebar.SourceControl.cc05b2d088', - 'Open in File Explorer' - )} - - - - ) -} - function EmptyState({ heading, supportingText diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx new file mode 100644 index 00000000000..b333d887587 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx @@ -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 ( + + {children} + + + + {translate( + 'auto.components.right.sidebar.SourceControlEntryContextMenu.a1f2c8d901', + 'View' + )} + + + + + {translate('auto.components.right.sidebar.FileExplorerRow.b5d436aa30', 'Copy Path')} + + + + + + {translate('auto.components.sidebar.WorktreeOpenInMenu.8009ab69a6', 'Open in')} + + + {openInEntries.map((entry) => ( + handleOpenInExternal(entry.target, entry.command)} + disabled={!absolutePath} + > + {entry.target === 'file-manager' ? ( + + ) : entry.command ? ( + + ) : ( + + )} + {entry.label} + + ))} + + + {translate( + 'auto.components.sidebar.WorktreeOpenInMenu.1417fd8380', + 'Customize apps...' + )} + + + + + + + {translate( + 'auto.components.right.sidebar.SourceControl.cc05b2d088', + 'Open in File Explorer' + )} + + + + ) +} diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index e4c9446f685..e4469704d7f 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -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) { return ( - + + + ) }