Skip to content

Commit 32656a9

Browse files
stefandevoclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 299b838 commit 32656a9

14 files changed

Lines changed: 588 additions & 52 deletions

File tree

apps/server/src/routes/worktree/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
2424
import {
2525
createOpenInEditorHandler,
2626
createGetDefaultEditorHandler,
27+
createGetAvailableEditorsHandler,
2728
} from './routes/open-in-editor.js';
2829
import { createInitGitHandler } from './routes/init-git.js';
2930
import { createMigrateHandler } from './routes/migrate.js';
@@ -77,6 +78,7 @@ export function createWorktreeRoutes(): Router {
7778
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
7879
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
7980
router.get('/default-editor', createGetDefaultEditorHandler());
81+
router.get('/available-editors', createGetAvailableEditorsHandler());
8082
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
8183
router.post('/migrate', createMigrateHandler());
8284
router.post(

apps/server/src/routes/worktree/routes/open-in-editor.ts

Lines changed: 124 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,64 +17,143 @@ interface EditorInfo {
1717
}
1818

1919
let cachedEditor: EditorInfo | null = null;
20+
let cachedEditors: EditorInfo[] | null = null;
2021

2122
/**
22-
* Detect which code editor is available on the system
23+
* Check if a CLI command exists in PATH
2324
*/
24-
async function detectDefaultEditor(): Promise<EditorInfo> {
25-
// Return cached result if available
26-
if (cachedEditor) {
27-
return cachedEditor;
28-
}
29-
30-
// Try Cursor first (if user has Cursor, they probably prefer it)
25+
async function commandExists(cmd: string): Promise<boolean> {
3126
try {
32-
await execAsync('which cursor || where cursor');
33-
cachedEditor = { name: 'Cursor', command: 'cursor' };
34-
return cachedEditor;
27+
await execAsync(process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`);
28+
return true;
3529
} catch {
36-
// Cursor not found
30+
return false;
3731
}
32+
}
33+
34+
/**
35+
* Check if a macOS app bundle exists and return the path if found
36+
* Checks both /Applications and ~/Applications
37+
*/
38+
async function findMacApp(appName: string): Promise<string | null> {
39+
if (process.platform !== 'darwin') return null;
3840

39-
// Try VS Code
41+
// Check /Applications first
4042
try {
41-
await execAsync('which code || where code');
42-
cachedEditor = { name: 'VS Code', command: 'code' };
43-
return cachedEditor;
43+
await execAsync(`test -d "/Applications/${appName}.app"`);
44+
return `/Applications/${appName}.app`;
4445
} catch {
45-
// VS Code not found
46+
// Not in /Applications
4647
}
4748

48-
// Try Zed
49+
// Check ~/Applications (used by JetBrains Toolbox and others)
4950
try {
50-
await execAsync('which zed || where zed');
51-
cachedEditor = { name: 'Zed', command: 'zed' };
52-
return cachedEditor;
51+
const homeDir = process.env.HOME || '~';
52+
await execAsync(`test -d "${homeDir}/Applications/${appName}.app"`);
53+
return `${homeDir}/Applications/${appName}.app`;
5354
} catch {
54-
// Zed not found
55+
return null;
56+
}
57+
}
58+
59+
/**
60+
* Try to add an editor - checks CLI first, then macOS app bundle
61+
*/
62+
async function tryAddEditor(
63+
editors: EditorInfo[],
64+
name: string,
65+
cliCommand: string,
66+
macAppName: string
67+
): Promise<void> {
68+
// Try CLI command first
69+
if (await commandExists(cliCommand)) {
70+
editors.push({ name, command: cliCommand });
71+
return;
5572
}
5673

57-
// Try Sublime Text
58-
try {
59-
await execAsync('which subl || where subl');
60-
cachedEditor = { name: 'Sublime Text', command: 'subl' };
61-
return cachedEditor;
62-
} catch {
63-
// Sublime not found
74+
// Try macOS app bundle (checks /Applications and ~/Applications)
75+
if (process.platform === 'darwin') {
76+
const appPath = await findMacApp(macAppName);
77+
if (appPath) {
78+
// Use 'open -a' with full path for apps not in /Applications
79+
editors.push({ name, command: `open -a "${appPath}"` });
80+
}
81+
}
82+
}
83+
84+
async function detectAllEditors(): Promise<EditorInfo[]> {
85+
// Return cached result if available
86+
if (cachedEditors) {
87+
return cachedEditors;
88+
}
89+
90+
const editors: EditorInfo[] = [];
91+
const isMac = process.platform === 'darwin';
92+
93+
// Try editors (CLI command, then macOS app bundle)
94+
await tryAddEditor(editors, 'Cursor', 'cursor', 'Cursor');
95+
await tryAddEditor(editors, 'VS Code', 'code', 'Visual Studio Code');
96+
await tryAddEditor(editors, 'Zed', 'zed', 'Zed');
97+
await tryAddEditor(editors, 'Sublime Text', 'subl', 'Sublime Text');
98+
await tryAddEditor(editors, 'Windsurf', 'windsurf', 'Windsurf');
99+
await tryAddEditor(editors, 'Trae', 'trae', 'Trae');
100+
await tryAddEditor(editors, 'Rider', 'rider', 'Rider');
101+
await tryAddEditor(editors, 'WebStorm', 'webstorm', 'WebStorm');
102+
103+
// Xcode (macOS only)
104+
if (isMac) {
105+
await tryAddEditor(editors, 'Xcode', 'xed', 'Xcode');
64106
}
65107

66-
// Fallback to file manager
108+
await tryAddEditor(editors, 'Android Studio', 'studio', 'Android Studio');
109+
await tryAddEditor(editors, 'Antigravity', 'agy', 'Antigravity');
110+
111+
// Always add file manager as fallback
67112
const platform = process.platform;
68113
if (platform === 'darwin') {
69-
cachedEditor = { name: 'Finder', command: 'open' };
114+
editors.push({ name: 'Finder', command: 'open' });
70115
} else if (platform === 'win32') {
71-
cachedEditor = { name: 'Explorer', command: 'explorer' };
116+
editors.push({ name: 'Explorer', command: 'explorer' });
72117
} else {
73-
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
118+
editors.push({ name: 'File Manager', command: 'xdg-open' });
119+
}
120+
121+
cachedEditors = editors;
122+
return editors;
123+
}
124+
125+
/**
126+
* Detect the default (first available) code editor on the system
127+
*/
128+
async function detectDefaultEditor(): Promise<EditorInfo> {
129+
// Return cached result if available
130+
if (cachedEditor) {
131+
return cachedEditor;
74132
}
133+
134+
// Get all editors and return the first one (highest priority)
135+
const editors = await detectAllEditors();
136+
cachedEditor = editors[0];
75137
return cachedEditor;
76138
}
77139

140+
export function createGetAvailableEditorsHandler() {
141+
return async (_req: Request, res: Response): Promise<void> => {
142+
try {
143+
const editors = await detectAllEditors();
144+
res.json({
145+
success: true,
146+
result: {
147+
editors,
148+
},
149+
});
150+
} catch (error) {
151+
logError(error, 'Get available editors failed');
152+
res.status(500).json({ success: false, error: getErrorMessage(error) });
153+
}
154+
};
155+
}
156+
78157
export function createGetDefaultEditorHandler() {
79158
return async (_req: Request, res: Response): Promise<void> => {
80159
try {
@@ -96,8 +175,9 @@ export function createGetDefaultEditorHandler() {
96175
export function createOpenInEditorHandler() {
97176
return async (req: Request, res: Response): Promise<void> => {
98177
try {
99-
const { worktreePath } = req.body as {
178+
const { worktreePath, editorCommand } = req.body as {
100179
worktreePath: string;
180+
editorCommand?: string;
101181
};
102182

103183
if (!worktreePath) {
@@ -108,7 +188,16 @@ export function createOpenInEditorHandler() {
108188
return;
109189
}
110190

111-
const editor = await detectDefaultEditor();
191+
// Use specified editor command or detect default
192+
let editor: EditorInfo;
193+
if (editorCommand) {
194+
// Find the editor info from the available editors list
195+
const allEditors = await detectAllEditors();
196+
const specifiedEditor = allEditors.find((e) => e.command === editorCommand);
197+
editor = specifiedEditor ?? (await detectDefaultEditor());
198+
} else {
199+
editor = await detectDefaultEditor();
200+
}
112201

113202
try {
114203
await execAsync(`${editor.command} "${worktreePath}"`);

0 commit comments

Comments
 (0)