From 6d267ce0fa48c2d0af6bd657faae4edfdc6b25dc Mon Sep 17 00:00:00 2001
From: Kacper
Date: Sun, 11 Jan 2026 18:07:45 +0100
Subject: [PATCH] feat(platform): add cross-platform editor utilities and
refresh functionality
- Add libs/platform/src/editor.ts with cross-platform editor detection and launching
- Handles Windows .cmd batch scripts (cursor.cmd, code.cmd, etc.)
- Supports macOS app bundles in /Applications and ~/Applications
- Includes caching with 5-minute TTL for performance
- Refactor open-in-editor.ts to use @automaker/platform utilities
- Add POST /api/worktree/refresh-editors endpoint to clear cache
- Add refresh button to Settings > Account for IDE selection
- Update useAvailableEditors hook with refresh() and isRefreshing
Fixes Windows issue where "Open in Editor" was falling back to Explorer
because execFile cannot run .cmd scripts without shell:true.
Co-Authored-By: Claude Opus 4.5
---
apps/server/src/routes/worktree/index.ts | 2 +
.../routes/worktree/routes/open-in-editor.ts | 254 ++++------------
.../hooks/use-available-editors.ts | 28 ++
.../settings-view/account/account-section.tsx | 107 ++++---
apps/ui/src/lib/electron.ts | 13 +
apps/ui/src/lib/http-api-client.ts | 1 +
apps/ui/src/types/electron.d.ts | 12 +
libs/platform/src/editor.ts | 282 ++++++++++++++++++
libs/platform/src/index.ts | 11 +
9 files changed, 474 insertions(+), 236 deletions(-)
create mode 100644 libs/platform/src/editor.ts
diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts
index 6d64ad3a..7972dcd6 100644
--- a/apps/server/src/routes/worktree/index.ts
+++ b/apps/server/src/routes/worktree/index.ts
@@ -25,6 +25,7 @@ import {
createOpenInEditorHandler,
createGetDefaultEditorHandler,
createGetAvailableEditorsHandler,
+ createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
@@ -79,6 +80,7 @@ export function createWorktreeRoutes(): Router {
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 de9c7b39..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,150 +1,24 @@
/**
* 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 { execFile } from 'child_process';
-import { promisify } from 'util';
-import { homedir } from 'os';
-import { isAbsolute, join } from 'path';
-import { access } from 'fs/promises';
-import type { EditorInfo } from '@automaker/types';
+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 execFileAsync = promisify(execFile);
-
-// Cache with TTL for editor detection
-// cachedEditors is the single source of truth; default editor is derived from it
-let cachedEditors: EditorInfo[] | null = null;
-let cacheTimestamp: number = 0;
-const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
-
-function isCacheValid(): boolean {
- return cachedEditors !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
-}
-
-/**
- * Check if a CLI command exists in PATH
- * Uses execFile to avoid shell injection
- */
-async function commandExists(cmd: string): Promise {
- try {
- const whichCmd = process.platform === 'win32' ? 'where' : 'which';
- await execFileAsync(whichCmd, [cmd]);
- return true;
- } catch {
- return false;
- }
-}
-
-/**
- * Check if a macOS app bundle exists and return the path if found
- * Uses Node fs methods instead of shell commands for safety
- */
-async function findMacApp(appName: string): Promise {
- if (process.platform !== 'darwin') 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;
- }
-}
-
-/**
- * Try to find an editor - checks CLI first, then macOS app bundle
- * Returns EditorInfo if found, null otherwise
- */
-async function findEditor(
- name: string,
- cliCommand: string,
- macAppName: string
-): Promise {
- // Try CLI command first
- if (await commandExists(cliCommand)) {
- return { name, command: cliCommand };
- }
-
- // 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
- return { name, command: `open -a "${appPath}"` };
- }
- }
-
- return null;
-}
-
-async function detectAllEditors(): Promise {
- // Return cached result if still valid
- if (cachedEditors && isCacheValid()) {
- return cachedEditors;
- }
-
- const isMac = process.platform === 'darwin';
-
- // 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'),
- ];
-
- // Wait for all checks to complete in parallel
- 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
- 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;
- cacheTimestamp = Date.now();
- return editors;
-}
-
-/**
- * Detect the default (first available) code editor on the system
- * Derives from detectAllEditors() to ensure cache consistency
- */
-async function detectDefaultEditor(): Promise {
- // Always go through detectAllEditors() which handles cache TTL
- const editors = await detectAllEditors();
- // Return first editor (highest priority) - always exists due to file manager fallback
- return editors[0];
-}
+const logger = createLogger('open-in-editor');
export function createGetAvailableEditorsHandler() {
return async (_req: Request, res: Response): Promise => {
@@ -182,18 +56,32 @@ export function createGetDefaultEditorHandler() {
}
/**
- * Safely execute an editor command with a path argument
- * Uses execFile to prevent command injection
+ * Handler to refresh the editor cache and re-detect available editors
+ * Useful when the user has installed/uninstalled editors
*/
-async function safeOpenInEditor(command: string, targetPath: string): Promise {
- // Handle 'open -a "AppPath"' style commands (macOS)
- if (command.startsWith('open -a ')) {
- const appPath = command.replace('open -a ', '').replace(/"/g, '');
- await execFileAsync('open', ['-a', appPath, targetPath]);
- } else {
- // Simple commands like 'code', 'cursor', 'zed', etc.
- await execFileAsync(command, [targetPath]);
- }
+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() {
@@ -221,61 +109,35 @@ export function createOpenInEditorHandler() {
return;
}
- // 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);
- if (specifiedEditor) {
- editor = specifiedEditor;
- } else {
- // Log warning when requested editor is not available
- const availableCommands = allEditors.map((e) => e.command).join(', ');
- console.warn(
- `[open-in-editor] Requested editor '${editorCommand}' not found. ` +
- `Available editors: [${availableCommands}]. Falling back to default editor.`
- );
- editor = allEditors[0]; // Fall back to default (first in priority list)
- }
- } else {
- editor = await detectDefaultEditor();
- }
-
try {
- await safeOpenInEditor(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 fallbackCommand: 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') {
- fallbackCommand = 'open';
- fallbackName = 'Finder';
- } else if (platform === 'win32') {
- fallbackCommand = 'explorer';
- fallbackName = 'Explorer';
- } else {
- fallbackCommand = 'xdg-open';
- 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 execFileAsync(fallbackCommand, [worktreePath]);
- 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/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 20cde622..a3db9750 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
@@ -12,6 +12,7 @@ export type { EditorInfo };
export function useAvailableEditors() {
const [editors, setEditors] = useState([]);
const [isLoading, setIsLoading] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableEditors = useCallback(async () => {
try {
@@ -31,6 +32,31 @@ export function useAvailableEditors() {
}
}, []);
+ /**
+ * 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]);
@@ -38,6 +64,8 @@ export function useAvailableEditors() {
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
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 3d6bfa61..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
@@ -8,7 +8,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { LogOut, User, Code2 } from 'lucide-react';
+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';
@@ -24,7 +26,7 @@ export function AccountSection() {
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Editor settings
- const { editors, isLoading: isLoadingEditors } = useAvailableEditors();
+ const { editors, isLoading: isLoadingEditors, isRefreshing, refresh } = useAvailableEditors();
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
@@ -39,6 +41,11 @@ export function AccountSection() {
// 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 {
@@ -85,46 +92,66 @@ export function AccountSection() {
-