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:
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>>;
|
||||
|
||||
Reference in New Issue
Block a user