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
This commit is contained in:
Mitsu
2025-12-30 19:40:08 +01:00
committed by GitHub
parent ac8dfcac7b
commit 666794b5fc
9 changed files with 493 additions and 2 deletions
@@ -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<IPCResult<string>> => {
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'
};
}
}
);
}
+4 -1
View File
@@ -5,10 +5,13 @@ import type { IPCResult } from '../../shared/types';
export interface FileAPI {
// File Explorer Operations
listDirectory: (dirPath: string) => Promise<IPCResult<import('../../shared/types').FileNode[]>>;
readFile: (filePath: string) => Promise<IPCResult<string>>;
}
export const createFileAPI = (): FileAPI => ({
// File Explorer Operations
listDirectory: (dirPath: string): Promise<IPCResult<import('../../shared/types').FileNode[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath)
ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath),
readFile: (filePath: string): Promise<IPCResult<string>> =>
ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_READ, filePath)
});
@@ -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
</TabsTrigger>
{showFilesTab && (
<TabsTrigger
value="files"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-sm"
>
{t('tasks:files.tab')}
</TabsTrigger>
)}
</TabsList>
{/* Overview Tab */}
@@ -440,6 +458,13 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
onTogglePhase={state.togglePhase}
/>
</TabsContent>
{/* Files Tab */}
{showFilesTab && (
<TabsContent value="files" className="flex-1 min-h-0 overflow-hidden mt-0">
<TaskFiles task={task} />
</TabsContent>
)}
</Tabs>
</div>
@@ -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 <FileJson className="h-4 w-4 text-amber-500" />;
}
return <FileText className="h-4 w-4 text-blue-500" />;
}
export function TaskFiles({ task }: TaskFilesProps) {
const { t } = useTranslation(['tasks']);
const { settings } = useSettingsStore();
// State for file listing
const [files, setFiles] = useState<FileNode[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [filesError, setFilesError] = useState<string | null>(null);
// State for file content
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [isLoadingContent, setIsLoadingContent] = useState(false);
const [contentError, setContentError] = useState<string | null>(null);
// Ref for keyboard navigation
const fileListRef = useRef<HTMLDivElement>(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 (
<div className="h-full flex items-center justify-center">
<div className="text-center py-12">
<FolderOpen className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground mb-1">
{t('tasks:files.noSpecPath')}
</p>
</div>
</div>
);
}
// Render file content based on type
const renderContent = () => {
if (!selectedFile) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('tasks:files.selectFile')}</p>
</div>
</div>
);
}
if (isLoadingContent) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (contentError) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
<p className="text-sm text-destructive mb-2">{t('tasks:files.errorLoadingContent')}</p>
<Button
variant="outline"
size="sm"
onClick={() => loadFileContent(selectedFile)}
>
<RefreshCw className="h-3 w-3 mr-1" />
{t('tasks:files.retry')}
</Button>
</div>
</div>
);
}
if (fileContent === null) return null;
// Render JSON with formatting
if (selectedFile.endsWith('.json')) {
try {
const formatted = JSON.stringify(JSON.parse(fileContent), null, 2);
return (
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-words p-4">
{formatted}
</pre>
);
} catch {
// If JSON parsing fails, show raw content
return (
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-words p-4">
{fileContent}
</pre>
);
}
}
// Render markdown/text files
return (
<div className="prose prose-sm dark:prose-invert max-w-none p-4">
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-words bg-transparent border-0 p-0">
{fileContent}
</pre>
</div>
);
};
// Get selected filename (cross-platform: handles both / and \ separators)
const selectedFileName = selectedFile ? selectedFile.split(/[/\\]/).pop() : null;
return (
<div className="h-full flex">
{/* File list sidebar */}
<div className="w-52 border-r border-border flex flex-col">
{/* Sidebar header */}
<div className="px-3 py-2 border-b border-border flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t('tasks:files.title')}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={loadFiles}
disabled={isLoadingFiles}
>
<RefreshCw className={cn("h-3 w-3", isLoadingFiles && "animate-spin")} />
</Button>
</div>
<ScrollArea className="flex-1">
<div
ref={fileListRef}
className="p-2 space-y-1"
role="listbox"
aria-label={t('tasks:files.title')}
tabIndex={files.length > 0 ? 0 : -1}
onKeyDown={handleKeyDown}
>
{isLoadingFiles ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filesError ? (
<div className="text-center py-4">
<AlertCircle className="h-5 w-5 mx-auto mb-2 text-destructive" />
<p className="text-xs text-destructive mb-2">{t('tasks:files.errorLoading')}</p>
<Button
variant="outline"
size="sm"
onClick={loadFiles}
className="text-xs"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t('tasks:files.retry')}
</Button>
</div>
) : files.length === 0 ? (
<div className="text-center py-8">
<FolderOpen className="h-8 w-8 mx-auto mb-2 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">{t('tasks:files.noFiles')}</p>
</div>
) : (
files.map((file) => (
<button
type="button"
key={file.path}
role="option"
aria-selected={selectedFile === file.path}
onClick={() => loadFileContent(file.path)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors',
'hover:bg-secondary/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
selectedFile === file.path && 'bg-secondary'
)}
>
{getFileIcon(file.name)}
<span className="text-xs font-medium truncate flex-1">
{file.name}
</span>
{selectedFile === file.path && (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</button>
))
)}
</div>
</ScrollArea>
</div>
{/* File content area */}
<div className="flex-1 min-w-0 flex flex-col">
{/* Content header */}
{selectedFileName && (
<div className="px-4 py-2 border-b border-border flex items-center gap-2 shrink-0 bg-muted/30">
{getFileIcon(selectedFileName)}
<span className="text-sm font-medium flex-1">{selectedFileName}</span>
{settings.preferredIDE && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleOpenInIDE}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('tasks:files.openInIDE')}
</TooltipContent>
</Tooltip>
)}
</div>
)}
<ScrollArea className="flex-1">
{renderContent()}
</ScrollArea>
</div>
</div>
);
}
@@ -77,6 +77,11 @@ export const projectMock = {
data: []
}),
readFile: async () => ({
success: true,
data: ''
}),
// Git operations
getGitBranches: async () => ({
success: true,
@@ -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',
@@ -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"
}
}
@@ -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"
}
}
+1
View File
@@ -660,6 +660,7 @@ export interface ElectronAPI {
// File explorer operations
listDirectory: (dirPath: string) => Promise<IPCResult<FileNode[]>>;
readFile: (filePath: string) => Promise<IPCResult<string>>;
// Git operations
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;