Merge pull request #760 from ZacharyZcR/fix/folder-drag-upload

fix: support folder drag-and-drop upload in file manager
This commit is contained in:
ZacharyZcR
2026-05-18 04:36:41 +08:00
committed by GitHub
2 changed files with 135 additions and 1 deletions
@@ -257,6 +257,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { dragHandlers } = useDragAndDrop({ const { dragHandlers } = useDragAndDrop({
onFilesDropped: handleFilesDropped, onFilesDropped: handleFilesDropped,
onItemsDropped: handleItemsDropped,
onError: (error) => toast.error(error), onError: (error) => toast.error(error),
maxFileSize: 5120, maxFileSize: 5120,
}); });
@@ -788,6 +789,127 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentPath]); }, [currentPath]);
async function handleItemsDropped(items: DataTransferItemList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
const files: { file: File; relativePath: string }[] = [];
async function readEntry(
entry: FileSystemEntry,
path: string,
): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
);
files.push({ file, relativePath: path });
} else if (entry.isDirectory) {
const reader = (
entry as FileSystemDirectoryEntry
).createReader();
const dirEntries = await new Promise<FileSystemEntry[]>(
(resolve, reject) => reader.readEntries(resolve, reject),
);
for (const child of dirEntries) {
await readEntry(child, `${path}/${child.name}`);
}
}
}
for (const entry of entries) {
await readEntry(entry, entry.name);
}
if (files.length === 0) return;
const progressToast = toast.loading(
`Uploading ${files.length} file(s)...`,
{ duration: Infinity },
);
try {
await ensureSSHConnection();
const dirs = new Set<string>();
for (const { relativePath } of files) {
const parts = relativePath.split("/");
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const sortedDirs = Array.from(dirs).sort();
for (const dir of sortedDirs) {
const parentPath = currentPath.endsWith("/")
? currentPath + dir.split("/").slice(0, -1).join("/")
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
const folderName = dir.split("/").pop()!;
const targetPath = parentPath.endsWith("/")
? parentPath
: parentPath + "/";
try {
await createSSHFolder(
sshSessionId,
targetPath,
folderName,
currentHost?.id,
);
} catch {
// directory may already exist
}
}
for (const { file, relativePath } of files) {
const dirPart = relativePath.includes("/")
? relativePath.substring(0, relativePath.lastIndexOf("/"))
: "";
const uploadPath = dirPart
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
dirPart +
"/"
: currentPath;
const fileContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result.split(",")[1] || "");
} else {
reject(new Error("Failed to read file"));
}
};
reader.readAsDataURL(file);
});
await uploadSSHFile(
sshSessionId,
uploadPath,
file.name,
fileContent,
currentHost?.id,
);
}
toast.dismiss(progressToast);
toast.success(`Uploaded ${files.length} file(s) successfully`);
handleRefreshDirectory();
} catch (error) {
toast.dismiss(progressToast);
toast.error(t("fileManager.failedToUploadFile"));
console.error("Folder upload failed:", error);
}
}
function handleFilesDropped(fileList: FileList) { function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) { if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection")); toast.error(t("fileManager.noSSHConnection"));
@@ -8,6 +8,7 @@ interface DragAndDropState {
interface UseDragAndDropProps { interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void; onFilesDropped: (files: FileList) => void;
onItemsDropped?: (items: DataTransferItemList) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
maxFileSize?: number; maxFileSize?: number;
allowedTypes?: string[]; allowedTypes?: string[];
@@ -15,6 +16,7 @@ interface UseDragAndDropProps {
export function useDragAndDrop({ export function useDragAndDrop({
onFilesDropped, onFilesDropped,
onItemsDropped,
onError, onError,
maxFileSize = 5120, maxFileSize = 5120,
allowedTypes = [], allowedTypes = [],
@@ -123,6 +125,16 @@ export function useDragAndDrop({
draggedFiles: [], draggedFiles: [],
}); });
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
const hasDirectory = Array.from(e.dataTransfer.items).some(
(item) => item.webkitGetAsEntry?.()?.isDirectory,
);
if (hasDirectory) {
onItemsDropped(e.dataTransfer.items);
return;
}
}
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files.length === 0) { if (files.length === 0) {
@@ -137,7 +149,7 @@ export function useDragAndDrop({
onFilesDropped(files); onFilesDropped(files);
}, },
[validateFiles, onFilesDropped, onError], [validateFiles, onFilesDropped, onItemsDropped, onError],
); );
const resetDragState = useCallback(() => { const resetDragState = useCallback(() => {