feat: convert file manager into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 16:27:29 -05:00
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>;