mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +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
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Terminal as TerminalIcon,
|
||||
@@ -216,7 +217,18 @@ function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalView() {
|
||||
interface TerminalViewProps {
|
||||
/** Initial working directory to open a terminal in (e.g., from worktree panel) */
|
||||
initialCwd?: string;
|
||||
/** Branch name for display in toast (optional) */
|
||||
initialBranch?: string;
|
||||
/** Mode for opening terminal: 'tab' for new tab, 'split' for split in current tab */
|
||||
initialMode?: 'tab' | 'split';
|
||||
/** Unique nonce to allow opening the same worktree multiple times */
|
||||
nonce?: number;
|
||||
}
|
||||
|
||||
export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
|
||||
const {
|
||||
terminalState,
|
||||
setTerminalUnlocked,
|
||||
@@ -246,6 +258,8 @@ export function TerminalView() {
|
||||
updateTerminalPanelSizes,
|
||||
} = useAppStore();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<TerminalStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -264,6 +278,7 @@ export function TerminalView() {
|
||||
max: number;
|
||||
} | null>(null);
|
||||
const hasShownHighRamWarningRef = useRef<boolean>(false);
|
||||
const initialCwdHandledRef = useRef<string | null>(null);
|
||||
|
||||
// Show warning when 20+ terminals are open
|
||||
useEffect(() => {
|
||||
@@ -537,6 +552,106 @@ export function TerminalView() {
|
||||
}
|
||||
}, [terminalState.isUnlocked, fetchServerSettings]);
|
||||
|
||||
// Handle initialCwd prop - auto-create a terminal with the specified working directory
|
||||
// This is triggered when navigating from worktree panel's "Open in Integrated Terminal"
|
||||
useEffect(() => {
|
||||
// Skip if no initialCwd provided
|
||||
if (!initialCwd) return;
|
||||
|
||||
// Skip if we've already handled this exact request (prevents duplicate terminals)
|
||||
// Include mode and nonce in the key to allow opening same cwd multiple times
|
||||
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
|
||||
if (initialCwdHandledRef.current === cwdKey) return;
|
||||
|
||||
// Skip if terminal is not enabled or not unlocked
|
||||
if (!status?.enabled) return;
|
||||
if (status.passwordRequired && !terminalState.isUnlocked) return;
|
||||
|
||||
// Skip if still loading
|
||||
if (loading) return;
|
||||
|
||||
// Mark this cwd as being handled
|
||||
initialCwdHandledRef.current = cwdKey;
|
||||
|
||||
// Create the terminal with the specified cwd
|
||||
const createTerminalWithCwd = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: { cwd: initialCwd, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Create in new tab or split based on mode
|
||||
if (initialMode === 'tab') {
|
||||
// Create in a new tab (tab name uses default "Terminal N" naming)
|
||||
const newTabId = addTerminalTab();
|
||||
const { addTerminalToTab } = useAppStore.getState();
|
||||
// Pass branch name for display in terminal panel header
|
||||
addTerminalToTab(data.data.id, newTabId, 'horizontal', initialBranch);
|
||||
} else {
|
||||
// Default: add to current tab (split if there's already a terminal)
|
||||
// Pass branch name for display in terminal panel header
|
||||
addTerminalToLayout(data.data.id, undefined, undefined, initialBranch);
|
||||
}
|
||||
|
||||
// Mark this session as new for running initial command
|
||||
if (defaultRunScript) {
|
||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||
}
|
||||
|
||||
// Show success toast with branch name if provided
|
||||
const displayName = initialBranch || initialCwd.split('/').pop() || initialCwd;
|
||||
toast.success(`Terminal opened at ${displayName}`);
|
||||
|
||||
// Refresh session count
|
||||
fetchServerSettings();
|
||||
|
||||
// Clear the cwd from the URL to prevent re-creating on refresh
|
||||
navigate({ to: '/terminal', search: {}, replace: true });
|
||||
} else {
|
||||
logger.error('Failed to create terminal for cwd:', data.error);
|
||||
toast.error('Failed to create terminal', {
|
||||
description: data.error || 'Unknown error',
|
||||
});
|
||||
// Reset the handled ref so the same cwd can be retried
|
||||
initialCwdHandledRef.current = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Create terminal with cwd error:', err);
|
||||
toast.error('Failed to create terminal', {
|
||||
description: 'Could not connect to server',
|
||||
});
|
||||
// Reset the handled ref so the same cwd can be retried
|
||||
initialCwdHandledRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
createTerminalWithCwd();
|
||||
}, [
|
||||
initialCwd,
|
||||
initialBranch,
|
||||
initialMode,
|
||||
nonce,
|
||||
status?.enabled,
|
||||
status?.passwordRequired,
|
||||
terminalState.isUnlocked,
|
||||
terminalState.authToken,
|
||||
terminalState.tabs.length,
|
||||
loading,
|
||||
defaultRunScript,
|
||||
addTerminalToLayout,
|
||||
addTerminalTab,
|
||||
fetchServerSettings,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Handle project switching - save and restore terminal layouts
|
||||
// Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref
|
||||
// This ensures terminals persist when navigating away from terminal route and back
|
||||
@@ -828,9 +943,11 @@ export function TerminalView() {
|
||||
|
||||
// Create a new terminal session
|
||||
// targetSessionId: the terminal to split (if splitting an existing terminal)
|
||||
// customCwd: optional working directory to use instead of the current project path
|
||||
const createTerminal = async (
|
||||
direction?: 'horizontal' | 'vertical',
|
||||
targetSessionId?: string
|
||||
targetSessionId?: string,
|
||||
customCwd?: string
|
||||
) => {
|
||||
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
|
||||
return;
|
||||
@@ -844,7 +961,7 @@ export function TerminalView() {
|
||||
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
body: { cwd: customCwd || currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1232,6 +1349,7 @@ export function TerminalView() {
|
||||
onCommandRan={() => handleCommandRan(content.sessionId)}
|
||||
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
||||
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
||||
branchName={content.branchName}
|
||||
/>
|
||||
</TerminalErrorBoundary>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user