mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
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>
This commit is contained in:
committed by
GitHub
parent
e73c92b031
commit
a52c0461e5
@@ -19,6 +19,15 @@ const execFileAsync = promisify(execFile);
|
||||
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;
|
||||
@@ -341,3 +350,100 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,4 +175,14 @@ export {
|
||||
findEditorByCommand,
|
||||
openInEditor,
|
||||
openInFileManager,
|
||||
openInTerminal,
|
||||
} from './editor.js';
|
||||
|
||||
// External terminal detection and launching
|
||||
export {
|
||||
clearTerminalCache,
|
||||
detectAllTerminals,
|
||||
detectDefaultTerminal,
|
||||
findTerminalById,
|
||||
openInExternalTerminal,
|
||||
} from './terminal.js';
|
||||
|
||||
607
libs/platform/src/terminal.ts
Normal file
607
libs/platform/src/terminal.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Cross-platform terminal detection and launching utilities
|
||||
*
|
||||
* Handles:
|
||||
* - Detecting available external terminals on the system
|
||||
* - Cross-platform terminal launching
|
||||
* - Caching of detected terminals 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 { TerminalInfo } from '@automaker/types';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Platform detection
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
|
||||
// Cache with TTL for terminal detection
|
||||
let cachedTerminals: TerminalInfo[] | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Check if the terminal cache is still valid
|
||||
*/
|
||||
function isCacheValid(): boolean {
|
||||
return cachedTerminals !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal detection cache
|
||||
* Useful when terminals may have been installed/uninstalled
|
||||
*/
|
||||
export function clearTerminalCache(): void {
|
||||
cachedTerminals = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CLI command exists in PATH
|
||||
* Uses platform-specific command lookup (where on Windows, which on Unix)
|
||||
*/
|
||||
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 /Applications, /System/Applications (for built-in apps), and ~/Applications
|
||||
*/
|
||||
async function findMacApp(appName: string): Promise<string | null> {
|
||||
if (!isMac) return null;
|
||||
|
||||
// Check /Applications first (third-party apps)
|
||||
const appPath = join('/Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(appPath);
|
||||
return appPath;
|
||||
} catch {
|
||||
// Not in /Applications
|
||||
}
|
||||
|
||||
// Check /System/Applications (built-in macOS apps like Terminal on Catalina+)
|
||||
const systemAppPath = join('/System/Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(systemAppPath);
|
||||
return systemAppPath;
|
||||
} catch {
|
||||
// Not in /System/Applications
|
||||
}
|
||||
|
||||
// Check ~/Applications (used by some installers)
|
||||
const userAppPath = join(homedir(), 'Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(userAppPath);
|
||||
return userAppPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Windows path exists
|
||||
*/
|
||||
async function windowsPathExists(path: string): Promise<boolean> {
|
||||
if (!isWindows) return false;
|
||||
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal definition with CLI command and platform-specific identifiers
|
||||
*/
|
||||
interface TerminalDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
/** CLI command (cross-platform, checked via which/where) */
|
||||
cliCommand?: string;
|
||||
/** Alternative CLI commands to check */
|
||||
cliAliases?: readonly string[];
|
||||
/** macOS app bundle name */
|
||||
macAppName?: string;
|
||||
/** Windows executable paths to check */
|
||||
windowsPaths?: readonly string[];
|
||||
/** Linux binary paths to check */
|
||||
linuxPaths?: readonly string[];
|
||||
/** Platform restriction */
|
||||
platform?: 'darwin' | 'win32' | 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* List of supported terminals in priority order
|
||||
*/
|
||||
const SUPPORTED_TERMINALS: TerminalDefinition[] = [
|
||||
// macOS terminals
|
||||
{
|
||||
id: 'iterm2',
|
||||
name: 'iTerm2',
|
||||
cliCommand: 'iterm2',
|
||||
macAppName: 'iTerm',
|
||||
platform: 'darwin',
|
||||
},
|
||||
{
|
||||
id: 'warp',
|
||||
name: 'Warp',
|
||||
cliCommand: 'warp',
|
||||
macAppName: 'Warp',
|
||||
platform: 'darwin',
|
||||
},
|
||||
{
|
||||
id: 'ghostty',
|
||||
name: 'Ghostty',
|
||||
cliCommand: 'ghostty',
|
||||
macAppName: 'Ghostty',
|
||||
},
|
||||
{
|
||||
id: 'rio',
|
||||
name: 'Rio',
|
||||
cliCommand: 'rio',
|
||||
macAppName: 'Rio',
|
||||
},
|
||||
{
|
||||
id: 'alacritty',
|
||||
name: 'Alacritty',
|
||||
cliCommand: 'alacritty',
|
||||
macAppName: 'Alacritty',
|
||||
},
|
||||
{
|
||||
id: 'wezterm',
|
||||
name: 'WezTerm',
|
||||
cliCommand: 'wezterm',
|
||||
macAppName: 'WezTerm',
|
||||
},
|
||||
{
|
||||
id: 'kitty',
|
||||
name: 'Kitty',
|
||||
cliCommand: 'kitty',
|
||||
macAppName: 'kitty',
|
||||
},
|
||||
{
|
||||
id: 'hyper',
|
||||
name: 'Hyper',
|
||||
cliCommand: 'hyper',
|
||||
macAppName: 'Hyper',
|
||||
},
|
||||
{
|
||||
id: 'tabby',
|
||||
name: 'Tabby',
|
||||
cliCommand: 'tabby',
|
||||
macAppName: 'Tabby',
|
||||
},
|
||||
{
|
||||
id: 'terminal-macos',
|
||||
name: 'System Terminal',
|
||||
macAppName: 'Utilities/Terminal',
|
||||
platform: 'darwin',
|
||||
},
|
||||
|
||||
// Windows terminals
|
||||
{
|
||||
id: 'windows-terminal',
|
||||
name: 'Windows Terminal',
|
||||
cliCommand: 'wt',
|
||||
windowsPaths: [join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'wt.exe')],
|
||||
platform: 'win32',
|
||||
},
|
||||
{
|
||||
id: 'powershell',
|
||||
name: 'PowerShell',
|
||||
cliCommand: 'pwsh',
|
||||
cliAliases: ['powershell'],
|
||||
windowsPaths: [
|
||||
join(
|
||||
process.env.SYSTEMROOT || 'C:\\Windows',
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe'
|
||||
),
|
||||
],
|
||||
platform: 'win32',
|
||||
},
|
||||
{
|
||||
id: 'cmd',
|
||||
name: 'Command Prompt',
|
||||
cliCommand: 'cmd',
|
||||
windowsPaths: [join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'cmd.exe')],
|
||||
platform: 'win32',
|
||||
},
|
||||
{
|
||||
id: 'git-bash',
|
||||
name: 'Git Bash',
|
||||
windowsPaths: [
|
||||
join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Git', 'git-bash.exe'),
|
||||
join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Git', 'git-bash.exe'),
|
||||
],
|
||||
platform: 'win32',
|
||||
},
|
||||
|
||||
// Linux terminals
|
||||
{
|
||||
id: 'gnome-terminal',
|
||||
name: 'GNOME Terminal',
|
||||
cliCommand: 'gnome-terminal',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
id: 'konsole',
|
||||
name: 'Konsole',
|
||||
cliCommand: 'konsole',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
id: 'xfce4-terminal',
|
||||
name: 'XFCE4 Terminal',
|
||||
cliCommand: 'xfce4-terminal',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
id: 'tilix',
|
||||
name: 'Tilix',
|
||||
cliCommand: 'tilix',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
id: 'terminator',
|
||||
name: 'Terminator',
|
||||
cliCommand: 'terminator',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
id: 'foot',
|
||||
name: 'Foot',
|
||||
cliCommand: 'foot',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
id: 'xterm',
|
||||
name: 'XTerm',
|
||||
cliCommand: 'xterm',
|
||||
platform: 'linux',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Try to find a terminal - checks CLI, macOS app bundle, or Windows paths
|
||||
* Returns TerminalInfo if found, null otherwise
|
||||
*/
|
||||
async function findTerminal(definition: TerminalDefinition): Promise<TerminalInfo | null> {
|
||||
// Skip if terminal is for a different platform
|
||||
if (definition.platform) {
|
||||
if (definition.platform === 'darwin' && !isMac) return null;
|
||||
if (definition.platform === 'win32' && !isWindows) return null;
|
||||
if (definition.platform === 'linux' && !isLinux) return null;
|
||||
}
|
||||
|
||||
// Try CLI command first (works on all platforms)
|
||||
const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])].filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
for (const cliCommand of cliCandidates) {
|
||||
if (await commandExists(cliCommand)) {
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
command: cliCommand,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try macOS app bundle
|
||||
if (isMac && definition.macAppName) {
|
||||
const appPath = await findMacApp(definition.macAppName);
|
||||
if (appPath) {
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
command: `open -a "${appPath}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try Windows paths
|
||||
if (isWindows && definition.windowsPaths) {
|
||||
for (const windowsPath of definition.windowsPaths) {
|
||||
if (await windowsPathExists(windowsPath)) {
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
command: windowsPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all available external terminals on the system
|
||||
* Results are cached for 5 minutes for performance
|
||||
*/
|
||||
export async function detectAllTerminals(): Promise<TerminalInfo[]> {
|
||||
// Return cached result if still valid
|
||||
if (isCacheValid() && cachedTerminals) {
|
||||
return cachedTerminals;
|
||||
}
|
||||
|
||||
// Check all terminals in parallel for better performance
|
||||
const terminalChecks = SUPPORTED_TERMINALS.map((def) => findTerminal(def));
|
||||
const results = await Promise.all(terminalChecks);
|
||||
|
||||
// Filter out null results (terminals not found)
|
||||
const terminals = results.filter((t): t is TerminalInfo => t !== null);
|
||||
|
||||
// Update cache
|
||||
cachedTerminals = terminals;
|
||||
cacheTimestamp = Date.now();
|
||||
|
||||
return terminals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the default (first available) external terminal on the system
|
||||
* Returns the highest priority terminal that is installed, or null if none found
|
||||
*/
|
||||
export async function detectDefaultTerminal(): Promise<TerminalInfo | null> {
|
||||
const terminals = await detectAllTerminals();
|
||||
return terminals[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific terminal by ID
|
||||
* Returns the terminal info if available, null otherwise
|
||||
*/
|
||||
export async function findTerminalById(id: string): Promise<TerminalInfo | null> {
|
||||
const terminals = await detectAllTerminals();
|
||||
return terminals.find((t) => t.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a directory in the specified external terminal
|
||||
*
|
||||
* Handles cross-platform differences:
|
||||
* - On macOS, uses 'open -a' for app bundles or direct command with --directory flag
|
||||
* - On Windows, uses spawn with shell:true
|
||||
* - On Linux, uses direct execution with working directory
|
||||
*
|
||||
* @param targetPath - The directory path to open
|
||||
* @param terminalId - The terminal ID to use (optional, uses default if not specified)
|
||||
* @returns Promise that resolves with terminal info when launched, rejects on error
|
||||
*/
|
||||
export async function openInExternalTerminal(
|
||||
targetPath: string,
|
||||
terminalId?: string
|
||||
): Promise<{ terminalName: string }> {
|
||||
// Determine which terminal to use
|
||||
let terminal: TerminalInfo | null;
|
||||
|
||||
if (terminalId) {
|
||||
terminal = await findTerminalById(terminalId);
|
||||
if (!terminal) {
|
||||
// Fall back to default if specified terminal not found
|
||||
terminal = await detectDefaultTerminal();
|
||||
}
|
||||
} else {
|
||||
terminal = await detectDefaultTerminal();
|
||||
}
|
||||
|
||||
if (!terminal) {
|
||||
throw new Error('No external terminal available');
|
||||
}
|
||||
|
||||
// Execute the terminal
|
||||
await executeTerminalCommand(terminal, targetPath);
|
||||
|
||||
return { terminalName: terminal.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a terminal command to open at a specific path
|
||||
* Handles platform-specific differences in command execution
|
||||
*/
|
||||
async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string): Promise<void> {
|
||||
const { id, command } = terminal;
|
||||
|
||||
// Handle 'open -a "AppPath"' style commands (macOS app bundles)
|
||||
if (command.startsWith('open -a ')) {
|
||||
const appPath = command.replace('open -a ', '').replace(/"/g, '');
|
||||
|
||||
// Different terminals have different ways to open at a directory
|
||||
if (id === 'iterm2') {
|
||||
// iTerm2: Use AppleScript to open a new window at the path
|
||||
await execFileAsync('osascript', [
|
||||
'-e',
|
||||
`tell application "iTerm"
|
||||
create window with default profile
|
||||
tell current session of current window
|
||||
write text "cd ${escapeShellArg(targetPath)}"
|
||||
end tell
|
||||
end tell`,
|
||||
]);
|
||||
} else if (id === 'terminal-macos') {
|
||||
// macOS Terminal: Use AppleScript
|
||||
await execFileAsync('osascript', [
|
||||
'-e',
|
||||
`tell application "Terminal"
|
||||
do script "cd ${escapeShellArg(targetPath)}"
|
||||
activate
|
||||
end tell`,
|
||||
]);
|
||||
} else if (id === 'warp') {
|
||||
// Warp: Open app and use AppleScript to cd
|
||||
await execFileAsync('open', ['-a', appPath, targetPath]);
|
||||
} else {
|
||||
// Generic: Just open the app with the directory as argument
|
||||
await execFileAsync('open', ['-a', appPath, targetPath]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different terminals based on their ID
|
||||
switch (id) {
|
||||
case 'iterm2':
|
||||
// iTerm2 CLI mode
|
||||
await execFileAsync('osascript', [
|
||||
'-e',
|
||||
`tell application "iTerm"
|
||||
create window with default profile
|
||||
tell current session of current window
|
||||
write text "cd ${escapeShellArg(targetPath)}"
|
||||
end tell
|
||||
end tell`,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'ghostty':
|
||||
// Ghostty: uses --working-directory=PATH format (single arg)
|
||||
await spawnDetached(command, [`--working-directory=${targetPath}`]);
|
||||
break;
|
||||
|
||||
case 'alacritty':
|
||||
// Alacritty: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'wezterm':
|
||||
// WezTerm: uses start --cwd flag
|
||||
await spawnDetached(command, ['start', '--cwd', targetPath]);
|
||||
break;
|
||||
|
||||
case 'kitty':
|
||||
// Kitty: uses --directory flag
|
||||
await spawnDetached(command, ['--directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'hyper':
|
||||
// Hyper: open at directory by setting cwd
|
||||
await spawnDetached(command, [targetPath]);
|
||||
break;
|
||||
|
||||
case 'tabby':
|
||||
// Tabby: open at directory
|
||||
await spawnDetached(command, ['open', targetPath]);
|
||||
break;
|
||||
|
||||
case 'rio':
|
||||
// Rio: uses --working-dir flag
|
||||
await spawnDetached(command, ['--working-dir', targetPath]);
|
||||
break;
|
||||
|
||||
case 'windows-terminal':
|
||||
// Windows Terminal: uses -d flag for directory
|
||||
await spawnDetached(command, ['-d', targetPath], { shell: true });
|
||||
break;
|
||||
|
||||
case 'powershell':
|
||||
case 'cmd':
|
||||
// PowerShell/CMD: Start in directory with /K to keep open
|
||||
await spawnDetached('start', [command, '/K', `cd /d "${targetPath}"`], {
|
||||
shell: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'git-bash':
|
||||
// Git Bash: uses --cd flag
|
||||
await spawnDetached(command, ['--cd', targetPath], { shell: true });
|
||||
break;
|
||||
|
||||
case 'gnome-terminal':
|
||||
// GNOME Terminal: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'konsole':
|
||||
// Konsole: uses --workdir flag
|
||||
await spawnDetached(command, ['--workdir', targetPath]);
|
||||
break;
|
||||
|
||||
case 'xfce4-terminal':
|
||||
// XFCE4 Terminal: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'tilix':
|
||||
// Tilix: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'terminator':
|
||||
// Terminator: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'foot':
|
||||
// Foot: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
break;
|
||||
|
||||
case 'xterm':
|
||||
// XTerm: uses -e to run a shell in the directory
|
||||
await spawnDetached(command, [
|
||||
'-e',
|
||||
'sh',
|
||||
'-c',
|
||||
`cd ${escapeShellArg(targetPath)} && $SHELL`,
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic fallback: try to run the command with the directory as argument
|
||||
await spawnDetached(command, [targetPath]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached process that won't block the parent
|
||||
*/
|
||||
function spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { shell?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child: ChildProcess = spawn(command, args, {
|
||||
shell: options.shell ?? false,
|
||||
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
|
||||
// Terminals run in background, so we don't wait for them to exit
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe use in shell commands
|
||||
*/
|
||||
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, "'\\''")}'`;
|
||||
}
|
||||
Reference in New Issue
Block a user