Files
claudecodeui/src/components/file-tree/view/FileTree.tsx
Haileyesus c235b05e1d feat: add file tree upload progress
Users need a visible upload path from the explorer itself, not only drag and
 drop behavior with no progress feedback. Routing picker and drop uploads
 through one XHR-backed hook keeps progress, validation, refresh, and success
 counts consistent for every upload source.

The 200MB limit is mirrored in the client, multer, and nginx template so large
 uploads fail predictably instead of being blocked by whichever layer sees the
 request first. The server also returns explicit requested and uploaded counts
 so partial or multi-file batches can render accurate status text.
2026-06-08 14:52:09 +03:00

302 lines
11 KiB
TypeScript

import { useCallback, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
import { useFileTreeData } from '../hooks/useFileTreeData';
import { useFileTreeOperations } from '../hooks/useFileTreeOperations';
import { useFileTreeSearch } from '../hooks/useFileTreeSearch';
import { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';
import { useFileTreeUpload } from '../hooks/useFileTreeUpload';
import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
import { Project } from '../../../types/app';
import { ScrollArea, Input } from '../../../shared/view/ui';
import FileTreeBody from './FileTreeBody';
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState';
import FileTreeUploadProgress from './FileTreeUploadProgress';
import ImageViewer from './ImageViewer';
type FileTreeProps = {
selectedProject: Project | null;
onFileOpen?: (filePath: string) => void;
};
export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) {
const { t } = useTranslation();
const [selectedImage, setSelectedImage] = useState<FileTreeImageSelection | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const newItemInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
// Show toast notification
const showToast = useCallback((message: string, type: 'success' | 'error') => {
setToast({ message, type });
}, []);
// Auto-hide toast
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
const { files, loading, refreshFiles } = useFileTreeData(selectedProject);
const { viewMode, changeViewMode } = useFileTreeViewMode();
const { expandedDirs, toggleDirectory, expandDirectories, collapseAll } = useExpandedDirectories();
const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({
files,
expandDirectories,
});
// File operations
const operations = useFileTreeOperations({
selectedProject,
onRefresh: refreshFiles,
showToast,
});
// File upload (drag and drop)
const upload = useFileTreeUpload({
selectedProject,
onRefresh: refreshFiles,
showToast,
});
const operationLoading = operations.operationLoading || upload.operationLoading;
// Focus input when creating new item
useEffect(() => {
if (operations.isCreating && newItemInputRef.current) {
newItemInputRef.current.focus();
newItemInputRef.current.select();
}
}, [operations.isCreating]);
// Focus input when renaming
useEffect(() => {
if (operations.renamingItem && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [operations.renamingItem]);
const renderFileIcon = useCallback((filename: string) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE_CLASS, color)} />;
}, []);
// Centralized click behavior keeps file actions identical across all presentation modes.
const handleItemClick = useCallback(
(item: FileTreeNode) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
return;
}
if (isImageFile(item.name) && selectedProject) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
// Image URL uses the DB projectId so ImageViewer can hit the
// /api/projects/:projectId/files/content endpoint directly.
projectId: selectedProject.projectId,
});
return;
}
onFileOpen?.(item.path);
},
[onFileOpen, selectedProject, toggleDirectory],
);
const formatRelativeTimeLabel = useCallback(
(date?: string) => formatRelativeTime(date, t),
[t],
);
if (loading) {
return <FileTreeLoadingState />;
}
return (
<div
ref={upload.treeRef}
className="relative flex h-full flex-col bg-background"
onDragEnter={upload.handleDragEnter}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
>
{/* Drag overlay */}
{upload.isDragOver && (
<div className="absolute inset-0 z-50 flex items-center justify-center border-2 border-dashed border-blue-500 bg-blue-500/10">
<div className="flex items-center gap-3 rounded-lg bg-background/95 px-6 py-4 shadow-lg">
<Upload className="h-6 w-6 text-blue-500" />
<span className="text-sm font-medium">{t('fileTree.dropToUpload', 'Drop files to upload')}</span>
</div>
</div>
)}
<FileTreeHeader
viewMode={viewMode}
onViewModeChange={changeViewMode}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onUploadFiles={upload.handleFileSelect}
onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')}
onRefresh={refreshFiles}
onCollapseAll={collapseAll}
loading={loading}
operationLoading={operationLoading}
isUploading={upload.uploadProgress?.status === 'uploading'}
uploadProgress={upload.uploadProgress?.progress ?? null}
/>
<FileTreeUploadProgress upload={upload.uploadProgress} />
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<ScrollArea className="flex-1 px-2 py-1">
{/* New item input */}
{operations.isCreating && (
<div
className="mb-1 flex items-center gap-1.5 py-[3px] pr-2"
style={{ paddingLeft: `${(operations.newItemParent.split('/').length - 1) * 16 + 4}px` }}
>
{operations.newItemType === 'directory' ? (
<Folder className={cn(ICON_SIZE_CLASS, 'text-blue-500')} />
) : (
<span className="ml-[18px]">{renderFileIcon(operations.newItemName)}</span>
)}
<Input
ref={newItemInputRef}
type="text"
value={operations.newItemName}
onChange={(e) => operations.setNewItemName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') operations.handleConfirmCreate();
if (e.key === 'Escape') operations.handleCancelCreate();
}}
onBlur={() => {
setTimeout(() => {
if (operations.isCreating) operations.handleConfirmCreate();
}, 100);
}}
className="h-6 flex-1 text-sm"
disabled={operationLoading}
/>
</div>
)}
<FileTreeBody
files={files}
filteredFiles={filteredFiles}
searchQuery={searchQuery}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={handleItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTimeLabel}
onRename={operations.handleStartRename}
onDelete={operations.handleStartDelete}
onNewFile={(path) => operations.handleStartCreate(path, 'file')}
onNewFolder={(path) => operations.handleStartCreate(path, 'directory')}
onCopyPath={operations.handleCopyPath}
onDownload={operations.handleDownload}
onRefresh={refreshFiles}
// Pass rename state and handlers for inline editing
renamingItem={operations.renamingItem}
renameValue={operations.renameValue}
setRenameValue={operations.setRenameValue}
handleConfirmRename={operations.handleConfirmRename}
handleCancelRename={operations.handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operationLoading}
/>
</ScrollArea>
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
{/* Delete Confirmation Dialog */}
{operations.deleteConfirmation.isOpen && operations.deleteConfirmation.item && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50">
<div className="mx-4 max-w-sm rounded-lg border border-border bg-background p-4 shadow-lg">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-red-100 p-2 dark:bg-red-900/30">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-medium text-foreground">
{t('fileTree.delete.title', 'Delete {{type}}', {
type: operations.deleteConfirmation.item.type === 'directory' ? 'Folder' : 'File'
})}
</h3>
<p className="text-sm text-muted-foreground">
{operations.deleteConfirmation.item.name}
</p>
</div>
</div>
<p className="mb-4 text-sm text-muted-foreground">
{operations.deleteConfirmation.item.type === 'directory'
? t('fileTree.delete.folderWarning', 'This folder and all its contents will be permanently deleted.')
: t('fileTree.delete.fileWarning', 'This file will be permanently deleted.')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={operations.handleCancelDelete}
disabled={operationLoading}
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={operations.handleConfirmDelete}
disabled={operationLoading}
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{t('fileTree.delete.confirm', 'Delete')}
</button>
</div>
</div>
</div>
)}
{/* Toast Notification */}
{toast && (
<div
className={cn(
'fixed bottom-4 right-4 z-[9999] px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2',
toast.type === 'success'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'
)}
>
{toast.type === 'success' ? (
<Check className="h-4 w-4" />
) : (
<X className="h-4 w-4" />
)}
<span className="text-sm">{toast.message}</span>
</div>
)}
</div>
);
}