diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7fef5c6e..7972dcd6 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -24,6 +24,8 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js'; import { createOpenInEditorHandler, createGetDefaultEditorHandler, + createGetAvailableEditorsHandler, + createRefreshEditorsHandler, } from './routes/open-in-editor.js'; import { createInitGitHandler } from './routes/init-git.js'; import { createMigrateHandler } from './routes/migrate.js'; @@ -77,6 +79,8 @@ 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('/refresh-editors', createRefreshEditorsHandler()); 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..c5ea6f9e 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -1,78 +1,40 @@ /** * POST /open-in-editor endpoint - Open a worktree directory in the default code editor * GET /default-editor endpoint - Get the name of the default code editor + * POST /refresh-editors endpoint - Clear editor cache and re-detect available editors + * + * This module uses @automaker/platform for cross-platform editor detection and launching. */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { isAbsolute } from 'path'; +import { + clearEditorCache, + detectAllEditors, + detectDefaultEditor, + openInEditor, + openInFileManager, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; -const execAsync = promisify(exec); +const logger = createLogger('open-in-editor'); -// Editor detection with caching -interface EditorInfo { - name: string; - command: string; -} - -let cachedEditor: EditorInfo | null = null; - -/** - * Detect which code editor is available on the system - */ -async function detectDefaultEditor(): Promise { - // Return cached result if available - if (cachedEditor) { - 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' }; - } - 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() { @@ -93,11 +55,41 @@ export function createGetDefaultEditorHandler() { }; } +/** + * Handler to refresh the editor cache and re-detect available editors + * Useful when the user has installed/uninstalled editors + */ +export function createRefreshEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearEditorCache(); + + // Re-detect editors (this will repopulate the cache) + const editors = await detectAllEditors(); + + logger.info(`Editor cache refreshed, found ${editors.length} editors`); + + res.json({ + success: true, + result: { + editors, + message: `Found ${editors.length} available editors`, + }, + }); + } catch (error) { + logError(error, 'Refresh editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + 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,42 +100,44 @@ export function createOpenInEditorHandler() { return; } - const editor = await detectDefaultEditor(); + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } try { - await execAsync(`${editor.command} "${worktreePath}"`); + // Use the platform utility to open in editor + const result = await openInEditor(worktreePath, editorCommand); res.json({ success: true, result: { - message: `Opened ${worktreePath} in ${editor.name}`, - editorName: editor.name, + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, }, }); } catch (editorError) { - // If the detected editor fails, try opening in default file manager as fallback - const platform = process.platform; - let openCommand: string; - let fallbackName: string; + // If the specified editor fails, try opening in default file manager as fallback + logger.warn( + `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` + ); - if (platform === 'darwin') { - openCommand = `open "${worktreePath}"`; - fallbackName = 'Finder'; - } else if (platform === 'win32') { - openCommand = `explorer "${worktreePath}"`; - fallbackName = 'Explorer'; - } else { - openCommand = `xdg-open "${worktreePath}"`; - fallbackName = 'File Manager'; + try { + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); + } catch (fallbackError) { + // Both editor and file manager failed + throw fallbackError; } - - await execAsync(openCommand); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${fallbackName}`, - editorName: fallbackName, - }, - }); } } catch (error) { logError(error, 'Open in editor failed'); 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..a4537d5f --- /dev/null +++ b/apps/ui/src/components/icons/editor-icons.tsx @@ -0,0 +1,220 @@ +import type { ComponentType, ComponentProps } from 'react'; +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 + */ +export function CursorIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * VS Code editor logo icon + */ +export function VSCodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * 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) + */ +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, + 'code-insiders': VSCodeInsidersIcon, + kido: KiroIcon, + zed: ZedIcon, + subl: SublimeTextIcon, + windsurf: WindsurfIcon, + trae: TraeIcon, + rider: RiderIcon, + webstorm: WebStormIcon, + xed: XcodeIcon, + studio: AndroidStudioIcon, + [PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon, + [LEGACY_ANTIGRAVITY_COMMAND]: 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 - 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; + 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..b94faed7 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,15 +23,18 @@ import { MessageSquare, GitMerge, AlertCircle, + Copy, } from 'lucide-react'; +import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; +import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; +import { getEditorIcon } from '@/components/icons/editor-icons'; interface WorktreeActionsDropdownProps { worktree: WorktreeInfo; isSelected: boolean; - defaultEditorName: string; aheadCount: number; behindCount: number; isPulling: boolean; @@ -41,7 +46,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; @@ -55,7 +60,6 @@ interface WorktreeActionsDropdownProps { export function WorktreeActionsDropdown({ worktree, isSelected, - defaultEditorName, aheadCount, behindCount, isPulling, @@ -77,6 +81,20 @@ export function WorktreeActionsDropdown({ onStopDevServer, onOpenDevServerUrl, }: WorktreeActionsDropdownProps) { + // Get available editors for the "Open In" submenu + const { editors } = useAvailableEditors(); + + // 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; @@ -200,10 +218,54 @@ 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" + > + {DefaultEditorIcon && } + 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 && } + { + try { + await navigator.clipboard.writeText(worktree.path); + toast.success('Path copied to clipboard'); + } catch { + toast.error('Failed to copy path to clipboard'); + } + }} + 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; @@ -58,7 +57,6 @@ export function WorktreeTab({ isActivating, isDevServerRunning, devServerInfo, - defaultEditorName, branches, filteredBranches, branchFilter, @@ -315,7 +313,6 @@ export function WorktreeTab({ ([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + + 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); + } + }, []); + + /** + * Refresh editors by clearing the server cache and re-detecting + * Use this when the user has installed/uninstalled editors + */ + const refresh = useCallback(async () => { + setIsRefreshing(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.refreshEditors) { + // Fallback to regular fetch if refresh not available + await fetchAvailableEditors(); + return; + } + const result = await api.worktree.refreshEditors(); + if (result.success && result.result?.editors) { + setEditors(result.result.editors); + logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`); + } + } catch (error) { + logger.error('Failed to refresh editors:', error); + } finally { + setIsRefreshing(false); + } + }, [fetchAvailableEditors]); + + useEffect(() => { + fetchAvailableEditors(); + }, [fetchAvailableEditors]); + + return { + editors, + isLoading, + isRefreshing, + refresh, + // 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, + }; +} + +/** + * 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/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/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index b56f65e1..034f9d2a 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -8,7 +8,6 @@ import { useDevServers, useBranches, useWorktreeActions, - useDefaultEditor, useRunningFeatures, } from './hooks'; import { WorktreeTab } from './components'; @@ -75,8 +74,6 @@ export function WorktreePanel({ fetchBranches, }); - const { defaultEditorName } = useDefaultEditor(); - const { hasRunningFeatures } = useRunningFeatures({ runningFeatureIds, features, @@ -137,7 +134,6 @@ export function WorktreePanel({ isActivating={isActivating} isDevServerRunning={isDevServerRunning(mainWorktree)} devServerInfo={getDevServerInfo(mainWorktree)} - defaultEditorName={defaultEditorName} branches={branches} filteredBranches={filteredBranches} branchFilter={branchFilter} @@ -192,7 +188,6 @@ export function WorktreePanel({ isActivating={isActivating} isDevServerRunning={isDevServerRunning(worktree)} devServerInfo={getDevServerInfo(worktree)} - defaultEditorName={defaultEditorName} branches={branches} filteredBranches={filteredBranches} branchFilter={branchFilter} 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..901e5040 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,51 @@ 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from 'sonner'; +import { LogOut, User, Code2, RefreshCw } 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, + useEffectiveDefaultEditor, +} 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, isRefreshing, refresh } = useAvailableEditors(); + const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand); + const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand); + + // Use shared hook for effective default editor + const effectiveEditor = useEffectiveDefaultEditor(editors); + + // 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 handleRefreshEditors = async () => { + await refresh(); + toast.success('Editor list refreshed'); + }; + const handleLogout = async () => { setIsLoggingOut(true); try { @@ -43,6 +79,81 @@ export function AccountSection() {

Manage your session and account.

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

Default IDE

+

+ Default IDE to use when opening branches or worktrees +

+
+
+
+ + + + + + + +

Refresh available editors

+
+
+
+
+
+ {/* Logout */}
diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 1f63923b..b87fa9d3 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -47,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'autoLoadClaudeMd', 'keyboardShortcuts', 'mcpServers', + 'defaultEditorCommand', 'promptCustomization', 'projects', 'trashedProjects', @@ -451,6 +452,7 @@ export async function refreshSettingsFromServer(): Promise { >), }, 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 3abe43fa..c93eba79 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1645,13 +1645,34 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - openInEditor: async (worktreePath: string) => { - console.log('[Mock] Opening in editor:', worktreePath); + 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', + code: 'VS Code', + zed: 'Zed', + subl: 'Sublime Text', + windsurf: 'Windsurf', + trae: 'Trae', + rider: 'Rider', + webstorm: 'WebStorm', + xed: 'Xcode', + studio: 'Android Studio', + [ANTIGRAVITY_EDITOR_COMMAND]: 'Antigravity', + [ANTIGRAVITY_LEGACY_COMMAND]: 'Antigravity', + open: 'Finder', + explorer: 'Explorer', + 'xdg-open': 'File Manager', + }; + const editorName = editorCommand ? (editorNameMap[editorCommand] ?? 'Editor') : '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, }, }; }, @@ -1667,6 +1688,32 @@ 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' }, + ], + }, + }; + }, + 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); return { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 26f5eae9..62b0c734 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1635,9 +1635,11 @@ 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'), + 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/store/app-store.ts b/apps/ui/src/store/app-store.ts index b147e6c2..4b9e319c 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -580,6 +580,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 @@ -960,6 +963,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; @@ -1160,6 +1166,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 @@ -1949,6 +1956,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 450f7b5e..745f6956 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -884,7 +884,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; @@ -903,6 +906,30 @@ export interface WorktreeAPI { error?: string; }>; + // Get all available code editors + getAvailableEditors: () => Promise<{ + success: boolean; + result?: { + editors: Array<{ + name: string; + command: string; + }>; + }; + 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..b6daa022 --- /dev/null +++ b/libs/platform/src/editor.ts @@ -0,0 +1,343 @@ +/** + * 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; + 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 + */ +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: 'kiro', macAppName: 'Kiro' }, + { 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: PRIMARY_ANTIGRAVITY_COMMAND, + cliAliases: LEGACY_ANTIGRAVITY_COMMANDS, + macAppName: 'Antigravity', + }, +]; + +/** + * 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) { + 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' + ); + } + } + + return isPointingToXcode; + } catch { + return false; + } +} + +/** + * 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; + } + + // Special handling for Xcode: verify full installation, not just xed command + if (definition.name === 'Xcode') { + if (!(await isXcodeFullyInstalled())) { + return null; + } + } + + // Try CLI command first (works on all platforms) + const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])]; + for (const cliCommand of cliCandidates) { + if (await commandExists(cliCommand)) { + return { name: definition.name, command: 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'; diff --git a/libs/types/src/editor.ts b/libs/types/src/editor.ts new file mode 100644 index 00000000..c74e146c --- /dev/null +++ b/libs/types/src/editor.ts @@ -0,0 +1,13 @@ +/** + * Editor types for the "Open In" functionality + */ + +/** + * Information about an available code editor + */ +export interface EditorInfo { + /** Display name of the editor (e.g., "VS Code", "Cursor") */ + name: string; + /** CLI command or open command to launch the editor */ + command: string; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 7b402274..621e5365 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -214,6 +214,9 @@ export type { // Port configuration export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; +// Editor types +export type { EditorInfo } from './editor.js'; + // Ideation types export type { IdeaCategory, diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 07b4290d..5b51c793 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -460,6 +460,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; @@ -712,6 +716,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { codexAdditionalDirs: DEFAULT_CODEX_ADDITIONAL_DIRS, codexThreadId: undefined, mcpServers: [], + defaultEditorCommand: null, enableSkills: true, skillsSources: ['user', 'project'], enableSubagents: true,