mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-17 11:01:30 +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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
382
src/components/file-tree/hooks/useFileTreeOperations.ts
Normal file
382
src/components/file-tree/hooks/useFileTreeOperations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
205
src/components/file-tree/hooks/useFileTreeUpload.ts
Normal file
205
src/components/file-tree/hooks/useFileTreeUpload.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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