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() {

- setDefaultEditorCommand(value === 'auto' ? null : value)} + disabled={isLoadingEditors || isRefreshing || editors.length === 0} + > + + + {effectiveEditor ? ( - - {editor.name} + {EffectiveEditorIcon && } + {effectiveEditor.name} + {selectValue === 'auto' && ( + (Auto) + )} - - ); - })} - - + ) : ( + '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';