diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 6d905a8f..7074691d 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -117,7 +117,11 @@ export function createWorktreeRoutes( router.get('/available-terminals', createGetAvailableTerminalsHandler()); router.get('/default-terminal', createGetDefaultTerminalHandler()); router.post('/refresh-terminals', createRefreshTerminalsHandler()); - router.post('/open-in-external-terminal', createOpenInExternalTerminalHandler()); + router.post( + '/open-in-external-terminal', + validatePathParams('worktreePath'), + createOpenInExternalTerminalHandler() + ); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/migrate', createMigrateHandler()); diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts index 87c8d147..37ba5480 100644 --- a/apps/server/src/routes/worktree/routes/open-in-terminal.ts +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -144,42 +144,20 @@ export function createRefreshTerminalsHandler() { 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; }; - 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; - } - - try { - const result = await openInExternalTerminal(worktreePath, terminalId); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${result.terminalName}`, - terminalName: result.terminalName, - }, - }); - } catch (terminalError) { - // Terminal failed to open - logger.warn(`Failed to open in terminal: ${getErrorMessage(terminalError)}`); - throw terminalError; - } + 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/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index ea4a835e..8e7f6e4e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -131,9 +131,10 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre (worktree: WorktreeInfo, mode?: 'tab' | 'split') => { // Navigate to the terminal view with the worktree path and branch name // The terminal view will handle creating the terminal with the specified cwd + // Include nonce to allow opening the same worktree multiple times navigate({ to: '/terminal', - search: { cwd: worktree.path, branch: worktree.branch, mode }, + search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() }, }); }, [navigate] diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 46e049d5..224ce60e 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -224,9 +224,11 @@ interface TerminalViewProps { 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 }: TerminalViewProps) { +export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) { const { terminalState, setTerminalUnlocked, @@ -556,9 +558,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode }: Termina // Skip if no initialCwd provided if (!initialCwd) return; - // Skip if we've already handled this exact cwd (prevents duplicate terminals) - // Include mode in the key to allow opening same cwd with different modes - const cwdKey = `${initialCwd}:${initialMode || 'default'}`; + // 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 @@ -632,6 +634,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode }: Termina initialCwd, initialBranch, initialMode, + nonce, status?.enabled, status?.passwordRequired, terminalState.isUnlocked, diff --git a/apps/ui/src/routes/terminal.tsx b/apps/ui/src/routes/terminal.tsx index 5130d58e..f7ced99b 100644 --- a/apps/ui/src/routes/terminal.tsx +++ b/apps/ui/src/routes/terminal.tsx @@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({ cwd: z.string().optional(), branch: z.string().optional(), mode: z.enum(['tab', 'split']).optional(), + nonce: z.number().optional(), }); export const Route = createFileRoute('/terminal')({ @@ -14,6 +15,6 @@ export const Route = createFileRoute('/terminal')({ }); function RouteComponent() { - const { cwd, branch, mode } = Route.useSearch(); - return ; + const { cwd, branch, mode, nonce } = Route.useSearch(); + return ; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 29d5b70e..11109a14 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -531,7 +531,6 @@ export interface TerminalState { lineHeight: number; // Line height multiplier for terminal text maxSessions: number; // Maximum concurrent terminal sessions (server setting) lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches - pendingTerminal: { cwd: string; branchName: string } | null; // Pending terminal to create (from "open in terminal" action) openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action } @@ -1239,7 +1238,6 @@ export interface AppActions { setTerminalLineHeight: (lineHeight: number) => void; setTerminalMaxSessions: (maxSessions: number) => void; setTerminalLastActiveProjectPath: (projectPath: string | null) => void; - setPendingTerminal: (pending: { cwd: string; branchName: string } | null) => void; setOpenTerminalMode: (mode: 'newTab' | 'split') => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; @@ -1459,7 +1457,6 @@ const initialState: AppState = { lineHeight: 1.0, maxSessions: 100, lastActiveProjectPath: null, - pendingTerminal: null, openTerminalMode: 'newTab', }, terminalLayoutByProject: {}, @@ -2912,9 +2909,6 @@ export const useAppStore = create()((set, get) => ({ maxSessions: current.maxSessions, // Preserve lastActiveProjectPath - it will be updated separately when needed lastActiveProjectPath: current.lastActiveProjectPath, - // Preserve pendingTerminal - this is set by "open in terminal" action and should - // survive the clearTerminalState() call that happens during project switching - pendingTerminal: current.pendingTerminal, // Preserve openTerminalMode - user preference openTerminalMode: current.openTerminalMode, }, @@ -3008,13 +3002,6 @@ export const useAppStore = create()((set, get) => ({ }); }, - setPendingTerminal: (pending) => { - const current = get().terminalState; - set({ - terminalState: { ...current, pendingTerminal: pending }, - }); - }, - setOpenTerminalMode: (mode) => { const current = get().terminalState; set({ diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index 02307edb..7f960747 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -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; @@ -342,34 +351,6 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam return { editorName: fileManager.name }; } -/** - * Get the platform-specific terminal information - */ -function getTerminalInfo(): { name: string; command: string; args: string[] } { - if (isMac) { - // On macOS, use Terminal.app with AppleScript to open in a specific directory - return { - name: 'Terminal', - command: 'open', - args: ['-a', 'Terminal'], - }; - } else if (isWindows) { - // On Windows, use Windows Terminal if available, otherwise cmd - return { - name: 'Windows Terminal', - command: 'wt', - args: ['-d'], - }; - } else { - // On Linux, try common terminal emulators in order of preference - return { - name: 'Terminal', - command: 'x-terminal-emulator', - args: ['--working-directory'], - }; - } -} - /** * Open a terminal in the specified directory * @@ -386,7 +367,7 @@ export async function openInTerminal(targetPath: string): Promise<{ terminalName // Use AppleScript to open Terminal.app in the specified directory const script = ` tell application "Terminal" - do script "cd ${targetPath.replace(/"/g, '\\"').replace(/\$/g, '\\$')}" + do script "cd ${escapeShellArg(targetPath)}" activate end tell `;