diff --git a/apps/frontend/src/main/ipc-handlers/task/shell-escape.test.ts b/apps/frontend/src/main/ipc-handlers/task/shell-escape.test.ts new file mode 100644 index 0000000000..4f24dab68b --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/task/shell-escape.test.ts @@ -0,0 +1,270 @@ +/** + * Unit tests for shell-escape utilities. + * Tests command injection prevention via path escaping. + */ + +import { describe, it, expect } from 'vitest'; +import { escapePathForShell, escapePathForAppleScript } from './shell-escape'; + +describe('escapePathForShell', () => { + describe('null byte injection prevention', () => { + it('should reject paths containing null bytes', () => { + expect(escapePathForShell('/path/with\0null', 'linux')).toBeNull(); + expect(escapePathForShell('C:\\path\\with\0null', 'win32')).toBeNull(); + }); + + it('should reject null byte at start of path', () => { + expect(escapePathForShell('\0/path', 'darwin')).toBeNull(); + }); + + it('should reject null byte at end of path', () => { + expect(escapePathForShell('/path\0', 'linux')).toBeNull(); + }); + }); + + describe('newline injection prevention', () => { + it('should reject paths containing LF newlines', () => { + expect(escapePathForShell('/path/with\nnewline', 'linux')).toBeNull(); + expect(escapePathForShell('C:\\path\\with\nnewline', 'win32')).toBeNull(); + }); + + it('should reject paths containing CR newlines', () => { + expect(escapePathForShell('/path/with\rnewline', 'darwin')).toBeNull(); + expect(escapePathForShell('C:\\path\\with\rnewline', 'win32')).toBeNull(); + }); + + it('should reject paths containing CRLF', () => { + expect(escapePathForShell('/path/with\r\nnewline', 'linux')).toBeNull(); + }); + }); + + describe('Windows platform (win32)', () => { + it('should reject paths with < character', () => { + expect(escapePathForShell('C:\\path character', () => { + expect(escapePathForShell('C:\\path>file', 'win32')).toBeNull(); + }); + + it('should reject paths with | pipe character', () => { + expect(escapePathForShell('C:\\path|command', 'win32')).toBeNull(); + }); + + it('should reject paths with & ampersand', () => { + expect(escapePathForShell('C:\\path&command', 'win32')).toBeNull(); + }); + + it('should reject paths with ^ caret', () => { + expect(escapePathForShell('C:\\path^file', 'win32')).toBeNull(); + }); + + it('should reject paths with % percent', () => { + expect(escapePathForShell('C:\\path%VAR%', 'win32')).toBeNull(); + }); + + it('should reject paths with ! exclamation', () => { + expect(escapePathForShell('C:\\path!file', 'win32')).toBeNull(); + }); + + it('should reject paths with ` backtick', () => { + expect(escapePathForShell('C:\\path`command`', 'win32')).toBeNull(); + }); + + it('should escape double quotes with double-double quotes', () => { + expect(escapePathForShell('C:\\path\\"file"', 'win32')).toBe('C:\\path\\""file""'); + }); + + it('should pass through safe Windows paths unchanged', () => { + expect(escapePathForShell('C:\\Users\\name\\project', 'win32')).toBe('C:\\Users\\name\\project'); + }); + + it('should allow paths with spaces', () => { + expect(escapePathForShell('C:\\Program Files\\app', 'win32')).toBe('C:\\Program Files\\app'); + }); + + it('should allow paths with parentheses', () => { + expect(escapePathForShell('C:\\Program Files (x86)\\app', 'win32')).toBe('C:\\Program Files (x86)\\app'); + }); + }); + + describe('Unix platforms (linux, darwin)', () => { + it('should escape single quotes with quote-escape-quote pattern', () => { + const result = escapePathForShell("/path/with'quote", 'linux'); + expect(result).toBe("/path/with'\\''quote"); + }); + + it('should handle multiple single quotes', () => { + const result = escapePathForShell("it's a 'test'", 'darwin'); + expect(result).toBe("it'\\''s a '\\''test'\\''"); + }); + + it('should pass through safe Unix paths unchanged', () => { + expect(escapePathForShell('/usr/local/bin', 'linux')).toBe('/usr/local/bin'); + expect(escapePathForShell('/Users/name/project', 'darwin')).toBe('/Users/name/project'); + }); + + it('should allow paths with spaces', () => { + expect(escapePathForShell('/path/with spaces/file', 'linux')).toBe('/path/with spaces/file'); + }); + + it('should allow paths with special characters (not injection vectors)', () => { + // These are safe when single-quoted in bash + expect(escapePathForShell('/path/$var', 'linux')).toBe('/path/$var'); + expect(escapePathForShell('/path/`cmd`', 'darwin')).toBe('/path/`cmd`'); + expect(escapePathForShell('/path/!history', 'linux')).toBe('/path/!history'); + }); + + it('should allow paths with glob characters', () => { + // Safe when single-quoted + expect(escapePathForShell('/path/*.txt', 'linux')).toBe('/path/*.txt'); + expect(escapePathForShell('/path/[abc]', 'darwin')).toBe('/path/[abc]'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(escapePathForShell('', 'linux')).toBe(''); + expect(escapePathForShell('', 'win32')).toBe(''); + }); + + it('should handle path with only quotes', () => { + expect(escapePathForShell("'''", 'linux')).toBe("'\\'''\\'''\\''"); + expect(escapePathForShell('"""', 'win32')).toBe('""""""'); + }); + + it('should handle very long paths', () => { + const longPath = '/a'.repeat(1000); + expect(escapePathForShell(longPath, 'linux')).toBe(longPath); + }); + + it('should handle Unicode characters', () => { + expect(escapePathForShell('/path/日本語/файл', 'linux')).toBe('/path/日本語/файл'); + expect(escapePathForShell('C:\\путь\\文件', 'win32')).toBe('C:\\путь\\文件'); + }); + + it('should handle emoji in paths', () => { + expect(escapePathForShell('/path/📁/file', 'darwin')).toBe('/path/📁/file'); + }); + }); + + describe('command injection attack patterns', () => { + it('should block semicolon command chaining on Windows via newline', () => { + // Attacker might try: path; malicious_command + // But semicolons are actually safe on Windows with proper quoting + // The danger comes from newlines which we block + expect(escapePathForShell('path\n; malicious', 'win32')).toBeNull(); + }); + + it('should block pipe injection on Windows', () => { + expect(escapePathForShell('path | evil-command', 'win32')).toBeNull(); + }); + + it('should block command substitution on Windows', () => { + expect(escapePathForShell('path & cmd /c evil', 'win32')).toBeNull(); + }); + + it('should escape quote escapes on Unix', () => { + // Attacker might try to break out with: ' ; evil ' + const result = escapePathForShell("' ; evil '", 'linux'); + expect(result).toBe("'\\'' ; evil '\\''"); + }); + + it('should block null byte truncation attacks', () => { + // Attacker might try: /safe/path\0/../../etc/passwd + expect(escapePathForShell('/safe/path\0/../../etc/passwd', 'linux')).toBeNull(); + }); + }); +}); + +describe('escapePathForAppleScript', () => { + describe('null byte injection prevention', () => { + it('should reject paths containing null bytes', () => { + expect(escapePathForAppleScript('/path/with\0null')).toBeNull(); + }); + + it('should reject null byte at start of path', () => { + expect(escapePathForAppleScript('\0/path')).toBeNull(); + }); + + it('should reject null byte at end of path', () => { + expect(escapePathForAppleScript('/path\0')).toBeNull(); + }); + }); + + describe('newline injection prevention', () => { + it('should reject paths containing LF newlines', () => { + expect(escapePathForAppleScript('/path/with\nnewline')).toBeNull(); + }); + + it('should reject paths containing CR newlines', () => { + expect(escapePathForAppleScript('/path/with\rnewline')).toBeNull(); + }); + + it('should reject paths containing CRLF', () => { + expect(escapePathForAppleScript('/path/with\r\nnewline')).toBeNull(); + }); + }); + + describe('AppleScript escaping', () => { + it('should escape backslashes', () => { + expect(escapePathForAppleScript('/path\\with\\backslashes')).toBe('/path\\\\with\\\\backslashes'); + }); + + it('should escape double quotes', () => { + expect(escapePathForAppleScript('/path/with"quotes"')).toBe('/path/with\\"quotes\\"'); + }); + + it('should escape both backslashes and double quotes', () => { + expect(escapePathForAppleScript('/path\\"complex"')).toBe('/path\\\\\\"complex\\"'); + }); + + it('should pass through safe paths unchanged', () => { + expect(escapePathForAppleScript('/Users/name/project')).toBe('/Users/name/project'); + }); + + it('should allow paths with spaces', () => { + expect(escapePathForAppleScript('/path/with spaces/file')).toBe('/path/with spaces/file'); + }); + + it('should allow single quotes (safe in AppleScript double-quoted strings)', () => { + expect(escapePathForAppleScript("/path/with'quote")).toBe("/path/with'quote"); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(escapePathForAppleScript('')).toBe(''); + }); + + it('should handle path with only double quotes', () => { + expect(escapePathForAppleScript('"""')).toBe('\\"\\"\\"'); + }); + + it('should handle path with only backslashes', () => { + expect(escapePathForAppleScript('\\\\\\')).toBe('\\\\\\\\\\\\'); + }); + + it('should handle Unicode characters', () => { + expect(escapePathForAppleScript('/path/日本語/файл')).toBe('/path/日本語/файл'); + }); + + it('should handle emoji in paths', () => { + expect(escapePathForAppleScript('/path/📁/file')).toBe('/path/📁/file'); + }); + }); + + describe('command injection prevention', () => { + it('should escape double quotes used in injection attempts', () => { + // Attacker might try to break out of the AppleScript string + const result = escapePathForAppleScript('/path" & do shell script "evil"'); + expect(result).toBe('/path\\" & do shell script \\"evil\\"'); + }); + + it('should escape backslash escape attempts', () => { + // Attacker might try to use backslash to escape the escaping + const result = escapePathForAppleScript('/path\\"'); + expect(result).toBe('/path\\\\\\"'); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/task/shell-escape.ts b/apps/frontend/src/main/ipc-handlers/task/shell-escape.ts new file mode 100644 index 0000000000..04f1ff21cc --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/task/shell-escape.ts @@ -0,0 +1,68 @@ +/** + * Shell path escaping utilities for secure command execution. + * + * These functions prevent command injection by properly escaping + * or rejecting dangerous characters in file paths. + */ + +/** + * Escape a path for use in shell commands. + * Prevents command injection by escaping or rejecting dangerous characters. + * + * @param filePath - The path to escape + * @param platform - Target platform ('win32', 'darwin', 'linux') + * @returns Escaped path string safe for shell use, or null if path is invalid + */ +export function escapePathForShell(filePath: string, platform: NodeJS.Platform): string | null { + // Reject paths with null bytes (always dangerous) + if (filePath.includes('\0')) { + return null; + } + + // Reject paths with newlines (can break command structure) + if (filePath.includes('\n') || filePath.includes('\r')) { + return null; + } + + if (platform === 'win32') { + // Windows: Reject paths with characters that could escape cmd.exe quoting + // These characters can break out of double-quoted strings in cmd + const dangerousWinChars = /[<>|&^%!`]/; + if (dangerousWinChars.test(filePath)) { + return null; + } + // Double-quote the path (already done in caller, but escape any internal quotes) + return filePath.replace(/"/g, '""'); + } else { + // Unix (macOS/Linux): Use single quotes and escape any internal single quotes + // Single-quoted strings in bash treat everything literally except single quotes + // Escape ' as '\'' (end quote, escaped quote, start quote) + return filePath.replace(/'/g, "'\\''"); + } +} + +/** + * Escape a path for use in AppleScript double-quoted strings. + * AppleScript uses different escaping rules than POSIX shells. + * + * @param filePath - The path to escape (should already be validated) + * @returns Escaped path string safe for AppleScript double-quoted context + */ +export function escapePathForAppleScript(filePath: string): string | null { + // Reject paths with null bytes (always dangerous) + if (filePath.includes('\0')) { + return null; + } + + // Reject paths with newlines (can break command structure) + if (filePath.includes('\n') || filePath.includes('\r')) { + return null; + } + + // AppleScript double-quoted strings require escaping: + // 1. Backslashes must be escaped as \\ + // 2. Double quotes must be escaped as \" + return filePath + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/"/g, '\\"'); // Then escape double quotes +} diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 16ddfcd0ef..adcb0a1e99 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -21,7 +21,8 @@ import { } from '../../worktree-paths'; import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils'; import { getIsolatedGitEnv } from '../../utils/git-isolation'; -import { killProcessGracefully } from '../../platform'; +import { escapePathForShell, escapePathForAppleScript } from './shell-escape'; +import { killProcessGracefully, isWindows, isMacOS } from '../../platform'; // Regex pattern for validating git branch names const GIT_BRANCH_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/; @@ -2822,6 +2823,219 @@ export function registerWorktreeHandlers( } ); + /** + * Launch the app dev server from a worktree directory + * Detects the project type and runs the appropriate dev command + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_LAUNCH_APP, + async (_, worktreePath: string): Promise> => { + try { + if (!existsSync(worktreePath)) { + return { success: false, error: 'Worktree path does not exist' }; + } + + // Use platform helpers for OS detection + const platform = process.platform; + + // Validate and escape the path to prevent command injection + const escapedPath = escapePathForShell(worktreePath, platform); + if (escapedPath === null) { + return { success: false, error: 'Invalid path: contains unsafe characters' }; + } + + // Try to detect the dev command from package.json + const packageJsonPath = path.join(worktreePath, 'package.json'); + let devCommand: string | null = null; + + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const scripts = packageJson.scripts || {}; + + // Priority order for dev commands + if (scripts.dev) { + devCommand = 'npm run dev'; + } else if (scripts.start) { + devCommand = 'npm start'; + } else if (scripts.serve) { + devCommand = 'npm run serve'; + } else if (scripts.develop) { + devCommand = 'npm run develop'; + } + } catch { + // Ignore JSON parse errors + } + } + + // If no valid dev script was found, return an error + if (!devCommand) { + return { + success: false, + error: 'No dev script found in package.json. Expected one of: dev, start, serve, develop' + }; + } + + // Open a terminal and run the dev command + // Use the user's preferred terminal (spawn is already imported at the top) + if (isWindows()) { + // Windows: Open cmd with the command + // Note: 'start' requires an empty title ("") as first arg when the command contains quotes + // Also: shell: true mangles nested quotes, so we avoid it + const proc = spawn('cmd.exe', ['/c', 'start', '""', 'cmd.exe', '/k', `cd /d "${escapedPath}" && ${devCommand}`], { + detached: true, + stdio: 'ignore' + }); + + // Handle spawn errors + let spawnFailed = false; + let spawnError: Error | undefined; + proc.once('error', (err) => { + spawnFailed = true; + spawnError = err; + }); + + // Give a brief moment for synchronous spawn errors to propagate + await new Promise(resolve => setImmediate(resolve)); + + if (spawnFailed) { + return { + success: false, + error: `Failed to launch terminal: ${spawnError?.message ?? 'Unknown error'}` + }; + } + + proc.unref(); + } else if (isMacOS()) { + // macOS: Use osascript to open Terminal.app with the command + // For AppleScript, we need to escape both: + // 1. Single quotes for bash (already done by escapedPath using '\'' pattern) + // 2. Double quotes and backslashes for AppleScript string context + // Use escapePathForAppleScript to properly handle double quotes and backslashes + const appleScriptEscaped = escapePathForAppleScript(escapedPath); + if (appleScriptEscaped === null) { + return { + success: false, + error: 'Invalid path: contains characters unsafe for AppleScript' + }; + } + const script = ` + tell application "Terminal" + activate + do script "cd '${appleScriptEscaped}' && ${devCommand}" + end tell + `; + const proc = spawn('osascript', ['-e', script], { + detached: true, + stdio: 'ignore' + }); + + // Wait for either 'spawn' event (success) or 'error' event (failure) + const spawnResult = await new Promise<{ success: boolean; error?: Error }>((resolve) => { + proc.once('spawn', () => { + resolve({ success: true }); + }); + proc.once('error', (err) => { + resolve({ success: false, error: err }); + }); + }); + + if (!spawnResult.success) { + return { + success: false, + error: `Failed to launch terminal: ${spawnResult.error?.message ?? 'Unknown error'}` + }; + } + + proc.unref(); + } else { + // Linux: Try common terminal emulators + // First check which terminals are actually installed using 'which' + const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm']; + let launched = false; + let lastError: Error | null = null; + + for (const term of terminals) { + try { + // Check if the terminal is installed before spawning + execSync(`which ${term}`, { stdio: 'ignore' }); + + // Terminal exists, spawn it with escaped path + let proc; + if (term === 'gnome-terminal') { + proc = spawn(term, ['--', 'bash', '-c', `cd '${escapedPath}' && ${devCommand}; exec bash`], { + detached: true, + stdio: 'ignore' + }); + } else if (term === 'konsole') { + // konsole's --workdir accepts the path directly (no shell interpolation) + proc = spawn(term, ['--workdir', worktreePath, '-e', 'bash', '-c', `${devCommand}; exec bash`], { + detached: true, + stdio: 'ignore' + }); + } else if (term === 'xfce4-terminal') { + // xfce4-terminal: use --working-directory to set the directory directly + // This avoids shell escaping issues entirely by passing the path as an option value + proc = spawn(term, ['--working-directory', worktreePath, '-x', 'bash', '-c', `${devCommand}; exec bash`], { + detached: true, + stdio: 'ignore' + }); + } else { + // xterm and other terminals: use same approach as gnome-terminal + // escapedPath has single quotes properly escaped with '\'' pattern + proc = spawn(term, ['-e', 'bash', '-c', `cd '${escapedPath}' && ${devCommand}; exec bash`], { + detached: true, + stdio: 'ignore' + }); + } + + // Wait for either 'spawn' event (success) or 'error' event (failure) + // This properly handles async spawn errors instead of using unreliable timeouts + const spawnResult = await new Promise<{ success: boolean; error?: Error }>((resolve) => { + proc.once('spawn', () => { + resolve({ success: true }); + }); + proc.once('error', (err) => { + resolve({ success: false, error: err }); + }); + }); + + if (!spawnResult.success) { + // Spawn failed, try next terminal + lastError = spawnResult.error || new Error('Spawn failed'); + continue; + } + + // Detach the process so it keeps running after our app exits + proc.unref(); + launched = true; + break; + } catch (e) { + // Terminal not found (which failed) or other error, try next one + lastError = e instanceof Error ? e : new Error(String(e)); + continue; + } + } + + if (!launched) { + const errorMsg = lastError + ? `No supported terminal emulator found: ${lastError.message}` + : 'No supported terminal emulator found'; + return { success: false, error: errorMsg }; + } + } + + return { success: true, data: { launched: true, command: devCommand } }; + } catch (error) { + console.error('Failed to launch app:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to launch app' + }; + } + } + ); + /** * Clear the staged state for a task * This allows the user to re-stage changes if needed diff --git a/apps/frontend/src/main/settings-utils.ts b/apps/frontend/src/main/settings-utils.ts index 64f3903fd3..98fdbc86d6 100644 --- a/apps/frontend/src/main/settings-utils.ts +++ b/apps/frontend/src/main/settings-utils.ts @@ -30,15 +30,17 @@ export function getSettingsPath(): string { export function readSettingsFile(): Record | undefined { const settingsPath = getSettingsPath(); - if (!existsSync(settingsPath)) { - return undefined; - } - try { + // Read file directly without checking existence first to avoid TOCTOU race condition const content = readFileSync(settingsPath, 'utf-8'); return JSON.parse(content); - } catch { - // Return undefined on parse error - caller will use defaults + } catch (e) { + // Return undefined if file doesn't exist or on parse error - caller will use defaults + // ENOENT means file doesn't exist, which is expected on first run + if (e && typeof e === 'object' && 'code' in e && e.code !== 'ENOENT') { + // Log unexpected errors (but not missing file, which is normal) + console.warn('[settings-utils] Failed to read settings file:', e); + } return undefined; } } @@ -74,16 +76,16 @@ export async function readSettingsFileAsync(): Promise | const settingsPath = getSettingsPath(); try { - await fsPromises.access(settingsPath); - } catch { - return undefined; - } - - try { + // Read file directly without checking existence first to avoid TOCTOU race condition const content = await fsPromises.readFile(settingsPath, 'utf-8'); return JSON.parse(content); - } catch { - // Return undefined on parse error - caller will use defaults + } catch (e) { + // Return undefined if file doesn't exist or on parse error - caller will use defaults + // ENOENT means file doesn't exist, which is expected on first run + if (e && typeof e === 'object' && 'code' in e && e.code !== 'ENOENT') { + // Log unexpected errors (but not missing file, which is normal) + console.warn('[settings-utils] Failed to read settings file:', e); + } return undefined; } } diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index 417b73f43f..2ff2fd913c 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -61,6 +61,7 @@ export interface TaskAPI { worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise>; worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise>; worktreeDetectTools: () => Promise; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>; + worktreeLaunchApp: (worktreePath: string) => Promise>; archiveTasks: (projectId: string, taskIds: string[], version?: string) => Promise>; unarchiveTasks: (projectId: string, taskIds: string[]) => Promise>; createWorktreePR: (taskId: string, options?: WorktreeCreatePROptions) => Promise>; @@ -166,6 +167,9 @@ export const createTaskAPI = (): TaskAPI => ({ worktreeDetectTools: (): Promise; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>> => ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DETECT_TOOLS), + worktreeLaunchApp: (worktreePath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_LAUNCH_APP, worktreePath), + archiveTasks: (projectId: string, taskIds: string[], version?: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_ARCHIVE, projectId, taskIds, version), diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx index 5948cd687a..e656c31234 100644 --- a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx +++ b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx @@ -13,7 +13,8 @@ import { CheckCircle, GitCommit, Code, - Terminal + Terminal, + Play } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../ui/button'; @@ -22,6 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip'; import { cn } from '../../../lib/utils'; import type { WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo, SupportedIDE, SupportedTerminal } from '../../../../shared/types'; import { useSettingsStore } from '../../../stores/settings-store'; +import { useToast } from '../../../hooks/use-toast'; interface WorkspaceStatusProps { worktreeStatus: WorktreeStatus; @@ -103,6 +105,7 @@ export function WorkspaceStatus({ }: WorkspaceStatusProps) { const { t } = useTranslation(['taskReview', 'common', 'tasks']); const { settings } = useSettingsStore(); + const { toast } = useToast(); const preferredIDE = settings.preferredIDE || 'vscode'; const preferredTerminal = settings.preferredTerminal || 'system'; @@ -132,6 +135,33 @@ export function WorkspaceStatus({ } }; + const handleLaunchApp = async () => { + if (!worktreeStatus.worktreePath) return; + try { + const result = await window.electronAPI.worktreeLaunchApp(worktreeStatus.worktreePath); + if (result.success) { + toast({ + title: t('taskReview:workspace.launchSuccess'), + description: result.data?.command, + }); + } else { + console.error('Failed to launch app:', result.error); + toast({ + title: t('taskReview:workspace.launchFailed'), + description: result.error, + variant: 'destructive', + }); + } + } catch (err) { + console.error('Failed to launch app:', err); + toast({ + title: t('taskReview:workspace.launchFailed'), + description: err instanceof Error ? err.message : 'Unknown error', + variant: 'destructive', + }); + } + }; + const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts; const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges; const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0; @@ -209,9 +239,19 @@ export function WorkspaceStatus({ )} - {/* Open in IDE/Terminal buttons */} + {/* Open in IDE/Terminal/Launch buttons */} {worktreeStatus.worktreePath && ( -
+
+