feat: add terminal dropdown with inbuilt and external options in task review (#347)
* feat: add dropdown to select inbuilt or external terminal in task review Replace the single terminal button on the task review modal with a dropdown menu that allows users to choose between: - Opening in an inbuilt terminal tab (existing behavior) - Opening in the system's default external terminal application Changes: - Add IPC channel SHELL_OPEN_TERMINAL for opening paths in system terminal - Create IPC handler with cross-platform support (macOS, Windows, Linux) - Update ShellAPI with openTerminal method - Extend useTerminalHandler hook to support both terminal types - Create TerminalDropdown component with dropdown menu UI - Update WorkspaceStatus to use dropdown for both terminal buttons Cross-platform support: - macOS: Uses 'open -a Terminal' to open Terminal.app - Windows: Uses 'start cmd' to open Command Prompt - Linux: Tries common terminal emulators (gnome-terminal, konsole, xfce4-terminal, xterm) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: correct import paths in TerminalDropdown component * feat: add modal close and view switch for inbuilt terminal option When opening the inbuilt terminal from the task review modal, the UI now: - Closes the task detail modal - Switches to the Agent Terminals view - Creates the terminal in that view This provides a better user experience by automatically navigating to where the terminal is created, rather than keeping the user in the modal. Changes: - Added onSwitchToTerminals prop to TaskDetailModal, TaskReview, and WorkspaceStatus - Updated TerminalDropdown handlers to call onClose and onSwitchToTerminals before creating terminal - Wired up App.tsx to pass setActiveView callback to TaskDetailModal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: pass terminal creation callback to parent to prevent unmount race condition The previous implementation called openTerminal from the WorkspaceStatus component, but when the modal closed and view switched, the component unmounted before the terminal could be created. This fix passes terminal creation parameters up to App.tsx where the modal close, view switch, and terminal creation are handled in the correct order at the parent level. Changes: - Added onOpenInbuiltTerminal callback prop through component hierarchy (TaskDetailModal → TaskReview → WorkspaceStatus) - Created handleOpenInbuiltTerminal in App.tsx that: 1. Closes the modal (setSelectedTask(null)) 2. Switches to terminals view (setActiveView('terminals')) 3. Creates the terminal (window.electronAPI.createTerminal) - Updated TerminalDropdown handlers in WorkspaceStatus to call the callback instead of creating terminal directly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: add terminal to frontend store to display in UI The previous implementation created the terminal in the backend but didn't add it to the frontend terminal store, so TerminalGrid had no terminal to render. The correct flow is: 1. Add terminal to store (creates Terminal object in frontend) 2. Terminal component mounts 3. usePtyProcess hook creates backend PTY process Changes: - Updated handleOpenInbuiltTerminal to use useTerminalStore.getState().addTerminal() - Removed direct window.electronAPI.createTerminal() call - Terminal now appears in TerminalGrid after creation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: add openTerminal to ElectronAPI type and browser mock TypeScript was complaining about missing openTerminal method: - Added openTerminal to ElectronAPI interface in ipc.ts - Added openTerminal mock to infrastructure-mock.ts for browser mode This fixes the typecheck errors in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: use node: prefix for child_process import for better TypeScript resolution Changed from 'child_process' to 'node:child_process' to ensure TypeScript properly resolves the execSync import in all environments including CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: improve terminal command security, deterministic mounting, and i18n This commit addresses several issues with the terminal dropdown feature: 1. **Improved Windows Command Security** (settings-handlers.ts): - Removed fragile nested quotes from Windows terminal command - Changed from `"cd /d "${dirPath}""` to `cd /d "${sanitizedPath}"` - Added path sanitization to escape double quotes and prevent command injection - Simplified command structure for better reliability 2. **Deterministic Component Readiness** (App.tsx, TerminalGrid.tsx): - Replaced hardcoded 100ms timeout with deterministic readiness signal - Added `onMounted` prop to TerminalGrid that fires when component mounts - Created Promise-based waiting mechanism in handleOpenInbuiltTerminal - Checks if terminals view is already active to avoid unnecessary waiting - Ensures Terminal component is mounted before creating backend PTY 3. **i18n Compliance** (TerminalDropdown.tsx): - Replaced all hardcoded English strings with translation keys - Added useTranslation hook with 'taskReview' namespace - Updated button title: "Open terminal" → t('terminal.openTerminal') - Updated menu items: - "Open in Inbuilt Terminal" → t('terminal.openInbuilt') - "Open in External Terminal" → t('terminal.openExternal') - Created taskReview.json translation files for English and French Files Changed: - src/main/ipc-handlers/settings-handlers.ts - src/renderer/App.tsx - src/renderer/components/TerminalGrid.tsx - src/renderer/components/task-detail/task-review/TerminalDropdown.tsx - src/shared/i18n/locales/en/taskReview.json (new) - src/shared/i18n/locales/fr/taskReview.json (new) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: use execFileSync with argument arrays to prevent command injection Security Fix: Replaced all execSync shell commands with execFileSync and argument arrays to completely eliminate command injection vulnerabilities. Previous issue: - Incomplete escaping: only escaped double quotes, not backslashes - Shell interpretation could lead to command injection with crafted paths Solution: - macOS: execFileSync('open', ['-a', 'Terminal', dirPath]) - Windows: execFileSync('cmd.exe', ['/K', 'cd', '/d', dirPath], {shell: false}) - Linux: execFileSync(terminal, ['--working-directory', dirPath]) Benefits: - No shell interpretation - arguments passed directly to executables - No escaping needed - OS handles path special characters correctly - Prevents all forms of command injection - More reliable cross-platform behavior For xterm (Linux fallback), single quotes are properly escaped using the pattern: dirPath.replace(/'/g, "'\\''") which handles single quotes in paths. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: register taskReview namespace with i18n configuration The taskReview translation files were created but not registered with the i18n configuration, causing translation keys to be displayed literally instead of the actual translated text. Changes: - Imported enTaskReview and frTaskReview translation files - Added taskReview to resources object for both en and fr - Added 'taskReview' to the ns (namespaces) array in i18n.init() This fixes the dropdown menu displaying "terminal.openInbuilt" instead of "Open in Inbuilt Terminal". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: remove unused onMounted callback and Promise-based readiness signaling - Remove handleTerminalGridMounted function reference that caused runtime error - Remove onMounted prop from TerminalGrid interface - Remove useEffect hook that called onMounted callback - Simplify handleOpenInbuiltTerminal to directly add terminal to store - TerminalGrid is always mounted (just hidden), so no readiness signaling needed This fixes "handleTerminalGridMounted is not defined" error and simplifies the terminal creation flow. * chore: remove unused imports (useRef, useCallback) from App.tsx * refactor: add input validation and remove unused import in terminal handler Security and code quality improvements: 1. Add comprehensive input validation for dirPath: - Check for non-empty string - Resolve to absolute path with path.resolve() - Verify path exists with existsSync() - Confirm it's a directory with statSync().isDirectory() - Return clear, actionable error messages if any check fails 2. Replace all uses of dirPath with validated resolvedPath 3. Remove unused execSync import from node:child_process 4. Add statSync to fs imports for directory validation This prevents potential issues with invalid paths and improves error handling with specific error messages for each validation failure. * refactor: rename unused id parameter to _id in handleOpenInbuiltTerminal - Prefix parameter with underscore to indicate intentionally unused - Add comment explaining terminal ID is auto-generated by addTerminal() - Keep parameter for callback signature consistency with callers - Remove id from console.log since it's not used in the logic This satisfies linter requirements while maintaining callback compatibility. --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { ipcMain, dialog, app, shell } from 'electron';
|
||||
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants';
|
||||
@@ -345,4 +345,96 @@ export function registerSettingsHandlers(
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SHELL_OPEN_TERMINAL,
|
||||
async (_, dirPath: string): Promise<IPCResult<void>> => {
|
||||
try {
|
||||
// Validate dirPath input
|
||||
if (!dirPath || typeof dirPath !== 'string' || dirPath.trim() === '') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Directory path is required and must be a non-empty string'
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
const resolvedPath = path.resolve(dirPath);
|
||||
|
||||
// Verify path exists
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Directory does not exist: ${resolvedPath}`
|
||||
};
|
||||
}
|
||||
|
||||
// Verify it's a directory
|
||||
try {
|
||||
if (!statSync(resolvedPath).isDirectory()) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Path is not a directory: ${resolvedPath}`
|
||||
};
|
||||
}
|
||||
} catch (statError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot access path: ${resolvedPath}`
|
||||
};
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use execFileSync with argument array to prevent injection
|
||||
execFileSync('open', ['-a', 'Terminal', resolvedPath], { stdio: 'ignore' });
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Use cmd.exe directly with argument array
|
||||
// /C tells cmd to execute the command and terminate
|
||||
// /K keeps the window open after executing cd
|
||||
execFileSync('cmd.exe', ['/K', 'cd', '/d', resolvedPath], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: false,
|
||||
shell: false // Explicitly disable shell to prevent injection
|
||||
});
|
||||
} else {
|
||||
// Linux: Try common terminal emulators with argument arrays
|
||||
const terminals: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: 'gnome-terminal', args: ['--working-directory', resolvedPath] },
|
||||
{ cmd: 'konsole', args: ['--workdir', resolvedPath] },
|
||||
{ cmd: 'xfce4-terminal', args: ['--working-directory', resolvedPath] },
|
||||
{ cmd: 'xterm', args: ['-e', 'bash', '-c', `cd '${resolvedPath.replace(/'/g, "'\\''")}' && exec bash`] }
|
||||
];
|
||||
|
||||
let opened = false;
|
||||
for (const { cmd, args } of terminals) {
|
||||
try {
|
||||
execFileSync(cmd, args, { stdio: 'ignore' });
|
||||
opened = true;
|
||||
break;
|
||||
} catch {
|
||||
// Try next terminal
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No supported terminal emulator found. Please install gnome-terminal, konsole, xfce4-terminal, or xterm.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to open terminal: ${errorMsg}`
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import { invokeIpc } from './ipc-utils';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
|
||||
/**
|
||||
* Shell Operations API
|
||||
*/
|
||||
export interface ShellAPI {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
openTerminal: (dirPath: string) => Promise<IPCResult<void>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -13,5 +15,7 @@ export interface ShellAPI {
|
||||
*/
|
||||
export const createShellAPI = (): ShellAPI => ({
|
||||
openExternal: (url: string): Promise<void> =>
|
||||
invokeIpc(IPC_CHANNELS.SHELL_OPEN_EXTERNAL, url)
|
||||
invokeIpc(IPC_CHANNELS.SHELL_OPEN_EXTERNAL, url),
|
||||
openTerminal: (dirPath: string): Promise<IPCResult<void>> =>
|
||||
invokeIpc(IPC_CHANNELS.SHELL_OPEN_TERMINAL, dirPath)
|
||||
});
|
||||
|
||||
@@ -391,6 +391,29 @@ export function App() {
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const handleOpenInbuiltTerminal = (_id: string, cwd: string) => {
|
||||
// Note: _id parameter is intentionally unused - terminal ID is auto-generated by addTerminal()
|
||||
// Parameter kept for callback signature consistency with callers
|
||||
console.log('[App] Opening inbuilt terminal:', { cwd });
|
||||
|
||||
// Switch to terminals view
|
||||
setActiveView('terminals');
|
||||
|
||||
// Close modal
|
||||
setSelectedTask(null);
|
||||
|
||||
// Add terminal to store - this will trigger Terminal component to mount
|
||||
// which will then create the backend PTY via usePtyProcess
|
||||
// Note: TerminalGrid is always mounted (just hidden), so no need to wait
|
||||
const terminal = useTerminalStore.getState().addTerminal(cwd, selectedProject?.path);
|
||||
|
||||
if (!terminal) {
|
||||
console.error('[App] Failed to add terminal to store (max terminals reached?)');
|
||||
} else {
|
||||
console.log('[App] Terminal added to store:', terminal.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = async () => {
|
||||
try {
|
||||
const path = await window.electronAPI.selectDirectory();
|
||||
@@ -716,6 +739,8 @@ export function App() {
|
||||
open={!!selectedTask}
|
||||
task={selectedTask}
|
||||
onOpenChange={(open) => !open && handleCloseTaskDetail()}
|
||||
onSwitchToTerminals={() => setActiveView('terminals')}
|
||||
onOpenInbuiltTerminal={handleOpenInbuiltTerminal}
|
||||
/>
|
||||
|
||||
{/* Dialogs */}
|
||||
|
||||
@@ -44,9 +44,11 @@ interface TaskDetailModalProps {
|
||||
open: boolean;
|
||||
task: Task | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSwitchToTerminals?: () => void;
|
||||
onOpenInbuiltTerminal?: (id: string, cwd: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailModal({ open, task, onOpenChange }: TaskDetailModalProps) {
|
||||
export function TaskDetailModal({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: TaskDetailModalProps) {
|
||||
// Don't render anything if no task
|
||||
if (!task) {
|
||||
return null;
|
||||
@@ -57,12 +59,14 @@ export function TaskDetailModal({ open, task, onOpenChange }: TaskDetailModalPro
|
||||
open={open}
|
||||
task={task}
|
||||
onOpenChange={onOpenChange}
|
||||
onSwitchToTerminals={onSwitchToTerminals}
|
||||
onOpenInbuiltTerminal={onOpenInbuiltTerminal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate component to use hooks only when task exists
|
||||
function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void }) {
|
||||
function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void; onSwitchToTerminals?: () => void; onOpenInbuiltTerminal?: (id: string, cwd: string) => void }) {
|
||||
const state = useTaskDetail({ task });
|
||||
const progressPercent = calculateProgress(task.subtasks);
|
||||
const completedSubtasks = task.subtasks.filter(s => s.status === 'completed').length;
|
||||
@@ -408,6 +412,8 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t
|
||||
onShowConflictDialog={state.setShowConflictDialog}
|
||||
onLoadMergePreview={state.loadMergePreview}
|
||||
onClose={handleClose}
|
||||
onSwitchToTerminals={onSwitchToTerminals}
|
||||
onOpenInbuiltTerminal={onOpenInbuiltTerminal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,8 @@ interface TaskReviewProps {
|
||||
onShowConflictDialog: (show: boolean) => void;
|
||||
onLoadMergePreview: () => void;
|
||||
onClose?: () => void;
|
||||
onSwitchToTerminals?: () => void;
|
||||
onOpenInbuiltTerminal?: (id: string, cwd: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +81,9 @@ export function TaskReview({
|
||||
onStageOnlyChange,
|
||||
onShowConflictDialog,
|
||||
onLoadMergePreview,
|
||||
onClose
|
||||
onClose,
|
||||
onSwitchToTerminals,
|
||||
onOpenInbuiltTerminal
|
||||
}: TaskReviewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -115,6 +119,9 @@ export function TaskReview({
|
||||
onLoadMergePreview={onLoadMergePreview}
|
||||
onStageOnlyChange={onStageOnlyChange}
|
||||
onMerge={onMerge}
|
||||
onClose={onClose}
|
||||
onSwitchToTerminals={onSwitchToTerminals}
|
||||
onOpenInbuiltTerminal={onOpenInbuiltTerminal}
|
||||
/>
|
||||
) : task.stagedInMainProject && !stagedSuccess ? (
|
||||
<StagedInProjectMessage
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for handling terminal creation with proper error handling and loading states.
|
||||
* Fixes silent failures when terminal buttons are clicked.
|
||||
* Supports both inbuilt terminal and external system terminal.
|
||||
*/
|
||||
export function useTerminalHandler() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
|
||||
/**
|
||||
* Open an inbuilt terminal tab
|
||||
*/
|
||||
const openTerminal = async (id: string, cwd: string) => {
|
||||
setIsOpening(true);
|
||||
setError(null);
|
||||
@@ -28,5 +31,28 @@ export function useTerminalHandler() {
|
||||
}
|
||||
};
|
||||
|
||||
return { openTerminal, error, isOpening };
|
||||
/**
|
||||
* Open the path in the system's default external terminal application
|
||||
*/
|
||||
const openExternalTerminal = async (cwd: string) => {
|
||||
setIsOpening(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.openTerminal(cwd);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to open external terminal');
|
||||
console.error('[Terminal] Failed to open external:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(`Failed to open external terminal: ${errorMsg}`);
|
||||
console.error('[Terminal] Exception:', err);
|
||||
} finally {
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { openTerminal, openExternalTerminal, error, isOpening };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Terminal, ExternalLink, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '../../ui/dropdown-menu';
|
||||
|
||||
interface TerminalDropdownProps {
|
||||
onOpenInbuilt: () => void;
|
||||
onOpenExternal: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown button for selecting terminal type (inbuilt or external)
|
||||
*/
|
||||
export function TerminalDropdown({
|
||||
onOpenInbuilt,
|
||||
onOpenExternal,
|
||||
disabled = false,
|
||||
className
|
||||
}: TerminalDropdownProps) {
|
||||
const { t } = useTranslation('taskReview');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
title={t('terminal.openTerminal')}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onOpenInbuilt}>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{t('terminal.openInbuilt')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenExternal}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('terminal.openExternal')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Checkbox } from '../../ui/checkbox';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||
import { useTerminalHandler } from '../hooks/useTerminalHandler';
|
||||
import { TerminalDropdown } from './TerminalDropdown';
|
||||
|
||||
interface WorkspaceStatusProps {
|
||||
task: Task;
|
||||
@@ -35,6 +36,9 @@ interface WorkspaceStatusProps {
|
||||
onLoadMergePreview: () => void;
|
||||
onStageOnlyChange: (value: boolean) => void;
|
||||
onMerge: () => void;
|
||||
onClose?: () => void;
|
||||
onSwitchToTerminals?: () => void;
|
||||
onOpenInbuiltTerminal?: (id: string, cwd: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,9 +58,12 @@ export function WorkspaceStatus({
|
||||
onShowConflictDialog,
|
||||
onLoadMergePreview,
|
||||
onStageOnlyChange,
|
||||
onMerge
|
||||
onMerge,
|
||||
onClose,
|
||||
onSwitchToTerminals,
|
||||
onOpenInbuiltTerminal
|
||||
}: WorkspaceStatusProps) {
|
||||
const { openTerminal, error: terminalError, isOpening } = useTerminalHandler();
|
||||
const { openTerminal, openExternalTerminal, error: terminalError, isOpening } = useTerminalHandler();
|
||||
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
|
||||
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
|
||||
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
||||
@@ -98,16 +105,16 @@ export function WorkspaceStatus({
|
||||
View
|
||||
</Button>
|
||||
{worktreeStatus.worktreePath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openTerminal(`open-${task.id}`, worktreeStatus.worktreePath!)}
|
||||
className="h-7 px-2"
|
||||
title="Open in terminal"
|
||||
<TerminalDropdown
|
||||
onOpenInbuilt={() => {
|
||||
if (onOpenInbuiltTerminal) {
|
||||
onOpenInbuiltTerminal(`open-${task.id}`, worktreeStatus.worktreePath!);
|
||||
}
|
||||
}}
|
||||
onOpenExternal={() => openExternalTerminal(worktreeStatus.worktreePath!)}
|
||||
disabled={isOpening}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
className="h-7 px-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,21 +184,22 @@ export function WorkspaceStatus({
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Commit or stash them before staging to avoid conflicts.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
<TerminalDropdown
|
||||
onOpenInbuilt={() => {
|
||||
const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || '';
|
||||
if (mainProjectPath) {
|
||||
openTerminal(`stash-${task.id}`, mainProjectPath);
|
||||
if (mainProjectPath && onOpenInbuiltTerminal) {
|
||||
onOpenInbuiltTerminal(`stash-${task.id}`, mainProjectPath);
|
||||
}
|
||||
}}
|
||||
onOpenExternal={() => {
|
||||
const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || '';
|
||||
if (mainProjectPath) {
|
||||
openExternalTerminal(mainProjectPath);
|
||||
}
|
||||
}}
|
||||
className="text-xs h-6 mt-2"
|
||||
disabled={isOpening}
|
||||
>
|
||||
<Terminal className="h-3 w-3 mr-1" />
|
||||
{isOpening ? 'Opening...' : 'Open Terminal'}
|
||||
</Button>
|
||||
className="text-xs h-6 mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -184,5 +184,10 @@ export const infrastructureMock = {
|
||||
openExternal: async (url: string) => {
|
||||
console.warn('[Browser Mock] openExternal:', url);
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
openTerminal: async (dirPath: string) => {
|
||||
console.warn('[Browser Mock] openTerminal:', dirPath);
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,6 +117,7 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// Shell operations
|
||||
SHELL_OPEN_EXTERNAL: 'shell:openExternal',
|
||||
SHELL_OPEN_TERMINAL: 'shell:openTerminal',
|
||||
|
||||
// Roadmap operations
|
||||
ROADMAP_GET: 'roadmap:get',
|
||||
|
||||
@@ -9,6 +9,7 @@ import enTasks from './locales/en/tasks.json';
|
||||
import enWelcome from './locales/en/welcome.json';
|
||||
import enOnboarding from './locales/en/onboarding.json';
|
||||
import enDialogs from './locales/en/dialogs.json';
|
||||
import enTaskReview from './locales/en/taskReview.json';
|
||||
|
||||
// Import French translation resources
|
||||
import frCommon from './locales/fr/common.json';
|
||||
@@ -18,6 +19,7 @@ import frTasks from './locales/fr/tasks.json';
|
||||
import frWelcome from './locales/fr/welcome.json';
|
||||
import frOnboarding from './locales/fr/onboarding.json';
|
||||
import frDialogs from './locales/fr/dialogs.json';
|
||||
import frTaskReview from './locales/fr/taskReview.json';
|
||||
|
||||
export const defaultNS = 'common';
|
||||
|
||||
@@ -29,7 +31,8 @@ export const resources = {
|
||||
tasks: enTasks,
|
||||
welcome: enWelcome,
|
||||
onboarding: enOnboarding,
|
||||
dialogs: enDialogs
|
||||
dialogs: enDialogs,
|
||||
taskReview: enTaskReview
|
||||
},
|
||||
fr: {
|
||||
common: frCommon,
|
||||
@@ -38,7 +41,8 @@ export const resources = {
|
||||
tasks: frTasks,
|
||||
welcome: frWelcome,
|
||||
onboarding: frOnboarding,
|
||||
dialogs: frDialogs
|
||||
dialogs: frDialogs,
|
||||
taskReview: frTaskReview
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -49,7 +53,7 @@ i18n
|
||||
lng: 'en', // Default language (will be overridden by settings)
|
||||
fallbackLng: 'en',
|
||||
defaultNS,
|
||||
ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs'],
|
||||
ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'taskReview'],
|
||||
interpolation: {
|
||||
escapeValue: false // React already escapes values
|
||||
},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"terminal": {
|
||||
"openTerminal": "Open terminal",
|
||||
"openInbuilt": "Open in Inbuilt Terminal",
|
||||
"openExternal": "Open in External Terminal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"terminal": {
|
||||
"openTerminal": "Ouvrir le terminal",
|
||||
"openInbuilt": "Ouvrir dans le terminal intégré",
|
||||
"openExternal": "Ouvrir dans le terminal externe"
|
||||
}
|
||||
}
|
||||
@@ -452,6 +452,7 @@ export interface ElectronAPI {
|
||||
|
||||
// Shell operations
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
openTerminal: (dirPath: string) => Promise<IPCResult<void>>;
|
||||
|
||||
// Auto Claude source environment operations
|
||||
getSourceEnv: () => Promise<IPCResult<SourceEnvConfig>>;
|
||||
|
||||
Reference in New Issue
Block a user