mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 17:12:06 +08:00
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.
302 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|