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

@@ -4,6 +4,7 @@ type UseExpandedDirectoriesResult = {
expandedDirs: Set<string>;
toggleDirectory: (path: string) => void;
expandDirectories: (paths: string[]) => void;
collapseAll: () => void;
};
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
@@ -35,10 +36,15 @@ export function useExpandedDirectories(): UseExpandedDirectoriesResult {
});
}, []);
const collapseAll = useCallback(() => {
setExpandedDirs(new Set());
}, []);
return {
expandedDirs,
toggleDirectory,
expandDirectories,
collapseAll,
};
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../../../utils/api';
import type { Project } from '../../../types/app';
import type { FileTreeNode } from '../types/types';
@@ -6,11 +6,18 @@ import type { FileTreeNode } from '../types/types';
type UseFileTreeDataResult = {
files: FileTreeNode[];
loading: boolean;
refreshFiles: () => void;
};
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
const [files, setFiles] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const refreshFiles = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
useEffect(() => {
const projectName = selectedProject?.name;
@@ -21,7 +28,12 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
return;
}
const abortController = new AbortController();
// Abort previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
// Track mount state so aborted or late responses do not enqueue stale state updates.
let isActive = true;
@@ -30,7 +42,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });
if (!response.ok) {
const errorText = await response.text();
@@ -65,12 +77,13 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
return () => {
isActive = false;
abortController.abort();
abortControllerRef.current?.abort();
};
}, [selectedProject?.name]);
}, [selectedProject?.name, refreshKey]);
return {
files,
loading,
refreshFiles,
};
}

View File

@@ -0,0 +1,382 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import JSZip from 'jszip';
import { api } from '../../../utils/api';
import type { FileTreeNode } from '../types/types';
import type { Project } from '../../../types/app';
// Invalid filename characters
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/;
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
export type ToastMessage = {
message: string;
type: 'success' | 'error';
};
export type DeleteConfirmation = {
isOpen: boolean;
item: FileTreeNode | null;
};
export type UseFileTreeOperationsOptions = {
selectedProject: Project | null;
onRefresh: () => void;
showToast: (message: string, type: 'success' | 'error') => void;
};
export type UseFileTreeOperationsResult = {
// Rename operations
renamingItem: FileTreeNode | null;
renameValue: string;
handleStartRename: (item: FileTreeNode) => void;
handleCancelRename: () => void;
handleConfirmRename: () => Promise<void>;
setRenameValue: (value: string) => void;
// Delete operations
deleteConfirmation: DeleteConfirmation;
handleStartDelete: (item: FileTreeNode) => void;
handleCancelDelete: () => void;
handleConfirmDelete: () => Promise<void>;
// Create operations
isCreating: boolean;
newItemParent: string;
newItemType: 'file' | 'directory';
newItemName: string;
handleStartCreate: (parentPath: string, type: 'file' | 'directory') => void;
handleCancelCreate: () => void;
handleConfirmCreate: () => Promise<void>;
setNewItemName: (name: string) => void;
// Other operations
handleCopyPath: (item: FileTreeNode) => void;
handleDownload: (item: FileTreeNode) => Promise<void>;
// Loading state
operationLoading: boolean;
// Validation
validateFilename: (name: string) => string | null;
};
export function useFileTreeOperations({
selectedProject,
onRefresh,
showToast,
}: UseFileTreeOperationsOptions): UseFileTreeOperationsResult {
const { t } = useTranslation();
// State
const [renamingItem, setRenamingItem] = useState<FileTreeNode | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation>({
isOpen: false,
item: null,
});
const [isCreating, setIsCreating] = useState(false);
const [newItemParent, setNewItemParent] = useState('');
const [newItemType, setNewItemType] = useState<'file' | 'directory'>('file');
const [newItemName, setNewItemName] = useState('');
const [operationLoading, setOperationLoading] = useState(false);
// Validation
const validateFilename = useCallback((name: string): string | null => {
if (!name || !name.trim()) {
return t('fileTree.validation.emptyName', 'Filename cannot be empty');
}
if (INVALID_FILENAME_CHARS.test(name)) {
return t('fileTree.validation.invalidChars', 'Filename contains invalid characters');
}
if (RESERVED_NAMES.test(name)) {
return t('fileTree.validation.reserved', 'Filename is a reserved name');
}
if (/^\.+$/.test(name)) {
return t('fileTree.validation.dotsOnly', 'Filename cannot be only dots');
}
return null;
}, [t]);
// Rename operations
const handleStartRename = useCallback((item: FileTreeNode) => {
setRenamingItem(item);
setRenameValue(item.name);
setIsCreating(false);
}, []);
const handleCancelRename = useCallback(() => {
setRenamingItem(null);
setRenameValue('');
}, []);
const handleConfirmRename = useCallback(async () => {
if (!renamingItem || !selectedProject) return;
const error = validateFilename(renameValue);
if (error) {
showToast(error, 'error');
return;
}
if (renameValue === renamingItem.name) {
handleCancelRename();
return;
}
setOperationLoading(true);
try {
const response = await api.renameFile(selectedProject.name, {
oldPath: renamingItem.path,
newName: renameValue,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to rename');
}
showToast(t('fileTree.toast.renamed', 'Renamed successfully'), 'success');
onRefresh();
handleCancelRename();
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [renamingItem, renameValue, selectedProject, validateFilename, showToast, t, onRefresh, handleCancelRename]);
// Delete operations
const handleStartDelete = useCallback((item: FileTreeNode) => {
setDeleteConfirmation({ isOpen: true, item });
}, []);
const handleCancelDelete = useCallback(() => {
setDeleteConfirmation({ isOpen: false, item: null });
}, []);
const handleConfirmDelete = useCallback(async () => {
const { item } = deleteConfirmation;
if (!item || !selectedProject) return;
setOperationLoading(true);
try {
const response = await api.deleteFile(selectedProject.name, {
path: item.path,
type: item.type,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete');
}
showToast(
item.type === 'directory'
? t('fileTree.toast.folderDeleted', 'Folder deleted')
: t('fileTree.toast.fileDeleted', 'File deleted'),
'success'
);
onRefresh();
handleCancelDelete();
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [deleteConfirmation, selectedProject, showToast, t, onRefresh, handleCancelDelete]);
// Create operations
const handleStartCreate = useCallback((parentPath: string, type: 'file' | 'directory') => {
setNewItemParent(parentPath || '');
setNewItemType(type);
setNewItemName(type === 'file' ? 'untitled.txt' : 'new-folder');
setIsCreating(true);
setRenamingItem(null);
}, []);
const handleCancelCreate = useCallback(() => {
setIsCreating(false);
setNewItemParent('');
setNewItemName('');
}, []);
const handleConfirmCreate = useCallback(async () => {
if (!selectedProject) return;
const error = validateFilename(newItemName);
if (error) {
showToast(error, 'error');
return;
}
setOperationLoading(true);
try {
const response = await api.createFile(selectedProject.name, {
path: newItemParent,
type: newItemType,
name: newItemName,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create');
}
showToast(
newItemType === 'file'
? t('fileTree.toast.fileCreated', 'File created successfully')
: t('fileTree.toast.folderCreated', 'Folder created successfully'),
'success'
);
onRefresh();
handleCancelCreate();
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [selectedProject, newItemParent, newItemType, newItemName, validateFilename, showToast, t, onRefresh, handleCancelCreate]);
// Copy path to clipboard
const handleCopyPath = useCallback((item: FileTreeNode) => {
navigator.clipboard.writeText(item.path).catch(() => {
// Clipboard API may fail in some contexts (e.g., non-HTTPS)
showToast(t('fileTree.toast.copyFailed', 'Failed to copy path'), 'error');
return;
});
showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');
}, [showToast, t]);
// Download file or folder
const handleDownload = useCallback(async (item: FileTreeNode) => {
if (!selectedProject) return;
setOperationLoading(true);
try {
if (item.type === 'directory') {
// Download folder as ZIP
await downloadFolderAsZip(item);
} else {
// Download single file
await downloadSingleFile(item);
}
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [selectedProject, showToast]);
// Download a single file
const downloadSingleFile = useCallback(async (item: FileTreeNode) => {
if (!selectedProject) return;
const response = await api.readFile(selectedProject.name, item.path);
if (!response.ok) {
throw new Error('Failed to download file');
}
const data = await response.json();
const content = data.content;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = item.name;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [selectedProject]);
// Download folder as ZIP
const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {
if (!selectedProject) return;
const zip = new JSZip();
// Recursively get all files in the folder
const collectFiles = async (node: FileTreeNode, currentPath: string) => {
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
if (node.type === 'file') {
// Fetch file content
const response = await api.readFile(selectedProject.name, node.path);
if (response.ok) {
const data = await response.json();
zip.file(fullPath, data.content);
}
} else if (node.type === 'directory' && node.children) {
// Recursively process children
for (const child of node.children) {
await collectFiles(child, fullPath);
}
}
};
// If the folder has children, process them
if (folder.children && folder.children.length > 0) {
for (const child of folder.children) {
await collectFiles(child, '');
}
}
// Generate ZIP file
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${folder.name}.zip`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');
}, [selectedProject, showToast, t]);
return {
// Rename operations
renamingItem,
renameValue,
handleStartRename,
handleCancelRename,
handleConfirmRename,
setRenameValue,
// Delete operations
deleteConfirmation,
handleStartDelete,
handleCancelDelete,
handleConfirmDelete,
// Create operations
isCreating,
newItemParent,
newItemType,
newItemName,
handleStartCreate,
handleCancelCreate,
handleConfirmCreate,
setNewItemName,
// Other operations
handleCopyPath,
handleDownload,
// Loading state
operationLoading,
// Validation
validateFilename,
};
}

View File

@@ -0,0 +1,205 @@
import { useCallback, useState, useRef } from 'react';
import type { Project } from '../../../types/app';
import { api } from '../../../utils/api';
type UseFileTreeUploadOptions = {
selectedProject: Project | null;
onRefresh: () => void;
showToast: (message: string, type: 'success' | 'error') => void;
};
// Helper function to read all files from a directory entry recursively
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
const files: File[] = [];
const reader = directoryEntry.createReader();
let entries: FileSystemEntry[] = [];
// Read all entries from the directory (may need multiple reads)
let batch: FileSystemEntry[];
do {
batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
entries = entries.concat(batch);
} while (batch.length > 0);
// Files to ignore (system files)
const ignoredFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini'];
for (const entry of entries) {
const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await new Promise<File>((resolve, reject) => {
fileEntry.file(resolve, reject);
});
// Skip ignored files
if (ignoredFiles.includes(file.name)) {
continue;
}
// Create a new file with the relative path as the name
const fileWithPath = new File([file], entryPath, {
type: file.type,
lastModified: file.lastModified,
});
files.push(fileWithPath);
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const subFiles = await readAllDirectoryEntries(dirEntry, entryPath);
files.push(...subFiles);
}
}
return files;
};
export const useFileTreeUpload = ({
selectedProject,
onRefresh,
showToast,
}: UseFileTreeUploadOptions) => {
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropTarget(null);
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || '';
setOperationLoading(true);
try {
const files: File[] = [];
// Use DataTransferItemList for folder support
const items = e.dataTransfer.items;
if (items) {
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
files.push(file);
} else if (entry.isDirectory) {
// Pass the directory name as basePath so files include the folder path
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
files.push(...dirFiles);
}
}
}
}
} else {
// Fallback for browsers that don't support webkitGetAsEntry
const fileList = e.dataTransfer.files;
for (const file of Array.from(fileList)) {
files.push(file);
}
}
if (files.length === 0) {
setOperationLoading(false);
setDropTarget(null);
return;
}
const formData = new FormData();
formData.append('targetPath', targetPath);
// Store relative paths separately since FormData strips path info from File.name
const relativePaths: string[] = [];
files.forEach((file) => {
// Create a new file with just the filename (without path) for FormData
// but store the relative path separately
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified
});
formData.append('files', cleanFile);
relativePaths.push(file.name); // Keep the full relative path
});
// Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post(
`/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`,
formData
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
showToast(
`Uploaded ${files.length} file(s)`,
'success'
);
onRefresh();
} catch (err) {
console.error('Upload error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
} finally {
setOperationLoading(false);
setDropTarget(null);
}
}, [dropTarget, selectedProject, onRefresh, showToast]);
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
}, []);
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
}, []);
return {
isDragOver,
dropTarget,
operationLoading,
treeRef,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
handleItemDragOver,
handleItemDrop,
setDropTarget,
};
};

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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>