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