mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -0,0 +1,178 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface DragAndDropState {
|
||||
isDragging: boolean;
|
||||
dragCounter: number;
|
||||
draggedFiles: File[];
|
||||
}
|
||||
|
||||
interface UseDragAndDropProps {
|
||||
onFilesDropped: (files: FileList) => void;
|
||||
onItemsDropped?: (entries: FileSystemEntry[]) => void;
|
||||
onError?: (error: string) => void;
|
||||
maxFileSize?: number;
|
||||
allowedTypes?: string[];
|
||||
}
|
||||
|
||||
export function useDragAndDrop({
|
||||
onFilesDropped,
|
||||
onItemsDropped,
|
||||
onError,
|
||||
maxFileSize = 5120,
|
||||
allowedTypes = [],
|
||||
}: UseDragAndDropProps) {
|
||||
const [state, setState] = useState<DragAndDropState>({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
|
||||
const validateFiles = useCallback(
|
||||
(files: FileList): string | null => {
|
||||
const maxSizeBytes = maxFileSize * 1024 * 1024;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
if (file.size > maxSizeBytes) {
|
||||
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
|
||||
}
|
||||
|
||||
if (allowedTypes.length > 0) {
|
||||
const fileExt = file.name.split(".").pop()?.toLowerCase();
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
const isAllowed = allowedTypes.some((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
return fileExt === type.slice(1);
|
||||
}
|
||||
if (type.includes("/")) {
|
||||
return (
|
||||
mimeType === type || mimeType.startsWith(type.replace("*", ""))
|
||||
);
|
||||
}
|
||||
switch (type) {
|
||||
case "image":
|
||||
return mimeType.startsWith("image/");
|
||||
case "video":
|
||||
return mimeType.startsWith("video/");
|
||||
case "audio":
|
||||
return mimeType.startsWith("audio/");
|
||||
case "text":
|
||||
return mimeType.startsWith("text/");
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
return `File type "${file.type || "unknown"}" is not allowed.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[maxFileSize, allowedTypes],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
dragCounter: prev.dragCounter + 1,
|
||||
}));
|
||||
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: true,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setState((prev) => {
|
||||
const newCounter = prev.dragCounter - 1;
|
||||
return {
|
||||
...prev,
|
||||
dragCounter: newCounter,
|
||||
isDragging: newCounter > 0,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Read the entries before touching state. Updating state flushes a render
|
||||
// and the browser clears dataTransfer once the drop handler unwinds, so
|
||||
// anything read later comes back empty (Firefox is strictest here).
|
||||
const entries: FileSystemEntry[] = [];
|
||||
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
|
||||
for (const item of Array.from(e.dataTransfer.items)) {
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
const files = e.dataTransfer.files;
|
||||
|
||||
setState({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
|
||||
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
|
||||
onItemsDropped(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateFiles(files);
|
||||
if (validationError) {
|
||||
onError?.(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
onFilesDropped(files);
|
||||
},
|
||||
[validateFiles, onFilesDropped, onItemsDropped, onError],
|
||||
);
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
setState({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDragging: state.isDragging,
|
||||
dragHandlers: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDragOver: handleDragOver,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
resetDragState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "../api/ssh-file-operations-api";
|
||||
import type { FileItem, SSHHost } from "@/types/index.js";
|
||||
|
||||
interface DragToDesktopState {
|
||||
isDragging: boolean;
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseDragToDesktopProps {
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
}
|
||||
|
||||
interface DragToDesktopOptions {
|
||||
enableToast?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
|
||||
const [state, setState] = useState<DragToDesktopState>({
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const isElectron = () => {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.electronAPI &&
|
||||
window.electronAPI.isElectron
|
||||
);
|
||||
};
|
||||
|
||||
const dragFileToDesktop = useCallback(
|
||||
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.type !== "file") {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (!response?.content) {
|
||||
throw new Error("Unable to get file content");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 50 }));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFile({
|
||||
fileName: file.name,
|
||||
content: response.content,
|
||||
encoding: "base64",
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary file",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`Dragging ${file.name} to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 10000);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to drag to desktop:", error);
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
isDragging: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
const dragFilesToDesktop = useCallback(
|
||||
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileList.length === 1) {
|
||||
return dragFileToDesktop(fileList[0], options);
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const downloadPromises = fileList.map((file) =>
|
||||
downloadSSHFile(sshSessionId, file.path),
|
||||
);
|
||||
|
||||
const responses = await Promise.all(downloadPromises);
|
||||
setState((prev) => ({ ...prev, progress: 40 }));
|
||||
|
||||
const folderName = `Files_${Date.now()}`;
|
||||
const filesData = fileList.map((file, index) => ({
|
||||
relativePath: file.name,
|
||||
content: responses[index]?.content || "",
|
||||
encoding: "base64" as const,
|
||||
}));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFolder({
|
||||
folderName,
|
||||
files: filesData,
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary folder",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: folderName,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`Dragging ${fileList.length} files to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 15000);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to batch drag to desktop:", error);
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Batch drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
isDragging: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Batch drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId, dragFileToDesktop],
|
||||
);
|
||||
|
||||
const dragFolderToDesktop = useCallback(
|
||||
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (folder.type !== "directory") {
|
||||
const error = "Only folder types can be dragged";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableToast) {
|
||||
toast.info("Folder drag functionality is under development...");
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isElectron: isElectron(),
|
||||
dragFileToDesktop,
|
||||
dragFilesToDesktop,
|
||||
dragFolderToDesktop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "../api/ssh-file-operations-api";
|
||||
import type { FileItem, SSHHost } from "@/types/index.js";
|
||||
|
||||
interface DragToSystemState {
|
||||
isDragging: boolean;
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseDragToSystemProps {
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
}
|
||||
|
||||
interface DragToSystemOptions {
|
||||
enableToast?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
|
||||
const [state, setState] = useState<DragToSystemState>({
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const dragDataRef = useRef<{
|
||||
files: FileItem[];
|
||||
options: DragToSystemOptions;
|
||||
} | null>(null);
|
||||
|
||||
const saveLastDirectory = async (fileHandle: {
|
||||
getParent?: () => Promise<unknown>;
|
||||
}) => {
|
||||
try {
|
||||
if ("indexedDB" in window && fileHandle.getParent) {
|
||||
const dirHandle = await fileHandle.getParent();
|
||||
const request = indexedDB.open("termix-dirs", 1);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction(["directories"], "readwrite");
|
||||
const store = transaction.objectStore("directories");
|
||||
store.put({ handle: dirHandle }, "lastSaveDir");
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Drag operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isFileSystemAPISupported = () => {
|
||||
return "showSaveFilePicker" in window;
|
||||
};
|
||||
|
||||
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||
const margin = 50;
|
||||
return (
|
||||
e.clientX < margin ||
|
||||
e.clientX > window.innerWidth - margin ||
|
||||
e.clientY < margin ||
|
||||
e.clientY > window.innerHeight - margin
|
||||
);
|
||||
};
|
||||
|
||||
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
if (!response?.content) {
|
||||
throw new Error(`Unable to get content for file ${file.name}`);
|
||||
}
|
||||
|
||||
const binaryString = atob(response.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes]);
|
||||
};
|
||||
|
||||
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of files) {
|
||||
const blob = await createFileBlob(file);
|
||||
zip.file(file.name, blob);
|
||||
}
|
||||
|
||||
return await zip.generateAsync({ type: "blob" });
|
||||
};
|
||||
|
||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDragToSystem = useCallback(
|
||||
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (files.length === 0) {
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const fileName =
|
||||
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
|
||||
|
||||
let fileHandle: {
|
||||
createWritable?: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
getParent?: () => Promise<unknown>;
|
||||
} | null = null;
|
||||
if (isFileSystemAPISupported()) {
|
||||
try {
|
||||
fileHandle = await (
|
||||
window as Window & {
|
||||
showSaveFilePicker?: (options: {
|
||||
suggestedName: string;
|
||||
startIn: string;
|
||||
types: Array<{
|
||||
description: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
}) => Promise<{
|
||||
createWritable?: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
getParent?: () => Promise<unknown>;
|
||||
}>;
|
||||
}
|
||||
).showSaveFilePicker!({
|
||||
suggestedName: fileName,
|
||||
startIn: "desktop",
|
||||
types: [
|
||||
{
|
||||
description: "Files",
|
||||
accept: {
|
||||
"*/*": [
|
||||
".txt",
|
||||
".jpg",
|
||||
".png",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string };
|
||||
if (err.name === "AbortError") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let blob: Blob;
|
||||
if (fileList.length === 1) {
|
||||
blob = await createFileBlob(fileList[0]);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
} else {
|
||||
blob = await createZipBlob(fileList);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 90 }));
|
||||
|
||||
if (fileHandle) {
|
||||
await saveLastDirectory(fileHandle);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} else {
|
||||
fallbackDownload(blob, fileName);
|
||||
if (enableToast) {
|
||||
toast.info(
|
||||
"Due to browser limitations, file will be downloaded to default download directory",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(
|
||||
fileList.length === 1
|
||||
? `${fileName} saved to specified location`
|
||||
: `${fileList.length} files packaged and saved`,
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Save failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Save failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
const startDragToSystem = useCallback(
|
||||
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
dragDataRef.current = { files, options };
|
||||
setState((prev) => ({ ...prev, isDragging: true, error: null }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!dragDataRef.current) return;
|
||||
|
||||
const { files, options } = dragDataRef.current;
|
||||
|
||||
if (isDraggedOutsideWindow(e)) {
|
||||
handleDragToSystem(files, options);
|
||||
}
|
||||
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false }));
|
||||
},
|
||||
[handleDragToSystem],
|
||||
);
|
||||
|
||||
const cancelDragToSystem = useCallback(() => {
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isFileSystemAPISupported: isFileSystemAPISupported(),
|
||||
startDragToSystem,
|
||||
handleDragEnd,
|
||||
cancelDragToSystem,
|
||||
handleDragToSystem,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export function useFileSelection() {
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
|
||||
|
||||
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
|
||||
if (multiSelect) {
|
||||
setSelectedFiles((prev) => {
|
||||
const isSelected = prev.some((f) => f.path === file.path);
|
||||
if (isSelected) {
|
||||
return prev.filter((f) => f.path !== file.path);
|
||||
} else {
|
||||
return [...prev, file];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setSelectedFiles([file]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectRange = useCallback(
|
||||
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
|
||||
const startIndex = files.findIndex((f) => f.path === startFile.path);
|
||||
const endIndex = files.findIndex((f) => f.path === endFile.path);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
const rangeFiles = files.slice(start, end + 1);
|
||||
setSelectedFiles(rangeFiles);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectAll = useCallback((files: FileItem[]) => {
|
||||
setSelectedFiles([...files]);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
}, []);
|
||||
|
||||
const toggleSelection = useCallback((file: FileItem) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const isSelected = prev.some((f) => f.path === file.path);
|
||||
if (isSelected) {
|
||||
return prev.filter((f) => f.path !== file.path);
|
||||
} else {
|
||||
return [...prev, file];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(file: FileItem) => {
|
||||
return selectedFiles.some((f) => f.path === file.path);
|
||||
},
|
||||
[selectedFiles],
|
||||
);
|
||||
|
||||
const getSelectedCount = useCallback(() => {
|
||||
return selectedFiles.length;
|
||||
}, [selectedFiles]);
|
||||
|
||||
const setSelection = useCallback((files: FileItem[]) => {
|
||||
setSelectedFiles(files);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
selectRange,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
toggleSelection,
|
||||
isSelected,
|
||||
getSelectedCount,
|
||||
setSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { createElement } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "@/types/index";
|
||||
import type { LocalWalkResult } from "@/types/electron";
|
||||
import {
|
||||
cancelLocalTransfer,
|
||||
createLocalTransferId,
|
||||
downloadSessionFileToLocal,
|
||||
uploadLocalFileToSession,
|
||||
} from "../api/local-transfer-api";
|
||||
import { createSSHFolder, listSSHFiles } from "../api/ssh-file-operations-api";
|
||||
import {
|
||||
ensureLocalDirectory,
|
||||
getLocalHome,
|
||||
localPathsExist,
|
||||
walkLocalPaths,
|
||||
} from "@/lib/local-files.ts";
|
||||
import {
|
||||
UnsafeLocalNameError,
|
||||
buildLocalDestination,
|
||||
getTransferConcurrency,
|
||||
runWithConcurrency,
|
||||
joinRemotePath,
|
||||
planRemoteDirectories,
|
||||
remoteBaseName,
|
||||
remoteDirForRelativePath,
|
||||
} from "../local-transfer-utils.ts";
|
||||
import {
|
||||
LocalTransferProgressToast,
|
||||
type LocalTransferBatchStatus,
|
||||
} from "../components/LocalTransferProgressToast.tsx";
|
||||
|
||||
interface UseLocalTransfersOptions {
|
||||
sshSessionId: string | null;
|
||||
hostId?: number;
|
||||
ensureSSHConnection: () => Promise<unknown>;
|
||||
/** Called after uploads so the remote listing can refresh. */
|
||||
onRemoteChanged: (remoteDir: string) => void;
|
||||
/** Called after downloads so the local pane can refresh. */
|
||||
onLocalChanged: (localDir: string) => void;
|
||||
}
|
||||
|
||||
interface RemoteDownloadPlanEntry {
|
||||
remotePath: string;
|
||||
/** "/"-separated, includes the dragged root's own name. */
|
||||
relativePath: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks whether existing local files may be replaced. Resolves `true` only on
|
||||
* an explicit "Replace"; dismissing, auto-close and "Skip" all mean skip, so
|
||||
* the batch can never overwrite silently.
|
||||
*/
|
||||
function askReplaceExisting(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
count: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
toast.warning(t("fileManager.localReplaceExistingPrompt", { count }), {
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: t("fileManager.localReplace"),
|
||||
onClick: () => settle(true),
|
||||
},
|
||||
cancel: {
|
||||
label: t("fileManager.localSkipExisting"),
|
||||
onClick: () => settle(false),
|
||||
},
|
||||
onDismiss: () => settle(false),
|
||||
onAutoClose: () => settle(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One line a user can act on, taken from whatever the bridge or backend said. */
|
||||
function describeTransferError(error: unknown): string | undefined {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? "").trim();
|
||||
return message ? message.slice(0, 200) : undefined;
|
||||
}
|
||||
|
||||
class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
function createSpeedometer() {
|
||||
let lastBytes = 0;
|
||||
let lastTime = Date.now();
|
||||
let mbPerSec: number | undefined;
|
||||
return (bytesDone: number) => {
|
||||
const now = Date.now();
|
||||
const deltaMs = now - lastTime;
|
||||
if (deltaMs >= 300) {
|
||||
const deltaBytes = bytesDone - lastBytes;
|
||||
if (deltaBytes >= 0) {
|
||||
mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
|
||||
}
|
||||
lastBytes = bytesDone;
|
||||
lastTime = now;
|
||||
}
|
||||
return mbPerSec;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates batched local<->remote transfers for the dual-pane file
|
||||
* manager: expands folders, creates directory skeletons, streams files one by
|
||||
* one through the Electron main process, and reports aggregate progress in a
|
||||
* single toast with a cancel button.
|
||||
*/
|
||||
export function useLocalTransfers({
|
||||
sshSessionId,
|
||||
hostId,
|
||||
ensureSSHConnection,
|
||||
onRemoteChanged,
|
||||
onLocalChanged,
|
||||
}: UseLocalTransfersOptions) {
|
||||
const { t } = useTranslation();
|
||||
const batchCounter = useRef(0);
|
||||
|
||||
const runBatch = useCallback(
|
||||
async (
|
||||
direction: "upload" | "download",
|
||||
totalFiles: number,
|
||||
totalBytes: number,
|
||||
work: (ctx: {
|
||||
isCancelled: () => boolean;
|
||||
/** Marks a transfer as in flight so Cancel can reach it. */
|
||||
trackTransfer: (id: string, fileName: string) => void;
|
||||
/** Progress of one in-flight transfer; bytes are summed across all. */
|
||||
progress: (id: string, transferred: number) => void;
|
||||
/** Transfer finished (success or failure); its size is now settled. */
|
||||
settleTransfer: (id: string, size: number) => void;
|
||||
}) => Promise<{ failed: string[]; reason?: string }>,
|
||||
) => {
|
||||
batchCounter.current += 1;
|
||||
const toastId = `local-transfer-${batchCounter.current}`;
|
||||
let cancelled = false;
|
||||
let cancelling = false;
|
||||
const inFlight = new Map<
|
||||
string,
|
||||
{ fileName: string; transferred: number }
|
||||
>();
|
||||
let settledBytes = 0;
|
||||
let completedFiles = 0;
|
||||
let lastFileName: string | undefined;
|
||||
const speed = createSpeedometer();
|
||||
|
||||
const status: LocalTransferBatchStatus = {
|
||||
direction,
|
||||
totalFiles,
|
||||
completedFiles: 0,
|
||||
bytesDone: 0,
|
||||
totalBytes,
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
toast.loading(
|
||||
createElement(LocalTransferProgressToast, {
|
||||
status: { ...status, cancelling },
|
||||
onCancel: () => {
|
||||
cancelled = true;
|
||||
cancelling = true;
|
||||
render();
|
||||
for (const id of inFlight.keys()) void cancelLocalTransfer(id);
|
||||
},
|
||||
}),
|
||||
{ id: toastId, duration: Infinity },
|
||||
);
|
||||
};
|
||||
render();
|
||||
|
||||
try {
|
||||
const refresh = () => {
|
||||
let bytesDone = settledBytes;
|
||||
for (const entry of inFlight.values()) bytesDone += entry.transferred;
|
||||
status.completedFiles = completedFiles;
|
||||
status.bytesDone = bytesDone;
|
||||
status.activeFiles = inFlight.size;
|
||||
status.currentFileName =
|
||||
lastFileName ?? inFlight.values().next().value?.fileName;
|
||||
status.mbPerSec = speed(bytesDone);
|
||||
render();
|
||||
};
|
||||
const { failed, reason } = await work({
|
||||
isCancelled: () => cancelled,
|
||||
trackTransfer: (id, fileName) => {
|
||||
inFlight.set(id, { fileName, transferred: 0 });
|
||||
lastFileName = fileName;
|
||||
refresh();
|
||||
},
|
||||
progress: (id, transferred) => {
|
||||
const entry = inFlight.get(id);
|
||||
if (!entry) return;
|
||||
entry.transferred = transferred;
|
||||
lastFileName = entry.fileName;
|
||||
refresh();
|
||||
},
|
||||
settleTransfer: (id, size) => {
|
||||
inFlight.delete(id);
|
||||
settledBytes += size;
|
||||
completedFiles += 1;
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
toast.dismiss(toastId);
|
||||
if (cancelled) {
|
||||
toast.info(t("fileManager.localTransferCancelled"));
|
||||
return;
|
||||
}
|
||||
const key = direction === "upload" ? "Upload" : "Download";
|
||||
if (failed.length === 0) {
|
||||
toast.success(
|
||||
t(`fileManager.local${key}Complete`, { count: totalFiles }),
|
||||
);
|
||||
} else if (failed.length === totalFiles) {
|
||||
// Every file failed the same way more often than not (auth,
|
||||
// permissions, a dead session); say why instead of just "failed".
|
||||
toast.error(
|
||||
t(`fileManager.local${key}Failed`),
|
||||
reason ? { description: reason } : undefined,
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
t(`fileManager.local${key}Partial`, {
|
||||
done: totalFiles - failed.length,
|
||||
failed: failed.length,
|
||||
}),
|
||||
reason ? { description: reason } : undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.dismiss(toastId);
|
||||
if (error instanceof TransferCancelledError || cancelled) {
|
||||
toast.info(t("fileManager.localTransferCancelled"));
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? "");
|
||||
toast.error(
|
||||
direction === "upload"
|
||||
? t("fileManager.localUploadFailed")
|
||||
: t("fileManager.localDownloadFailed"),
|
||||
message ? { description: message } : undefined,
|
||||
);
|
||||
console.error(`Local ${direction} batch failed:`, error);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
/** Uploads local files/folders (by absolute path) into a remote directory. */
|
||||
const uploadLocalPaths = useCallback(
|
||||
async (localPaths: string[], remoteDir: string) => {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
if (localPaths.length === 0) return;
|
||||
|
||||
let plan: LocalWalkResult;
|
||||
try {
|
||||
plan = await walkLocalPaths(localPaths);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("fileManager.localUploadFailed"),
|
||||
error instanceof Error ? { description: error.message } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (plan.files.length === 0 && plan.emptyDirs.length === 0) {
|
||||
toast.info(t("fileManager.localNothingToTransfer"));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = sshSessionId;
|
||||
await runBatch(
|
||||
"upload",
|
||||
plan.files.length,
|
||||
plan.totalBytes,
|
||||
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const dirs = planRemoteDirectories(
|
||||
plan.files.map((f) => f.relativePath),
|
||||
plan.emptyDirs,
|
||||
);
|
||||
for (const dir of dirs) {
|
||||
if (isCancelled()) throw new TransferCancelledError();
|
||||
const parent = dir.includes("/")
|
||||
? joinRemotePath(remoteDir, dir.slice(0, dir.lastIndexOf("/")))
|
||||
: remoteDir;
|
||||
const name = dir.split("/").pop()!;
|
||||
// The backend tolerates existing directories (#1442), so a
|
||||
// failure here is real and should stop the batch up front.
|
||||
await createSSHFolder(sessionId, parent, name, hostId);
|
||||
}
|
||||
|
||||
// Files go up several at a time (see "Simultaneous File Transfers"
|
||||
// in the profile settings); directories were created above, so
|
||||
// order between files no longer matters.
|
||||
const failed: string[] = [];
|
||||
let reason: string | undefined;
|
||||
await runWithConcurrency(
|
||||
plan.files,
|
||||
getTransferConcurrency(),
|
||||
async (file) => {
|
||||
const fileName = file.relativePath.split("/").pop()!;
|
||||
const targetDir = remoteDirForRelativePath(
|
||||
remoteDir,
|
||||
file.relativePath,
|
||||
);
|
||||
const transferId = createLocalTransferId("local-upload");
|
||||
trackTransfer(transferId, fileName);
|
||||
try {
|
||||
await uploadLocalFileToSession({
|
||||
sessionId,
|
||||
remoteDir: targetDir,
|
||||
localPath: file.localPath,
|
||||
fileName,
|
||||
hostId,
|
||||
transferId,
|
||||
onProgress: ({ transferred }) =>
|
||||
progress(transferId, transferred),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCancelled()) {
|
||||
failed.push(file.relativePath);
|
||||
reason ??= describeTransferError(error);
|
||||
console.error(`Failed to upload ${file.localPath}:`, error);
|
||||
}
|
||||
} finally {
|
||||
settleTransfer(transferId, file.size);
|
||||
}
|
||||
},
|
||||
isCancelled,
|
||||
);
|
||||
return { failed, reason };
|
||||
},
|
||||
);
|
||||
|
||||
onRemoteChanged(remoteDir);
|
||||
},
|
||||
[sshSessionId, hostId, ensureSSHConnection, onRemoteChanged, runBatch, t],
|
||||
);
|
||||
|
||||
/** Downloads remote files/folders into a local directory. */
|
||||
const downloadRemoteItems = useCallback(
|
||||
async (items: FileItem[], localDir: string) => {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return;
|
||||
const sessionId = sshSessionId;
|
||||
|
||||
const { separator } = await getLocalHome();
|
||||
// Every remote name is validated for the local platform and the result
|
||||
// must stay inside `localDir`; anything else is reported and skipped.
|
||||
const toLocalPath = (relativePath: string) =>
|
||||
buildLocalDestination(localDir, relativePath, separator);
|
||||
|
||||
// Expand directories into a flat file plan first so the toast can show
|
||||
// a real total. Listing is the only part that goes through the
|
||||
// renderer's normal API path.
|
||||
const plan: RemoteDownloadPlanEntry[] = [];
|
||||
const emptyDirs: string[] = [];
|
||||
|
||||
const expandTaskId = `local-download-expand-${Date.now()}`;
|
||||
toast.loading(t("fileManager.localPreparingDownload"), {
|
||||
id: expandTaskId,
|
||||
duration: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const walkRemote = async (remotePath: string, relDir: string) => {
|
||||
const { files } = await listSSHFiles(sessionId, remotePath, {
|
||||
force: true,
|
||||
});
|
||||
if (files.length === 0) {
|
||||
emptyDirs.push(relDir);
|
||||
return;
|
||||
}
|
||||
for (const child of files) {
|
||||
const rel = `${relDir}/${child.name}`;
|
||||
if (child.type === "directory") {
|
||||
await walkRemote(child.path, rel);
|
||||
} else if (child.type === "file" || child.type === "link") {
|
||||
plan.push({
|
||||
remotePath: child.path,
|
||||
relativePath: rel,
|
||||
size: child.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const name = item.name || remoteBaseName(item.path);
|
||||
if (item.type === "directory") {
|
||||
await walkRemote(item.path, name);
|
||||
} else {
|
||||
plan.push({
|
||||
remotePath: item.path,
|
||||
relativePath: name,
|
||||
size: item.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.dismiss(expandTaskId);
|
||||
toast.error(
|
||||
t("fileManager.localDownloadFailed"),
|
||||
error instanceof Error ? { description: error.message } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.dismiss(expandTaskId);
|
||||
|
||||
if (plan.length === 0 && emptyDirs.length === 0) {
|
||||
toast.info(t("fileManager.localNothingToTransfer"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop anything whose remote name cannot become a safe local path
|
||||
// (separators, traversal, Windows-invalid characters). These never
|
||||
// reach the filesystem; the user is told how many were skipped.
|
||||
const unsafe: string[] = [];
|
||||
const safeDest = (relativePath: string): string | null => {
|
||||
try {
|
||||
return toLocalPath(relativePath);
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalNameError) {
|
||||
unsafe.push(relativePath);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const plannedDirs = emptyDirs
|
||||
.map((dir) => ({ dir, dest: safeDest(dir) }))
|
||||
.filter((d): d is { dir: string; dest: string } => d.dest !== null);
|
||||
const plannedFiles = plan
|
||||
.map((entry) => ({ entry, dest: safeDest(entry.relativePath) }))
|
||||
.filter(
|
||||
(p): p is { entry: RemoteDownloadPlanEntry; dest: string } =>
|
||||
p.dest !== null,
|
||||
);
|
||||
if (unsafe.length > 0) {
|
||||
toast.error(
|
||||
t("fileManager.localUnsafeNamesSkipped", { count: unsafe.length }),
|
||||
{ description: unsafe.slice(0, 3).join(", ") },
|
||||
);
|
||||
console.warn("Skipped remote items with unsafe local names:", unsafe);
|
||||
}
|
||||
if (plannedFiles.length === 0 && plannedDirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collision policy: never replace silently. Find destinations that
|
||||
// already exist and let the user choose Replace or Skip for the batch.
|
||||
const destinations = plannedFiles.map((p) => p.dest);
|
||||
let existing: Set<string>;
|
||||
try {
|
||||
existing = new Set(await localPathsExist(destinations));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("fileManager.localDownloadFailed"),
|
||||
error instanceof Error ? { description: error.message } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let overwriteExisting = false;
|
||||
if (existing.size > 0) {
|
||||
overwriteExisting = await askReplaceExisting(t, existing.size);
|
||||
}
|
||||
const skipped = overwriteExisting
|
||||
? []
|
||||
: plannedFiles.filter(({ dest }) => existing.has(dest));
|
||||
const work = overwriteExisting
|
||||
? plannedFiles
|
||||
: plannedFiles.filter(({ dest }) => !existing.has(dest));
|
||||
if (skipped.length > 0) {
|
||||
toast.info(
|
||||
t("fileManager.localSkippedExisting", { count: skipped.length }),
|
||||
);
|
||||
}
|
||||
if (work.length === 0 && plannedDirs.length === 0) {
|
||||
onLocalChanged(localDir);
|
||||
return;
|
||||
}
|
||||
const workBytes = work.reduce((sum, w) => sum + (w.entry.size ?? 0), 0);
|
||||
|
||||
await runBatch(
|
||||
"download",
|
||||
work.length,
|
||||
workBytes,
|
||||
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
|
||||
for (const { dest } of plannedDirs) {
|
||||
if (isCancelled()) throw new TransferCancelledError();
|
||||
await ensureLocalDirectory(dest, localDir);
|
||||
}
|
||||
|
||||
const failed: string[] = [];
|
||||
let reason: string | undefined;
|
||||
await runWithConcurrency(
|
||||
work,
|
||||
getTransferConcurrency(),
|
||||
async ({ entry, dest }) => {
|
||||
const fileName = entry.relativePath.split("/").pop()!;
|
||||
const transferId = createLocalTransferId("local-download");
|
||||
trackTransfer(transferId, fileName);
|
||||
try {
|
||||
await downloadSessionFileToLocal({
|
||||
sessionId,
|
||||
remotePath: entry.remotePath,
|
||||
destPath: dest,
|
||||
rootPath: localDir,
|
||||
expectedSize: entry.size,
|
||||
overwrite: overwriteExisting && existing.has(dest),
|
||||
transferId,
|
||||
onProgress: ({ transferred }) =>
|
||||
progress(transferId, transferred),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCancelled()) {
|
||||
failed.push(entry.relativePath);
|
||||
reason ??= describeTransferError(error);
|
||||
console.error(
|
||||
`Failed to download ${entry.remotePath}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
settleTransfer(transferId, entry.size ?? 0);
|
||||
}
|
||||
},
|
||||
isCancelled,
|
||||
);
|
||||
return { failed, reason };
|
||||
},
|
||||
);
|
||||
|
||||
onLocalChanged(localDir);
|
||||
},
|
||||
[sshSessionId, ensureSSHConnection, onLocalChanged, runBatch, t],
|
||||
);
|
||||
|
||||
return { uploadLocalPaths, downloadRemoteItems };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
interface UsePaneWidthOptions {
|
||||
/** localStorage key the width is remembered under. */
|
||||
storageKey: string;
|
||||
defaultWidth: number;
|
||||
minWidth: number;
|
||||
/**
|
||||
* Upper bound as a fraction of the containing row's width (measured at
|
||||
* drag start from `containerRef`), so a pane can never swallow the layout.
|
||||
*/
|
||||
maxFraction?: number;
|
||||
containerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function readStoredWidth(
|
||||
storageKey: string,
|
||||
defaultWidth: number,
|
||||
minWidth: number,
|
||||
): number {
|
||||
try {
|
||||
const saved = Number(localStorage.getItem(storageKey));
|
||||
return Number.isFinite(saved) && saved >= minWidth ? saved : defaultWidth;
|
||||
} catch {
|
||||
return defaultWidth;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted, drag-resizable pane width. `startResize` goes on the
|
||||
* mousedown of a vertical drag handle sitting on the pane's right edge;
|
||||
* `resetWidth` (e.g. on double-click) restores the default.
|
||||
*/
|
||||
export function usePaneWidth({
|
||||
storageKey,
|
||||
defaultWidth,
|
||||
minWidth,
|
||||
maxFraction = 0.65,
|
||||
containerRef,
|
||||
}: UsePaneWidthOptions) {
|
||||
const [width, setWidth] = useState<number>(() =>
|
||||
readStoredWidth(storageKey, defaultWidth, minWidth),
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
widthRef.current = width;
|
||||
|
||||
const persist = useCallback(
|
||||
(value: number) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startWidth = widthRef.current;
|
||||
const rowWidth =
|
||||
containerRef?.current?.getBoundingClientRect().width ?? 0;
|
||||
const maxWidth = Math.max(
|
||||
minWidth,
|
||||
rowWidth ? rowWidth * maxFraction : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
let latest = startWidth;
|
||||
setIsResizing(true);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
latest = Math.round(
|
||||
Math.min(
|
||||
maxWidth,
|
||||
Math.max(minWidth, startWidth + (e.clientX - startX)),
|
||||
),
|
||||
);
|
||||
if (latest !== widthRef.current) setWidth(latest);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
setIsResizing(false);
|
||||
persist(latest);
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[containerRef, maxFraction, minWidth, persist],
|
||||
);
|
||||
|
||||
const resetWidth = useCallback(() => {
|
||||
setWidth(defaultWidth);
|
||||
persist(defaultWidth);
|
||||
}, [defaultWidth, persist]);
|
||||
|
||||
return { width, isResizing, startResize, resetWidth };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
export interface ResizableColumnSpec {
|
||||
/** Stable key, also used for persistence. */
|
||||
key: string;
|
||||
/** i18n key for the column's label (used by the show/hide menu). */
|
||||
labelKey: string;
|
||||
defaultWidth: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
interface UseResizableColumnsOptions {
|
||||
/** localStorage key; widths are remembered per pane. */
|
||||
storageKey: string;
|
||||
/**
|
||||
* Fixed-width columns, in visual order, that follow a leading flexible
|
||||
* column (the file name). The flexible column absorbs whatever is left.
|
||||
*/
|
||||
columns: ResizableColumnSpec[];
|
||||
}
|
||||
|
||||
const DEFAULT_MIN = 48;
|
||||
const DEFAULT_MAX = 600;
|
||||
|
||||
function readStoredWidths(
|
||||
storageKey: string,
|
||||
columns: ResizableColumnSpec[],
|
||||
): Record<string, number> {
|
||||
const widths: Record<string, number> = {};
|
||||
for (const column of columns) widths[column.key] = column.defaultWidth;
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return widths;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
for (const column of columns) {
|
||||
const value = parsed?.[column.key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
widths[column.key] = clamp(value, column);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable or corrupt: fall back to defaults
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
function readHiddenKeys(
|
||||
storageKey: string,
|
||||
columns: ResizableColumnSpec[],
|
||||
): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${storageKey}:hidden`);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
const known = new Set(columns.map((c) => c.key));
|
||||
return new Set(
|
||||
parsed.filter((k): k is string => typeof k === "string" && known.has(k)),
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, column: ResizableColumnSpec): number {
|
||||
const min = column.minWidth ?? DEFAULT_MIN;
|
||||
const max = column.maxWidth ?? DEFAULT_MAX;
|
||||
return Math.round(Math.min(max, Math.max(min, value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Column widths for a list view whose first column is flexible and whose
|
||||
* remaining columns are fixed and user-resizable by dragging the boundary at
|
||||
* the left edge of each header cell. Double-clicking a handle resets that
|
||||
* column to its default. Widths persist in localStorage.
|
||||
*/
|
||||
export function useResizableColumns({
|
||||
storageKey,
|
||||
columns,
|
||||
}: UseResizableColumnsOptions) {
|
||||
const [widths, setWidths] = useState<Record<string, number>>(() =>
|
||||
readStoredWidths(storageKey, columns),
|
||||
);
|
||||
const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(() =>
|
||||
readHiddenKeys(storageKey, columns),
|
||||
);
|
||||
const [resizingKey, setResizingKey] = useState<string | null>(null);
|
||||
const widthsRef = useRef(widths);
|
||||
widthsRef.current = widths;
|
||||
|
||||
// Re-read if the pane is re-keyed (e.g. a different storage key).
|
||||
useEffect(() => {
|
||||
setWidths(readStoredWidths(storageKey, columns));
|
||||
setHiddenKeys(readHiddenKeys(storageKey, columns));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storageKey]);
|
||||
|
||||
const isVisible = useCallback(
|
||||
(key: string) => !hiddenKeys.has(key),
|
||||
[hiddenKeys],
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() => columns.filter((c) => !hiddenKeys.has(c.key)),
|
||||
[columns, hiddenKeys],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(key: string) => {
|
||||
setHiddenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${storageKey}:hidden`,
|
||||
JSON.stringify(Array.from(next)),
|
||||
);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
(next: Record<string, number>) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const gridTemplateColumns = useMemo(
|
||||
() =>
|
||||
[
|
||||
"minmax(140px, 1fr)",
|
||||
...visibleColumns.map((c) => `${widths[c.key]}px`),
|
||||
].join(" "),
|
||||
[visibleColumns, widths],
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
(key: string, event: React.MouseEvent) => {
|
||||
const column = columns.find((c) => c.key === key);
|
||||
if (!column) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = widthsRef.current[key];
|
||||
setResizingKey(key);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
// The handle sits on the column's left edge, so dragging right
|
||||
// narrows the column and dragging left widens it.
|
||||
const next = clamp(startWidth - (e.clientX - startX), column);
|
||||
if (next !== widthsRef.current[key]) {
|
||||
setWidths((prev) => ({ ...prev, [key]: next }));
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
setResizingKey(null);
|
||||
persist(widthsRef.current);
|
||||
|
||||
// The browser dispatches a click to the common ancestor of the
|
||||
// mousedown/mouseup targets, which here is the sortable header cell.
|
||||
// Swallow that one click so finishing a drag never toggles the sort.
|
||||
const swallow = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener("click", swallow, { capture: true });
|
||||
setTimeout(
|
||||
() =>
|
||||
document.removeEventListener("click", swallow, { capture: true }),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[columns, persist],
|
||||
);
|
||||
|
||||
const resetColumn = useCallback(
|
||||
(key: string) => {
|
||||
const column = columns.find((c) => c.key === key);
|
||||
if (!column) return;
|
||||
setWidths((prev) => {
|
||||
const next = { ...prev, [key]: column.defaultWidth };
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[columns, persist],
|
||||
);
|
||||
|
||||
const getHandleProps = useCallback(
|
||||
(key: string) => ({
|
||||
onMouseDown: (event: React.MouseEvent) => startResize(key, event),
|
||||
onDoubleClick: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resetColumn(key);
|
||||
},
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
"data-resizing": resizingKey === key ? "true" : undefined,
|
||||
}),
|
||||
[startResize, resetColumn, resizingKey],
|
||||
);
|
||||
|
||||
return {
|
||||
widths,
|
||||
gridTemplateColumns,
|
||||
getHandleProps,
|
||||
resizingKey,
|
||||
columns,
|
||||
visibleColumns,
|
||||
isVisible,
|
||||
toggleColumn,
|
||||
};
|
||||
}
|
||||
|
||||
export type ResizableColumns = ReturnType<typeof useResizableColumns>;
|
||||
Reference in new issue
Block a user