From 32656a9662c5bf0bbda2862435693a289ce21a6c Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 16:17:05 +0100 Subject: [PATCH 01/11] feat: add default IDE setting and multi-editor support with icons Add comprehensive editor detection and selection system that allows users to configure their preferred IDE for opening branches and worktrees. ## Server-side Changes - Add `/api/worktree/available-editors` endpoint to detect installed editors - Support detection via CLI commands (cursor, code, zed, subl, etc.) - Support detection via macOS app bundles in /Applications and ~/Applications - Detect editors: Cursor, VS Code, Zed, Sublime Text, Windsurf, Trae, Rider, WebStorm, Xcode, Android Studio, Antigravity, and file managers ## UI Changes ### Editor Icons - Add new `editor-icons.tsx` with SVG icons for all supported editors - Icons: Cursor, VS Code, Zed, Sublime Text, Windsurf, Trae, Rider, WebStorm, Xcode, Android Studio, Antigravity, Finder - `getEditorIcon()` helper maps editor commands to appropriate icons ### Default IDE Setting - Add "Default IDE" selector in Settings > Account section - Options: Auto-detect (Cursor > VS Code > first available) or explicit choice - Setting persists via `defaultEditorCommand` in global settings ### Worktree Dropdown Improvements - Implement split-button UX for "Open In" action - Click main area: opens directly in default IDE (single click) - Click chevron: shows submenu with other editors + Copy Path - Each editor shows with its branded icon ## Type & Store Changes - Add `defaultEditorCommand: string | null` to GlobalSettings - Add to app-store with `setDefaultEditorCommand` action - Add to SETTINGS_FIELDS_TO_SYNC for persistence - Add `useAvailableEditors` hook for fetching detected editors Co-Authored-By: Claude Opus 4.5 --- apps/server/src/routes/worktree/index.ts | 2 + .../routes/worktree/routes/open-in-editor.ts | 185 ++++++++++++----- apps/ui/src/components/icons/editor-icons.tsx | 193 ++++++++++++++++++ .../components/worktree-actions-dropdown.tsx | 83 +++++++- .../components/worktree-tab.tsx | 2 +- .../hooks/use-available-editors.ts | 46 +++++ .../hooks/use-worktree-actions.ts | 4 +- .../settings-view/account/account-section.tsx | 90 +++++++- apps/ui/src/hooks/use-settings-sync.ts | 2 + apps/ui/src/lib/electron.ts | 22 +- apps/ui/src/lib/http-api-client.ts | 5 +- apps/ui/src/store/app-store.ts | 10 + apps/ui/src/types/electron.d.ts | 17 +- libs/types/src/settings.ts | 5 + 14 files changed, 601 insertions(+), 65 deletions(-) create mode 100644 apps/ui/src/components/icons/editor-icons.tsx create mode 100644 apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7fef5c6e..6d64ad3a 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -24,6 +24,7 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js'; import { createOpenInEditorHandler, createGetDefaultEditorHandler, + createGetAvailableEditorsHandler, } from './routes/open-in-editor.js'; import { createInitGitHandler } from './routes/init-git.js'; import { createMigrateHandler } from './routes/migrate.js'; @@ -77,6 +78,7 @@ export function createWorktreeRoutes(): Router { router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); router.get('/default-editor', createGetDefaultEditorHandler()); + router.get('/available-editors', createGetAvailableEditorsHandler()); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/migrate', createMigrateHandler()); router.post( diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index 40e71b00..492ac85a 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -17,9 +17,113 @@ interface EditorInfo { } let cachedEditor: EditorInfo | null = null; +let cachedEditors: EditorInfo[] | null = null; /** - * Detect which code editor is available on the system + * Check if a CLI command exists in PATH + */ +async function commandExists(cmd: string): Promise { + try { + await execAsync(process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`); + return true; + } catch { + return false; + } +} + +/** + * Check if a macOS app bundle exists and return the path if found + * Checks both /Applications and ~/Applications + */ +async function findMacApp(appName: string): Promise { + if (process.platform !== 'darwin') return null; + + // Check /Applications first + try { + await execAsync(`test -d "/Applications/${appName}.app"`); + return `/Applications/${appName}.app`; + } catch { + // Not in /Applications + } + + // Check ~/Applications (used by JetBrains Toolbox and others) + try { + const homeDir = process.env.HOME || '~'; + await execAsync(`test -d "${homeDir}/Applications/${appName}.app"`); + return `${homeDir}/Applications/${appName}.app`; + } catch { + return null; + } +} + +/** + * Try to add an editor - checks CLI first, then macOS app bundle + */ +async function tryAddEditor( + editors: EditorInfo[], + name: string, + cliCommand: string, + macAppName: string +): Promise { + // Try CLI command first + if (await commandExists(cliCommand)) { + editors.push({ name, command: cliCommand }); + return; + } + + // Try macOS app bundle (checks /Applications and ~/Applications) + if (process.platform === 'darwin') { + const appPath = await findMacApp(macAppName); + if (appPath) { + // Use 'open -a' with full path for apps not in /Applications + editors.push({ name, command: `open -a "${appPath}"` }); + } + } +} + +async function detectAllEditors(): Promise { + // Return cached result if available + if (cachedEditors) { + return cachedEditors; + } + + const editors: EditorInfo[] = []; + const isMac = process.platform === 'darwin'; + + // Try editors (CLI command, then macOS app bundle) + await tryAddEditor(editors, 'Cursor', 'cursor', 'Cursor'); + await tryAddEditor(editors, 'VS Code', 'code', 'Visual Studio Code'); + await tryAddEditor(editors, 'Zed', 'zed', 'Zed'); + await tryAddEditor(editors, 'Sublime Text', 'subl', 'Sublime Text'); + await tryAddEditor(editors, 'Windsurf', 'windsurf', 'Windsurf'); + await tryAddEditor(editors, 'Trae', 'trae', 'Trae'); + await tryAddEditor(editors, 'Rider', 'rider', 'Rider'); + await tryAddEditor(editors, 'WebStorm', 'webstorm', 'WebStorm'); + + // Xcode (macOS only) + if (isMac) { + await tryAddEditor(editors, 'Xcode', 'xed', 'Xcode'); + } + + await tryAddEditor(editors, 'Android Studio', 'studio', 'Android Studio'); + await tryAddEditor(editors, 'Antigravity', 'agy', 'Antigravity'); + + // Always add file manager as fallback + const platform = process.platform; + if (platform === 'darwin') { + editors.push({ name: 'Finder', command: 'open' }); + } else if (platform === 'win32') { + editors.push({ name: 'Explorer', command: 'explorer' }); + } else { + editors.push({ name: 'File Manager', command: 'xdg-open' }); + } + + cachedEditors = editors; + return editors; +} + +/** + * Detect the default (first available) code editor on the system */ async function detectDefaultEditor(): Promise { // Return cached result if available @@ -27,54 +131,29 @@ async function detectDefaultEditor(): Promise { return cachedEditor; } - // Try Cursor first (if user has Cursor, they probably prefer it) - try { - await execAsync('which cursor || where cursor'); - cachedEditor = { name: 'Cursor', command: 'cursor' }; - return cachedEditor; - } catch { - // Cursor not found - } - - // Try VS Code - try { - await execAsync('which code || where code'); - cachedEditor = { name: 'VS Code', command: 'code' }; - return cachedEditor; - } catch { - // VS Code not found - } - - // Try Zed - try { - await execAsync('which zed || where zed'); - cachedEditor = { name: 'Zed', command: 'zed' }; - return cachedEditor; - } catch { - // Zed not found - } - - // Try Sublime Text - try { - await execAsync('which subl || where subl'); - cachedEditor = { name: 'Sublime Text', command: 'subl' }; - return cachedEditor; - } catch { - // Sublime not found - } - - // Fallback to file manager - const platform = process.platform; - if (platform === 'darwin') { - cachedEditor = { name: 'Finder', command: 'open' }; - } else if (platform === 'win32') { - cachedEditor = { name: 'Explorer', command: 'explorer' }; - } else { - cachedEditor = { name: 'File Manager', command: 'xdg-open' }; - } + // Get all editors and return the first one (highest priority) + const editors = await detectAllEditors(); + cachedEditor = editors[0]; return cachedEditor; } +export function createGetAvailableEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const editors = await detectAllEditors(); + res.json({ + success: true, + result: { + editors, + }, + }); + } catch (error) { + logError(error, 'Get available editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + export function createGetDefaultEditorHandler() { return async (_req: Request, res: Response): Promise => { try { @@ -96,8 +175,9 @@ export function createGetDefaultEditorHandler() { export function createOpenInEditorHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, editorCommand } = req.body as { worktreePath: string; + editorCommand?: string; }; if (!worktreePath) { @@ -108,7 +188,16 @@ export function createOpenInEditorHandler() { return; } - const editor = await detectDefaultEditor(); + // Use specified editor command or detect default + let editor: EditorInfo; + if (editorCommand) { + // Find the editor info from the available editors list + const allEditors = await detectAllEditors(); + const specifiedEditor = allEditors.find((e) => e.command === editorCommand); + editor = specifiedEditor ?? (await detectDefaultEditor()); + } else { + editor = await detectDefaultEditor(); + } try { await execAsync(`${editor.command} "${worktreePath}"`); diff --git a/apps/ui/src/components/icons/editor-icons.tsx b/apps/ui/src/components/icons/editor-icons.tsx new file mode 100644 index 00000000..5e3cd092 --- /dev/null +++ b/apps/ui/src/components/icons/editor-icons.tsx @@ -0,0 +1,193 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { FolderOpen } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +/** + * Cursor editor logo icon - from LobeHub icons + */ +export function CursorIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * VS Code editor logo icon + */ +export function VSCodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Zed editor logo icon (from Simple Icons) + */ +export function ZedIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Sublime Text editor logo icon + */ +export function SublimeTextIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Finder icon + */ +export function FinderIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windsurf editor logo icon (by Codeium) - from LobeHub icons + */ +export function WindsurfIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Trae editor logo icon (by ByteDance) - from LobeHub icons + */ +export function TraeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * JetBrains Rider logo icon + */ +export function RiderIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * JetBrains WebStorm logo icon + */ +export function WebStormIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Xcode logo icon + */ +export function XcodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Android Studio logo icon + */ +export function AndroidStudioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Google Antigravity IDE logo icon - stylized "A" arch shape + */ +export function AntigravityIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for an editor command + */ +export function getEditorIcon(command: string): IconComponent { + // Handle direct CLI commands + const cliIcons: Record = { + cursor: CursorIcon, + code: VSCodeIcon, + zed: ZedIcon, + subl: SublimeTextIcon, + windsurf: WindsurfIcon, + trae: TraeIcon, + rider: RiderIcon, + webstorm: WebStormIcon, + xed: XcodeIcon, + studio: AndroidStudioIcon, + agy: AntigravityIcon, + open: FinderIcon, + explorer: FolderOpen, + 'xdg-open': FolderOpen, + }; + + // Check direct match first + if (cliIcons[command]) { + return cliIcons[command]; + } + + // Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"' + if (command.startsWith('open')) { + const cmdLower = command.toLowerCase(); + if (cmdLower.includes('cursor')) return CursorIcon; + if (cmdLower.includes('visual studio code')) return VSCodeIcon; + if (cmdLower.includes('zed')) return ZedIcon; + if (cmdLower.includes('sublime')) return SublimeTextIcon; + if (cmdLower.includes('windsurf')) return WindsurfIcon; + if (cmdLower.includes('trae')) return TraeIcon; + if (cmdLower.includes('rider')) return RiderIcon; + if (cmdLower.includes('webstorm')) return WebStormIcon; + if (cmdLower.includes('xcode')) return XcodeIcon; + if (cmdLower.includes('android studio')) return AndroidStudioIcon; + if (cmdLower.includes('antigravity')) return AntigravityIcon; + // If just 'open' without app name, it's Finder + if (command === 'open') return FinderIcon; + } + + return FolderOpen; +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index c6542256..af7acd67 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -6,13 +6,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuLabel, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, } from '@/components/ui/dropdown-menu'; import { Trash2, MoreHorizontal, GitCommit, GitPullRequest, - ExternalLink, Download, Upload, Play, @@ -21,10 +23,14 @@ import { MessageSquare, GitMerge, AlertCircle, + Copy, } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; +import { useAvailableEditors } from '../hooks/use-available-editors'; +import { getEditorIcon } from '@/components/icons/editor-icons'; +import { useAppStore } from '@/store/app-store'; interface WorktreeActionsDropdownProps { worktree: WorktreeInfo; @@ -41,7 +47,7 @@ interface WorktreeActionsDropdownProps { onOpenChange: (open: boolean) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; - onOpenInEditor: (worktree: WorktreeInfo) => void; + onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -77,6 +83,31 @@ export function WorktreeActionsDropdown({ onStopDevServer, onOpenDevServerUrl, }: WorktreeActionsDropdownProps) { + // Get available editors for the "Open In" submenu + const { editors, hasMultipleEditors } = useAvailableEditors(); + + // Get the user's preferred default editor from settings + const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand); + + // Calculate effective default editor based on user setting or auto-detect (Cursor > VS Code > first) + const getEffectiveDefaultEditor = () => { + if (defaultEditorCommand) { + const found = editors.find((e) => e.command === defaultEditorCommand); + if (found) return found; + } + // Auto-detect: prefer Cursor, then VS Code, then first available + const cursor = editors.find((e) => e.command === 'cursor'); + if (cursor) return cursor; + const vscode = editors.find((e) => e.command === 'code'); + if (vscode) return vscode; + return editors[0]; + }; + + const effectiveDefaultEditor = getEffectiveDefaultEditor(); + + // Get other editors (excluding the default) for the submenu + const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command); + // Check if there's a PR associated with this worktree from stored metadata const hasPR = !!worktree.pr; @@ -200,10 +231,50 @@ export function WorktreeActionsDropdown({ )} - onOpenInEditor(worktree)} className="text-xs"> - - Open in {defaultEditorName} - + {/* Open in editor - split button: click main area for default, chevron for other options */} + {effectiveDefaultEditor && ( + +
+ {/* Main clickable area - opens in default editor */} + onOpenInEditor(worktree, effectiveDefaultEditor.command)} + className="text-xs flex-1 pr-0 rounded-r-none" + > + {(() => { + const EditorIcon = getEditorIcon(effectiveDefaultEditor.command); + return ; + })()} + Open in {effectiveDefaultEditor.name} + + {/* Chevron trigger for submenu with other editors and Copy Path */} + +
+ + {/* Other editors */} + {otherEditors.map((editor) => { + const EditorIcon = getEditorIcon(editor.command); + return ( + onOpenInEditor(worktree, editor.command)} + className="text-xs" + > + + {editor.name} + + ); + })} + {otherEditors.length > 0 && } + navigator.clipboard.writeText(worktree.path)} + className="text-xs" + > + + Copy Path + + +
+ )} {worktree.hasChanges && ( void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; - onOpenInEditor: (worktree: WorktreeInfo) => void; + onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts new file mode 100644 index 00000000..9cf97576 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useCallback } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getElectronAPI } from '@/lib/electron'; + +const logger = createLogger('AvailableEditors'); + +export interface EditorInfo { + name: string; + command: string; +} + +export function useAvailableEditors() { + const [editors, setEditors] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const fetchAvailableEditors = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.getAvailableEditors) { + setIsLoading(false); + return; + } + const result = await api.worktree.getAvailableEditors(); + if (result.success && result.result?.editors) { + setEditors(result.result.editors); + } + } catch (error) { + logger.error('Failed to fetch available editors:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchAvailableEditors(); + }, [fetchAvailableEditors]); + + return { + editors, + isLoading, + // Convenience property: has multiple editors (for deciding whether to show submenu) + hasMultipleEditors: editors.length > 1, + // The first editor is the "default" one + defaultEditor: editors[0] ?? null, + }; +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index ac53c12b..f1f245dc 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -125,14 +125,14 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre [isPushing, fetchBranches, fetchWorktrees] ); - const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => { + const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => { try { const api = getElectronAPI(); if (!api?.worktree?.openInEditor) { logger.warn('Open in editor API not available'); return; } - const result = await api.worktree.openInEditor(worktree.path); + const result = await api.worktree.openInEditor(worktree.path, editorCommand); if (result.success && result.result) { toast.success(result.result.message); } else if (result.error) { diff --git a/apps/ui/src/components/views/settings-view/account/account-section.tsx b/apps/ui/src/components/views/settings-view/account/account-section.tsx index 8c44706b..ae594166 100644 --- a/apps/ui/src/components/views/settings-view/account/account-section.tsx +++ b/apps/ui/src/components/views/settings-view/account/account-section.tsx @@ -1,15 +1,45 @@ import { useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { Button } from '@/components/ui/button'; -import { LogOut, User } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { LogOut, User, Code2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { logout } from '@/lib/http-api-client'; import { useAuthStore } from '@/store/auth-store'; +import { useAppStore } from '@/store/app-store'; +import { useAvailableEditors } from '@/components/views/board-view/worktree-panel/hooks/use-available-editors'; +import { getEditorIcon } from '@/components/icons/editor-icons'; export function AccountSection() { const navigate = useNavigate(); const [isLoggingOut, setIsLoggingOut] = useState(false); + // Editor settings + const { editors, isLoading: isLoadingEditors } = useAvailableEditors(); + const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand); + const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand); + + // Get effective default editor (respecting auto-detect order: Cursor > VS Code > first) + const getEffectiveDefaultEditor = () => { + if (defaultEditorCommand) { + return editors.find((e) => e.command === defaultEditorCommand) ?? editors[0]; + } + // Auto-detect: prefer Cursor, then VS Code, then first available + const cursor = editors.find((e) => e.command === 'cursor'); + if (cursor) return cursor; + const vscode = editors.find((e) => e.command === 'code'); + if (vscode) return vscode; + return editors[0]; + }; + + const effectiveEditor = getEffectiveDefaultEditor(); + const handleLogout = async () => { setIsLoggingOut(true); try { @@ -43,6 +73,64 @@ export function AccountSection() {

Manage your session and account.

+ {/* Default IDE */} +
+
+
+ +
+
+

Default IDE

+

+ Default IDE to use when opening branches or worktrees +

+
+
+ +
+ {/* Logout */}
diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index e7c4c406..107ea0e5 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -51,6 +51,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'keyboardShortcuts', 'aiProfiles', 'mcpServers', + 'defaultEditorCommand', 'promptCustomization', 'projects', 'trashedProjects', @@ -407,6 +408,7 @@ export async function refreshSettingsFromServer(): Promise { }, aiProfiles: serverSettings.aiProfiles, mcpServers: serverSettings.mcpServers, + defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, promptCustomization: serverSettings.promptCustomization ?? {}, projects: serverSettings.projects, trashedProjects: serverSettings.trashedProjects, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 84e0d945..7b0b3789 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1625,13 +1625,14 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - openInEditor: async (worktreePath: string) => { - console.log('[Mock] Opening in editor:', worktreePath); + openInEditor: async (worktreePath: string, editorCommand?: string) => { + const editorName = editorCommand === 'cursor' ? 'Cursor' : 'VS Code'; + console.log('[Mock] Opening in editor:', worktreePath, 'using:', editorName); return { success: true, result: { - message: `Opened ${worktreePath} in VS Code`, - editorName: 'VS Code', + message: `Opened ${worktreePath} in ${editorName}`, + editorName, }, }; }, @@ -1647,6 +1648,19 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + getAvailableEditors: async () => { + console.log('[Mock] Getting available editors'); + return { + success: true, + result: { + editors: [ + { name: 'VS Code', command: 'code' }, + { name: 'Finder', command: 'open' }, + ], + }, + }; + }, + initGit: async (projectPath: string) => { console.log('[Mock] Initializing git:', projectPath); return { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 691dcfec..df95124e 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1598,9 +1598,10 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/list-branches', { worktreePath }), switchBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/switch-branch', { worktreePath, branchName }), - openInEditor: (worktreePath: string) => - this.post('/api/worktree/open-in-editor', { worktreePath }), + openInEditor: (worktreePath: string, editorCommand?: string) => + this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), + getAvailableEditors: () => this.get('/api/worktree/available-editors'), initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }), startDevServer: (projectPath: string, worktreePath: string) => this.post('/api/worktree/start-dev', { projectPath, worktreePath }), diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 78d6e65c..89a1d95f 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -585,6 +585,9 @@ export interface AppState { // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use + // Editor Configuration + defaultEditorCommand: string | null; // Default editor for "Open In" action + // Skills Configuration enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories) skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from @@ -958,6 +961,9 @@ export interface AppActions { setAutoLoadClaudeMd: (enabled: boolean) => Promise; setSkipSandboxWarning: (skip: boolean) => Promise; + // Editor Configuration actions + setDefaultEditorCommand: (command: string | null) => void; + // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; @@ -1196,6 +1202,7 @@ const initialState: AppState = { autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default + defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available enableSkills: true, // Skills enabled by default skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default enableSubagents: true, // Subagents enabled by default @@ -1959,6 +1966,9 @@ export const useAppStore = create()((set, get) => ({ set({ skipSandboxWarning: previous }); } }, + + // Editor Configuration actions + setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }), // Prompt Customization actions setPromptCustomization: async (customization) => { set({ promptCustomization: customization }); diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index fc64f375..8bf3efe6 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -872,7 +872,10 @@ export interface WorktreeAPI { }>; // Open a worktree directory in the editor - openInEditor: (worktreePath: string) => Promise<{ + openInEditor: ( + worktreePath: string, + editorCommand?: string + ) => Promise<{ success: boolean; result?: { message: string; @@ -891,6 +894,18 @@ export interface WorktreeAPI { error?: string; }>; + // Get all available code editors + getAvailableEditors: () => Promise<{ + success: boolean; + result?: { + editors: Array<{ + name: string; + command: string; + }>; + }; + error?: string; + }>; + // Initialize git repository in a project initGit: (projectPath: string) => Promise<{ success: boolean; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 6f13c8a3..e5420df3 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -565,6 +565,10 @@ export interface GlobalSettings { /** List of configured MCP servers for agent use */ mcpServers: MCPServerConfig[]; + // Editor Configuration + /** Default editor command for "Open In" action (null = auto-detect: Cursor > VS Code > first available) */ + defaultEditorCommand: string | null; + // Prompt Customization /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ promptCustomization?: PromptCustomization; @@ -819,6 +823,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { codexAdditionalDirs: DEFAULT_CODEX_ADDITIONAL_DIRS, codexThreadId: undefined, mcpServers: [], + defaultEditorCommand: null, enableSkills: true, skillsSources: ['user', 'project'], enableSubagents: true, From ac87594b5d227e61be7851af62ebae657be7f2b9 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 16:28:31 +0100 Subject: [PATCH 02/11] fix: address code review feedback from PR #423 Addresses feedback from gemini-code-assist and coderabbitai reviewers: ## Duplicate Code (High Priority) - Extract `getEffectiveDefaultEditor` logic into shared `useEffectiveDefaultEditor` hook - Both account-section.tsx and worktree-actions-dropdown.tsx now use the shared hook ## Performance (Medium Priority) - Refactor `detectAllEditors` to use `Promise.all` for parallel editor detection - Replace sequential `await tryAddEditor()` calls with parallel `findEditor()` checks ## Code Quality (Medium Priority) - Remove verbose IIFE pattern for editor icon rendering - Pre-compute icon components before JSX return statement ## Bug Fixes - Use `os.homedir()` instead of `~` fallback which doesn't expand in shell - Normalize Select value to 'auto' when saved editor command not found in editors - Add defensive check for empty editors array in useEffectiveDefaultEditor - Improve mock openInEditor to correctly map all editor commands to display names Co-Authored-By: Claude Opus 4.5 --- .../routes/worktree/routes/open-in-editor.ts | 53 ++++++++++--------- .../components/worktree-actions-dropdown.tsx | 35 ++++-------- .../hooks/use-available-editors.ts | 30 ++++++++++- .../settings-view/account/account-section.tsx | 36 ++++++------- apps/ui/src/lib/electron.ts | 19 ++++++- 5 files changed, 102 insertions(+), 71 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index 492ac85a..43053692 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -6,6 +6,7 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import { homedir } from 'os'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -48,7 +49,7 @@ async function findMacApp(appName: string): Promise { // Check ~/Applications (used by JetBrains Toolbox and others) try { - const homeDir = process.env.HOME || '~'; + const homeDir = homedir(); await execAsync(`test -d "${homeDir}/Applications/${appName}.app"`); return `${homeDir}/Applications/${appName}.app`; } catch { @@ -57,18 +58,17 @@ async function findMacApp(appName: string): Promise { } /** - * Try to add an editor - checks CLI first, then macOS app bundle + * Try to find an editor - checks CLI first, then macOS app bundle + * Returns EditorInfo if found, null otherwise */ -async function tryAddEditor( - editors: EditorInfo[], +async function findEditor( name: string, cliCommand: string, macAppName: string -): Promise { +): Promise { // Try CLI command first if (await commandExists(cliCommand)) { - editors.push({ name, command: cliCommand }); - return; + return { name, command: cliCommand }; } // Try macOS app bundle (checks /Applications and ~/Applications) @@ -76,9 +76,11 @@ async function tryAddEditor( const appPath = await findMacApp(macAppName); if (appPath) { // Use 'open -a' with full path for apps not in /Applications - editors.push({ name, command: `open -a "${appPath}"` }); + return { name, command: `open -a "${appPath}"` }; } } + + return null; } async function detectAllEditors(): Promise { @@ -87,26 +89,29 @@ async function detectAllEditors(): Promise { return cachedEditors; } - const editors: EditorInfo[] = []; const isMac = process.platform === 'darwin'; - // Try editors (CLI command, then macOS app bundle) - await tryAddEditor(editors, 'Cursor', 'cursor', 'Cursor'); - await tryAddEditor(editors, 'VS Code', 'code', 'Visual Studio Code'); - await tryAddEditor(editors, 'Zed', 'zed', 'Zed'); - await tryAddEditor(editors, 'Sublime Text', 'subl', 'Sublime Text'); - await tryAddEditor(editors, 'Windsurf', 'windsurf', 'Windsurf'); - await tryAddEditor(editors, 'Trae', 'trae', 'Trae'); - await tryAddEditor(editors, 'Rider', 'rider', 'Rider'); - await tryAddEditor(editors, 'WebStorm', 'webstorm', 'WebStorm'); + // Check all editors in parallel for better performance + const editorChecks = [ + findEditor('Cursor', 'cursor', 'Cursor'), + findEditor('VS Code', 'code', 'Visual Studio Code'), + findEditor('Zed', 'zed', 'Zed'), + findEditor('Sublime Text', 'subl', 'Sublime Text'), + findEditor('Windsurf', 'windsurf', 'Windsurf'), + findEditor('Trae', 'trae', 'Trae'), + findEditor('Rider', 'rider', 'Rider'), + findEditor('WebStorm', 'webstorm', 'WebStorm'), + // Xcode (macOS only) - will return null on other platforms + isMac ? findEditor('Xcode', 'xed', 'Xcode') : Promise.resolve(null), + findEditor('Android Studio', 'studio', 'Android Studio'), + findEditor('Antigravity', 'agy', 'Antigravity'), + ]; - // Xcode (macOS only) - if (isMac) { - await tryAddEditor(editors, 'Xcode', 'xed', 'Xcode'); - } + // Wait for all checks to complete in parallel + const results = await Promise.all(editorChecks); - await tryAddEditor(editors, 'Android Studio', 'studio', 'Android Studio'); - await tryAddEditor(editors, 'Antigravity', 'agy', 'Antigravity'); + // Filter out null results (editors not found) + const editors = results.filter((e): e is EditorInfo => e !== null); // Always add file manager as fallback const platform = process.platform; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index af7acd67..8d404341 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -28,9 +28,8 @@ import { import { cn } from '@/lib/utils'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; -import { useAvailableEditors } from '../hooks/use-available-editors'; +import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; import { getEditorIcon } from '@/components/icons/editor-icons'; -import { useAppStore } from '@/store/app-store'; interface WorktreeActionsDropdownProps { worktree: WorktreeInfo; @@ -84,30 +83,19 @@ export function WorktreeActionsDropdown({ onOpenDevServerUrl, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu - const { editors, hasMultipleEditors } = useAvailableEditors(); + const { editors } = useAvailableEditors(); - // Get the user's preferred default editor from settings - const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand); - - // Calculate effective default editor based on user setting or auto-detect (Cursor > VS Code > first) - const getEffectiveDefaultEditor = () => { - if (defaultEditorCommand) { - const found = editors.find((e) => e.command === defaultEditorCommand); - if (found) return found; - } - // Auto-detect: prefer Cursor, then VS Code, then first available - const cursor = editors.find((e) => e.command === 'cursor'); - if (cursor) return cursor; - const vscode = editors.find((e) => e.command === 'code'); - if (vscode) return vscode; - return editors[0]; - }; - - const effectiveDefaultEditor = getEffectiveDefaultEditor(); + // Use shared hook for effective default editor + const effectiveDefaultEditor = useEffectiveDefaultEditor(editors); // Get other editors (excluding the default) for the submenu const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command); + // Get icon component for the effective editor (avoid IIFE in JSX) + const DefaultEditorIcon = effectiveDefaultEditor + ? getEditorIcon(effectiveDefaultEditor.command) + : null; + // Check if there's a PR associated with this worktree from stored metadata const hasPR = !!worktree.pr; @@ -240,10 +228,7 @@ export function WorktreeActionsDropdown({ onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)} className="text-xs flex-1 pr-0 rounded-r-none" > - {(() => { - const EditorIcon = getEditorIcon(effectiveDefaultEditor.command); - return ; - })()} + {DefaultEditorIcon && } Open in {effectiveDefaultEditor.name} {/* Chevron trigger for submenu with other editors and Copy Path */} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts index 9cf97576..024a763e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts @@ -1,6 +1,7 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { getElectronAPI } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; const logger = createLogger('AvailableEditors'); @@ -44,3 +45,30 @@ export function useAvailableEditors() { defaultEditor: editors[0] ?? null, }; } + +/** + * Hook to get the effective default editor based on user settings + * Falls back to: Cursor > VS Code > first available editor + */ +export function useEffectiveDefaultEditor(editors: EditorInfo[]): EditorInfo | null { + const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand); + + return useMemo(() => { + if (editors.length === 0) return null; + + // If user has a saved preference and it exists in available editors, use it + if (defaultEditorCommand) { + const found = editors.find((e) => e.command === defaultEditorCommand); + if (found) return found; + } + + // Auto-detect: prefer Cursor, then VS Code, then first available + const cursor = editors.find((e) => e.command === 'cursor'); + if (cursor) return cursor; + + const vscode = editors.find((e) => e.command === 'code'); + if (vscode) return vscode; + + return editors[0]; + }, [editors, defaultEditorCommand]); +} diff --git a/apps/ui/src/components/views/settings-view/account/account-section.tsx b/apps/ui/src/components/views/settings-view/account/account-section.tsx index ae594166..3d6bfa61 100644 --- a/apps/ui/src/components/views/settings-view/account/account-section.tsx +++ b/apps/ui/src/components/views/settings-view/account/account-section.tsx @@ -13,7 +13,10 @@ import { cn } from '@/lib/utils'; import { logout } from '@/lib/http-api-client'; import { useAuthStore } from '@/store/auth-store'; import { useAppStore } from '@/store/app-store'; -import { useAvailableEditors } from '@/components/views/board-view/worktree-panel/hooks/use-available-editors'; +import { + useAvailableEditors, + useEffectiveDefaultEditor, +} from '@/components/views/board-view/worktree-panel/hooks/use-available-editors'; import { getEditorIcon } from '@/components/icons/editor-icons'; export function AccountSection() { @@ -25,20 +28,16 @@ export function AccountSection() { const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand); const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand); - // Get effective default editor (respecting auto-detect order: Cursor > VS Code > first) - const getEffectiveDefaultEditor = () => { - if (defaultEditorCommand) { - return editors.find((e) => e.command === defaultEditorCommand) ?? editors[0]; - } - // Auto-detect: prefer Cursor, then VS Code, then first available - const cursor = editors.find((e) => e.command === 'cursor'); - if (cursor) return cursor; - const vscode = editors.find((e) => e.command === 'code'); - if (vscode) return vscode; - return editors[0]; - }; + // Use shared hook for effective default editor + const effectiveEditor = useEffectiveDefaultEditor(editors); - const effectiveEditor = getEffectiveDefaultEditor(); + // Normalize Select value: if saved editor isn't found, show 'auto' + const hasSavedEditor = + !!defaultEditorCommand && editors.some((e) => e.command === defaultEditorCommand); + const selectValue = hasSavedEditor ? defaultEditorCommand : 'auto'; + + // Get icon component for the effective editor + const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null; const handleLogout = async () => { setIsLoggingOut(true); @@ -87,7 +86,7 @@ export function AccountSection() {
setDefaultEditorCommand(value === 'auto' ? null : value)} - disabled={isLoadingEditors || editors.length === 0} - > - - - {effectiveEditor ? ( - - {EffectiveEditorIcon && } - {effectiveEditor.name} - {selectValue === 'auto' && ( - (Auto) - )} - - ) : ( - 'Select editor' - )} - - - - - - - Auto-detect - - - {editors.map((editor) => { - const Icon = getEditorIcon(editor.command); - return ( - +
+ + ) : ( + 'Select editor' + )} + + + + + + + Auto-detect + + + {editors.map((editor) => { + const Icon = getEditorIcon(editor.command); + return ( + + + + {editor.name} + + + ); + })} + + + + + + + + +

Refresh available editors

+
+
+
+
{/* Logout */} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 7c2e74e9..27780fba 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1697,6 +1697,19 @@ function createMockWorktreeAPI(): WorktreeAPI { }, }; }, + refreshEditors: async () => { + console.log('[Mock] Refreshing available editors'); + return { + success: true, + result: { + editors: [ + { name: 'VS Code', command: 'code' }, + { name: 'Finder', command: 'open' }, + ], + message: 'Found 2 available editors', + }, + }; + }, initGit: async (projectPath: string) => { console.log('[Mock] Initializing git:', projectPath); diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b443589f..62b0c734 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1639,6 +1639,7 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), getAvailableEditors: () => this.get('/api/worktree/available-editors'), + refreshEditors: () => this.post('/api/worktree/refresh-editors', {}), initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }), startDevServer: (projectPath: string, worktreePath: string) => this.post('/api/worktree/start-dev', { projectPath, worktreePath }), diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 8067d4a0..745f6956 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -918,6 +918,18 @@ export interface WorktreeAPI { error?: string; }>; + // Refresh editor cache and re-detect available editors + refreshEditors: () => Promise<{ + success: boolean; + result?: { + editors: Array<{ + name: string; + command: string; + }>; + message: string; + }; + error?: string; + }>; // Initialize git repository in a project initGit: (projectPath: string) => Promise<{ success: boolean; diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts new file mode 100644 index 00000000..c9260a27 --- /dev/null +++ b/libs/platform/src/editor.ts @@ -0,0 +1,282 @@ +/** + * Cross-platform editor detection and launching utilities + * + * Handles: + * - Detecting available code editors on the system + * - Cross-platform editor launching (handles Windows .cmd files) + * - Caching of detected editors for performance + */ + +import { execFile, spawn, type ChildProcess } from 'child_process'; +import { promisify } from 'util'; +import { homedir } from 'os'; +import { join } from 'path'; +import { access } from 'fs/promises'; +import type { EditorInfo } from '@automaker/types'; + +const execFileAsync = promisify(execFile); + +// Platform detection +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; + +// Cache with TTL for editor detection +let cachedEditors: EditorInfo[] | null = null; +let cacheTimestamp: number = 0; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Check if the editor cache is still valid + */ +function isCacheValid(): boolean { + return cachedEditors !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS; +} + +/** + * Clear the editor detection cache + * Useful when editors may have been installed/uninstalled + */ +export function clearEditorCache(): void { + cachedEditors = null; + cacheTimestamp = 0; +} + +/** + * Check if a CLI command exists in PATH + * Uses platform-specific command lookup (where on Windows, which on Unix) + */ +export async function commandExists(cmd: string): Promise { + try { + const whichCmd = isWindows ? 'where' : 'which'; + await execFileAsync(whichCmd, [cmd]); + return true; + } catch { + return false; + } +} + +/** + * Check if a macOS app bundle exists and return the path if found + * Checks both /Applications and ~/Applications + */ +async function findMacApp(appName: string): Promise { + if (!isMac) return null; + + // Check /Applications first + const systemAppPath = join('/Applications', `${appName}.app`); + try { + await access(systemAppPath); + return systemAppPath; + } catch { + // Not in /Applications + } + + // Check ~/Applications (used by JetBrains Toolbox and others) + const userAppPath = join(homedir(), 'Applications', `${appName}.app`); + try { + await access(userAppPath); + return userAppPath; + } catch { + return null; + } +} + +/** + * Editor definition with CLI command and macOS app bundle name + */ +interface EditorDefinition { + name: string; + cliCommand: string; + macAppName: string; + /** If true, only available on macOS */ + macOnly?: boolean; +} + +/** + * List of supported editors in priority order + */ +const SUPPORTED_EDITORS: EditorDefinition[] = [ + { name: 'Cursor', cliCommand: 'cursor', macAppName: 'Cursor' }, + { name: 'VS Code', cliCommand: 'code', macAppName: 'Visual Studio Code' }, + { name: 'Zed', cliCommand: 'zed', macAppName: 'Zed' }, + { name: 'Sublime Text', cliCommand: 'subl', macAppName: 'Sublime Text' }, + { name: 'Windsurf', cliCommand: 'windsurf', macAppName: 'Windsurf' }, + { name: 'Trae', cliCommand: 'trae', macAppName: 'Trae' }, + { name: 'Rider', cliCommand: 'rider', macAppName: 'Rider' }, + { name: 'WebStorm', cliCommand: 'webstorm', macAppName: 'WebStorm' }, + { name: 'Xcode', cliCommand: 'xed', macAppName: 'Xcode', macOnly: true }, + { name: 'Android Studio', cliCommand: 'studio', macAppName: 'Android Studio' }, + { name: 'Antigravity', cliCommand: 'agy', macAppName: 'Antigravity' }, +]; + +/** + * Try to find an editor - checks CLI first, then macOS app bundle + * Returns EditorInfo if found, null otherwise + */ +async function findEditor(definition: EditorDefinition): Promise { + // Skip macOS-only editors on other platforms + if (definition.macOnly && !isMac) { + return null; + } + + // Try CLI command first (works on all platforms) + if (await commandExists(definition.cliCommand)) { + return { name: definition.name, command: definition.cliCommand }; + } + + // Try macOS app bundle (checks /Applications and ~/Applications) + if (isMac) { + const appPath = await findMacApp(definition.macAppName); + if (appPath) { + // Use 'open -a' with full path for apps not in /Applications + return { name: definition.name, command: `open -a "${appPath}"` }; + } + } + + return null; +} + +/** + * Get the platform-specific file manager + */ +function getFileManagerInfo(): EditorInfo { + if (isMac) { + return { name: 'Finder', command: 'open' }; + } else if (isWindows) { + return { name: 'Explorer', command: 'explorer' }; + } else { + return { name: 'File Manager', command: 'xdg-open' }; + } +} + +/** + * Detect all available code editors on the system + * Results are cached for 5 minutes for performance + */ +export async function detectAllEditors(): Promise { + // Return cached result if still valid + if (isCacheValid() && cachedEditors) { + return cachedEditors; + } + + // Check all editors in parallel for better performance + const editorChecks = SUPPORTED_EDITORS.map((def) => findEditor(def)); + const results = await Promise.all(editorChecks); + + // Filter out null results (editors not found) + const editors = results.filter((e): e is EditorInfo => e !== null); + + // Always add file manager as fallback + editors.push(getFileManagerInfo()); + + // Update cache + cachedEditors = editors; + cacheTimestamp = Date.now(); + + return editors; +} + +/** + * Detect the default (first available) code editor on the system + * Returns the highest priority editor that is installed + */ +export async function detectDefaultEditor(): Promise { + const editors = await detectAllEditors(); + // Return first editor (highest priority) - always exists due to file manager fallback + return editors[0]; +} + +/** + * Find a specific editor by command + * Returns the editor info if available, null otherwise + */ +export async function findEditorByCommand(command: string): Promise { + const editors = await detectAllEditors(); + return editors.find((e) => e.command === command) ?? null; +} + +/** + * Open a path in the specified editor + * + * Handles cross-platform differences: + * - On Windows, uses spawn with shell:true to handle .cmd batch scripts + * - On macOS, handles 'open -a' style commands for app bundles + * - On Linux, uses direct execution + * + * @param targetPath - The file or directory path to open + * @param editorCommand - The editor command to use (optional, uses default if not specified) + * @returns Promise that resolves with editor info when launched, rejects on error + */ +export async function openInEditor( + targetPath: string, + editorCommand?: string +): Promise<{ editorName: string }> { + // Determine which editor to use + let editor: EditorInfo; + + if (editorCommand) { + const found = await findEditorByCommand(editorCommand); + if (found) { + editor = found; + } else { + // Fall back to default if specified editor not found + editor = await detectDefaultEditor(); + } + } else { + editor = await detectDefaultEditor(); + } + + // Execute the editor + await executeEditorCommand(editor.command, targetPath); + + return { editorName: editor.name }; +} + +/** + * Execute an editor command with a path argument + * Handles platform-specific differences in command execution + */ +async function executeEditorCommand(command: string, targetPath: string): Promise { + // Handle 'open -a "AppPath"' style commands (macOS app bundles) + if (command.startsWith('open -a ')) { + const appPath = command.replace('open -a ', '').replace(/"/g, ''); + await execFileAsync('open', ['-a', appPath, targetPath]); + return; + } + + // On Windows, editor CLI commands are typically .cmd batch scripts + // spawn with shell:true is required to execute them properly + if (isWindows) { + return new Promise((resolve, reject) => { + const child: ChildProcess = spawn(command, [targetPath], { + shell: true, + stdio: 'ignore', + detached: true, + }); + + // Unref to allow the parent process to exit independently + child.unref(); + + child.on('error', (err) => { + reject(err); + }); + + // Resolve after a small delay to catch immediate spawn errors + // Editors run in background, so we don't wait for them to exit + setTimeout(() => resolve(), 100); + }); + } + + // Unix/macOS: use execFile for direct execution + await execFileAsync(command, [targetPath]); +} + +/** + * Open a path in the platform's default file manager + * Always available as a fallback option + */ +export async function openInFileManager(targetPath: string): Promise<{ editorName: string }> { + const fileManager = getFileManagerInfo(); + await execFileAsync(fileManager.command, [targetPath]); + return { editorName: fileManager.name }; +} diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index d4c28cd8..5fd985c4 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -157,3 +157,14 @@ export { // Port configuration export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js'; + +// Editor detection and launching (cross-platform) +export { + commandExists, + clearEditorCache, + detectAllEditors, + detectDefaultEditor, + findEditorByCommand, + openInEditor, + openInFileManager, +} from './editor.js'; From 01cf81a105df616db677bd1f6ac99c96ed02cafa Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 23:22:13 +0530 Subject: [PATCH 07/11] fix(platform): detect Antigravity CLI aliases --- apps/ui/src/components/icons/editor-icons.tsx | 6 +++++- apps/ui/src/lib/electron.ts | 5 ++++- libs/platform/src/editor.ts | 18 +++++++++++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/components/icons/editor-icons.tsx b/apps/ui/src/components/icons/editor-icons.tsx index 5e3cd092..bf32a38a 100644 --- a/apps/ui/src/components/icons/editor-icons.tsx +++ b/apps/ui/src/components/icons/editor-icons.tsx @@ -4,6 +4,9 @@ import { FolderOpen } from 'lucide-react'; type IconProps = ComponentProps<'svg'>; type IconComponent = ComponentType; +const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const; +const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS; + /** * Cursor editor logo icon - from LobeHub icons */ @@ -160,7 +163,8 @@ export function getEditorIcon(command: string): IconComponent { webstorm: WebStormIcon, xed: XcodeIcon, studio: AndroidStudioIcon, - agy: AntigravityIcon, + [PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon, + [LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon, open: FinderIcon, explorer: FolderOpen, 'xdg-open': FolderOpen, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 27780fba..c93eba79 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1646,6 +1646,8 @@ function createMockWorktreeAPI(): WorktreeAPI { }, openInEditor: async (worktreePath: string, editorCommand?: string) => { + const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity'; + const ANTIGRAVITY_LEGACY_COMMAND = 'agy'; // Map editor commands to display names const editorNameMap: Record = { cursor: 'Cursor', @@ -1658,7 +1660,8 @@ function createMockWorktreeAPI(): WorktreeAPI { webstorm: 'WebStorm', xed: 'Xcode', studio: 'Android Studio', - agy: 'Antigravity', + [ANTIGRAVITY_EDITOR_COMMAND]: 'Antigravity', + [ANTIGRAVITY_LEGACY_COMMAND]: 'Antigravity', open: 'Finder', explorer: 'Explorer', 'xdg-open': 'File Manager', diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index c9260a27..a29608d7 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -87,11 +87,15 @@ async function findMacApp(appName: string): Promise { interface EditorDefinition { name: string; cliCommand: string; + cliAliases?: readonly string[]; macAppName: string; /** If true, only available on macOS */ macOnly?: boolean; } +const ANTIGRAVITY_CLI_COMMANDS = ['antigravity', 'agy'] as const; +const [PRIMARY_ANTIGRAVITY_COMMAND, ...LEGACY_ANTIGRAVITY_COMMANDS] = ANTIGRAVITY_CLI_COMMANDS; + /** * List of supported editors in priority order */ @@ -106,7 +110,12 @@ const SUPPORTED_EDITORS: EditorDefinition[] = [ { name: 'WebStorm', cliCommand: 'webstorm', macAppName: 'WebStorm' }, { name: 'Xcode', cliCommand: 'xed', macAppName: 'Xcode', macOnly: true }, { name: 'Android Studio', cliCommand: 'studio', macAppName: 'Android Studio' }, - { name: 'Antigravity', cliCommand: 'agy', macAppName: 'Antigravity' }, + { + name: 'Antigravity', + cliCommand: PRIMARY_ANTIGRAVITY_COMMAND, + cliAliases: LEGACY_ANTIGRAVITY_COMMANDS, + macAppName: 'Antigravity', + }, ]; /** @@ -120,8 +129,11 @@ async function findEditor(definition: EditorDefinition): Promise Date: Sun, 11 Jan 2026 19:04:39 +0100 Subject: [PATCH 08/11] fix(platform): verify full Xcode installation for xed command The xed command requires full Xcode.app, not just Command Line Tools. This fix adds validation to ensure Xcode is properly configured before offering it as an editor option. Changes: - Added isXcodeFullyInstalled() to check xcode-select points to Xcode.app - Added helpful warning when Xcode is installed but xcode-select points to CLT - Users see clear instructions on how to fix the configuration Fixes issue where xed would fail with "tool 'xed' requires Xcode" error when only Command Line Tools are configured via xcode-select. --- libs/platform/src/editor.ts | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index a29608d7..a2210385 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -13,6 +13,7 @@ import { homedir } from 'os'; import { join } from 'path'; import { access } from 'fs/promises'; import type { EditorInfo } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; const execFileAsync = promisify(execFile); @@ -20,6 +21,9 @@ const execFileAsync = promisify(execFile); const isWindows = process.platform === 'win32'; const isMac = process.platform === 'darwin'; +// Logger for editor detection +const logger = createLogger('editor'); + // Cache with TTL for editor detection let cachedEditors: EditorInfo[] | null = null; let cacheTimestamp: number = 0; @@ -118,6 +122,43 @@ const SUPPORTED_EDITORS: EditorDefinition[] = [ }, ]; +/** + * Check if Xcode is fully installed (not just Command Line Tools) + * xed command requires full Xcode.app, not just CLT + */ +async function isXcodeFullyInstalled(): Promise { + if (!isMac) return false; + + try { + // Check if xcode-select points to full Xcode, not just CommandLineTools + const { stdout } = await execFileAsync('xcode-select', ['-p']); + const devPath = stdout.trim(); + + // Full Xcode path: /Applications/Xcode.app/Contents/Developer + // Command Line Tools: /Library/Developer/CommandLineTools + const isPointingToXcode = devPath.includes('Xcode.app'); + + if (!isPointingToXcode && devPath.includes('CommandLineTools')) { + // Check if xed command exists (indicates CLT are installed) + const xedExists = await commandExists('xed'); + + // Check if Xcode.app actually exists + const xcodeAppPath = await findMacApp('Xcode'); + + if (xedExists && xcodeAppPath) { + logger.warn( + 'Xcode is installed but xcode-select is pointing to Command Line Tools. ' + + 'To use Xcode as an editor, run: sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer' + ); + } + } + + return isPointingToXcode; + } catch { + return false; + } +} + /** * Try to find an editor - checks CLI first, then macOS app bundle * Returns EditorInfo if found, null otherwise @@ -128,6 +169,13 @@ async function findEditor(definition: EditorDefinition): Promise Date: Sun, 11 Jan 2026 19:14:44 +0100 Subject: [PATCH 09/11] feat(platform): add VS Code Insiders and Kiro editor support Added support for two new editors: - VS Code Insiders (code-insiders command) - Kiro (kido command) - VS Code fork Changes: - Added editor definitions to SUPPORTED_EDITORS list - Added VSCodeInsidersIcon (reuses VS Code icon) - Added KiroIcon with custom SVG logo - Updated getEditorIcon() to handle both new commands - Fixed logger initialization to be lazy-loaded, preventing circular dependency error with isBrowser variable during module initialization Both editors were tested and successfully open directories on macOS. --- apps/ui/src/components/icons/editor-icons.tsx | 23 +++++++++++++++++++ libs/platform/src/editor.ts | 18 ++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/components/icons/editor-icons.tsx b/apps/ui/src/components/icons/editor-icons.tsx index bf32a38a..a4537d5f 100644 --- a/apps/ui/src/components/icons/editor-icons.tsx +++ b/apps/ui/src/components/icons/editor-icons.tsx @@ -29,6 +29,25 @@ export function VSCodeIcon(props: IconProps) { ); } +/** + * VS Code Insiders editor logo icon (same as VS Code) + */ +export function VSCodeInsidersIcon(props: IconProps) { + return ; +} + +/** + * Kiro editor logo icon (VS Code fork) + */ +export function KiroIcon(props: IconProps) { + return ( + + + + + ); +} + /** * Zed editor logo icon (from Simple Icons) */ @@ -155,6 +174,8 @@ export function getEditorIcon(command: string): IconComponent { const cliIcons: Record = { cursor: CursorIcon, code: VSCodeIcon, + 'code-insiders': VSCodeInsidersIcon, + kido: KiroIcon, zed: ZedIcon, subl: SublimeTextIcon, windsurf: WindsurfIcon, @@ -179,7 +200,9 @@ export function getEditorIcon(command: string): IconComponent { if (command.startsWith('open')) { const cmdLower = command.toLowerCase(); if (cmdLower.includes('cursor')) return CursorIcon; + if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon; if (cmdLower.includes('visual studio code')) return VSCodeIcon; + if (cmdLower.includes('kiro')) return KiroIcon; if (cmdLower.includes('zed')) return ZedIcon; if (cmdLower.includes('sublime')) return SublimeTextIcon; if (cmdLower.includes('windsurf')) return WindsurfIcon; diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index a2210385..e51fb487 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -21,8 +21,14 @@ const execFileAsync = promisify(execFile); const isWindows = process.platform === 'win32'; const isMac = process.platform === 'darwin'; -// Logger for editor detection -const logger = createLogger('editor'); +// Lazy-initialized logger for editor detection +let logger: ReturnType | null = null; +function getLogger() { + if (!logger) { + logger = createLogger('editor'); + } + return logger; +} // Cache with TTL for editor detection let cachedEditors: EditorInfo[] | null = null; @@ -106,6 +112,12 @@ const [PRIMARY_ANTIGRAVITY_COMMAND, ...LEGACY_ANTIGRAVITY_COMMANDS] = ANTIGRAVIT const SUPPORTED_EDITORS: EditorDefinition[] = [ { name: 'Cursor', cliCommand: 'cursor', macAppName: 'Cursor' }, { name: 'VS Code', cliCommand: 'code', macAppName: 'Visual Studio Code' }, + { + name: 'VS Code Insiders', + cliCommand: 'code-insiders', + macAppName: 'Visual Studio Code - Insiders', + }, + { name: 'Kiro', cliCommand: 'kido', macAppName: 'Kiro' }, { name: 'Zed', cliCommand: 'zed', macAppName: 'Zed' }, { name: 'Sublime Text', cliCommand: 'subl', macAppName: 'Sublime Text' }, { name: 'Windsurf', cliCommand: 'windsurf', macAppName: 'Windsurf' }, @@ -146,7 +158,7 @@ async function isXcodeFullyInstalled(): Promise { const xcodeAppPath = await findMacApp('Xcode'); if (xedExists && xcodeAppPath) { - logger.warn( + getLogger().warn( 'Xcode is installed but xcode-select is pointing to Command Line Tools. ' + 'To use Xcode as an editor, run: sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer' ); From cbca9b68e6675f38b20c6c272364a3d78d9f1697 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 11 Jan 2026 19:25:26 +0100 Subject: [PATCH 10/11] fix: correct Kiro CLI command typo (kido -> kiro) Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/editor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index e51fb487..a0da688c 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -117,7 +117,7 @@ const SUPPORTED_EDITORS: EditorDefinition[] = [ cliCommand: 'code-insiders', macAppName: 'Visual Studio Code - Insiders', }, - { name: 'Kiro', cliCommand: 'kido', macAppName: 'Kiro' }, + { name: 'Kiro', cliCommand: 'kiro', macAppName: 'Kiro' }, { name: 'Zed', cliCommand: 'zed', macAppName: 'Zed' }, { name: 'Sublime Text', cliCommand: 'subl', macAppName: 'Sublime Text' }, { name: 'Windsurf', cliCommand: 'windsurf', macAppName: 'Windsurf' }, From 80081b60bf26326cbf50ba5a504e586de89a77b7 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 11 Jan 2026 19:34:29 +0100 Subject: [PATCH 11/11] fix(platform): remove logger import to avoid circular dependency Replace createLogger with console.warn to prevent circular import between @automaker/platform and @automaker/utils. Co-Authored-By: Claude Opus 4.5 --- libs/platform/src/editor.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index a0da688c..b6daa022 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -13,23 +13,12 @@ import { homedir } from 'os'; import { join } from 'path'; import { access } from 'fs/promises'; import type { EditorInfo } from '@automaker/types'; -import { createLogger } from '@automaker/utils'; - const execFileAsync = promisify(execFile); // Platform detection const isWindows = process.platform === 'win32'; const isMac = process.platform === 'darwin'; -// Lazy-initialized logger for editor detection -let logger: ReturnType | null = null; -function getLogger() { - if (!logger) { - logger = createLogger('editor'); - } - return logger; -} - // Cache with TTL for editor detection let cachedEditors: EditorInfo[] | null = null; let cacheTimestamp: number = 0; @@ -158,7 +147,7 @@ async function isXcodeFullyInstalled(): Promise { const xcodeAppPath = await findMacApp('Xcode'); if (xedExists && xcodeAppPath) { - getLogger().warn( + console.warn( 'Xcode is installed but xcode-select is pointing to Command Line Tools. ' + 'To use Xcode as an editor, run: sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer' );