diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 4b54ae9e2..7074691d6 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -29,6 +29,13 @@ import { createGetAvailableEditorsHandler, createRefreshEditorsHandler, } from './routes/open-in-editor.js'; +import { createOpenInTerminalHandler } from './routes/open-in-terminal.js'; +import { + 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( diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts new file mode 100644 index 000000000..37ba54803 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -0,0 +1,166 @@ +/** + * 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 => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + 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 => { + 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 => { + 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 => { + 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 => { + try { + // worktreePath is validated by validatePathParams middleware + const { worktreePath, terminalId } = req.body as { + worktreePath: string; + terminalId?: string; + }; + + 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) }); + } + }; +} diff --git a/apps/ui/src/components/icons/terminal-icons.tsx b/apps/ui/src/components/icons/terminal-icons.tsx new file mode 100644 index 000000000..38e8a47d9 --- /dev/null +++ b/apps/ui/src/components/icons/terminal-icons.tsx @@ -0,0 +1,213 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { Terminal } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +/** + * iTerm2 logo icon + */ +export function ITerm2Icon(props: IconProps) { + return ( + + + + ); +} + +/** + * Warp terminal logo icon + */ +export function WarpIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Ghostty terminal logo icon + */ +export function GhosttyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Alacritty terminal logo icon + */ +export function AlacrittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * WezTerm terminal logo icon + */ +export function WezTermIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Kitty terminal logo icon + */ +export function KittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Hyper terminal logo icon + */ +export function HyperIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Tabby terminal logo icon + */ +export function TabbyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Rio terminal logo icon + */ +export function RioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windows Terminal logo icon + */ +export function WindowsTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * PowerShell logo icon + */ +export function PowerShellIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Command Prompt (cmd) logo icon + */ +export function CmdIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Git Bash logo icon + */ +export function GitBashIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * GNOME Terminal logo icon + */ +export function GnomeTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Konsole logo icon + */ +export function KonsoleIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Terminal logo icon + */ +export function MacOSTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for a terminal ID + */ +export function getTerminalIcon(terminalId: string): IconComponent { + const terminalIcons: Record = { + iterm2: ITerm2Icon, + warp: WarpIcon, + ghostty: GhosttyIcon, + alacritty: AlacrittyIcon, + wezterm: WezTermIcon, + kitty: KittyIcon, + hyper: HyperIcon, + tabby: TabbyIcon, + rio: RioIcon, + 'windows-terminal': WindowsTerminalIcon, + powershell: PowerShellIcon, + cmd: CmdIcon, + 'git-bash': GitBashIcon, + 'gnome-terminal': GnomeTerminalIcon, + konsole: KonsoleIcon, + 'terminal-macos': MacOSTerminalIcon, + // Linux terminals - use generic terminal icon + 'xfce4-terminal': Terminal, + tilix: Terminal, + terminator: Terminal, + foot: Terminal, + xterm: Terminal, + }; + + return terminalIcons[terminalId] ?? Terminal; +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx index c1a2fa261..36ff80688 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -37,10 +37,14 @@ export function ProjectSwitcherItem({ const IconComponent = getIconComponent(); const hasCustomIcon = !!project.customIconPath; + // Create a sanitized project name for test ID (lowercase, hyphens instead of spaces) + const sanitizedName = project.name.toLowerCase().replace(/\s+/g, '-'); + return ( + +

+ Terminal to use when selecting "Open in Terminal" from the worktree menu +

+ + {terminals.length === 0 && ( +

+ No external terminals detected. Click refresh to re-scan. +

+ )} + + + {/* Default Open Mode */} +
+ +

+ How to open the integrated terminal when using "Open in Terminal" from the worktree menu +

+ +
+ {/* Font Family */}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 328afc21b..5d99924e5 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -264,6 +278,7 @@ export function TerminalView() { max: number; } | null>(null); const hasShownHighRamWarningRef = useRef(false); + const initialCwdHandledRef = useRef(null); // Show warning when 20+ terminals are open useEffect(() => { @@ -537,6 +552,102 @@ 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 = {}; + 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', + }); + } + } catch (err) { + logger.error('Create terminal with cwd error:', err); + toast.error('Failed to create terminal', { + description: 'Could not connect to server', + }); + } + }; + + 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 +939,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 +957,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 +1345,7 @@ export function TerminalView() { onCommandRan={() => handleCommandRan(content.sessionId)} isMaximized={terminalState.maximizedSessionId === content.sessionId} onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)} + branchName={content.branchName} /> ); diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 481ee6b41..8a35f4315 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -22,6 +22,7 @@ import { Maximize2, Minimize2, ArrowDown, + GitBranch, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -94,6 +95,7 @@ interface TerminalPanelProps { onCommandRan?: () => void; // Callback when the initial command has been sent isMaximized?: boolean; onToggleMaximize?: () => void; + branchName?: string; // Branch name to display in header (from "Open in Terminal" action) } // Type for xterm Terminal - we'll use any since we're dynamically importing @@ -124,6 +126,7 @@ export function TerminalPanel({ onCommandRan, isMaximized = false, onToggleMaximize, + branchName, }: TerminalPanelProps) { const terminalRef = useRef(null); const containerRef = useRef(null); @@ -1776,6 +1779,13 @@ export function TerminalPanel({
{shellName} + {/* Branch name indicator - show when terminal was opened from worktree */} + {branchName && ( + + + {branchName} + + )} {/* Font size indicator - only show when not default */} {fontSize !== DEFAULT_FONT_SIZE && (