mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13: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
@@ -29,6 +29,13 @@ import {
|
||||
createGetAvailableEditorsHandler,
|
||||
createRefreshEditorsHandler,
|
||||
} from './routes/open-in-editor.js';
|
||||
import {
|
||||
createOpenInTerminalHandler,
|
||||
createGetAvailableTerminalsHandler,
|
||||
createGetDefaultTerminalHandler,
|
||||
createRefreshTerminalsHandler,
|
||||
createOpenInExternalTerminalHandler,
|
||||
} from './routes/open-in-terminal.js';
|
||||
import { createInitGitHandler } from './routes/init-git.js';
|
||||
import { createMigrateHandler } from './routes/migrate.js';
|
||||
import { createStartDevHandler } from './routes/start-dev.js';
|
||||
@@ -97,9 +104,25 @@ export function createWorktreeRoutes(
|
||||
);
|
||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||
router.post(
|
||||
'/open-in-terminal',
|
||||
validatePathParams('worktreePath'),
|
||||
createOpenInTerminalHandler()
|
||||
);
|
||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||
|
||||
// External terminal routes
|
||||
router.get('/available-terminals', createGetAvailableTerminalsHandler());
|
||||
router.get('/default-terminal', createGetDefaultTerminalHandler());
|
||||
router.post('/refresh-terminals', createRefreshTerminalsHandler());
|
||||
router.post(
|
||||
'/open-in-external-terminal',
|
||||
validatePathParams('worktreePath'),
|
||||
createOpenInExternalTerminalHandler()
|
||||
);
|
||||
|
||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||
router.post('/migrate', createMigrateHandler());
|
||||
router.post(
|
||||
|
||||
181
apps/server/src/routes/worktree/routes/open-in-terminal.ts
Normal file
181
apps/server/src/routes/worktree/routes/open-in-terminal.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Terminal endpoints for opening worktree directories in terminals
|
||||
*
|
||||
* POST /open-in-terminal - Open in system default terminal (integrated)
|
||||
* GET /available-terminals - List all available external terminals
|
||||
* GET /default-terminal - Get the default external terminal
|
||||
* POST /refresh-terminals - Clear terminal cache and re-detect
|
||||
* POST /open-in-external-terminal - Open a directory in an external terminal
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { isAbsolute } from 'path';
|
||||
import {
|
||||
openInTerminal,
|
||||
clearTerminalCache,
|
||||
detectAllTerminals,
|
||||
detectDefaultTerminal,
|
||||
openInExternalTerminal,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('open-in-terminal');
|
||||
|
||||
/**
|
||||
* Handler to open in system default terminal (integrated terminal behavior)
|
||||
*/
|
||||
export function createOpenInTerminalHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: Validate that worktreePath is an absolute path
|
||||
if (!isAbsolute(worktreePath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath must be an absolute path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the platform utility to open in terminal
|
||||
const result = await openInTerminal(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened terminal in ${worktreePath}`,
|
||||
terminalName: result.terminalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Open in terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to get all available external terminals
|
||||
*/
|
||||
export function createGetAvailableTerminalsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminals = await detectAllTerminals();
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
terminals,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get available terminals failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to get the default external terminal
|
||||
*/
|
||||
export function createGetDefaultTerminalHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminal = await detectDefaultTerminal();
|
||||
res.json({
|
||||
success: true,
|
||||
result: terminal
|
||||
? {
|
||||
terminalId: terminal.id,
|
||||
terminalName: terminal.name,
|
||||
terminalCommand: terminal.command,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get default terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to refresh the terminal cache and re-detect available terminals
|
||||
* Useful when the user has installed/uninstalled terminals
|
||||
*/
|
||||
export function createRefreshTerminalsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Clear the cache
|
||||
clearTerminalCache();
|
||||
|
||||
// Re-detect terminals (this will repopulate the cache)
|
||||
const terminals = await detectAllTerminals();
|
||||
|
||||
logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
terminals,
|
||||
message: `Found ${terminals.length} available external terminals`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Refresh terminals failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to open a directory in an external terminal
|
||||
*/
|
||||
export function createOpenInExternalTerminalHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, terminalId } = req.body as {
|
||||
worktreePath: string;
|
||||
terminalId?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAbsolute(worktreePath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath must be an absolute path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await openInExternalTerminal(worktreePath, terminalId);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.terminalName}`,
|
||||
terminalName: result.terminalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Open in external terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user