Files
automaker/libs/platform/src/editor.ts
Stefan de Vogelaere a52c0461e5 feat: add external terminal support with cross-platform detection (#565)
* feat(platform): add cross-platform openInTerminal utility

Add utility function to open a terminal in a specified directory:
- macOS: Uses Terminal.app via AppleScript
- Windows: Tries Windows Terminal, falls back to cmd
- Linux: Tries common terminal emulators (gnome-terminal,
  konsole, xfce4-terminal, xterm, x-terminal-emulator)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(server): add open-in-terminal endpoint

Add POST /open-in-terminal endpoint to open a system terminal in the
worktree directory using the cross-platform openInTerminal utility.

The endpoint validates that worktreePath is provided and is an
absolute path for security.

Extracted from PR #558.

* feat(ui): add Open in Terminal action to worktree dropdown

Add "Open in Terminal" option to the worktree actions dropdown menu.
This opens the system terminal in the worktree directory.

Changes:
- Add openInTerminal method to http-api-client
- Add Terminal icon and menu item to worktree-actions-dropdown
- Add onOpenInTerminal prop to WorktreeTab component
- Add handleOpenInTerminal handler to use-worktree-actions hook
- Wire up handler in worktree-panel for both mobile and desktop views

Extracted from PR #558.

* fix(ui): open in terminal navigates to Automaker terminal view

Instead of opening the system terminal, the "Open in Terminal" action
now opens Automaker's built-in terminal with the worktree directory:

- Add pendingTerminalCwd state to app store
- Update use-worktree-actions to set pending cwd and navigate to /terminal
- Add effect in terminal-view to create session with pending cwd

This matches the original PR #558 behavior.

* feat(ui): add terminal open mode setting (new tab vs split)

Add a setting to choose how "Open in Terminal" behaves:
- New Tab: Creates a new tab named after the branch (default)
- Split: Adds to current tab as a split view

Changes:
- Add openTerminalMode setting to terminal state ('newTab' | 'split')
- Update terminal-view to respect the setting
- Add UI in Terminal Settings to toggle the behavior
- Rename pendingTerminalCwd to pendingTerminal with branch name

The new tab mode names tabs after the branch for easy identification.
The split mode is useful for comparing terminals side by side.

* feat(ui): display branch name in terminal header with git icon

- Move branch name display from tab name to terminal header
- Show full branch name (no truncation) with GitBranch icon
- Display branch name for both 'new tab' and 'split' modes
- Persist openTerminalMode setting to server and include in import/export
- Update settings dropdown to simplified "New Tab" label

* feat: add external terminal support with cross-platform detection

Add support for opening worktree directories in external terminals
(iTerm2, Warp, Ghostty, System Terminal, etc.) while retaining the
integrated terminal as the default option.

Changes:
- Add terminal detection for macOS, Windows, and Linux
- Add "Open in Terminal" split-button in worktree dropdown
- Add external terminal selection in Settings > Terminal
- Add default open mode setting (new tab vs split)
- Display branch name in terminal panel header
- Support 20+ terminals across platforms

Part of #558, Closes #550

* fix: address PR review comments

- Add nonce parameter to terminal navigation to allow reopening same
  worktree multiple times
- Fix shell path escaping in editor.ts using single-quote wrapper
- Add validatePathParams middleware to open-in-external-terminal route
- Remove redundant validation block from createOpenInExternalTerminalHandler
- Remove unused pendingTerminal state and setPendingTerminal action
- Remove unused getTerminalInfo function from editor.ts

* fix: address PR review security and validation issues

- Add runtime type check for worktreePath in open-in-terminal handler
- Fix Windows Terminal detection using commandExists before spawn
- Fix xterm shell injection by using sh -c with escapeShellArg
- Use loose equality for null/undefined in useEffectiveDefaultTerminal
- Consolidate duplicate imports from open-in-terminal.js

* chore: update package-lock.json

* fix: use response.json() to prevent disposal race condition in E2E test

Replace response.body() with response.json() in open-existing-project.spec.ts
to fix the "Response has been disposed" error. This matches the pattern used
in other test files.

* Revert "fix: use response.json() to prevent disposal race condition in E2E test"

This reverts commit 36bdf8c24a.

* fix: address PR review feedback for terminal feature

- Add explicit validation for worktreePath in createOpenInExternalTerminalHandler
- Add aria-label to refresh button in terminal settings for accessibility
- Only show "no terminals" message when not refreshing
- Reset initialCwdHandledRef on failure to allow retries
- Use z.coerce.number() for nonce URL param to handle string coercion
- Preserve branchName when creating layout for empty tab
- Update getDefaultTerminal return type to allow null result

---------

Co-authored-by: Kacper <kacperlachowiczwp.pl@wp.pl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:22:26 +01:00

450 lines
14 KiB
TypeScript

/**
* 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';
/**
* Escape a string for safe use in shell commands
* Handles paths with spaces, special characters, etc.
*/
function escapeShellArg(arg: string): string {
// Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
return `'${arg.replace(/'/g, "'\\''")}'`;
}
// 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;
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<boolean> {
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<EditorInfo | null> {
// 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<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 };
}
/**
* Open a terminal in the specified directory
*
* Handles cross-platform differences:
* - On macOS, uses Terminal.app via 'open -a Terminal' or AppleScript for directory
* - On Windows, uses Windows Terminal (wt) or falls back to cmd
* - On Linux, uses x-terminal-emulator or common terminal emulators
*
* @param targetPath - The directory path to open the terminal in
* @returns Promise that resolves with terminal info when launched, rejects on error
*/
export async function openInTerminal(targetPath: string): Promise<{ terminalName: string }> {
if (isMac) {
// Use AppleScript to open Terminal.app in the specified directory
const script = `
tell application "Terminal"
do script "cd ${escapeShellArg(targetPath)}"
activate
end tell
`;
await execFileAsync('osascript', ['-e', script]);
return { terminalName: 'Terminal' };
} else if (isWindows) {
// Try Windows Terminal first - check if it exists before trying to spawn
const hasWindowsTerminal = await commandExists('wt');
if (hasWindowsTerminal) {
return await new Promise((resolve, reject) => {
const child: ChildProcess = spawn('wt', ['-d', targetPath], {
shell: true,
stdio: 'ignore',
detached: true,
});
child.unref();
child.on('error', (err) => {
reject(err);
});
setTimeout(() => resolve({ terminalName: 'Windows Terminal' }), 100);
});
}
// Fall back to cmd
return await new Promise((resolve, reject) => {
const child: ChildProcess = spawn(
'cmd',
['/c', 'start', 'cmd', '/k', `cd /d "${targetPath}"`],
{
shell: true,
stdio: 'ignore',
detached: true,
}
);
child.unref();
child.on('error', (err) => {
reject(err);
});
setTimeout(() => resolve({ terminalName: 'Command Prompt' }), 100);
});
} else {
// Linux: Try common terminal emulators in order
const terminals = [
{
name: 'GNOME Terminal',
command: 'gnome-terminal',
args: ['--working-directory', targetPath],
},
{ name: 'Konsole', command: 'konsole', args: ['--workdir', targetPath] },
{
name: 'xfce4-terminal',
command: 'xfce4-terminal',
args: ['--working-directory', targetPath],
},
{
name: 'xterm',
command: 'xterm',
args: ['-e', 'sh', '-c', `cd ${escapeShellArg(targetPath)} && $SHELL`],
},
{
name: 'x-terminal-emulator',
command: 'x-terminal-emulator',
args: ['--working-directory', targetPath],
},
];
for (const terminal of terminals) {
if (await commandExists(terminal.command)) {
await execFileAsync(terminal.command, terminal.args);
return { terminalName: terminal.name };
}
}
throw new Error('No terminal emulator found');
}
}