Files
Termix/src/ui/features/file-manager/FileManager.tsx
T

3411 lines
101 KiB
TypeScript
Raw Normal View History

+5
2026-08-19 14:12:06 -05:00
import { getErrorMessage } from "../../lib/error-message.js";
2026-06-04 15:16:53 -04:00
/* eslint-disable react-hooks/exhaustive-deps */
+4
2026-02-12 22:28:13 -06:00
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
+5
2026-08-19 14:12:06 -05:00
import { asHttpError } from "@/lib/http-error";
2026-05-11 01:23:11 -05:00
import { cn } from "@/lib/utils.ts";
+1
2025-12-31 22:20:12 -06:00
import { FileManagerGrid } from "./FileManagerGrid.tsx";
+5
2026-08-19 14:12:06 -05:00
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
+1
2025-12-31 22:20:12 -06:00
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
import { useFileSelection } from "./hooks/useFileSelection.ts";
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
import {
WindowManager,
useWindowManager,
} from "./components/WindowManager.tsx";
import { FileWindow } from "./components/FileWindow.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
2026-05-11 01:23:11 -05:00
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
2025-10-06 10:11:25 -05:00
import { useConfirmation } from "@/hooks/use-confirmation.ts";
2025-09-12 14:42:00 -05:00
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
2026-06-04 15:16:53 -04:00
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
2026-06-21 15:55:41 -05:00
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
2026-06-04 15:16:53 -04:00
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
+5
2026-08-19 14:12:06 -05:00
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
+1
2025-12-31 22:20:12 -06:00
import { TerminalWindow } from "./components/TerminalWindow.tsx";
2026-05-11 01:23:11 -05:00
import type { SSHHost, FileItem } from "@/types/index";
2026-01-24 19:49:42 -06:00
import {
ConnectionLogProvider,
useConnectionLog,
2026-05-11 01:23:11 -05:00
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
+5
2026-08-19 14:12:06 -05:00
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
+2
2026-06-16 15:59:53 -05:00
import { copyToClipboard } from "@/lib/clipboard.ts";
2025-10-01 15:40:10 -05:00
import {
listSSHFiles,
+5
2026-08-19 14:12:06 -05:00
readSSHFile,
+5
2026-03-08 18:02:14 -05:00
resolveSSHPath,
2025-10-01 15:40:10 -05:00
uploadSSHFile,
createSSHFile,
createSSHFolder,
deleteSSHItem,
copySSHItem,
renameSSHItem,
moveSSHItem,
2025-09-12 14:42:00 -05:00
connectSSH,
+7
2025-11-05 10:36:16 -06:00
verifySSHTOTP,
2026-01-24 19:49:42 -06:00
verifySSHWarpgate,
2025-10-01 15:40:10 -05:00
getSSHStatus,
keepSSHAlive,
identifySSHSymlink,
addRecentFile,
addPinnedFile,
removePinnedFile,
removeRecentFile,
addFolderShortcut,
getPinnedFiles,
+7
2025-11-05 10:36:16 -06:00
logActivity,
2025-11-17 09:46:05 -06:00
changeSSHPermissions,
extractSSHArchive,
compressSSHFiles,
2026-01-24 19:49:42 -06:00
setSudoPassword,
2026-05-11 01:23:11 -05:00
getServerMetricsById,
2026-06-04 15:16:53 -04:00
transferToHost,
addTransferRecent,
type TransferMethodPreference,
+3
2026-08-06 14:41:39 -05:00
type DiskFilesystem,
2026-05-11 01:23:11 -05:00
} from "@/main-axios.ts";
+5
2026-08-19 14:12:06 -05:00
import {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
import { shouldPrefetchFileContent } from "@/lib/file-content-request-cache";
import {
markFilePreviewUsed,
preloadFilePreview,
} from "./file-preview-preload";
2026-06-04 15:16:53 -04:00
import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import type {
CreateIntent,
FileManagerProps,
PendingSudoOperation,
SSHConnectionError,
} from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
+5
2026-08-19 14:12:06 -05:00
import {
invalidateCachedFileList,
peekCachedFileList,
updateCachedFileList,
} from "@/lib/file-list-request-cache";
import {
addOptimisticItem,
childPath,
removePaths,
renameOptimisticItem,
restoreItems,
} from "./optimistic-file-list";
2025-10-01 15:40:10 -05:00
2026-06-29 13:28:26 -05:00
const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024;
2026-06-21 15:55:41 -05:00
function FileManagerContent({
initialHost,
initialFilePath,
initialPath,
onClose,
2026-06-29 13:28:26 -05:00
onOpenTerminalTab,
+8
2026-07-19 12:29:52 -05:00
isVisible = true,
2026-06-21 15:55:41 -05:00
}: FileManagerProps) {
2025-10-01 15:40:10 -05:00
const { openWindow } = useWindowManager();
2025-09-12 14:42:00 -05:00
const { t } = useTranslation();
2026-06-04 15:16:53 -04:00
const formatTransferMetrics = useMemo(
() => createFormatTransferMetrics(t),
[t],
);
2025-10-06 10:11:25 -05:00
const { confirmWithToast } = useConfirmation();
+5
2026-08-19 14:12:06 -05:00
const { addLog, clearLogs } = useConnectionLog();
2025-09-12 14:42:00 -05:00
+7
2025-11-05 10:36:16 -06:00
const [currentHost] = useState<SSHHost | null>(initialHost || null);
2025-10-01 15:40:10 -05:00
const [currentPath, setCurrentPath] = useState(
2026-06-21 15:55:41 -05:00
initialPath || initialHost?.defaultPath || "/",
2025-10-01 15:40:10 -05:00
);
+5
2026-08-19 14:12:06 -05:00
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
+3
2026-08-06 14:41:39 -05:00
const lastSuccessfulPathRef = useRef(
initialPath || initialHost?.defaultPath || "/",
);
2026-05-11 01:23:11 -05:00
const [navHistory, setNavHistory] = useState<string[]>([
2026-06-21 15:55:41 -05:00
initialPath || initialHost?.defaultPath || "/",
2026-05-11 01:23:11 -05:00
]);
const [navIndex, setNavIndex] = useState(0);
2025-10-01 15:40:10 -05:00
const [files, setFiles] = useState<FileItem[]>([]);
+5
2026-08-19 14:12:06 -05:00
const filesRef = useRef(files);
filesRef.current = files;
const directoryRequestRef = useRef(0);
2025-10-01 15:40:10 -05:00
const [isLoading, setIsLoading] = useState(false);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
+5
2026-08-19 14:12:06 -05:00
const sshSessionIdRef = useRef(sshSessionId);
sshSessionIdRef.current = sshSessionId;
2025-10-01 15:40:10 -05:00
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
2025-11-17 09:46:05 -06:00
const [viewMode, setViewMode] = useState<"grid" | "list">(() => {
const saved = localStorage.getItem("fileManagerViewMode");
return saved === "grid" || saved === "list" ? saved : "grid";
});
+5
2026-08-19 14:12:06 -05:00
// Picking an interface preset seeds this key from another part of the app.
useEffect(() => {
const handler = () => {
const saved = localStorage.getItem("fileManagerViewMode");
if (saved === "grid" || saved === "list") setViewMode(saved);
};
window.addEventListener("fileManagerViewModeChanged", handler);
return () =>
window.removeEventListener("fileManagerViewModeChanged", handler);
}, []);
+5
2026-03-08 18:02:14 -05:00
const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
const saved = localStorage.getItem("fileManagerSortBy");
return saved === "name" || saved === "modified" || saved === "size"
? saved
: "name";
});
const [sortOrder, setSortOrder] = useState<"asc" | "desc">(() => {
const saved = localStorage.getItem("fileManagerSortOrder");
return saved === "asc" || saved === "desc" ? saved : "asc";
});
+7
2025-11-05 10:36:16 -06:00
const [totpRequired, setTotpRequired] = useState(false);
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = useState<string>("");
2026-01-24 19:49:42 -06:00
const [warpgateRequired, setWarpgateRequired] = useState(false);
const [warpgateSessionId, setWarpgateSessionId] = useState<string | null>(
null,
);
const [warpgateUrl, setWarpgateUrl] = useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
+7
2025-11-05 10:36:16 -06:00
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [authDialogReason, setAuthDialogReason] = useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
2026-06-21 15:55:41 -05:00
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
2025-10-01 15:40:10 -05:00
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
+5
2026-08-19 14:12:06 -05:00
const [trashOpen, setTrashOpen] = useState(false);
2026-01-24 19:49:42 -06:00
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
2026-05-11 01:23:11 -05:00
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [diskInfo, setDiskInfo] = useState<{
usedHuman: string;
totalHuman: string;
percent: number;
+3
2026-08-06 14:41:39 -05:00
mount: string | null;
filesystems: DiskFilesystem[];
2026-05-11 01:23:11 -05:00
} | null>(null);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
files: FileItem[];
}>({
x: 0,
y: 0,
isVisible: false,
files: [],
});
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const [clipboard, setClipboard] = useState<{
files: FileItem[];
operation: "copy" | "cut";
2026-06-29 13:28:26 -05:00
sourceHostId: number | null;
sourceSessionId: string | null;
2025-10-01 15:40:10 -05:00
} | null>(null);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
interface UndoAction {
type: "copy" | "cut" | "delete";
description: string;
data: {
operation: "copy" | "cut";
copiedFiles?: {
originalPath: string;
targetPath: string;
targetName: string;
}[];
deletedFiles?: { path: string; name: string }[];
targetDirectory?: string;
};
timestamp: number;
}
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const [undoHistory, setUndoHistory] = useState<UndoAction[]>([]);
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
2025-11-17 09:46:05 -06:00
const [permissionsDialogFile, setPermissionsDialogFile] =
useState<FileItem | null>(null);
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
[],
);
2026-06-04 15:16:53 -04:00
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
const [transferFiles, setTransferFiles] = useState<FileItem[]>([]);
const [transferMove, setTransferMove] = useState(false);
2025-10-01 15:40:10 -05:00
2026-01-24 19:49:42 -06:00
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
2026-06-04 15:16:53 -04:00
const [pendingSudoOperation, setPendingSudoOperation] =
useState<PendingSudoOperation | null>(null);
2026-01-24 19:49:42 -06:00
+7
2025-11-05 10:36:16 -06:00
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
2025-10-01 15:40:10 -05:00
+5
2026-08-19 14:12:06 -05:00
const commitFilesForPath = useCallback(
(sessionId: string, path: string, next: FileItem[]) => {
updateCachedFileList(sessionId, path, next);
if (
sshSessionIdRef.current !== sessionId ||
currentPathRef.current !== path
) {
return;
}
directoryRequestRef.current += 1;
filesRef.current = next;
setFiles(next);
},
[],
);
const filesForPath = useCallback((sessionId: string, path: string) => {
if (
sshSessionIdRef.current === sessionId &&
currentPathRef.current === path
) {
return filesRef.current;
}
return peekCachedFileList(sessionId, path)?.files ?? [];
}, []);
+7
2025-11-05 10:36:16 -06:00
const { dragHandlers } = useDragAndDrop({
2025-10-01 15:40:10 -05:00
onFilesDropped: handleFilesDropped,
2026-05-28 22:29:20 -05:00
onItemsDropped: handleItemsDropped,
2025-10-01 15:40:10 -05:00
onError: (error) => toast.error(error),
maxFileSize: 5120,
});
const dragToDesktop = useDragToDesktop({
sshSessionId: sshSessionId || "",
sshHost: currentHost!,
});
const systemDrag = useDragToSystemDesktop({
sshSessionId: sshSessionId || "",
sshHost: currentHost!,
});
const startKeepalive = useCallback(() => {
if (!sshSessionId) return;
if (keepaliveTimerRef.current) {
clearInterval(keepaliveTimerRef.current);
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
keepaliveTimerRef.current = setInterval(async () => {
if (sshSessionId) {
try {
await keepSSHAlive(sshSessionId);
} catch (error) {
console.error("SSH keepalive failed:", error);
}
}
}, 30 * 1000);
}, [sshSessionId]);
const stopKeepalive = useCallback(() => {
if (keepaliveTimerRef.current) {
clearInterval(keepaliveTimerRef.current);
keepaliveTimerRef.current = null;
}
}, []);
const handleCloseWithError = useCallback(
(errorMessage: string) => {
2026-01-24 19:49:42 -06:00
setHasConnectionError(true);
addLog({
type: "error",
stage: "connection",
message: errorMessage,
});
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current?.markFailed();
2025-10-01 15:40:10 -05:00
},
2026-01-24 19:49:42 -06:00
[addLog],
2025-10-01 15:40:10 -05:00
);
2025-09-12 14:42:00 -05:00
+5
2026-08-19 14:12:06 -05:00
const connectRetry = useConnectionRetry({
connect: () => {
void initializeSSHConnection();
},
enabled:
!!currentHost &&
!sshSessionId &&
!totpRequired &&
!warpgateRequired &&
!showAuthDialog &&
!showPassphraseDialog,
autoStart: false,
});
const connectRetryRef = useRef(connectRetry);
connectRetryRef.current = connectRetry;
2025-09-12 14:42:00 -05:00
useEffect(() => {
if (currentHost) {
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current.reset();
connectRetryRef.current.retryNow();
2025-09-12 14:42:00 -05:00
}
}, [currentHost]);
useEffect(() => {
+8
2026-07-19 12:29:52 -05:00
if (sshSessionId && isVisible) {
2025-10-01 15:40:10 -05:00
startKeepalive();
} else {
stopKeepalive();
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
return () => {
stopKeepalive();
};
+8
2026-07-19 12:29:52 -05:00
}, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
2025-10-01 15:40:10 -05:00
2026-06-21 15:55:41 -05:00
const initialFileOpenedRef = useRef(false);
useEffect(() => {
if (!sshSessionId || !initialFilePath || initialFileOpenedRef.current)
return;
initialFileOpenedRef.current = true;
const fileName = initialFilePath.split("/").pop() || initialFilePath;
const fileDir =
initialFilePath.lastIndexOf("/") > 0
? initialFilePath.substring(0, initialFilePath.lastIndexOf("/"))
: "/";
setCurrentPath(fileDir);
const file: FileItem = {
name: fileName,
path: initialFilePath,
type: "file",
};
const windowCount = Date.now() % 10;
const offsetX = Math.min(
120 + windowCount * 30,
Math.max(0, window.innerWidth - 820),
);
const offsetY = Math.min(
120 + windowCount * 30,
Math.max(0, window.innerHeight - 620),
);
const createWindowComponent = (windowId: string) => (
<FileWindow
windowId={windowId}
file={file}
sshSessionId={sshSessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
onFileNotFound={handleFileNotFound}
/>
);
openWindow({
title: fileName,
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createWindowComponent,
});
}, [sshSessionId, initialFilePath]);
2025-10-01 15:40:10 -05:00
const initialLoadDoneRef = useRef(false);
const lastPathChangeRef = useRef<string>("");
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentLoadingPathRef = useRef<string>("");
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
+7
2025-11-05 10:36:16 -06:00
const activityLoggedRef = useRef(false);
const activityLoggingRef = useRef(false);
const logFileManagerActivity = useCallback(async () => {
if (
!currentHost?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
await logActivity("file_manager", currentHost.id, hostName);
} catch (err) {
console.warn("Failed to log file manager activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
}, [currentHost]);
2025-10-01 15:40:10 -05:00
const handleFileDragStart = useCallback(
(files: FileItem[]) => {
systemDrag.startDragToSystem(files, {
enableToast: true,
onSuccess: () => {
clearSelection();
},
onError: (error) => {
console.error("Drag failed:", error);
},
});
},
[systemDrag, clearSelection],
);
const handleFileDragEnd = useCallback(
(e: DragEvent, draggedFiles: FileItem[]) => {
const isOutside =
e.clientX < 0 ||
e.clientX > window.innerWidth ||
e.clientY < 0 ||
e.clientY > window.innerHeight;
if (isOutside) {
if (draggedFiles.length === 0) {
console.error("No files to drag - this should not happen");
return;
}
systemDrag.startDragToSystem(draggedFiles, {
enableToast: true,
onSuccess: () => {
clearSelection();
},
onError: (error) => {
console.error("Drag failed:", error);
},
});
systemDrag.handleDragEnd(e);
} else {
systemDrag.cancelDragToSystem();
}
},
[systemDrag, clearSelection],
);
+7
2025-11-05 10:36:16 -06:00
const isConnectingRef = useRef(false);
2025-10-01 15:40:10 -05:00
async function initializeSSHConnection() {
+7
2025-11-05 10:36:16 -06:00
if (!currentHost || isConnectingRef.current) return;
2026-05-28 22:29:20 -05:00
if (currentHost.enableSsh === false) {
setHasConnectionError(true);
addLog({
type: "error",
+5
2026-08-19 14:12:06 -05:00
stage: "error",
2026-05-28 22:29:20 -05:00
message: t("fileManager.sshRequiredForFileManager"),
});
setIsLoading(false);
return;
}
+7
2025-11-05 10:36:16 -06:00
isConnectingRef.current = true;
2025-09-12 14:42:00 -05:00
try {
2025-10-01 15:40:10 -05:00
setIsLoading(true);
initialLoadDoneRef.current = false;
2026-01-24 19:49:42 -06:00
setHasConnectionError(false);
clearLogs();
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const sessionId = currentHost.id.toString();
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const result = await connectSSH(sessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
+7
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
+1
2025-12-31 22:20:12 -06:00
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
2025-10-01 15:40:10 -05:00
});
2025-09-12 14:42:00 -05:00
2026-01-24 19:49:42 -06:00
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sessionId);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "N/A");
setIsLoading(false);
return;
}
+7
2025-11-05 10:36:16 -06:00
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
+1
2025-12-31 22:20:12 -06:00
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
+7
2025-11-05 10:36:16 -06:00
setIsLoading(false);
return;
}
if (result?.status === "auth_required") {
setAuthDialogReason(result.reason || "no_keyboard");
setShowAuthDialog(true);
setIsLoading(false);
return;
}
2026-06-21 15:55:41 -05:00
if (result?.status === "passphrase_required") {
setShowPassphraseDialog(true);
setIsLoading(false);
return;
}
2025-10-01 15:40:10 -05:00
setSshSessionId(sessionId);
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current.markConnected();
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
try {
const response = await listSSHFiles(sessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
+3
2026-08-06 14:41:39 -05:00
lastSuccessfulPathRef.current = currentPath;
2025-10-01 15:40:10 -05:00
clearSelection();
initialLoadDoneRef.current = true;
+7
2025-11-05 10:36:16 -06:00
if (!result?.requires_totp) {
logFileManagerActivity();
}
} catch (dirError: unknown) {
2025-10-01 15:40:10 -05:00
console.error("Failed to load initial directory:", dirError);
2025-09-12 14:42:00 -05:00
}
2026-05-06 15:12:07 -05:00
} catch (error: unknown) {
const sshError = error as SSHConnectionError;
2025-10-01 15:40:10 -05:00
console.error("SSH connection failed:", error);
2026-01-24 19:49:42 -06:00
2026-05-06 15:12:07 -05:00
if (sshError.connectionLogs) {
sshError.connectionLogs.forEach((log) => {
2026-01-24 19:49:42 -06:00
addLog({
type: log.type,
stage: log.stage,
message: log.message,
details: log.details,
});
});
2026-05-06 15:12:07 -05:00
if (sshError.requires_totp) {
2026-01-24 19:49:42 -06:00
setTotpRequired(true);
2026-05-06 15:12:07 -05:00
setTotpSessionId(sshError.sessionId || currentHost.id.toString());
2026-01-24 19:49:42 -06:00
setTotpPrompt(
2026-05-06 15:12:07 -05:00
sshError.prompt || t("fileManager.verificationCodePrompt"),
2026-01-24 19:49:42 -06:00
);
setIsLoading(false);
return;
}
2026-05-06 15:12:07 -05:00
if (sshError.requires_warpgate) {
2026-01-24 19:49:42 -06:00
setWarpgateRequired(true);
2026-05-06 15:12:07 -05:00
setWarpgateSessionId(sshError.sessionId || currentHost.id.toString());
setWarpgateUrl(sshError.url || "");
setWarpgateSecurityKey(sshError.securityKey || "N/A");
2026-01-24 19:49:42 -06:00
setIsLoading(false);
return;
}
2026-05-06 15:12:07 -05:00
if (sshError.status === "auth_required") {
setAuthDialogReason(sshError.reason || "no_keyboard");
2026-01-24 19:49:42 -06:00
setShowAuthDialog(true);
setIsLoading(false);
return;
}
} else {
addLog({
type: "error",
stage: "connection",
2026-05-06 15:12:07 -05:00
message:
error instanceof Error
? error.message
: t("fileManager.failedToConnect"),
2026-01-24 19:49:42 -06:00
});
}
2025-10-01 15:40:10 -05:00
handleCloseWithError(
2026-05-28 22:29:20 -05:00
t("fileManager.failedToConnect") +
": " +
+5
2026-08-19 14:12:06 -05:00
getErrorMessage(error, String(error)),
2025-10-01 15:40:10 -05:00
);
} finally {
setIsLoading(false);
+7
2025-11-05 10:36:16 -06:00
isConnectingRef.current = false;
2025-09-12 14:42:00 -05:00
}
}
2025-10-01 15:40:10 -05:00
const loadDirectory = useCallback(
+3
2026-08-06 14:41:39 -05:00
async (path: string, conflictAttempt = 0): Promise<boolean> => {
2025-10-01 15:40:10 -05:00
if (!sshSessionId) {
console.error("Cannot load directory: no SSH session ID");
2026-01-24 19:49:42 -06:00
return false;
2025-09-12 14:42:00 -05:00
}
+5
2026-08-19 14:12:06 -05:00
const requestId = ++directoryRequestRef.current;
2025-09-12 14:42:00 -05:00
+5
2026-03-08 18:02:14 -05:00
let resolvedPath = path;
if (path.includes("$") || path.startsWith("~")) {
resolvedPath = await resolveSSHPath(sshSessionId, path);
if (resolvedPath !== path) {
setCurrentPath(resolvedPath);
lastPathChangeRef.current = resolvedPath;
}
}
currentLoadingPathRef.current = resolvedPath;
+5
2026-08-19 14:12:06 -05:00
const cached = peekCachedFileList(sshSessionId, resolvedPath);
if (cached) {
setFiles(cached.files);
clearSelection();
}
setIsLoading(!cached);
2025-10-01 15:40:10 -05:00
2025-09-12 14:42:00 -05:00
try {
+5
2026-08-19 14:12:06 -05:00
const response = await listSSHFiles(sshSessionId, resolvedPath, {
force: cached !== null,
});
2025-09-12 14:42:00 -05:00
+5
2026-08-19 14:12:06 -05:00
if (directoryRequestRef.current !== requestId) {
2026-01-24 19:49:42 -06:00
return false;
2025-10-01 15:40:10 -05:00
}
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const files = Array.isArray(response)
? response
: response?.files || [];
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
setFiles(files);
+3
2026-08-06 14:41:39 -05:00
lastSuccessfulPathRef.current = resolvedPath;
2025-10-01 15:40:10 -05:00
clearSelection();
2026-01-24 19:49:42 -06:00
return true;
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
if (directoryRequestRef.current === requestId) {
2026-05-06 15:12:07 -05:00
// ApiError has .status directly; raw axios errors have .response.status
const apiError = error as {
status?: number;
code?: string;
2026-01-24 19:49:42 -06:00
response?: {
status?: number;
data?: {
needsSudo?: boolean;
error?: string;
sudoFailed?: boolean;
2026-05-06 15:12:07 -05:00
disconnected?: boolean;
2026-01-24 19:49:42 -06:00
};
};
message?: string;
};
2026-05-06 15:12:07 -05:00
const httpStatus = apiError.status ?? apiError.response?.status;
+3
2026-08-06 14:41:39 -05:00
// The sidebar may be listing the same path to populate its tree.
// Retry instead of leaving the breadcrumb and visible files out of sync.
2026-05-06 15:12:07 -05:00
if (httpStatus === 409) {
+3
2026-08-06 14:41:39 -05:00
if (conflictAttempt < 3) {
await new Promise((resolve) => setTimeout(resolve, 250));
return loadDirectory(resolvedPath, conflictAttempt + 1);
}
const previousPath = lastSuccessfulPathRef.current;
lastPathChangeRef.current = previousPath;
setCurrentPath((current) =>
current === resolvedPath ? previousPath : current,
);
2026-05-06 15:12:07 -05:00
return false;
}
if (apiError.response?.data?.needsSudo) {
+3
2026-08-06 14:41:39 -05:00
const previousPath = lastSuccessfulPathRef.current;
lastPathChangeRef.current = previousPath;
setCurrentPath((current) =>
current === resolvedPath ? previousPath : current,
);
2026-01-24 19:49:42 -06:00
if (!sudoDialogOpen) {
+5
2026-03-08 18:02:14 -05:00
setPendingSudoOperation({ type: "navigate", path: resolvedPath });
2026-01-24 19:49:42 -06:00
setSudoDialogOpen(true);
}
2026-05-06 15:12:07 -05:00
if (apiError.response.data.sudoFailed) {
2026-01-24 19:49:42 -06:00
toast.error(t("fileManager.sudoAuthFailed"));
} else {
toast.error(t("fileManager.permissionDenied"));
}
return false;
}
2025-10-01 15:40:10 -05:00
console.error("Failed to load directory:", error);
2025-09-12 14:42:00 -05:00
2026-01-24 19:49:42 -06:00
const errorMessage =
2026-05-06 15:12:07 -05:00
apiError.response?.data?.error || apiError.message || String(error);
2026-01-24 19:49:42 -06:00
2026-05-06 15:12:07 -05:00
const isConnectionError =
// 500s from the file manager are SSH channel/session errors
httpStatus === 500 ||
httpStatus === 503 ||
apiError.response?.data?.disconnected === true ||
errorMessage?.includes("channel open failure") ||
errorMessage?.includes("open failed") ||
errorMessage?.includes("SSH connection not established") ||
errorMessage?.includes("SSH session") ||
errorMessage?.toLowerCase().includes("not connected");
if (isConnectionError && sshSessionId && currentHost) {
setIsReconnecting(true);
setIsLoading(false);
setFiles([]);
currentLoadingPathRef.current = "";
void (async () => {
const delays = [1000, 2000, 3000, 5000, 5000];
for (let attempt = 0; attempt < delays.length; attempt++) {
await new Promise((r) => setTimeout(r, delays[attempt]));
try {
await ensureSSHConnection();
setIsReconnecting(false);
loadDirectory(resolvedPath);
return;
} catch {
// keep retrying
}
}
setIsReconnecting(false);
handleCloseWithError(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
})();
return false;
} else if (initialLoadDoneRef.current) {
2026-05-28 22:29:20 -05:00
const isPermissionDenied =
httpStatus === 403 ||
errorMessage?.toLowerCase().includes("permission denied") ||
errorMessage?.toLowerCase().includes("eacces");
if (isPermissionDenied) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
}
2025-10-01 15:40:10 -05:00
}
}
2026-01-24 19:49:42 -06:00
return false;
2025-09-12 14:42:00 -05:00
} finally {
+5
2026-08-19 14:12:06 -05:00
if (directoryRequestRef.current === requestId) {
2025-10-01 15:40:10 -05:00
setIsLoading(false);
currentLoadingPathRef.current = "";
2025-09-12 14:42:00 -05:00
}
}
2025-10-01 15:40:10 -05:00
},
+3
2026-08-06 14:41:39 -05:00
[sshSessionId, clearSelection, t, sudoDialogOpen, currentHost],
2025-10-01 15:40:10 -05:00
);
const debouncedLoadDirectory = useCallback(
+9
2026-04-22 16:55:23 -05:00
(path: string, force?: boolean) => {
2025-10-01 15:40:10 -05:00
if (pathChangeTimerRef.current) {
clearTimeout(pathChangeTimerRef.current);
}
pathChangeTimerRef.current = setTimeout(() => {
+9
2026-04-22 16:55:23 -05:00
if ((force || path !== lastPathChangeRef.current) && sshSessionId) {
2025-10-01 15:40:10 -05:00
lastPathChangeRef.current = path;
loadDirectory(path);
}
}, 150);
},
[sshSessionId, loadDirectory],
);
2026-05-11 01:23:11 -05:00
const navigateTo = useCallback(
(path: string) => {
+5
2026-08-19 14:12:06 -05:00
directoryRequestRef.current += 1;
const cached = sshSessionId
? peekCachedFileList(sshSessionId, path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
2026-05-11 01:23:11 -05:00
setCurrentPath(path);
setNavHistory((prev) => {
const next = [...prev.slice(0, navIndex + 1), path];
setNavIndex(next.length - 1);
return next;
});
},
2026-05-28 22:29:20 -05:00
[navIndex, sshSessionId],
2026-05-11 01:23:11 -05:00
);
const goBack = useCallback(() => {
if (navIndex > 0) {
+5
2026-08-19 14:12:06 -05:00
directoryRequestRef.current += 1;
2026-05-11 01:23:11 -05:00
const newIndex = navIndex - 1;
+5
2026-08-19 14:12:06 -05:00
const path = navHistory[newIndex];
const cached = sshSessionId
? peekCachedFileList(sshSessionId, path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
2026-05-11 01:23:11 -05:00
setNavIndex(newIndex);
+5
2026-08-19 14:12:06 -05:00
setCurrentPath(path);
2026-05-11 01:23:11 -05:00
}
2026-05-28 22:29:20 -05:00
}, [navIndex, navHistory, sshSessionId]);
2026-05-11 01:23:11 -05:00
const goForward = useCallback(() => {
if (navIndex < navHistory.length - 1) {
+5
2026-08-19 14:12:06 -05:00
directoryRequestRef.current += 1;
2026-05-11 01:23:11 -05:00
const newIndex = navIndex + 1;
+5
2026-08-19 14:12:06 -05:00
const path = navHistory[newIndex];
const cached = sshSessionId
? peekCachedFileList(sshSessionId, path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
2026-05-11 01:23:11 -05:00
setNavIndex(newIndex);
+5
2026-08-19 14:12:06 -05:00
setCurrentPath(path);
2026-05-11 01:23:11 -05:00
}
2026-05-28 22:29:20 -05:00
}, [navIndex, navHistory, sshSessionId]);
2026-05-11 01:23:11 -05:00
const goUp = useCallback(() => {
if (currentPath === "/") return;
const parent =
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
navigateTo(parent);
}, [currentPath, navigateTo]);
2025-10-01 15:40:10 -05:00
useEffect(() => {
if (sshSessionId && currentPath) {
if (!initialLoadDoneRef.current) {
initialLoadDoneRef.current = true;
lastPathChangeRef.current = currentPath;
return;
}
debouncedLoadDirectory(currentPath);
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
return () => {
if (pathChangeTimerRef.current) {
clearTimeout(pathChangeTimerRef.current);
}
};
}, [sshSessionId, currentPath, debouncedLoadDirectory]);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
const handleRefreshDirectory = useCallback(() => {
const now = Date.now();
const DEBOUNCE_MS = 500;
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
if (now - lastRefreshTime < DEBOUNCE_MS) {
2025-09-12 14:42:00 -05:00
return;
}
2025-10-01 15:40:10 -05:00
setLastRefreshTime(now);
+5
2026-08-19 14:12:06 -05:00
if (sshSessionId) invalidateCachedFileList(sshSessionId, currentPath);
+9
2026-04-22 16:55:23 -05:00
// Force reset loading state to ensure refresh is not blocked
setIsLoading(false);
currentLoadingPathRef.current = "";
2025-10-01 15:40:10 -05:00
loadDirectory(currentPath);
+5
2026-08-19 14:12:06 -05:00
}, [currentPath, lastRefreshTime, loadDirectory, sshSessionId]);
2025-10-01 15:40:10 -05:00
2026-06-04 15:16:53 -04:00
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ hostId: number; path: string }>)
.detail;
if (!detail || !currentHost?.id) return;
if (detail.hostId === currentHost.id) {
handleRefreshDirectory();
}
};
window.addEventListener("file-manager:refresh", handler);
return () => window.removeEventListener("file-manager:refresh", handler);
}, [currentHost?.id, handleRefreshDirectory]);
2026-06-29 13:28:26 -05:00
useEffect(() => {
const handler = (event: Event) => {
const detail = (
event as CustomEvent<{
files: FileItem[];
operation: "copy" | "cut";
sourceHostId: number | null;
sourceSessionId: string | null;
}>
).detail;
if (!detail) return;
setClipboard(detail);
};
window.addEventListener("file-manager:clipboard", handler);
return () => window.removeEventListener("file-manager:clipboard", handler);
}, []);
2025-10-01 15:40:10 -05:00
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
if (
activeElement &&
(activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
+5
2026-08-19 14:12:06 -05:00
(activeElement as HTMLElement).contentEditable === "true")
2025-10-01 15:40:10 -05:00
) {
return;
}
if (event.key === "T" && event.ctrlKey && event.shiftKey) {
event.preventDefault();
handleOpenTerminal(currentPath);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentPath]);
2026-05-28 22:29:20 -05:00
async function handleItemsDropped(items: DataTransferItemList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
const files: { file: File; relativePath: string }[] = [];
async function readEntry(
entry: FileSystemEntry,
path: string,
): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
);
files.push({ file, relativePath: path });
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
2026-06-29 13:28:26 -05:00
let batch: FileSystemEntry[];
do {
batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject),
);
for (const child of batch) {
await readEntry(child, `${path}/${child.name}`);
}
} while (batch.length > 0);
2026-05-28 22:29:20 -05:00
}
}
for (const entry of entries) {
await readEntry(entry, entry.name);
}
if (files.length === 0) return;
const progressToast = toast.loading(
`Uploading ${files.length} file(s)...`,
{ duration: Infinity },
);
try {
await ensureSSHConnection();
const dirs = new Set<string>();
for (const { relativePath } of files) {
const parts = relativePath.split("/");
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const sortedDirs = Array.from(dirs).sort();
for (const dir of sortedDirs) {
const parentPath = currentPath.endsWith("/")
? currentPath + dir.split("/").slice(0, -1).join("/")
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
const folderName = dir.split("/").pop()!;
const targetPath = parentPath.endsWith("/")
? parentPath
: parentPath + "/";
try {
await createSSHFolder(
sshSessionId,
targetPath,
folderName,
currentHost?.id,
);
} catch {
// directory may already exist
}
}
for (const { file, relativePath } of files) {
const dirPart = relativePath.includes("/")
? relativePath.substring(0, relativePath.lastIndexOf("/"))
: "";
const uploadPath = dirPart
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
dirPart +
"/"
: currentPath;
await uploadSSHFile(
sshSessionId,
uploadPath,
file.name,
2026-06-21 15:55:41 -05:00
file,
2026-05-28 22:29:20 -05:00
currentHost?.id,
);
}
toast.dismiss(progressToast);
toast.success(`Uploaded ${files.length} file(s) successfully`);
handleRefreshDirectory();
} catch (error) {
toast.dismiss(progressToast);
toast.error(t("fileManager.failedToUploadFile"));
console.error("Folder upload failed:", error);
}
}
2025-10-01 15:40:10 -05:00
function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
Array.from(fileList).forEach((file) => {
handleUploadFile(file);
});
}
async function handleUploadFile(file: File) {
if (!sshSessionId) return;
const progressToast = toast.loading(
t("fileManager.uploadingFile", {
name: file.name,
size: formatFileSize(file.size),
}),
{ duration: Infinity },
);
2025-09-12 14:42:00 -05:00
2026-06-29 13:28:26 -05:00
const updateProgress = (p: {
chunkIndex: number;
totalChunks: number;
bytesSent: number;
totalBytes: number;
}) => {
const percent = Math.min(
100,
Math.round((p.bytesSent / p.totalBytes) * 100),
);
toast.loading(
`Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`,
{ id: progressToast, duration: Infinity },
);
};
2025-09-12 14:42:00 -05:00
try {
2025-10-01 15:40:10 -05:00
await ensureSSHConnection();
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
await uploadSSHFile(
sshSessionId,
currentPath,
file.name,
2026-06-21 15:55:41 -05:00
file,
2025-10-01 15:40:10 -05:00
currentHost?.id,
undefined,
2026-06-29 13:28:26 -05:00
updateProgress,
2025-09-12 14:42:00 -05:00
);
2025-10-01 15:40:10 -05:00
toast.dismiss(progressToast);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
toast.success(
t("fileManager.fileUploadedSuccessfully", { name: file.name }),
2025-09-12 14:42:00 -05:00
);
2025-10-01 15:40:10 -05:00
handleRefreshDirectory();
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
toast.dismiss(progressToast);
2026-05-28 22:29:20 -05:00
const uploadErr = error instanceof Error ? error : null;
2025-09-12 14:42:00 -05:00
if (
2026-05-28 22:29:20 -05:00
uploadErr?.message?.includes("connection") ||
uploadErr?.message?.includes("established")
2025-09-12 14:42:00 -05:00
) {
2025-10-01 15:40:10 -05:00
toast.error(
+1
2025-12-31 22:20:12 -06:00
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
2025-10-01 15:40:10 -05:00
);
} else {
toast.error(t("fileManager.failedToUploadFile"));
}
console.error("Upload failed:", error);
}
}
async function handleDownloadFile(file: FileItem) {
if (!sshSessionId) return;
try {
await ensureSSHConnection();
2026-05-28 22:29:20 -05:00
const { downloadSSHFileStream } = await import("@/main-axios.ts");
await downloadSSHFileStream(sshSessionId, file.path);
2025-10-01 15:40:10 -05:00
2026-05-28 22:29:20 -05:00
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
);
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2026-05-28 22:29:20 -05:00
const err = error instanceof Error ? error : null;
2025-10-01 15:40:10 -05:00
if (
2026-05-28 22:29:20 -05:00
err?.message?.includes("connection") ||
err?.message?.includes("established")
2025-10-01 15:40:10 -05:00
) {
toast.error(
+1
2025-12-31 22:20:12 -06:00
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
2025-10-01 15:40:10 -05:00
);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
}
console.error("Download failed:", error);
}
}
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
2025-10-06 10:11:25 -05:00
let confirmMessage: string;
if (files.length === 1) {
const file = files[0];
if (file.type === "directory") {
confirmMessage = t("fileManager.confirmDeleteFolder", {
name: file.name,
});
2025-10-01 15:40:10 -05:00
} else {
2025-10-06 10:11:25 -05:00
confirmMessage = t("fileManager.confirmDeleteSingleItem", {
name: file.name,
});
2025-10-01 15:40:10 -05:00
}
2025-10-06 10:11:25 -05:00
} else {
const hasDirectory = files.some((file) => file.type === "directory");
const translationKey = hasDirectory
? "fileManager.confirmDeleteMultipleItemsWithFolders"
: "fileManager.confirmDeleteMultipleItems";
confirmMessage = t(translationKey, {
count: files.length,
});
2025-10-01 15:40:10 -05:00
}
2025-10-06 10:11:25 -05:00
+5
2026-08-19 14:12:06 -05:00
const fullMessage = `${confirmMessage}\n\n${t("fileManager.moveToTrashWarning")}`;
2025-10-06 10:11:25 -05:00
confirmWithToast(
fullMessage,
async () => {
+5
2026-08-19 14:12:06 -05:00
const operationPath = currentPath;
const operationSession = sshSessionId;
const deletedPaths = new Set(files.map((file) => file.path));
commitFilesForPath(
operationSession,
operationPath,
removePaths(
filesForPath(operationSession, operationPath),
deletedPaths,
),
);
clearSelection();
let completed = 0;
2025-10-06 10:11:25 -05:00
try {
await ensureSSHConnection();
for (const file of files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
);
+5
2026-08-19 14:12:06 -05:00
completed += 1;
2025-10-06 10:11:25 -05:00
}
toast.success(
+5
2026-08-19 14:12:06 -05:00
t("fileManager.itemsMovedToTrash", { count: files.length }),
2025-10-06 10:11:25 -05:00
);
+5
2026-08-19 14:12:06 -05:00
if (
sshSessionIdRef.current === operationSession &&
currentPathRef.current === operationPath
) {
handleRefreshDirectory();
} else {
invalidateCachedFileList(operationSession, operationPath);
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
commitFilesForPath(
operationSession,
operationPath,
restoreItems(
filesForPath(operationSession, operationPath),
files.slice(completed),
),
);
2026-01-24 19:49:42 -06:00
const axiosError = error as {
2026-05-28 22:29:20 -05:00
response?: {
data?: { needsSudo?: boolean; error?: string };
status?: number;
};
2026-01-24 19:49:42 -06:00
message?: string;
};
if (axiosError.response?.data?.needsSudo) {
+5
2026-08-19 14:12:06 -05:00
setPendingSudoOperation({
type: "delete",
files: files.slice(completed),
});
2026-01-24 19:49:42 -06:00
setSudoDialogOpen(true);
return;
}
+5
2026-08-19 14:12:06 -05:00
if (
(axiosError.response?.data as { trashUnavailable?: boolean })
?.trashUnavailable &&
window.confirm(t("fileManager.trashUnavailableConfirm"))
) {
for (const file of files.slice(completed)) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
true,
);
}
toast.success(
t("fileManager.itemsDeletedSuccessfully", {
count: files.length - completed,
}),
);
handleRefreshDirectory();
return;
}
2025-10-06 10:11:25 -05:00
if (
2026-05-28 22:29:20 -05:00
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else if (
2026-01-24 19:49:42 -06:00
axiosError.message?.includes("connection") ||
axiosError.message?.includes("established")
2025-10-06 10:11:25 -05:00
) {
toast.error(
2026-05-28 22:29:20 -05:00
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
2025-10-06 10:11:25 -05:00
);
} else {
toast.error(t("fileManager.failedToDeleteItems"));
}
console.error("Delete failed:", error);
}
},
"destructive",
);
2025-10-01 15:40:10 -05:00
}
2026-01-24 19:49:42 -06:00
async function handleSudoPasswordSubmit(password: string) {
if (!sshSessionId || !pendingSudoOperation) return;
try {
await setSudoPassword(sshSessionId, password);
setSudoDialogOpen(false);
if (pendingSudoOperation.type === "delete") {
for (const file of pendingSudoOperation.files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
);
}
toast.success(
t("fileManager.itemsDeletedSuccessfully", {
count: pendingSudoOperation.files.length,
}),
);
handleRefreshDirectory();
clearSelection();
} else if (pendingSudoOperation.type === "navigate") {
const success = await loadDirectory(pendingSudoOperation.path);
if (success) {
setCurrentPath(pendingSudoOperation.path);
setPendingSudoOperation(null);
}
return;
}
setPendingSudoOperation(null);
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { needsSudo?: boolean; sudoFailed?: boolean } };
message?: string;
};
if (axiosError.response?.data?.sudoFailed) {
toast.error(t("fileManager.sudoAuthFailed"));
setSudoDialogOpen(true);
return;
}
toast.error(axiosError.message || t("fileManager.sudoOperationFailed"));
setPendingSudoOperation(null);
}
}
2025-10-01 15:40:10 -05:00
function handleCreateNewFolder() {
const defaultName = generateUniqueName(
t("fileManager.newFolderDefault"),
"directory",
);
2026-05-28 22:29:20 -05:00
setCreateIntent({
2025-10-01 15:40:10 -05:00
id: Date.now().toString(),
type: "directory" as const,
defaultName,
currentName: defaultName,
2026-05-28 22:29:20 -05:00
});
2025-10-01 15:40:10 -05:00
}
function handleCreateNewFile() {
const defaultName = generateUniqueName(
t("fileManager.newFileDefault"),
"file",
);
2026-05-28 22:29:20 -05:00
setCreateIntent({
2025-10-01 15:40:10 -05:00
id: Date.now().toString(),
type: "file" as const,
defaultName,
currentName: defaultName,
2026-05-28 22:29:20 -05:00
});
2025-10-01 15:40:10 -05:00
}
const handleSymlinkClick = async (file: FileItem) => {
if (!currentHost || !sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
+7
2025-11-05 10:36:16 -06:00
const currentSessionId = sshSessionId;
const status = await getSSHStatus(currentSessionId);
if (!status.connected) {
const result = await connectSSH(currentSessionId, {
hostId: currentHost.id,
+1
2025-12-31 22:20:12 -06:00
ip: currentHost.ip,
+7
2025-11-05 10:36:16 -06:00
port: currentHost.port,
username: currentHost.username,
authType: currentHost.authType,
password: currentHost.password,
+1
2025-12-31 22:20:12 -06:00
sshKey: currentHost.key,
+7
2025-11-05 10:36:16 -06:00
keyPassword: currentHost.keyPassword,
credentialId: currentHost.credentialId,
+1
2025-12-31 22:20:12 -06:00
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
+7
2025-11-05 10:36:16 -06:00
});
2025-10-01 15:40:10 -05:00
+7
2025-11-05 10:36:16 -06:00
if (!result.success) {
throw new Error(t("fileManager.failedToReconnectSSH"));
2025-10-01 15:40:10 -05:00
}
}
const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path);
if (symlinkInfo.type === "directory") {
setCurrentPath(symlinkInfo.target);
} else if (symlinkInfo.type === "file") {
const windowCount = Date.now() % 10;
const offsetX = 120 + windowCount * 30;
const offsetY = 120 + windowCount * 30;
const targetFile: FileItem = {
...file,
path: symlinkInfo.target,
};
const createWindowComponent = (windowId: string) => (
<FileWindow
windowId={windowId}
file={targetFile}
sshSessionId={currentSessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
title: file.name,
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createWindowComponent,
});
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
const httpError = asHttpError(error);
2025-10-01 15:40:10 -05:00
toast.error(
+5
2026-08-19 14:12:06 -05:00
httpError.response?.data?.error ||
httpError.message ||
2025-10-01 15:40:10 -05:00
t("fileManager.failedToResolveSymlink"),
);
}
};
2026-06-29 13:28:26 -05:00
function openFileWindow(file: FileItem, sessionId: string) {
const windowCount = Date.now() % 10;
const baseOffsetX = 120 + windowCount * 30;
const baseOffsetY = 120 + windowCount * 30;
const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100);
const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100);
const offsetX = Math.min(baseOffsetX, maxOffsetX);
const offsetY = Math.min(baseOffsetY, maxOffsetY);
const createWindowComponent = (windowId: string) => (
<FileWindow
windowId={windowId}
file={file}
sshSessionId={sessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
onFileNotFound={handleFileNotFound}
/>
);
openWindow({
title: file.name,
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createWindowComponent,
});
}
async function confirmLargeFileOpen(file: FileItem) {
if (!file.size || file.size <= LARGE_FILE_WARNING_SIZE) return true;
return confirmWithToast(
{
title: t("fileManager.largeFileWarning"),
description: t("fileManager.largeFileWarningDesc", {
size: formatFileSize(file.size),
}),
confirmText: t("fileManager.confirm"),
cancelText: t("common.cancel"),
},
undefined,
"default",
t("common.cancel"),
{ duration: 12000 },
);
}
+7
2025-11-05 10:36:16 -06:00
async function handleFileOpen(file: FileItem) {
2025-10-01 15:40:10 -05:00
if (file.type === "directory") {
+5
2026-08-19 14:12:06 -05:00
markAdaptiveResourceUsed("network", "directory-list");
directoryRequestRef.current += 1;
const cached = sshSessionId
? peekCachedFileList(sshSessionId, file.path)
: null;
if (cached) setFiles(cached.files);
setIsLoading(!!sshSessionId && !cached);
2025-10-01 15:40:10 -05:00
setCurrentPath(file.path);
2026-06-29 13:28:26 -05:00
return;
2025-10-01 15:40:10 -05:00
}
2026-06-29 13:28:26 -05:00
if (file.type === "link") {
await handleSymlinkClick(file);
return;
}
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
if (!(await confirmLargeFileOpen(file))) return;
+5
2026-08-19 14:12:06 -05:00
markFilePreviewUsed(file.name);
markAdaptiveResourceUsed(
"network",
file.size && file.size > 128 * 1024
? "file-content:medium"
: "file-content:small",
);
2026-06-29 13:28:26 -05:00
await recordRecentFile(file);
openFileWindow(file, sshSessionId);
2025-10-01 15:40:10 -05:00
}
+5
2026-08-19 14:12:06 -05:00
const prefetchFileIntent = useCallback(
(file: FileItem) => {
if (!sshSessionId) return;
if (file.type === "directory") {
runAdaptiveBackgroundTask("network", "directory-list", () =>
listSSHFiles(sshSessionId, file.path),
);
return;
}
const budget = getAdaptiveResourceBudget("network");
if (shouldPrefetchFileContent(file, budget.maxPrefetchBytes)) {
preloadFilePreview(file.name);
runAdaptiveBackgroundTask(
"network",
file.size && file.size > 128 * 1024
? "file-content:medium"
: "file-content:small",
() => readSSHFile(sshSessionId, file.path),
{ estimatedBytes: file.size },
);
}
},
[sshSessionId],
);
2025-10-01 15:40:10 -05:00
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
event.preventDefault();
let files: FileItem[];
if (file) {
const isFileSelected = selectedFiles.some((f) => f.path === file.path);
files = isFileSelected ? selectedFiles : [file];
} else {
files = selectedFiles;
}
setContextMenu({
x: event.clientX,
y: event.clientY,
isVisible: true,
files,
});
}
2026-06-04 15:16:53 -04:00
function handleSidebarItemContextMenu(
event: React.MouseEvent,
item: SidebarItem,
) {
const file: FileItem = {
name: item.name,
path: item.path,
type:
item.type === "recent" || item.type === "pinned" ? "file" : "directory",
};
handleContextMenu(event, file);
}
2025-10-01 15:40:10 -05:00
function handleCopyFiles(files: FileItem[]) {
2026-06-29 13:28:26 -05:00
const entry = {
files,
operation: "copy" as const,
sourceHostId: currentHost?.id ?? null,
sourceSessionId: sshSessionId,
};
setClipboard(entry);
window.dispatchEvent(
new CustomEvent("file-manager:clipboard", { detail: entry }),
);
2025-10-01 15:40:10 -05:00
toast.success(
t("fileManager.filesCopiedToClipboard", { count: files.length }),
);
}
function handleCutFiles(files: FileItem[]) {
2026-06-29 13:28:26 -05:00
const entry = {
files,
operation: "cut" as const,
sourceHostId: currentHost?.id ?? null,
sourceSessionId: sshSessionId,
};
setClipboard(entry);
window.dispatchEvent(
new CustomEvent("file-manager:clipboard", { detail: entry }),
);
2025-10-01 15:40:10 -05:00
toast.success(
t("fileManager.filesCutToClipboard", { count: files.length }),
);
}
2025-11-17 09:46:05 -06:00
function handleCopyPath(files: FileItem[]) {
if (files.length === 0) return;
const paths = files.map((file) => file.path).join("\n");
+2
2026-06-16 15:59:53 -05:00
copyToClipboard(paths).then((ok) => {
if (ok) {
2025-11-17 09:46:05 -06:00
toast.success(
files.length === 1
? t("fileManager.pathCopiedToClipboard")
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
);
+2
2026-06-16 15:59:53 -05:00
} else {
2025-11-17 09:46:05 -06:00
toast.error(t("fileManager.failedToCopyPath"));
+2
2026-06-16 15:59:53 -05:00
}
});
2025-11-17 09:46:05 -06:00
}
2026-06-21 15:55:41 -05:00
function handleCopyFolderLink(path: string) {
if (!currentHost?.id) return;
const params = new URLSearchParams({
view: "file-manager",
hostId: String(currentHost.id),
path,
});
const url = `${window.location.origin}?${params.toString()}`;
copyToClipboard(url).then((ok) => {
if (ok) {
toast.success(t("fileManager.folderLinkCopied"));
} else {
toast.error(t("fileManager.failedToCopyFolderLink"));
}
});
}
2026-06-29 13:28:26 -05:00
async function handleCrossHostPaste() {
if (!clipboard || !sshSessionId || !currentHost) return;
const { files, operation, sourceSessionId } = clipboard;
if (!sourceSessionId) return;
const sourcePaths = files.map((f) => f.path);
try {
const { transferId } = await transferToHost(
sourceSessionId,
sourcePaths,
sshSessionId,
currentPath,
operation === "cut",
"auto",
2,
);
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
formatTransferMetrics,
});
if (!monitorHandle) return;
const finalStatus = await monitorHandle.waitForCompletion;
if (
finalStatus.status !== "success" &&
finalStatus.status !== "partial"
) {
return;
}
if (operation === "cut") {
setClipboard(null);
}
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
2025-10-01 15:40:10 -05:00
async function handlePasteFiles() {
if (!clipboard || !sshSessionId) return;
2026-06-29 13:28:26 -05:00
if (
clipboard.sourceHostId !== null &&
clipboard.sourceHostId !== currentHost?.id
) {
await handleCrossHostPaste();
return;
}
2025-10-01 15:40:10 -05:00
try {
await ensureSSHConnection();
const { files, operation } = clipboard;
let successCount = 0;
const copiedItems: string[] = [];
for (const file of files) {
try {
if (operation === "copy") {
const result = await copySSHItem(
sshSessionId,
file.path,
currentPath,
currentHost?.id,
currentHost?.userId?.toString(),
);
copiedItems.push(result.uniqueName || file.name);
successCount++;
} else {
const targetPath = currentPath.endsWith("/")
? `${currentPath}${file.name}`
: `${currentPath}/${file.name}`;
if (file.path !== targetPath) {
await moveSSHItem(
sshSessionId,
file.path,
targetPath,
currentHost?.id,
currentHost?.userId?.toString(),
);
successCount++;
}
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
console.error(`Failed to ${operation} file ${file.name}:`, error);
2026-05-28 22:29:20 -05:00
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(
t("fileManager.operationFailed", {
operation:
operation === "copy"
? t("fileManager.copy")
: t("fileManager.move"),
name: file.name,
+5
2026-08-19 14:12:06 -05:00
error: getErrorMessage(error, String(error)),
2026-05-28 22:29:20 -05:00
}),
);
}
2025-10-01 15:40:10 -05:00
}
}
if (successCount > 0) {
if (operation === "copy") {
const copiedFiles = files
.slice(0, successCount)
.map((file, index) => ({
originalPath: file.path,
targetPath: `${currentPath}/${copiedItems[index] || file.name}`,
targetName: copiedItems[index] || file.name,
}));
const undoAction: UndoAction = {
type: "copy",
description: t("fileManager.copiedItems", { count: successCount }),
data: {
operation: "copy",
copiedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
} else if (operation === "cut") {
const movedFiles = files.slice(0, successCount).map((file) => {
const targetPath = currentPath.endsWith("/")
? `${currentPath}${file.name}`
: `${currentPath}/${file.name}`;
return {
originalPath: file.path,
targetPath: targetPath,
targetName: file.name,
};
});
const undoAction: UndoAction = {
type: "cut",
description: t("fileManager.movedItems", { count: successCount }),
data: {
operation: "cut",
copiedFiles: movedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
}
}
if (successCount > 0) {
const operationText =
operation === "copy" ? t("fileManager.copy") : t("fileManager.move");
if (operation === "copy" && copiedItems.length > 0) {
const hasRenamed = copiedItems.some(
(name) => !files.some((file) => file.name === name),
);
if (hasRenamed) {
toast.success(
t("fileManager.operationCompletedSuccessfully", {
operation: operationText,
count: successCount,
}),
);
} else {
toast.success(
t("fileManager.operationCompleted", {
operation: operationText,
count: successCount,
}),
);
}
} else {
toast.success(
t("fileManager.operationCompleted", {
operation: operationText,
count: successCount,
}),
);
}
}
handleRefreshDirectory();
clearSelection();
if (operation === "cut") {
setClipboard(null);
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
const errorMessage = getErrorMessage(error, String(error));
2026-05-28 22:29:20 -05:00
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
2025-10-01 15:40:10 -05:00
}
}
2025-11-17 09:46:05 -06:00
async function handleExtractArchive(file: FileItem) {
if (!sshSessionId) return;
try {
await ensureSSHConnection();
toast.info(t("fileManager.extractingArchive", { name: file.name }));
await extractSSHArchive(
sshSessionId,
file.path,
undefined,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
);
handleRefreshDirectory();
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
const errorMessage = getErrorMessage(error, String(error));
2026-05-28 22:29:20 -05:00
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
2025-11-17 09:46:05 -06:00
}
}
function handleOpenCompressDialog(files: FileItem[]) {
setCompressDialogFiles(files);
}
async function handleCompress(archiveName: string, format: string) {
if (!sshSessionId || compressDialogFiles.length === 0) return;
try {
await ensureSSHConnection();
const paths = compressDialogFiles.map((f) => f.path);
const fileNames = compressDialogFiles.map((f) => f.name);
toast.info(
t("fileManager.compressingFiles", {
count: fileNames.length,
name: archiveName,
}),
);
await compressSSHFiles(
sshSessionId,
paths,
archiveName,
format,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(
t("fileManager.filesCompressedSuccessfully", {
name: archiveName,
}),
);
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
const errorMessage = getErrorMessage(error, String(error));
2026-05-28 22:29:20 -05:00
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
2025-11-17 09:46:05 -06:00
}
}
2026-06-04 15:16:53 -04:00
function handleOpenTransferDialog(files: FileItem[], move: boolean) {
setTransferFiles(files);
setTransferMove(move);
setTransferDialogOpen(true);
}
async function handleTransferConfirm(
destSessionId: string,
destHostId: number,
destPath: string,
destPathLabel: string,
methodPreference: TransferMethodPreference,
+5
2026-08-19 14:12:06 -05:00
parallelSegmentCount?: number,
2026-06-04 15:16:53 -04:00
) {
if (!sshSessionId || !currentHost?.id || transferFiles.length === 0) return;
const sourcePaths = transferFiles.map((f) => f.path);
try {
await ensureSSHConnection();
const { transferId } = await transferToHost(
sshSessionId,
sourcePaths,
destSessionId,
destPath,
transferMove,
methodPreference,
parallelSegmentCount,
);
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
formatTransferMetrics,
});
if (!monitorHandle) return;
const finalStatus = await monitorHandle.waitForCompletion;
if (
finalStatus.status !== "success" &&
finalStatus.status !== "partial"
) {
return;
}
void addTransferRecent(
currentHost.id,
destHostId,
destPathLabel,
destPathLabel,
);
window.dispatchEvent(
new CustomEvent("file-manager:refresh", {
detail: { hostId: destHostId, path: destPathLabel },
}),
);
if (transferMove) {
handleRefreshDirectory();
clearSelection();
}
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
2025-10-01 15:40:10 -05:00
async function handleUndo() {
if (undoHistory.length === 0) {
toast.info(t("fileManager.noUndoableActions"));
return;
}
const lastAction = undoHistory[undoHistory.length - 1];
try {
await ensureSSHConnection();
switch (lastAction.type) {
case "copy":
if (lastAction.data.copiedFiles) {
let successCount = 0;
for (const copiedFile of lastAction.data.copiedFiles) {
try {
const isDirectory =
files.find((f) => f.path === copiedFile.targetPath)?.type ===
"directory";
await deleteSSHItem(
sshSessionId!,
copiedFile.targetPath,
isDirectory,
currentHost?.id,
currentHost?.userId?.toString(),
);
successCount++;
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
console.error(
`Failed to delete copied file ${copiedFile.targetName}:`,
error,
);
toast.error(
t("fileManager.deleteCopiedFileFailed", {
name: copiedFile.targetName,
+5
2026-08-19 14:12:06 -05:00
error: getErrorMessage(error, String(error)),
2025-10-01 15:40:10 -05:00
}),
);
}
}
if (successCount > 0) {
setUndoHistory((prev) => prev.slice(0, -1));
toast.success(
t("fileManager.undoCopySuccess", { count: successCount }),
);
} else {
toast.error(t("fileManager.undoCopyFailedDelete"));
return;
}
} else {
toast.error(t("fileManager.undoCopyFailedNoInfo"));
return;
}
break;
case "cut":
if (lastAction.data.copiedFiles) {
let successCount = 0;
for (const movedFile of lastAction.data.copiedFiles) {
try {
await moveSSHItem(
sshSessionId!,
movedFile.targetPath,
movedFile.originalPath,
currentHost?.id,
currentHost?.userId?.toString(),
);
successCount++;
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
console.error(
`Failed to move back file ${movedFile.targetName}:`,
error,
);
toast.error(
t("fileManager.moveBackFileFailed", {
name: movedFile.targetName,
+5
2026-08-19 14:12:06 -05:00
error: getErrorMessage(error, String(error)),
2025-10-01 15:40:10 -05:00
}),
);
}
}
if (successCount > 0) {
setUndoHistory((prev) => prev.slice(0, -1));
toast.success(
t("fileManager.undoMoveSuccess", { count: successCount }),
);
} else {
toast.error(t("fileManager.undoMoveFailedMove"));
return;
}
} else {
toast.error(t("fileManager.undoMoveFailedNoInfo"));
return;
}
break;
case "delete":
toast.info(t("fileManager.undoDeleteNotSupported"));
setUndoHistory((prev) => prev.slice(0, -1));
return;
default:
toast.error(t("fileManager.undoTypeNotSupported"));
return;
}
handleRefreshDirectory();
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
const errorMessage = getErrorMessage(error, String(error));
2026-05-28 22:29:20 -05:00
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
2025-10-01 15:40:10 -05:00
console.error("Undo failed:", error);
}
}
function handleRenameFile(file: FileItem) {
setEditingFile(file);
}
2025-11-17 09:46:05 -06:00
function handleOpenPermissionsDialog(file: FileItem) {
setPermissionsDialogFile(file);
}
async function handleSavePermissions(file: FileItem, permissions: string) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
await changeSSHPermissions(
sshSessionId,
file.path,
permissions,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.permissionsChangedSuccessfully"));
await handleRefreshDirectory();
} catch (error: unknown) {
console.error("Failed to change permissions:", error);
toast.error(t("fileManager.failedToChangePermissions"));
throw error;
}
}
2025-10-01 15:40:10 -05:00
async function ensureSSHConnection() {
2026-05-06 15:12:07 -05:00
if (!sshSessionId || !currentHost) return;
2025-10-01 15:40:10 -05:00
2026-05-06 15:12:07 -05:00
const status = await getSSHStatus(sshSessionId);
2025-10-01 15:40:10 -05:00
2026-05-06 15:12:07 -05:00
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
}
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
async function handleConfirmCreate(name: string) {
if (!createIntent || !sshSessionId) return;
2025-09-12 14:42:00 -05:00
+5
2026-08-19 14:12:06 -05:00
const operationPath = currentPath;
const operationSession = sshSessionId;
const optimisticPath = childPath(operationPath, name);
const previousFiles = filesForPath(operationSession, operationPath);
const optimisticFiles = addOptimisticItem(
previousFiles,
operationPath,
name,
createIntent.type,
);
const didAddOptimistically = optimisticFiles !== previousFiles;
commitFilesForPath(operationSession, operationPath, optimisticFiles);
const createdType = createIntent.type;
setCreateIntent(null);
2025-10-01 15:40:10 -05:00
try {
await ensureSSHConnection();
+5
2026-08-19 14:12:06 -05:00
if (createdType === "file") {
2025-10-01 15:40:10 -05:00
await createSSHFile(
sshSessionId,
currentPath,
name,
"",
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.fileCreatedSuccessfully", { name }));
} else {
await createSSHFolder(
sshSessionId,
currentPath,
name,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.folderCreatedSuccessfully", { name }));
}
+5
2026-08-19 14:12:06 -05:00
if (
sshSessionIdRef.current === operationSession &&
currentPathRef.current === operationPath
) {
handleRefreshDirectory();
} else {
invalidateCachedFileList(operationSession, operationPath);
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
if (didAddOptimistically) {
commitFilesForPath(
operationSession,
operationPath,
removePaths(
filesForPath(operationSession, operationPath),
new Set([optimisticPath]),
),
);
}
2026-05-28 22:29:20 -05:00
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(t("fileManager.failedToCreateItem"));
}
2025-10-01 15:40:10 -05:00
console.error("Create failed:", error);
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
}
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
function handleCancelCreate() {
setCreateIntent(null);
}
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
async function handleRenameConfirm(file: FileItem, newName: string) {
if (!sshSessionId) return;
2025-09-12 14:42:00 -05:00
+5
2026-08-19 14:12:06 -05:00
const operationPath = currentPath;
const operationSession = sshSessionId;
const renamedPath = childPath(
file.path.slice(0, Math.max(1, file.path.lastIndexOf("/"))),
newName,
);
const operationFiles = filesForPath(operationSession, operationPath);
const didRenameOptimistically = !operationFiles.some(
(entry) => entry.path === renamedPath && entry.path !== file.path,
);
if (didRenameOptimistically) {
commitFilesForPath(
operationSession,
operationPath,
renameOptimisticItem(operationFiles, file.path, newName),
);
}
setEditingFile(null);
2025-10-01 15:40:10 -05:00
try {
await ensureSSHConnection();
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
await renameSSHItem(
sshSessionId,
file.path,
newName,
currentHost?.id,
currentHost?.userId?.toString(),
);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
toast.success(
t("fileManager.itemRenamedSuccessfully", { name: newName }),
);
+5
2026-08-19 14:12:06 -05:00
if (
sshSessionIdRef.current === operationSession &&
currentPathRef.current === operationPath
) {
handleRefreshDirectory();
} else {
invalidateCachedFileList(operationSession, operationPath);
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
+5
2026-08-19 14:12:06 -05:00
if (didRenameOptimistically) {
commitFilesForPath(
operationSession,
operationPath,
renameOptimisticItem(
filesForPath(operationSession, operationPath),
renamedPath,
file.name,
),
);
}
2026-05-28 22:29:20 -05:00
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(t("fileManager.failedToRenameItem"));
}
2025-10-01 15:40:10 -05:00
console.error("Rename failed:", error);
}
}
function handleStartEdit(file: FileItem) {
setEditingFile(file);
}
function handleCancelEdit() {
setEditingFile(null);
}
+7
2025-11-05 10:36:16 -06:00
async function handleTotpSubmit(code: string) {
if (!totpSessionId || !code) return;
try {
setIsLoading(true);
const result = await verifySSHTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSshSessionId(totpSessionId);
setTotpSessionId(null);
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current.markConnected();
+7
2025-11-05 10:36:16 -06:00
try {
const response = await listSSHFiles(totpSessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
logFileManagerActivity();
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}
}
} catch (error: unknown) {
console.error("TOTP verification failed:", error);
toast.error(t("fileManager.totpVerificationFailed"));
} finally {
setIsLoading(false);
}
}
function handleTotpCancel() {
setTotpRequired(false);
setTotpPrompt("");
setTotpSessionId(null);
if (onClose) onClose();
}
2026-01-24 19:49:42 -06:00
async function handleWarpgateContinue() {
if (!warpgateSessionId) return;
try {
setIsLoading(true);
const result = await verifySSHWarpgate(warpgateSessionId);
if (result?.status === "success") {
setWarpgateRequired(false);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setSshSessionId(warpgateSessionId);
setWarpgateSessionId(null);
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current.markConnected();
2026-01-24 19:49:42 -06:00
try {
const response = await listSSHFiles(warpgateSessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
logFileManagerActivity();
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}
}
} catch (error: unknown) {
console.error("Warpgate verification failed:", error);
toast.error(t("fileManager.warpgateVerificationFailed"));
} finally {
setIsLoading(false);
}
}
function handleWarpgateCancel() {
setWarpgateRequired(false);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setWarpgateSessionId(null);
if (onClose) onClose();
}
function handleWarpgateOpenUrl() {
if (warpgateUrl) {
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
}
}
+7
2025-11-05 10:36:16 -06:00
async function handleAuthDialogSubmit(credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) {
if (!currentHost) return;
try {
setIsLoading(true);
setShowAuthDialog(false);
const sessionId = currentHost.id.toString();
const result = await connectSSH(sessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: credentials.password,
sshKey: credentials.sshKey,
keyPassword: credentials.keyPassword,
authType: credentials.password ? "password" : "key",
credentialId: currentHost.credentialId,
userId: currentHost.userId,
+1
2025-12-31 22:20:12 -06:00
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
+7
2025-11-05 10:36:16 -06:00
});
2026-01-24 19:49:42 -06:00
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sessionId);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "N/A");
setIsLoading(false);
return;
}
+7
2025-11-05 10:36:16 -06:00
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
+1
2025-12-31 22:20:12 -06:00
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
+7
2025-11-05 10:36:16 -06:00
setIsLoading(false);
return;
}
if (result?.status === "auth_required") {
setAuthDialogReason(result.reason || "auth_failed");
setShowAuthDialog(true);
setIsLoading(false);
toast.error(t("fileManager.authenticationFailed"));
return;
}
setSshSessionId(sessionId);
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current.markConnected();
+7
2025-11-05 10:36:16 -06:00
try {
const response = await listSSHFiles(sessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
logFileManagerActivity();
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}
} catch (error: unknown) {
console.error("SSH connection with credentials failed:", error);
setAuthDialogReason("auth_failed");
setShowAuthDialog(true);
toast.error(
2026-05-28 22:29:20 -05:00
t("fileManager.failedToConnect") +
": " +
+5
2026-08-19 14:12:06 -05:00
getErrorMessage(error, String(error)),
+7
2025-11-05 10:36:16 -06:00
);
} finally {
setIsLoading(false);
}
}
function handleAuthDialogCancel() {
setShowAuthDialog(false);
if (onClose) onClose();
}
2026-06-21 15:55:41 -05:00
async function handlePassphraseSubmit(passphrase: string) {
if (!currentHost) return;
try {
setIsLoading(true);
setShowPassphraseDialog(false);
const sessionId = currentHost.id.toString();
const result = await connectSSH(sessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
sshKey: currentHost.key,
keyPassword: passphrase,
authType: "key",
credentialId: currentHost.credentialId,
userId: currentHost.userId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
if (result?.status === "passphrase_required") {
setShowPassphraseDialog(true);
setIsLoading(false);
toast.error(t("fileManager.incorrectPassphrase"));
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
setIsLoading(false);
return;
}
if (result?.status === "auth_required") {
setAuthDialogReason(result.reason || "auth_failed");
setShowAuthDialog(true);
setIsLoading(false);
return;
}
setSshSessionId(sessionId);
+5
2026-08-19 14:12:06 -05:00
connectRetryRef.current.markConnected();
2026-06-21 15:55:41 -05:00
try {
const response = await listSSHFiles(sessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
logFileManagerActivity();
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}
} catch (error: unknown) {
console.error("SSH connection with passphrase failed:", error);
setShowPassphraseDialog(true);
toast.error(t("fileManager.incorrectPassphrase"));
} finally {
setIsLoading(false);
}
}
function handlePassphraseCancel() {
setShowPassphraseDialog(false);
if (onClose) onClose();
}
2025-10-01 15:40:10 -05:00
function generateUniqueName(
baseName: string,
type: "file" | "directory",
): string {
const existingNames = files.map((f) => f.name.toLowerCase());
let candidateName = baseName;
let counter = 1;
while (existingNames.includes(candidateName.toLowerCase())) {
if (type === "file" && baseName.includes(".")) {
const lastDotIndex = baseName.lastIndexOf(".");
const nameWithoutExt = baseName.substring(0, lastDotIndex);
const extension = baseName.substring(lastDotIndex);
candidateName = `${nameWithoutExt}${counter}${extension}`;
} else {
candidateName = `${baseName}${counter}`;
}
counter++;
}
return candidateName;
}
async function handleFileDrop(
draggedFiles: FileItem[],
targetFolder: FileItem,
) {
if (!sshSessionId || targetFolder.type !== "directory") return;
try {
await ensureSSHConnection();
let successCount = 0;
const movedItems: string[] = [];
for (const file of draggedFiles) {
try {
const targetPath = targetFolder.path.endsWith("/")
? `${targetFolder.path}${file.name}`
: `${targetFolder.path}/${file.name}`;
if (file.path !== targetPath) {
await moveSSHItem(
sshSessionId,
file.path,
targetPath,
currentHost?.id,
currentHost?.userId?.toString(),
);
movedItems.push(file.name);
successCount++;
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
console.error(`Failed to move file ${file.name}:`, error);
toast.error(
t("fileManager.moveFileFailed", { name: file.name }) +
": " +
+5
2026-08-19 14:12:06 -05:00
getErrorMessage(error, String(error)),
2025-10-01 15:40:10 -05:00
);
}
}
if (successCount > 0) {
+7
2025-11-05 10:36:16 -06:00
const movedFiles = draggedFiles.slice(0, successCount).map((file) => {
const targetPath = targetFolder.path.endsWith("/")
? `${targetFolder.path}${file.name}`
: `${targetFolder.path}/${file.name}`;
return {
originalPath: file.path,
targetPath: targetPath,
targetName: file.name,
};
});
2025-10-01 15:40:10 -05:00
const undoAction: UndoAction = {
type: "cut",
description: t("fileManager.dragMovedItems", {
count: successCount,
target: targetFolder.name,
}),
data: {
operation: "cut",
copiedFiles: movedFiles,
targetDirectory: targetFolder.path,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
toast.success(
t("fileManager.successfullyMovedItems", {
count: successCount,
target: targetFolder.name,
}),
);
handleRefreshDirectory();
clearSelection();
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
console.error("Drag move operation failed:", error);
2026-05-28 22:29:20 -05:00
toast.error(
t("fileManager.moveOperationFailed") +
": " +
+5
2026-08-19 14:12:06 -05:00
getErrorMessage(error, String(error)),
2026-05-28 22:29:20 -05:00
);
2025-10-01 15:40:10 -05:00
}
}
function handleFileDiff(file1: FileItem, file2: FileItem) {
if (file1.type !== "file" || file2.type !== "file") {
toast.error(t("fileManager.canOnlyCompareFiles"));
return;
}
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
const offsetX = 100;
const offsetY = 80;
const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const createWindowComponent = (windowId: string) => (
<DiffWindow
windowId={windowId}
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
title: t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
}),
+5
2026-08-19 14:12:06 -05:00
x: offsetX,
y: offsetY,
width: 800,
height: 600,
2025-10-01 15:40:10 -05:00
isMaximized: false,
+5
2026-08-19 14:12:06 -05:00
isMinimized: false,
2025-10-01 15:40:10 -05:00
component: createWindowComponent,
});
toast.success(
t("fileManager.comparingFiles", { file1: file1.name, file2: file2.name }),
);
}
async function handleDragToDesktop(files: FileItem[]) {
if (!currentHost || !sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
if (systemDrag.isFileSystemAPISupported) {
await systemDrag.handleDragToSystem(files, {
enableToast: true,
onError: (error) => {
console.error("System-level drag failed:", error);
},
});
} else {
if (files.length === 1) {
await dragToDesktop.dragFileToDesktop(files[0]);
} else if (files.length > 1) {
await dragToDesktop.dragFilesToDesktop(files);
}
}
+7
2025-11-05 10:36:16 -06:00
} catch (error: unknown) {
2025-10-01 15:40:10 -05:00
console.error("Drag to desktop failed:", error);
toast.error(
t("fileManager.dragFailed") +
": " +
+5
2026-08-19 14:12:06 -05:00
getErrorMessage(error, String(error)),
2025-10-01 15:40:10 -05:00
);
}
}
function handleOpenTerminal(path: string) {
if (!currentHost) {
toast.error(t("fileManager.noHostSelected"));
return;
}
const windowCount = Date.now() % 10;
const offsetX = 200 + windowCount * 40;
const offsetY = 150 + windowCount * 40;
const createTerminalComponent = (windowId: string) => (
<TerminalWindow
windowId={windowId}
hostConfig={currentHost}
initialPath={path}
initialX={offsetX}
initialY={offsetY}
2026-06-29 13:28:26 -05:00
onPromoteToTab={onOpenTerminalTab}
2025-10-01 15:40:10 -05:00
/>
);
openWindow({
title: t("fileManager.terminal", { host: currentHost.name, path }),
x: offsetX,
y: offsetY,
width: 800,
height: 500,
isMaximized: false,
isMinimized: false,
component: createTerminalComponent,
});
toast.success(
t("terminal.terminalWithPath", { host: currentHost.name, path }),
);
}
function handleRunExecutable(file: FileItem) {
if (!currentHost) {
toast.error(t("fileManager.noHostSelected"));
return;
}
if (file.type !== "file" || !file.executable) {
toast.error(t("fileManager.onlyRunExecutableFiles"));
return;
}
const fileDir = file.path.substring(0, file.path.lastIndexOf("/"));
const fileName = file.name;
const executeCmd = `./${fileName}`;
const windowCount = Date.now() % 10;
const offsetX = 250 + windowCount * 40;
const offsetY = 200 + windowCount * 40;
const createExecutionTerminal = (windowId: string) => (
<TerminalWindow
windowId={windowId}
hostConfig={currentHost}
initialPath={fileDir}
initialX={offsetX}
initialY={offsetY}
executeCommand={executeCmd}
2026-06-29 13:28:26 -05:00
onPromoteToTab={onOpenTerminalTab}
2025-10-01 15:40:10 -05:00
/>
);
openWindow({
title: t("fileManager.runningFile", { file: file.name }),
x: offsetX,
y: offsetY,
width: 800,
height: 500,
isMaximized: false,
isMinimized: false,
component: createExecutionTerminal,
});
toast.success(t("fileManager.runningFile", { file: file.name }));
}
async function loadPinnedFiles() {
2025-09-12 14:42:00 -05:00
if (!currentHost?.id) return;
try {
2025-10-01 15:40:10 -05:00
const pinnedData = await getPinnedFiles(currentHost.id);
+5
2026-08-19 14:12:06 -05:00
const pinnedPaths = new Set(pinnedData.map((item) => item.path));
2025-10-01 15:40:10 -05:00
setPinnedFiles(pinnedPaths);
} catch (error) {
console.error("Failed to load pinned files:", error);
2025-09-12 14:42:00 -05:00
}
2025-10-01 15:40:10 -05:00
}
async function handlePinFile(file: FileItem) {
if (!currentHost?.id) return;
try {
await addPinnedFile(currentHost.id, file.path, file.name);
setPinnedFiles((prev) => new Set([...prev, file.path]));
setSidebarRefreshTrigger((prev) => prev + 1);
toast.success(
t("fileManager.filePinnedSuccessfully", { name: file.name }),
);
} catch (error) {
console.error("Failed to pin file:", error);
toast.error(t("fileManager.pinFileFailed"));
}
}
async function handleUnpinFile(file: FileItem) {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, file.path);
setPinnedFiles((prev) => {
const newSet = new Set(prev);
newSet.delete(file.path);
return newSet;
});
setSidebarRefreshTrigger((prev) => prev + 1);
toast.success(
t("fileManager.fileUnpinnedSuccessfully", { name: file.name }),
);
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFileFailed"));
}
}
async function handleAddShortcut(path: string) {
if (!currentHost?.id) return;
try {
const folderName = path.split("/").pop() || path;
await addFolderShortcut(currentHost.id, path, folderName);
setSidebarRefreshTrigger((prev) => prev + 1);
toast.success(
t("fileManager.shortcutAddedSuccessfully", { name: folderName }),
);
} catch (error) {
console.error("Failed to add shortcut:", error);
toast.error(t("fileManager.addShortcutFailed"));
}
}
function isPinnedFile(file: FileItem): boolean {
return pinnedFiles.has(file.path);
}
async function recordRecentFile(file: FileItem) {
if (!currentHost?.id || file.type === "directory") return;
try {
await addRecentFile(currentHost.id, file.path, file.name);
setSidebarRefreshTrigger((prev) => prev + 1);
} catch (error) {
console.error("Failed to record recent file:", error);
}
}
async function handleSidebarFileOpen(sidebarItem: SidebarItem) {
const file: FileItem = {
name: sidebarItem.name,
path: sidebarItem.path,
type: "file",
};
await handleFileOpen(file);
}
async function handleFileNotFound(file: FileItem) {
if (!currentHost) return;
try {
await removeRecentFile(currentHost.id, file.path);
await removePinnedFile(currentHost.id, file.path);
setSidebarRefreshTrigger((prev) => prev + 1);
} catch (error) {
console.error("Failed to cleanup missing file:", error);
}
}
useEffect(() => {
setCreateIntent(null);
}, [currentPath]);
useEffect(() => {
if (currentHost?.id) {
loadPinnedFiles();
2026-05-11 01:23:11 -05:00
getServerMetricsById(currentHost.id)
.then((metrics) => {
if (
metrics?.disk?.percent != null &&
metrics.disk.usedHuman &&
metrics.disk.totalHuman
) {
setDiskInfo({
usedHuman: metrics.disk.usedHuman,
totalHuman: metrics.disk.totalHuman,
percent: metrics.disk.percent,
+3
2026-08-06 14:41:39 -05:00
mount: metrics.disk.mount ?? null,
filesystems: metrics.disk.filesystems ?? [],
2026-05-11 01:23:11 -05:00
});
}
})
.catch(() => {});
2025-10-01 15:40:10 -05:00
}
}, [currentHost?.id]);
2025-11-17 09:46:05 -06:00
useEffect(() => {
localStorage.setItem("fileManagerViewMode", viewMode);
}, [viewMode]);
+5
2026-03-08 18:02:14 -05:00
useEffect(() => {
localStorage.setItem("fileManagerSortBy", sortBy);
localStorage.setItem("fileManagerSortOrder", sortOrder);
}, [sortBy, sortOrder]);
+4
2026-02-12 22:28:13 -06:00
const filteredFiles = useMemo(
() =>
files
.filter((file) =>
file.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
.sort((a, b) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
+5
2026-03-08 18:02:14 -05:00
let result = 0;
switch (sortBy) {
case "name":
result = a.name.localeCompare(b.name, undefined, {
numeric: true,
sensitivity: "base",
});
break;
case "modified":
2026-06-29 13:28:26 -05:00
result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0);
+5
2026-03-08 18:02:14 -05:00
break;
case "size":
result = (a.size || 0) - (b.size || 0);
break;
}
return sortOrder === "desc" ? -result : result;
+4
2026-02-12 22:28:13 -06:00
}),
+5
2026-03-08 18:02:14 -05:00
[files, searchQuery, sortBy, sortOrder],
2025-10-01 15:40:10 -05:00
);
2025-09-12 14:42:00 -05:00
if (!currentHost) {
return (
2025-10-01 15:40:10 -05:00
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-muted-foreground mb-4">
{t("fileManager.selectHostToStart")}
</p>
2025-09-12 14:42:00 -05:00
</div>
</div>
);
}
2026-05-28 22:29:20 -05:00
if ((isLoading || isReconnecting) && !sshSessionId) {
return (
<div className="h-full w-full flex flex-col bg-background relative">
+5
2026-08-19 14:12:06 -05:00
<ConnectionScreen
status={isReconnecting ? "connecting" : connectRetry.status}
message={t("fileManager.connecting")}
attempt={connectRetry.attempt}
maxAttempts={connectRetry.maxAttempts}
nextRetryInMs={connectRetry.nextRetryInMs}
onManualRetry={connectRetry.retryNow}
logPosition={hasConnectionError ? "top" : "bottom"}
2026-05-28 22:29:20 -05:00
/>
</div>
);
}
2025-09-12 14:42:00 -05:00
return (
2026-05-28 22:29:20 -05:00
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
+5
2026-08-19 14:12:06 -05:00
<div className="h-full w-full flex flex-col min-h-0">
2026-06-04 15:16:53 -04:00
<FileManagerToolbar
t={t}
currentPath={currentPath}
navIndex={navIndex}
navHistoryLength={navHistory.length}
isLoading={isLoading}
sshSessionId={sshSessionId}
selectedFiles={selectedFiles}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
viewMode={viewMode}
setViewMode={setViewMode}
sortBy={sortBy}
setSortBy={setSortBy}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
setMobileSidebarOpen={setMobileSidebarOpen}
goBack={goBack}
goForward={goForward}
goUp={goUp}
navigateTo={navigateTo}
handleRefreshDirectory={handleRefreshDirectory}
handleDeleteFiles={handleDeleteFiles}
handleCopyFiles={handleCopyFiles}
handleFilesDropped={handleFilesDropped}
handleCreateNewFolder={handleCreateNewFolder}
handleCreateNewFile={handleCreateNewFile}
/>
2026-05-11 01:23:11 -05:00
<div
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
{...dragHandlers}
>
{/* Mobile sidebar backdrop */}
{mobileSidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{/* Sidebar — fixed overlay on mobile, static on desktop */}
<div
className={cn(
"w-56 flex-shrink-0 h-full flex flex-col",
"md:flex",
mobileSidebarOpen
? "fixed left-0 top-0 bottom-0 w-64 z-30 flex"
: "hidden md:flex",
)}
>
2026-05-28 22:29:20 -05:00
<div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
2026-05-11 01:23:11 -05:00
<FileManagerSidebar
currentHost={currentHost}
currentPath={currentPath}
onPathChange={navigateTo}
onFileOpen={handleSidebarFileOpen}
2026-06-04 15:16:53 -04:00
onItemContextMenu={handleSidebarItemContextMenu}
2026-05-11 01:23:11 -05:00
sshSessionId={sshSessionId}
refreshTrigger={sidebarRefreshTrigger}
+5
2026-08-19 14:12:06 -05:00
onOpenTrash={() => setTrashOpen(true)}
2026-05-11 01:23:11 -05:00
diskInfo={diskInfo ?? undefined}
/>
2026-05-28 22:29:20 -05:00
</div>
2026-05-11 01:23:11 -05:00
</div>
2026-05-28 22:29:20 -05:00
<div className="flex-1 relative overflow-hidden min-h-0 flex flex-col border border-border bg-card">
2026-05-11 01:23:11 -05:00
<div className="flex-1 relative min-h-0 h-full">
<FileManagerGrid
files={filteredFiles}
selectedFiles={selectedFiles}
onFileOpen={handleFileOpen}
+5
2026-08-19 14:12:06 -05:00
onFileIntent={prefetchFileIntent}
2026-05-11 01:23:11 -05:00
onSelectionChange={setSelection}
onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={(field) => {
if (field === sortBy) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(field);
setSortOrder("asc");
}
}}
2026-05-28 22:29:20 -05:00
onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
2026-05-11 01:23:11 -05:00
onContextMenu={handleContextMenu}
viewMode={viewMode}
onRename={handleRenameConfirm}
editingFile={editingFile}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onDelete={handleDeleteFiles}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
onPaste={handlePasteFiles}
onUndo={handleUndo}
hasClipboard={!!clipboard}
onFileDrop={handleFileDrop}
onFileDiff={handleFileDiff}
onSystemDragStart={handleFileDragStart}
onSystemDragEnd={handleFileDragEnd}
createIntent={createIntent}
onConfirmCreate={handleConfirmCreate}
onCancelCreate={handleCancelCreate}
onNewFile={handleCreateNewFile}
onNewFolder={handleCreateNewFolder}
/>
<FileManagerContextMenu
x={contextMenu.x}
y={contextMenu.y}
files={contextMenu.files}
isVisible={contextMenu.isVisible}
onClose={() =>
setContextMenu((prev) => ({ ...prev, isVisible: false }))
}
2026-05-28 22:29:20 -05:00
onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onPreview={handleFileOpen}
2026-05-11 01:23:11 -05:00
onRename={handleRenameFile}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
onPaste={handlePasteFiles}
onDelete={handleDeleteFiles}
onUpload={() => {
2026-01-24 19:49:42 -06:00
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files) handleFilesDropped(files);
};
input.click();
}}
2026-05-11 01:23:11 -05:00
onNewFolder={handleCreateNewFolder}
onNewFile={handleCreateNewFile}
onRefresh={handleRefreshDirectory}
hasClipboard={!!clipboard}
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
onOpenTerminal={(path) => handleOpenTerminal(path)}
onRunExecutable={(file) => handleRunExecutable(file)}
onPinFile={handlePinFile}
onUnpinFile={handleUnpinFile}
onAddShortcut={handleAddShortcut}
isPinned={isPinnedFile}
currentPath={currentPath}
onProperties={handleOpenPermissionsDialog}
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
onCopyPath={handleCopyPath}
2026-06-21 15:55:41 -05:00
onCopyFolderLink={handleCopyFolderLink}
2026-06-04 15:16:53 -04:00
onTransferToHost={handleOpenTransferDialog}
2026-05-11 01:23:11 -05:00
/>
2026-01-24 19:49:42 -06:00
</div>
2026-05-28 22:29:20 -05:00
</div>
2025-09-12 14:42:00 -05:00
</div>
+5
2026-08-19 14:12:06 -05:00
<FileManagerTrashDialog
open={trashOpen}
onOpenChange={setTrashOpen}
sessionId={sshSessionId}
onChanged={() => {
if (sshSessionId)
invalidateCachedFileList(sshSessionId, currentPath);
void handleRefreshDirectory();
}}
/>
2025-09-12 14:42:00 -05:00
</div>
2025-10-01 15:40:10 -05:00
+7
2025-11-05 10:36:16 -06:00
{currentHost && (
2026-06-04 15:16:53 -04:00
<TransferToHostDialog
open={transferDialogOpen}
onOpenChange={setTransferDialogOpen}
files={transferFiles}
move={transferMove}
sourceHost={currentHost}
sourceSessionId={sshSessionId}
onConfirm={handleTransferConfirm}
+7
2025-11-05 10:36:16 -06:00
/>
)}
2025-11-17 09:46:05 -06:00
2026-06-21 15:55:41 -05:00
{currentHost && (
<PassphraseDialog
isOpen={showPassphraseDialog}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
2026-06-04 15:16:53 -04:00
<FileManagerDialogs
compressDialogFiles={compressDialogFiles}
setCompressDialogFiles={setCompressDialogFiles}
handleCompress={handleCompress}
totpRequired={totpRequired}
totpPrompt={totpPrompt}
handleTotpSubmit={handleTotpSubmit}
handleTotpCancel={handleTotpCancel}
warpgateRequired={warpgateRequired}
warpgateUrl={warpgateUrl}
warpgateSecurityKey={warpgateSecurityKey}
handleWarpgateContinue={handleWarpgateContinue}
handleWarpgateCancel={handleWarpgateCancel}
handleWarpgateOpenUrl={handleWarpgateOpenUrl}
currentHost={currentHost}
showAuthDialog={showAuthDialog}
authDialogReason={authDialogReason}
handleAuthDialogSubmit={handleAuthDialogSubmit}
handleAuthDialogCancel={handleAuthDialogCancel}
permissionsDialogFile={permissionsDialogFile}
setPermissionsDialogFile={setPermissionsDialogFile}
handleSavePermissions={handleSavePermissions}
sudoDialogOpen={sudoDialogOpen}
setSudoDialogOpen={setSudoDialogOpen}
setPendingSudoOperation={setPendingSudoOperation}
handleSudoPasswordSubmit={handleSudoPasswordSubmit}
2026-01-24 19:49:42 -06:00
/>
+5
2026-08-19 14:12:06 -05:00
<ConnectionLogPanel
2026-01-24 19:49:42 -06:00
isConnecting={isReconnecting || isLoading}
+5
2026-08-19 14:12:06 -05:00
isConnected={!!sshSessionId && !isReconnecting}
2026-01-24 19:49:42 -06:00
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
2025-09-12 14:42:00 -05:00
</div>
);
}
2025-10-01 15:40:10 -05:00
2026-06-21 15:55:41 -05:00
function FileManagerInner({
initialHost,
initialFilePath,
initialPath,
onClose,
2026-06-29 13:28:26 -05:00
onOpenTerminalTab,
+8
2026-07-19 12:29:52 -05:00
isVisible = true,
2026-06-21 15:55:41 -05:00
}: FileManagerProps) {
2025-10-01 15:40:10 -05:00
return (
<WindowManager>
2026-06-21 15:55:41 -05:00
<FileManagerContent
initialHost={initialHost}
initialFilePath={initialFilePath}
initialPath={initialPath}
onClose={onClose}
2026-06-29 13:28:26 -05:00
onOpenTerminalTab={onOpenTerminalTab}
+8
2026-07-19 12:29:52 -05:00
isVisible={isVisible}
2026-06-21 15:55:41 -05:00
/>
2025-10-01 15:40:10 -05:00
</WindowManager>
);
}
2026-01-24 19:49:42 -06:00
export function FileManager(props: FileManagerProps) {
return (
<ConnectionLogProvider>
<FileManagerInner {...props} />
</ConnectionLogProvider>
);
}