From 666794b5fc6d7fc3a9e4e481215fc6e15c41c8ec Mon Sep 17 00:00:00 2001 From: Mitsu <50143759+Mitsu13Ion@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:40:08 +0100 Subject: [PATCH] feat(frontend): Add Files tab to task details panel (#430) * auto-claude: subtask-1-1 - Add FILE_EXPLORER_READ IPC channel constant * auto-claude: subtask-1-2 - Add readFile IPC handler in file-handlers.ts * auto-claude: subtask-1-3 - Add readFile method to FileAPI in preload/api/file * auto-claude: subtask-2-1 - Add English translation keys for Files tab Added i18n translation keys for the Files tab in tasks.json: - files.tab: Tab title - files.noSpecPath: Message when spec path is unavailable - files.noFiles: Empty state message - files.loading/loadingContent: Loading states - files.errorLoading/errorLoadingContent: Error states - files.retry: Retry action button - files.selectFile: Placeholder message * auto-claude: subtask-2-2 - Add French translation keys for Files tab in tasks * auto-claude: subtask-3-1 - Create TaskFiles.tsx component with file listing and content display - Create TaskFiles component with file sidebar and content viewer - Use listDirectory API to fetch spec files (*.md, *.json) - Use readFile API to load file content - Handle loading, error, and empty states - Display JSON files with proper formatting - Show spec.md first in file list - Use i18n for all user-facing text * auto-claude: subtask-4-2 - Verify TypeScript compilation passes - Add readFile method to ElectronAPI interface in shared types - Add readFile mock in browser-mock for development/testing * feat(files-tab): add Files tab to task details with IDE integration - Add Files tab to TaskDetailModal (was missing, only in deprecated TaskDetailPanel) - Auto-select first file (spec.md) on load - Add sidebar header with refresh button - Add content header showing selected filename - Add "Open in IDE" button using configured IDE from settings - Add i18n translations for new features (en/fr) * feat(files-tab): add localStorage feature flag for Files tab - Add `use_files_tab` localStorage flag (enabled by default) - Set to 'false' in localStorage to disable the Files tab - Allows users to opt-out if needed * fix: remove unused Pencil import from TaskFiles * fix: address CodeRabbit review comments - file-handlers: add path validation, size limit, and async file read - TaskDetailModal: use i18n translation for Files tab label - TaskFiles: add explicit type="button" attribute * fix(TaskFiles): prevent potential infinite loop in auto-select effect Only trigger auto-select when files array changes, not on every selectedFile change, to prevent re-triggering if loadFileContent fails. * fix(TaskFiles): improve security, cross-platform support, and accessibility - Add validatePath() function with robust path traversal protection - Fix cross-platform filename extraction (handles both / and \ separators) - Reset selectedFile state when task.specsPath changes - Add keyboard navigation (Arrow keys, Home, End) - Add ARIA attributes (role="listbox", role="option", aria-selected) - Add focus ring styles for better visibility --- .../src/main/ipc-handlers/file-handlers.ts | 58 ++- apps/frontend/src/preload/api/file-api.ts | 5 +- .../task-detail/TaskDetailModal.tsx | 25 ++ .../components/task-detail/TaskFiles.tsx | 374 ++++++++++++++++++ .../src/renderer/lib/mocks/project-mock.ts | 5 + apps/frontend/src/shared/constants/ipc.ts | 1 + .../src/shared/i18n/locales/en/tasks.json | 13 + .../src/shared/i18n/locales/fr/tasks.json | 13 + apps/frontend/src/shared/types/ipc.ts | 1 + 9 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/renderer/components/task-detail/TaskFiles.tsx diff --git a/apps/frontend/src/main/ipc-handlers/file-handlers.ts b/apps/frontend/src/main/ipc-handlers/file-handlers.ts index e2a9f016..fc32e2f0 100644 --- a/apps/frontend/src/main/ipc-handlers/file-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/file-handlers.ts @@ -1,9 +1,36 @@ import { ipcMain } from 'electron'; -import { readdirSync } from 'fs'; +import { readdirSync, statSync } from 'fs'; +import { readFile } from 'fs/promises'; import path from 'path'; import { IPC_CHANNELS } from '../../shared/constants'; import type { IPCResult, FileNode } from '../../shared/types'; +// Maximum file size to read (1MB) +const MAX_FILE_SIZE = 1024 * 1024; + +/** + * Validates and normalizes a file path for safe reading. + * Returns the normalized path if valid, or an error message. + */ +function validatePath(filePath: string): { valid: true; path: string } | { valid: false; error: string } { + // Resolve to absolute path (handles .., ., etc.) + const resolvedPath = path.resolve(filePath); + + // Must be absolute after resolution + if (!path.isAbsolute(resolvedPath)) { + return { valid: false, error: 'Path must be absolute' }; + } + + // After resolution, path should not contain .. segments + // This catches edge cases where resolve might not fully normalize + const segments = resolvedPath.split(path.sep); + if (segments.includes('..')) { + return { valid: false, error: 'Invalid path: contains parent directory references' }; + } + + return { valid: true, path: resolvedPath }; +} + // Directories to ignore when listing const IGNORED_DIRS = new Set([ 'node_modules', '.git', '__pycache__', 'dist', 'build', @@ -60,4 +87,33 @@ export function registerFileHandlers(): void { } } ); + + ipcMain.handle( + IPC_CHANNELS.FILE_EXPLORER_READ, + async (_, filePath: string): Promise> => { + try { + // Validate and normalize path + const validation = validatePath(filePath); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + const safePath = validation.path; + + // Check file size before reading + const stats = statSync(safePath); + if (stats.size > MAX_FILE_SIZE) { + return { success: false, error: 'File too large (max 1MB)' }; + } + + // Use async file read to avoid blocking + const content = await readFile(safePath, 'utf-8'); + return { success: true, data: content }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read file' + }; + } + } + ); } diff --git a/apps/frontend/src/preload/api/file-api.ts b/apps/frontend/src/preload/api/file-api.ts index 5a07d05c..d096be0f 100644 --- a/apps/frontend/src/preload/api/file-api.ts +++ b/apps/frontend/src/preload/api/file-api.ts @@ -5,10 +5,13 @@ import type { IPCResult } from '../../shared/types'; export interface FileAPI { // File Explorer Operations listDirectory: (dirPath: string) => Promise>; + readFile: (filePath: string) => Promise>; } export const createFileAPI = (): FileAPI => ({ // File Explorer Operations listDirectory: (dirPath: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath) + ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath), + readFile: (filePath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_READ, filePath) }); diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index 6113454d..6b9d421a 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { Separator } from '../ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; @@ -37,6 +38,7 @@ import { TaskMetadata } from './TaskMetadata'; import { TaskWarnings } from './TaskWarnings'; import { TaskSubtasks } from './TaskSubtasks'; import { TaskLogs } from './TaskLogs'; +import { TaskFiles } from './TaskFiles'; import { TaskReview } from './TaskReview'; import type { Task } from '../../../shared/types'; @@ -65,9 +67,17 @@ export function TaskDetailModal({ open, task, onOpenChange, onSwitchToTerminals, ); } +// Feature flag for Files tab (enabled by default, can be disabled via localStorage) +const isFilesTabEnabled = () => { + const flag = localStorage.getItem('use_files_tab'); + return flag === null || flag === 'true'; // Enabled by default +}; + // Separate component to use hooks only when task exists function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void; onSwitchToTerminals?: () => void; onOpenInbuiltTerminal?: (id: string, cwd: string) => void }) { + const { t } = useTranslation(['tasks']); const state = useTaskDetail({ task }); + const showFilesTab = isFilesTabEnabled(); const progressPercent = calculateProgress(task.subtasks); const completedSubtasks = task.subtasks.filter(s => s.status === 'completed').length; const totalSubtasks = task.subtasks.length; @@ -370,6 +380,14 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, > Logs + {showFilesTab && ( + + {t('tasks:files.tab')} + + )} {/* Overview Tab */} @@ -440,6 +458,13 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onTogglePhase={state.togglePhase} /> + + {/* Files Tab */} + {showFilesTab && ( + + + + )} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskFiles.tsx b/apps/frontend/src/renderer/components/task-detail/TaskFiles.tsx new file mode 100644 index 00000000..2145d41f --- /dev/null +++ b/apps/frontend/src/renderer/components/task-detail/TaskFiles.tsx @@ -0,0 +1,374 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FileText, + FileJson, + Loader2, + AlertCircle, + FolderOpen, + RefreshCw, + ChevronRight, + ExternalLink +} from 'lucide-react'; +import { ScrollArea } from '../ui/scroll-area'; +import { Button } from '../ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { cn } from '../../lib/utils'; +import { useSettingsStore } from '../../stores/settings-store'; +import type { Task } from '../../../shared/types'; +import type { FileNode } from '../../../shared/types/project'; + +interface TaskFilesProps { + task: Task; +} + +// File extensions to display +const ALLOWED_EXTENSIONS = ['.md', '.json']; + +// Get icon for file type +function getFileIcon(filename: string) { + if (filename.endsWith('.json')) { + return ; + } + return ; +} + +export function TaskFiles({ task }: TaskFilesProps) { + const { t } = useTranslation(['tasks']); + const { settings } = useSettingsStore(); + + // State for file listing + const [files, setFiles] = useState([]); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); + const [filesError, setFilesError] = useState(null); + + // State for file content + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [isLoadingContent, setIsLoadingContent] = useState(false); + const [contentError, setContentError] = useState(null); + + // Ref for keyboard navigation + const fileListRef = useRef(null); + + // Load files from spec directory + const loadFiles = useCallback(async () => { + if (!task.specsPath) return; + + setIsLoadingFiles(true); + setFilesError(null); + + try { + const result = await window.electronAPI.listDirectory(task.specsPath); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to load directory'); + } + + // Filter to only show allowed file types + const filteredFiles = result.data.filter( + (file) => !file.isDirectory && ALLOWED_EXTENSIONS.some(ext => file.name.endsWith(ext)) + ); + + // Sort files: spec.md first, then alphabetically + filteredFiles.sort((a, b) => { + if (a.name === 'spec.md') return -1; + if (b.name === 'spec.md') return 1; + return a.name.localeCompare(b.name); + }); + + setFiles(filteredFiles); + } catch (err) { + setFilesError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setIsLoadingFiles(false); + } + }, [task.specsPath]); + + // Load file content + const loadFileContent = useCallback(async (filePath: string) => { + setSelectedFile(filePath); + setIsLoadingContent(true); + setContentError(null); + setFileContent(null); + + try { + const result = await window.electronAPI.readFile(filePath); + if (!result.success || result.data === undefined) { + throw new Error(result.error || 'Failed to read file'); + } + setFileContent(result.data); + } catch (err) { + setContentError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setIsLoadingContent(false); + } + }, []); + + // Reset state when task.specsPath changes + useEffect(() => { + setSelectedFile(null); + setFileContent(null); + setContentError(null); + }, [task.specsPath]); + + // Load files on mount and when specsPath changes + useEffect(() => { + loadFiles(); + }, [loadFiles]); + + // Auto-select first file (spec.md) when files are loaded + useEffect(() => { + if (files.length > 0 && selectedFile === null) { + loadFileContent(files[0].path); + } + // Only run when files change, not on selectedFile changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [files]); + + // Open spec directory in IDE + const handleOpenInIDE = useCallback(async () => { + if (!settings.preferredIDE || !task.specsPath) return; + + try { + await window.electronAPI.worktreeOpenInIDE( + task.specsPath, + settings.preferredIDE, + settings.customIDEPath + ); + } catch (err) { + console.error('Failed to open in IDE:', err); + } + }, [settings.preferredIDE, settings.customIDEPath, task.specsPath]); + + // Keyboard navigation for file list + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (files.length === 0) return; + + const currentIndex = selectedFile + ? files.findIndex(f => f.path === selectedFile) + : -1; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (currentIndex < files.length - 1) { + loadFileContent(files[currentIndex + 1].path); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (currentIndex > 0) { + loadFileContent(files[currentIndex - 1].path); + } + break; + case 'Home': + e.preventDefault(); + loadFileContent(files[0].path); + break; + case 'End': + e.preventDefault(); + loadFileContent(files[files.length - 1].path); + break; + } + }, [files, selectedFile, loadFileContent]); + + // Handle no specsPath + if (!task.specsPath) { + return ( +
+
+ +

+ {t('tasks:files.noSpecPath')} +

+
+
+ ); + } + + // Render file content based on type + const renderContent = () => { + if (!selectedFile) { + return ( +
+
+ +

{t('tasks:files.selectFile')}

+
+
+ ); + } + + if (isLoadingContent) { + return ( +
+ +
+ ); + } + + if (contentError) { + return ( +
+
+ +

{t('tasks:files.errorLoadingContent')}

+ +
+
+ ); + } + + if (fileContent === null) return null; + + // Render JSON with formatting + if (selectedFile.endsWith('.json')) { + try { + const formatted = JSON.stringify(JSON.parse(fileContent), null, 2); + return ( +
+            {formatted}
+          
+ ); + } catch { + // If JSON parsing fails, show raw content + return ( +
+            {fileContent}
+          
+ ); + } + } + + // Render markdown/text files + return ( +
+
+          {fileContent}
+        
+
+ ); + }; + + // Get selected filename (cross-platform: handles both / and \ separators) + const selectedFileName = selectedFile ? selectedFile.split(/[/\\]/).pop() : null; + + return ( +
+ {/* File list sidebar */} +
+ {/* Sidebar header */} +
+ + {t('tasks:files.title')} + + +
+ +
0 ? 0 : -1} + onKeyDown={handleKeyDown} + > + {isLoadingFiles ? ( +
+ +
+ ) : filesError ? ( +
+ +

{t('tasks:files.errorLoading')}

+ +
+ ) : files.length === 0 ? ( +
+ +

{t('tasks:files.noFiles')}

+
+ ) : ( + files.map((file) => ( + + )) + )} +
+
+
+ + {/* File content area */} +
+ {/* Content header */} + {selectedFileName && ( +
+ {getFileIcon(selectedFileName)} + {selectedFileName} + {settings.preferredIDE && ( + + + + + + {t('tasks:files.openInIDE')} + + + )} +
+ )} + + {renderContent()} + +
+
+ ); +} diff --git a/apps/frontend/src/renderer/lib/mocks/project-mock.ts b/apps/frontend/src/renderer/lib/mocks/project-mock.ts index b1dded3d..fdd22943 100644 --- a/apps/frontend/src/renderer/lib/mocks/project-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/project-mock.ts @@ -77,6 +77,11 @@ export const projectMock = { data: [] }), + readFile: async () => ({ + success: true, + data: '' + }), + // Git operations getGitBranches: async () => ({ success: true, diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 08a0bcf5..774f02d9 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -429,6 +429,7 @@ export const IPC_CHANNELS = { // File explorer operations FILE_EXPLORER_LIST: 'fileExplorer:list', + FILE_EXPLORER_READ: 'fileExplorer:read', // Git operations GIT_GET_BRANCHES: 'git:getBranches', diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index c5b7c887..8602b8a2 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -82,5 +82,18 @@ "code": "Code", "qa": "QA" } + }, + "files": { + "title": "Files", + "tab": "Files", + "noSpecPath": "No spec files available", + "noFiles": "No files found", + "loading": "Loading files...", + "loadingContent": "Loading content...", + "errorLoading": "Failed to load files", + "errorLoadingContent": "Failed to load file content", + "retry": "Retry", + "selectFile": "Select a file to view its contents", + "openInIDE": "Open in IDE" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 4a819a32..ea3e5b38 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -82,5 +82,18 @@ "code": "Code", "qa": "QA" } + }, + "files": { + "title": "Fichiers", + "tab": "Fichiers", + "noSpecPath": "Aucun fichier de spécification disponible", + "noFiles": "Aucun fichier trouvé", + "loading": "Chargement des fichiers...", + "loadingContent": "Chargement du contenu...", + "errorLoading": "Échec du chargement des fichiers", + "errorLoadingContent": "Échec du chargement du contenu du fichier", + "retry": "Réessayer", + "selectFile": "Sélectionnez un fichier pour voir son contenu", + "openInIDE": "Ouvrir dans l'IDE" } } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 71b390a7..7d057a83 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -660,6 +660,7 @@ export interface ElectronAPI { // File explorer operations listDirectory: (dirPath: string) => Promise>; + readFile: (filePath: string) => Promise>; // Git operations getGitBranches: (projectPath: string) => Promise>;