feat: Advanced file editor and file tree improvements (#444)

# Features
- File drag and drop upload: Support uploading files and folders via drag and drop
- Binary file handling: Detect binary files and display a friendly message instead of trying to edit them
- Folder download: Download folders as ZIP files (using JSZip library)
- Context menu integration: Full right-click context menu for file operations (rename, delete, copy path, download, new file/folder)
This commit is contained in:
朱见
2026-03-03 20:19:46 +08:00
committed by GitHub
parent 503c384685
commit 97689588aa
30 changed files with 2270 additions and 94 deletions

View File

@@ -1,12 +1,15 @@
import { useCallback, useState } from 'react';
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 ImageViewer from './ImageViewer';
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 FileTreeBody from './FileTreeBody';
@@ -14,24 +17,72 @@ import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState';
import { Project } from '../../../types/app';
import { Input } from '../../ui/input';
import { ScrollArea } from '../../ui/scroll-area';
type FileTreeProps = {
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);
const { files, loading } = useFileTreeData(selectedProject);
// 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 } = useExpandedDirectories();
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,
});
// 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)} />;
@@ -70,27 +121,99 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
}
return (
<div className="h-full flex flex-col bg-background">
<div
ref={upload.treeRef}
className="h-full flex flex-col bg-background relative"
onDragEnter={upload.handleDragEnter}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
>
{/* Drag overlay */}
{upload.isDragOver && (
<div className="absolute inset-0 z-50 bg-blue-500/10 border-2 border-dashed border-blue-500 flex items-center justify-center">
<div className="bg-background/95 px-6 py-4 rounded-lg shadow-lg flex items-center gap-3">
<Upload className="w-6 h-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}
onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')}
onRefresh={refreshFiles}
onCollapseAll={collapseAll}
loading={loading}
operationLoading={operations.operationLoading}
/>
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<FileTreeBody
files={files}
filteredFiles={filteredFiles}
searchQuery={searchQuery}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={handleItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTimeLabel}
/>
<ScrollArea className="flex-1 px-2 py-1">
{/* New item input */}
{operations.isCreating && (
<div
className="flex items-center gap-1.5 py-[3px] pr-2 mb-1"
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 text-sm flex-1"
disabled={operations.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={operations.operationLoading}
/>
</ScrollArea>
{selectedImage && (
<ImageViewer
@@ -98,6 +221,70 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
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="bg-background border border-border rounded-lg shadow-lg p-4 max-w-sm mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="w-5 h-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="text-sm text-muted-foreground mb-4">
{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={operations.operationLoading}
className="px-3 py-1.5 text-sm rounded-md hover:bg-accent transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={operations.handleConfirmDelete}
disabled={operations.operationLoading}
className="px-3 py-1.5 text-sm rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{operations.operationLoading && <Loader2 className="w-4 h-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="w-4 h-4" />
) : (
<X className="w-4 h-4" />
)}
<span className="text-sm">{toast.message}</span>
</div>
)}
</div>
);
}