mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-07 15:07:38 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { Folder, Search } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import type { FileTreeNode, FileTreeViewMode } from '../types/types';
|
||||
import FileTreeEmptyState from './FileTreeEmptyState';
|
||||
import FileTreeList from './FileTreeList';
|
||||
@@ -16,6 +15,21 @@ type FileTreeBodyProps = {
|
||||
renderFileIcon: (filename: string) => ReactNode;
|
||||
formatFileSize: (bytes?: number) => string;
|
||||
formatRelativeTime: (date?: string) => string;
|
||||
onRename?: (item: FileTreeNode) => void;
|
||||
onDelete?: (item: FileTreeNode) => void;
|
||||
onNewFile?: (path: string) => void;
|
||||
onNewFolder?: (path: string) => void;
|
||||
onCopyPath?: (item: FileTreeNode) => void;
|
||||
onDownload?: (item: FileTreeNode) => void;
|
||||
onRefresh?: () => void;
|
||||
// Rename state for inline editing
|
||||
renamingItem?: FileTreeNode | null;
|
||||
renameValue?: string;
|
||||
setRenameValue?: (value: string) => void;
|
||||
handleConfirmRename?: () => void;
|
||||
handleCancelRename?: () => void;
|
||||
renameInputRef?: RefObject<HTMLInputElement>;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function FileTreeBody({
|
||||
@@ -28,11 +42,25 @@ export default function FileTreeBody({
|
||||
renderFileIcon,
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
handleConfirmRename,
|
||||
handleCancelRename,
|
||||
renameInputRef,
|
||||
operationLoading,
|
||||
}: FileTreeBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
<>
|
||||
{files.length === 0 ? (
|
||||
<FileTreeEmptyState
|
||||
icon={Folder}
|
||||
@@ -54,9 +82,22 @@ export default function FileTreeBody({
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
handleConfirmRename={handleConfirmRename}
|
||||
handleCancelRename={handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Eye, List, Search, TableProperties, X } from 'lucide-react';
|
||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { FileTreeViewMode } from '../types/types';
|
||||
|
||||
type FileTreeHeaderProps = {
|
||||
@@ -9,6 +10,14 @@ type FileTreeHeaderProps = {
|
||||
onViewModeChange: (mode: FileTreeViewMode) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
// Toolbar actions
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function FileTreeHeader({
|
||||
@@ -16,20 +25,83 @@ export default function FileTreeHeader({
|
||||
onViewModeChange,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onRefresh,
|
||||
onCollapseAll,
|
||||
loading,
|
||||
operationLoading,
|
||||
}: FileTreeHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
|
||||
{/* Title and Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Action buttons */}
|
||||
{onNewFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onNewFile}
|
||||
title={t('fileTree.newFile', 'New File (Cmd+N)')}
|
||||
aria-label={t('fileTree.newFile', 'New File (Cmd+N)')}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onNewFolder && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onNewFolder}
|
||||
title={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
|
||||
aria-label={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onRefresh}
|
||||
title={t('fileTree.refresh', 'Refresh')}
|
||||
aria-label={t('fileTree.refresh', 'Refresh')}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
{onCollapseAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onCollapseAll}
|
||||
title={t('fileTree.collapseAll', 'Collapse All')}
|
||||
aria-label={t('fileTree.collapseAll', 'Collapse All')}
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
{/* View mode buttons */}
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('simple')}
|
||||
title={t('fileTree.simpleView')}
|
||||
aria-label={t('fileTree.simpleView')}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -39,6 +111,7 @@ export default function FileTreeHeader({
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title={t('fileTree.compactView')}
|
||||
aria-label={t('fileTree.compactView')}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -48,12 +121,14 @@ export default function FileTreeHeader({
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('detailed')}
|
||||
title={t('fileTree.detailedView')}
|
||||
aria-label={t('fileTree.detailedView')}
|
||||
>
|
||||
<TableProperties className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -70,6 +145,7 @@ export default function FileTreeHeader({
|
||||
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
|
||||
onClick={() => onSearchQueryChange('')}
|
||||
title={t('fileTree.clearSearch')}
|
||||
aria-label={t('fileTree.clearSearch')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -78,4 +154,3 @@ export default function FileTreeHeader({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
|
||||
import FileTreeNode from './FileTreeNode';
|
||||
|
||||
@@ -10,6 +10,21 @@ type FileTreeListProps = {
|
||||
renderFileIcon: (filename: string) => ReactNode;
|
||||
formatFileSize: (bytes?: number) => string;
|
||||
formatRelativeTime: (date?: string) => string;
|
||||
onRename?: (item: FileTreeNodeType) => void;
|
||||
onDelete?: (item: FileTreeNodeType) => void;
|
||||
onNewFile?: (path: string) => void;
|
||||
onNewFolder?: (path: string) => void;
|
||||
onCopyPath?: (item: FileTreeNodeType) => void;
|
||||
onDownload?: (item: FileTreeNodeType) => void;
|
||||
onRefresh?: () => void;
|
||||
// Rename state for inline editing
|
||||
renamingItem?: FileTreeNodeType | null;
|
||||
renameValue?: string;
|
||||
setRenameValue?: (value: string) => void;
|
||||
handleConfirmRename?: () => void;
|
||||
handleCancelRename?: () => void;
|
||||
renameInputRef?: RefObject<HTMLInputElement>;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function FileTreeList({
|
||||
@@ -20,6 +35,20 @@ export default function FileTreeList({
|
||||
renderFileIcon,
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
handleConfirmRename,
|
||||
handleCancelRename,
|
||||
renameInputRef,
|
||||
operationLoading,
|
||||
}: FileTreeListProps) {
|
||||
return (
|
||||
<div>
|
||||
@@ -34,9 +63,22 @@ export default function FileTreeList({
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
handleConfirmRename={handleConfirmRename}
|
||||
handleCancelRename={handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import FileContextMenu from '../../FileContextMenu';
|
||||
import { Input } from '../../ui/input';
|
||||
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
|
||||
|
||||
type FileTreeNodeProps = {
|
||||
@@ -12,6 +14,21 @@ type FileTreeNodeProps = {
|
||||
renderFileIcon: (filename: string) => ReactNode;
|
||||
formatFileSize: (bytes?: number) => string;
|
||||
formatRelativeTime: (date?: string) => string;
|
||||
onRename?: (item: FileTreeNodeType) => void;
|
||||
onDelete?: (item: FileTreeNodeType) => void;
|
||||
onNewFile?: (path: string) => void;
|
||||
onNewFolder?: (path: string) => void;
|
||||
onCopyPath?: (item: FileTreeNodeType) => void;
|
||||
onDownload?: (item: FileTreeNodeType) => void;
|
||||
onRefresh?: () => void;
|
||||
// Rename state for inline editing
|
||||
renamingItem?: FileTreeNodeType | null;
|
||||
renameValue?: string;
|
||||
setRenameValue?: (value: string) => void;
|
||||
handleConfirmRename?: () => void;
|
||||
handleCancelRename?: () => void;
|
||||
renameInputRef?: RefObject<HTMLInputElement>;
|
||||
operationLoading?: boolean;
|
||||
};
|
||||
|
||||
type TreeItemIconProps = {
|
||||
@@ -51,10 +68,25 @@ export default function FileTreeNode({
|
||||
renderFileIcon,
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
handleConfirmRename,
|
||||
handleCancelRename,
|
||||
renameInputRef,
|
||||
operationLoading,
|
||||
}: FileTreeNodeProps) {
|
||||
const isDirectory = item.type === 'directory';
|
||||
const isOpen = isDirectory && expandedDirs.has(item.path);
|
||||
const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);
|
||||
const isRenaming = renamingItem?.path === item.path;
|
||||
|
||||
const nameClassName = cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
@@ -72,47 +104,100 @@ export default function FileTreeNode({
|
||||
(isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
// Render rename input if this item is being renamed
|
||||
if (isRenaming && setRenameValue && handleConfirmRename && handleCancelRename) {
|
||||
return (
|
||||
<div
|
||||
className={rowClassName}
|
||||
className={cn(rowClassName, 'bg-accent/30')}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => onItemClick(item)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{viewMode === 'detailed' ? (
|
||||
<>
|
||||
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : ''}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div>
|
||||
</>
|
||||
) : viewMode === 'compact' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span className="tabular-nums">{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue || ''}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') handleConfirmRename();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
handleConfirmRename();
|
||||
}, 100);
|
||||
}}
|
||||
className="h-6 text-sm flex-1"
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClassName}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
{viewMode === 'detailed' ? (
|
||||
<>
|
||||
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : ''}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div>
|
||||
</>
|
||||
) : viewMode === 'compact' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span className="tabular-nums">{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
|
||||
<span className={nameClassName}>{item.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Check if context menu callbacks are provided
|
||||
const hasContextMenu = onRename || onDelete || onNewFile || onNewFolder || onCopyPath || onDownload || onRefresh;
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
{hasContextMenu ? (
|
||||
<FileContextMenu
|
||||
item={item}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
>
|
||||
{rowContent}
|
||||
</FileContextMenu>
|
||||
) : (
|
||||
rowContent
|
||||
)}
|
||||
|
||||
{isDirectory && isOpen && hasChildren && (
|
||||
<div className="relative">
|
||||
@@ -132,6 +217,20 @@ export default function FileTreeNode({
|
||||
renderFileIcon={renderFileIcon}
|
||||
formatFileSize={formatFileSize}
|
||||
formatRelativeTime={formatRelativeTime}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onNewFile={onNewFile}
|
||||
onNewFolder={onNewFolder}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownload={onDownload}
|
||||
onRefresh={onRefresh}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
handleConfirmRename={handleConfirmRename}
|
||||
handleCancelRename={handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user