mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
|||||||
createOpenInEditorHandler,
|
createOpenInEditorHandler,
|
||||||
createGetDefaultEditorHandler,
|
createGetDefaultEditorHandler,
|
||||||
createGetAvailableEditorsHandler,
|
createGetAvailableEditorsHandler,
|
||||||
|
createRefreshEditorsHandler,
|
||||||
} from './routes/open-in-editor.js';
|
} from './routes/open-in-editor.js';
|
||||||
import { createInitGitHandler } from './routes/init-git.js';
|
import { createInitGitHandler } from './routes/init-git.js';
|
||||||
import { createMigrateHandler } from './routes/migrate.js';
|
import { createMigrateHandler } from './routes/migrate.js';
|
||||||
@@ -79,6 +80,7 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||||
router.get('/available-editors', createGetAvailableEditorsHandler());
|
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||||
|
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||||
router.post('/migrate', createMigrateHandler());
|
router.post('/migrate', createMigrateHandler());
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -1,150 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
|
* 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
|
* 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 type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import { isAbsolute } from 'path';
|
||||||
import { promisify } from 'util';
|
import {
|
||||||
import { homedir } from 'os';
|
clearEditorCache,
|
||||||
import { isAbsolute, join } from 'path';
|
detectAllEditors,
|
||||||
import { access } from 'fs/promises';
|
detectDefaultEditor,
|
||||||
import type { EditorInfo } from '@automaker/types';
|
openInEditor,
|
||||||
|
openInFileManager,
|
||||||
|
} from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const logger = createLogger('open-in-editor');
|
||||||
|
|
||||||
// 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<boolean> {
|
|
||||||
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<string | null> {
|
|
||||||
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<EditorInfo | null> {
|
|
||||||
// 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<EditorInfo[]> {
|
|
||||||
// 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<EditorInfo> {
|
|
||||||
// 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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGetAvailableEditorsHandler() {
|
export function createGetAvailableEditorsHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
@@ -182,18 +56,32 @@ export function createGetDefaultEditorHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely execute an editor command with a path argument
|
* Handler to refresh the editor cache and re-detect available editors
|
||||||
* Uses execFile to prevent command injection
|
* Useful when the user has installed/uninstalled editors
|
||||||
*/
|
*/
|
||||||
async function safeOpenInEditor(command: string, targetPath: string): Promise<void> {
|
export function createRefreshEditorsHandler() {
|
||||||
// Handle 'open -a "AppPath"' style commands (macOS)
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
if (command.startsWith('open -a ')) {
|
try {
|
||||||
const appPath = command.replace('open -a ', '').replace(/"/g, '');
|
// Clear the cache
|
||||||
await execFileAsync('open', ['-a', appPath, targetPath]);
|
clearEditorCache();
|
||||||
} else {
|
|
||||||
// Simple commands like 'code', 'cursor', 'zed', etc.
|
// Re-detect editors (this will repopulate the cache)
|
||||||
await execFileAsync(command, [targetPath]);
|
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() {
|
export function createOpenInEditorHandler() {
|
||||||
@@ -221,61 +109,35 @@ export function createOpenInEditorHandler() {
|
|||||||
return;
|
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 {
|
try {
|
||||||
await safeOpenInEditor(editor.command, worktreePath);
|
// Use the platform utility to open in editor
|
||||||
|
const result = await openInEditor(worktreePath, editorCommand);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in ${editor.name}`,
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
editorName: editor.name,
|
editorName: result.editorName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
// If the detected editor fails, try opening in default file manager as fallback
|
// If the specified editor fails, try opening in default file manager as fallback
|
||||||
const platform = process.platform;
|
logger.warn(
|
||||||
let fallbackCommand: string;
|
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||||
let fallbackName: string;
|
);
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
try {
|
||||||
fallbackCommand = 'open';
|
const result = await openInFileManager(worktreePath);
|
||||||
fallbackName = 'Finder';
|
res.json({
|
||||||
} else if (platform === 'win32') {
|
success: true,
|
||||||
fallbackCommand = 'explorer';
|
result: {
|
||||||
fallbackName = 'Explorer';
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
} else {
|
editorName: result.editorName,
|
||||||
fallbackCommand = 'xdg-open';
|
},
|
||||||
fallbackName = 'File Manager';
|
});
|
||||||
|
} 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) {
|
} catch (error) {
|
||||||
logError(error, 'Open in editor failed');
|
logError(error, 'Open in editor failed');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type { EditorInfo };
|
|||||||
export function useAvailableEditors() {
|
export function useAvailableEditors() {
|
||||||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const fetchAvailableEditors = useCallback(async () => {
|
const fetchAvailableEditors = useCallback(async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchAvailableEditors();
|
fetchAvailableEditors();
|
||||||
}, [fetchAvailableEditors]);
|
}, [fetchAvailableEditors]);
|
||||||
@@ -38,6 +64,8 @@ export function useAvailableEditors() {
|
|||||||
return {
|
return {
|
||||||
editors,
|
editors,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
refresh,
|
||||||
// Convenience property: has multiple editors (for deciding whether to show submenu)
|
// Convenience property: has multiple editors (for deciding whether to show submenu)
|
||||||
hasMultipleEditors: editors.length > 1,
|
hasMultipleEditors: editors.length > 1,
|
||||||
// The first editor is the "default" one
|
// The first editor is the "default" one
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 { cn } from '@/lib/utils';
|
||||||
import { logout } from '@/lib/http-api-client';
|
import { logout } from '@/lib/http-api-client';
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
@@ -24,7 +26,7 @@ export function AccountSection() {
|
|||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
// Editor settings
|
// Editor settings
|
||||||
const { editors, isLoading: isLoadingEditors } = useAvailableEditors();
|
const { editors, isLoading: isLoadingEditors, isRefreshing, refresh } = useAvailableEditors();
|
||||||
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
|
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
|
||||||
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
|
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
|
||||||
|
|
||||||
@@ -39,6 +41,11 @@ export function AccountSection() {
|
|||||||
// Get icon component for the effective editor
|
// Get icon component for the effective editor
|
||||||
const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null;
|
const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null;
|
||||||
|
|
||||||
|
const handleRefreshEditors = async () => {
|
||||||
|
await refresh();
|
||||||
|
toast.success('Editor list refreshed');
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
@@ -85,46 +92,66 @@ export function AccountSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<div className="flex items-center gap-2">
|
||||||
value={selectValue}
|
<Select
|
||||||
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
|
value={selectValue}
|
||||||
disabled={isLoadingEditors || editors.length === 0}
|
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
|
||||||
>
|
disabled={isLoadingEditors || isRefreshing || editors.length === 0}
|
||||||
<SelectTrigger className="w-[180px] shrink-0">
|
>
|
||||||
<SelectValue placeholder="Select editor">
|
<SelectTrigger className="w-[180px] shrink-0">
|
||||||
{effectiveEditor ? (
|
<SelectValue placeholder="Select editor">
|
||||||
<span className="flex items-center gap-2">
|
{effectiveEditor ? (
|
||||||
{EffectiveEditorIcon && <EffectiveEditorIcon className="w-4 h-4" />}
|
|
||||||
{effectiveEditor.name}
|
|
||||||
{selectValue === 'auto' && (
|
|
||||||
<span className="text-muted-foreground text-xs">(Auto)</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Select editor'
|
|
||||||
)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Code2 className="w-4 h-4" />
|
|
||||||
Auto-detect
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
{editors.map((editor) => {
|
|
||||||
const Icon = getEditorIcon(editor.command);
|
|
||||||
return (
|
|
||||||
<SelectItem key={editor.command} value={editor.command}>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Icon className="w-4 h-4" />
|
{EffectiveEditorIcon && <EffectiveEditorIcon className="w-4 h-4" />}
|
||||||
{editor.name}
|
{effectiveEditor.name}
|
||||||
|
{selectValue === 'auto' && (
|
||||||
|
<span className="text-muted-foreground text-xs">(Auto)</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
) : (
|
||||||
);
|
'Select editor'
|
||||||
})}
|
)}
|
||||||
</SelectContent>
|
</SelectValue>
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Code2 className="w-4 h-4" />
|
||||||
|
Auto-detect
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{editors.map((editor) => {
|
||||||
|
const Icon = getEditorIcon(editor.command);
|
||||||
|
return (
|
||||||
|
<SelectItem key={editor.command} value={editor.command}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{editor.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefreshEditors}
|
||||||
|
disabled={isRefreshing || isLoadingEditors}
|
||||||
|
className="shrink-0 h-9 w-9"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Refresh available editors</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
|
|||||||
@@ -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) => {
|
initGit: async (projectPath: string) => {
|
||||||
console.log('[Mock] Initializing git:', projectPath);
|
console.log('[Mock] Initializing git:', projectPath);
|
||||||
|
|||||||
@@ -1639,6 +1639,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
|
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
|
||||||
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
||||||
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
|
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
|
||||||
|
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
|
||||||
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
||||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||||
|
|||||||
12
apps/ui/src/types/electron.d.ts
vendored
12
apps/ui/src/types/electron.d.ts
vendored
@@ -918,6 +918,18 @@ export interface WorktreeAPI {
|
|||||||
error?: 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
|
// Initialize git repository in a project
|
||||||
initGit: (projectPath: string) => Promise<{
|
initGit: (projectPath: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
282
libs/platform/src/editor.ts
Normal file
282
libs/platform/src/editor.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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<EditorInfo | null> {
|
||||||
|
// 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<EditorInfo[]> {
|
||||||
|
// 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<EditorInfo> {
|
||||||
|
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<EditorInfo | null> {
|
||||||
|
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<void> {
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
@@ -157,3 +157,14 @@ export {
|
|||||||
|
|
||||||
// Port configuration
|
// Port configuration
|
||||||
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js';
|
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';
|
||||||
|
|||||||
Reference in New Issue
Block a user