1851 lines
58 KiB
TypeScript
1851 lines
58 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
import { FileManagerGrid } from "./FileManagerGrid";
|
|
import { FileManagerSidebar } from "./FileManagerSidebar";
|
|
import { FileManagerContextMenu } from "./FileManagerContextMenu";
|
|
import { useFileSelection } from "./hooks/useFileSelection";
|
|
import { useDragAndDrop } from "./hooks/useDragAndDrop";
|
|
import { WindowManager, useWindowManager } from "./components/WindowManager";
|
|
import { FileWindow } from "./components/FileWindow";
|
|
import { DiffWindow } from "./components/DiffWindow";
|
|
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
|
|
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { toast } from "sonner";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Upload,
|
|
FolderPlus,
|
|
FilePlus,
|
|
RefreshCw,
|
|
Search,
|
|
Grid3X3,
|
|
List,
|
|
Eye,
|
|
Settings,
|
|
} from "lucide-react";
|
|
import { TerminalWindow } from "./components/TerminalWindow";
|
|
import type { SSHHost, FileItem } from "../../../types/index.js";
|
|
import {
|
|
listSSHFiles,
|
|
uploadSSHFile,
|
|
downloadSSHFile,
|
|
createSSHFile,
|
|
createSSHFolder,
|
|
deleteSSHItem,
|
|
copySSHItem,
|
|
renameSSHItem,
|
|
moveSSHItem,
|
|
connectSSH,
|
|
getSSHStatus,
|
|
keepSSHAlive,
|
|
identifySSHSymlink,
|
|
addRecentFile,
|
|
addPinnedFile,
|
|
removePinnedFile,
|
|
removeRecentFile,
|
|
addFolderShortcut,
|
|
getPinnedFiles,
|
|
} from "@/ui/main-axios.ts";
|
|
import type { SidebarItem } from "./FileManagerSidebar";
|
|
|
|
interface FileManagerProps {
|
|
initialHost?: SSHHost | null;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
// Linus-style data structure: creation intent completely separated from actual files
|
|
interface CreateIntent {
|
|
id: string;
|
|
type: 'file' | 'directory';
|
|
defaultName: string;
|
|
currentName: string;
|
|
}
|
|
|
|
// Internal component, uses window manager
|
|
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|
const { openWindow } = useWindowManager();
|
|
const { t } = useTranslation();
|
|
|
|
// State
|
|
const [currentHost, setCurrentHost] = useState<SSHHost | null>(
|
|
initialHost || null,
|
|
);
|
|
const [currentPath, setCurrentPath] = useState("/");
|
|
const [files, setFiles] = useState<FileItem[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
|
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
|
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
|
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
|
|
|
// Context menu state
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
isVisible: boolean;
|
|
files: FileItem[];
|
|
}>({
|
|
x: 0,
|
|
y: 0,
|
|
isVisible: false,
|
|
files: [],
|
|
});
|
|
|
|
// Operation state
|
|
const [clipboard, setClipboard] = useState<{
|
|
files: FileItem[];
|
|
operation: "copy" | "cut";
|
|
} | null>(null);
|
|
|
|
// Undo history
|
|
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;
|
|
}
|
|
|
|
const [undoHistory, setUndoHistory] = useState<UndoAction[]>([]);
|
|
|
|
// Linus-style state: creation intent separated from file editing
|
|
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
|
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
|
|
|
// Hooks
|
|
const { selectedFiles, selectFile, selectAll, clearSelection, setSelection } =
|
|
useFileSelection();
|
|
|
|
const { isDragging, dragHandlers } = useDragAndDrop({
|
|
onFilesDropped: handleFilesDropped,
|
|
onError: (error) => toast.error(error),
|
|
maxFileSize: 5120, // 5GB - support large files like SSH tools should
|
|
});
|
|
|
|
// Drag to desktop functionality
|
|
const dragToDesktop = useDragToDesktop({
|
|
sshSessionId: sshSessionId || "",
|
|
sshHost: currentHost!,
|
|
});
|
|
|
|
// System-level drag to desktop functionality (new approach)
|
|
const systemDrag = useDragToSystemDesktop({
|
|
sshSessionId: sshSessionId || "",
|
|
sshHost: currentHost!,
|
|
});
|
|
|
|
// SSH keepalive function
|
|
const startKeepalive = useCallback(() => {
|
|
if (!sshSessionId) return;
|
|
|
|
// Clear existing timer
|
|
if (keepaliveTimerRef.current) {
|
|
clearInterval(keepaliveTimerRef.current);
|
|
}
|
|
|
|
// Send keepalive every 30 seconds to match backend SSH settings
|
|
keepaliveTimerRef.current = setInterval(async () => {
|
|
if (sshSessionId) {
|
|
try {
|
|
await keepSSHAlive(sshSessionId);
|
|
console.log("SSH keepalive sent successfully");
|
|
} catch (error) {
|
|
console.error("SSH keepalive failed:", error);
|
|
// If keepalive fails, session might be dead - could trigger reconnect here
|
|
}
|
|
}
|
|
}, 30 * 1000); // 30 seconds - matches backend keepaliveInterval
|
|
}, [sshSessionId]);
|
|
|
|
const stopKeepalive = useCallback(() => {
|
|
if (keepaliveTimerRef.current) {
|
|
clearInterval(keepaliveTimerRef.current);
|
|
keepaliveTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Initialize SSH connection
|
|
useEffect(() => {
|
|
if (currentHost) {
|
|
initializeSSHConnection();
|
|
}
|
|
}, [currentHost]);
|
|
|
|
// Start/stop keepalive based on SSH session
|
|
useEffect(() => {
|
|
if (sshSessionId) {
|
|
startKeepalive();
|
|
} else {
|
|
stopKeepalive();
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
return () => {
|
|
stopKeepalive();
|
|
};
|
|
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
|
|
|
// Track if initial directory load is done to prevent duplicate loading
|
|
const initialLoadDoneRef = useRef(false);
|
|
// Track last path change to prevent rapid navigation issues
|
|
const lastPathChangeRef = useRef<string>("");
|
|
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
// Track current loading request to handle cancellation
|
|
const currentLoadingPathRef = useRef<string>("");
|
|
// SSH keepalive timer
|
|
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Handle file drag to external
|
|
const handleFileDragStart = useCallback(
|
|
(files: FileItem[]) => {
|
|
// Record currently dragged files
|
|
systemDrag.startDragToSystem(files, {
|
|
enableToast: true,
|
|
onSuccess: () => {
|
|
clearSelection();
|
|
},
|
|
onError: (error) => {
|
|
console.error("Drag failed:", error);
|
|
},
|
|
});
|
|
},
|
|
[systemDrag, clearSelection],
|
|
);
|
|
|
|
const handleFileDragEnd = useCallback(
|
|
(e: DragEvent, draggedFiles: FileItem[]) => {
|
|
// More conservative detection - only trigger download if clearly outside window
|
|
const margin = 10; // Very small margin to reduce false positives
|
|
const isOutside =
|
|
e.clientX < margin ||
|
|
e.clientX > window.innerWidth - margin ||
|
|
e.clientY < margin ||
|
|
e.clientY > window.innerHeight - margin;
|
|
|
|
// Only trigger download if clearly outside the window bounds
|
|
if (isOutside) {
|
|
console.log("Drag ended outside window bounds, triggering download");
|
|
console.log("Dragged files:", draggedFiles);
|
|
console.log("Dragged files length:", draggedFiles.length);
|
|
|
|
if (draggedFiles.length === 0) {
|
|
console.error("No files to drag - this should not happen");
|
|
return;
|
|
}
|
|
|
|
// Start system drag with the dragged files
|
|
systemDrag.startDragToSystem(draggedFiles, {
|
|
enableToast: true,
|
|
onSuccess: () => {
|
|
clearSelection();
|
|
},
|
|
onError: (error) => {
|
|
console.error("Drag failed:", error);
|
|
},
|
|
});
|
|
// Execute immediately to preserve user gesture context
|
|
systemDrag.handleDragEnd(e);
|
|
} else {
|
|
console.log("Drag ended inside window bounds, cancelling download");
|
|
// Cancel drag - user probably didn't intend to download
|
|
systemDrag.cancelDragToSystem();
|
|
}
|
|
},
|
|
[systemDrag, clearSelection],
|
|
);
|
|
|
|
async function initializeSSHConnection() {
|
|
if (!currentHost) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
// Reset initial load flag for new connections
|
|
initialLoadDoneRef.current = false;
|
|
|
|
const sessionId = currentHost.id.toString();
|
|
|
|
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,
|
|
});
|
|
|
|
setSshSessionId(sessionId);
|
|
|
|
// Load initial directory immediately after connection to prevent jarring transition
|
|
try {
|
|
console.log("Loading initial directory:", currentPath);
|
|
const response = await listSSHFiles(sessionId, currentPath);
|
|
const files = Array.isArray(response) ? response : response?.files || [];
|
|
console.log("Initial directory loaded successfully:", files.length, "items");
|
|
setFiles(files);
|
|
clearSelection();
|
|
// Mark initial load as completed
|
|
initialLoadDoneRef.current = true;
|
|
} catch (dirError: any) {
|
|
console.error("Failed to load initial directory:", dirError);
|
|
// Don't show error toast here as it will be handled by the useEffect retry
|
|
// Also don't close tab here since the connection succeeded, just directory loading failed
|
|
}
|
|
} catch (error: any) {
|
|
console.error("SSH connection failed:", error);
|
|
toast.error(
|
|
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
|
);
|
|
// Close the tab when SSH connection fails
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
const loadDirectory = useCallback(async (path: string) => {
|
|
if (!sshSessionId) {
|
|
console.error("Cannot load directory: no SSH session ID");
|
|
return;
|
|
}
|
|
|
|
// Prevent concurrent loading requests
|
|
if (isLoading && currentLoadingPathRef.current !== path) {
|
|
console.log("Directory loading already in progress, skipping:", path);
|
|
return;
|
|
}
|
|
|
|
// Set current loading path for tracking
|
|
currentLoadingPathRef.current = path;
|
|
setIsLoading(true);
|
|
|
|
// Clear createIntent when changing directories
|
|
setCreateIntent(null);
|
|
|
|
try {
|
|
console.log("Loading directory:", path);
|
|
|
|
const response = await listSSHFiles(sshSessionId, path);
|
|
|
|
// Check if this is still the current request (avoid race conditions)
|
|
if (currentLoadingPathRef.current !== path) {
|
|
console.log("Directory load canceled, newer request in progress:", path);
|
|
return;
|
|
}
|
|
|
|
console.log("Directory response received:", response);
|
|
|
|
const files = Array.isArray(response) ? response : response?.files || [];
|
|
|
|
console.log("Directory loaded successfully:", files.length, "items");
|
|
|
|
setFiles(files);
|
|
clearSelection();
|
|
} catch (error: any) {
|
|
// Only show error if this is still the current request
|
|
if (currentLoadingPathRef.current === path) {
|
|
console.error("Failed to load directory:", error);
|
|
|
|
// Only show toast if this is not the initial load (to prevent duplicate toasts)
|
|
if (initialLoadDoneRef.current) {
|
|
toast.error(
|
|
t("fileManager.failedToLoadDirectory") + ": " + (error.message || error)
|
|
);
|
|
}
|
|
|
|
// Close the tab when directory loading fails due to SSH issues
|
|
if (error.message?.includes("connection") || error.message?.includes("SSH")) {
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
// Only clear loading if this is still the current request
|
|
if (currentLoadingPathRef.current === path) {
|
|
setIsLoading(false);
|
|
currentLoadingPathRef.current = "";
|
|
}
|
|
}
|
|
}, [sshSessionId, isLoading, clearSelection, t]);
|
|
|
|
// Debounced directory loading for path changes
|
|
const debouncedLoadDirectory = useCallback((path: string) => {
|
|
// Clear any existing timer
|
|
if (pathChangeTimerRef.current) {
|
|
clearTimeout(pathChangeTimerRef.current);
|
|
}
|
|
|
|
// Set new timer for debounced loading
|
|
pathChangeTimerRef.current = setTimeout(() => {
|
|
if (path !== lastPathChangeRef.current && sshSessionId) {
|
|
console.log("Loading directory after path change:", path);
|
|
lastPathChangeRef.current = path;
|
|
loadDirectory(path);
|
|
}
|
|
}, 150); // 150ms debounce for path changes
|
|
}, [sshSessionId, loadDirectory]);
|
|
|
|
// File list update - only reload when path changes, not on initial connection
|
|
useEffect(() => {
|
|
if (sshSessionId && currentPath) {
|
|
// Skip the first load since it's handled in initializeSSHConnection
|
|
if (!initialLoadDoneRef.current) {
|
|
initialLoadDoneRef.current = true;
|
|
lastPathChangeRef.current = currentPath;
|
|
return;
|
|
}
|
|
|
|
// Use debounced loading for path changes to prevent rapid clicking issues
|
|
debouncedLoadDirectory(currentPath);
|
|
}
|
|
|
|
// Cleanup timer on unmount or dependency change
|
|
return () => {
|
|
if (pathChangeTimerRef.current) {
|
|
clearTimeout(pathChangeTimerRef.current);
|
|
}
|
|
};
|
|
}, [sshSessionId, currentPath, debouncedLoadDirectory]);
|
|
|
|
// Debounced refresh function - prevent excessive clicking
|
|
const handleRefreshDirectory = useCallback(() => {
|
|
const now = Date.now();
|
|
const DEBOUNCE_MS = 500; // 500ms debounce
|
|
|
|
if (now - lastRefreshTime < DEBOUNCE_MS) {
|
|
console.log("Refresh ignored - too frequent");
|
|
return;
|
|
}
|
|
|
|
setLastRefreshTime(now);
|
|
loadDirectory(currentPath);
|
|
}, [currentPath, lastRefreshTime, loadDirectory]);
|
|
|
|
// Global keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
// Check if input box or editable element has focus, skip if so
|
|
const activeElement = document.activeElement;
|
|
if (
|
|
activeElement &&
|
|
(activeElement.tagName === "INPUT" ||
|
|
activeElement.tagName === "TEXTAREA" ||
|
|
activeElement.contentEditable === "true")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Handle Ctrl+Shift+T for opening terminal
|
|
if (event.key === "T" && event.ctrlKey && event.shiftKey) {
|
|
event.preventDefault();
|
|
handleOpenTerminal(currentPath);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [currentPath]);
|
|
|
|
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;
|
|
|
|
try {
|
|
// Ensure SSH connection is valid
|
|
await ensureSSHConnection();
|
|
|
|
// Read file content
|
|
const fileContent = await new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(reader.error);
|
|
|
|
// Check file type to determine reading method
|
|
const isTextFile =
|
|
file.type.startsWith("text/") ||
|
|
file.type === "application/json" ||
|
|
file.type === "application/javascript" ||
|
|
file.type === "application/xml" ||
|
|
file.name.match(
|
|
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
|
|
);
|
|
|
|
if (isTextFile) {
|
|
reader.onload = () => {
|
|
if (reader.result) {
|
|
resolve(reader.result as string);
|
|
} else {
|
|
reject(new Error("Failed to read text file content"));
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
} else {
|
|
reader.onload = () => {
|
|
if (reader.result instanceof ArrayBuffer) {
|
|
const bytes = new Uint8Array(reader.result);
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
const base64 = btoa(binary);
|
|
resolve(base64);
|
|
} else {
|
|
reject(new Error("Failed to read binary file"));
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
}
|
|
});
|
|
|
|
await uploadSSHFile(
|
|
sshSessionId,
|
|
currentPath,
|
|
file.name,
|
|
fileContent,
|
|
currentHost?.id,
|
|
undefined, // userId - will be handled by backend
|
|
);
|
|
toast.success(
|
|
t("fileManager.fileUploadedSuccessfully", { name: file.name }),
|
|
);
|
|
handleRefreshDirectory();
|
|
} catch (error: any) {
|
|
if (
|
|
error.message?.includes("connection") ||
|
|
error.message?.includes("established")
|
|
) {
|
|
toast.error(
|
|
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToUploadFile"));
|
|
}
|
|
console.error("Upload failed:", error);
|
|
}
|
|
}
|
|
|
|
async function handleDownloadFile(file: FileItem) {
|
|
if (!sshSessionId) return;
|
|
|
|
try {
|
|
// Ensure SSH connection is valid
|
|
await ensureSSHConnection();
|
|
|
|
const response = await downloadSSHFile(sshSessionId, file.path);
|
|
|
|
if (response?.content) {
|
|
// Convert to blob and trigger download
|
|
const byteCharacters = atob(response.content);
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
const blob = new Blob([byteArray], {
|
|
type: response.mimeType || "application/octet-stream",
|
|
});
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = response.fileName || file.name;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
toast.success(
|
|
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
if (
|
|
error.message?.includes("connection") ||
|
|
error.message?.includes("established")
|
|
) {
|
|
toast.error(
|
|
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToDownloadFile"));
|
|
}
|
|
console.error("Download failed:", error);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteFiles(files: FileItem[]) {
|
|
if (!sshSessionId || files.length === 0) return;
|
|
|
|
try {
|
|
// Ensure SSH connection is valid
|
|
await ensureSSHConnection();
|
|
|
|
for (const file of files) {
|
|
await deleteSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
file.type === "directory", // isDirectory
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
}
|
|
|
|
// Record deletion history (although cannot truly undo)
|
|
const deletedFiles = files.map((file) => ({
|
|
path: file.path,
|
|
name: file.name,
|
|
}));
|
|
|
|
const undoAction: UndoAction = {
|
|
type: "delete",
|
|
description: t("fileManager.deletedItems", { count: files.length }),
|
|
data: {
|
|
operation: "cut", // Placeholder
|
|
deletedFiles,
|
|
targetDirectory: currentPath,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
|
|
|
toast.success(
|
|
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
|
|
);
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
} catch (error: any) {
|
|
if (
|
|
error.message?.includes("connection") ||
|
|
error.message?.includes("established")
|
|
) {
|
|
toast.error(
|
|
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToDeleteItems"));
|
|
}
|
|
console.error("Delete failed:", error);
|
|
}
|
|
}
|
|
|
|
// Linus-style creation: pure intent, no side effects
|
|
function handleCreateNewFolder() {
|
|
const defaultName = generateUniqueName("NewFolder", "directory");
|
|
const newCreateIntent = {
|
|
id: Date.now().toString(),
|
|
type: 'directory' as const,
|
|
defaultName,
|
|
currentName: defaultName
|
|
};
|
|
|
|
|
|
setCreateIntent(newCreateIntent);
|
|
}
|
|
|
|
function handleCreateNewFile() {
|
|
const defaultName = generateUniqueName("NewFile.txt", "file");
|
|
const newCreateIntent = {
|
|
id: Date.now().toString(),
|
|
type: 'file' as const,
|
|
defaultName,
|
|
currentName: defaultName
|
|
};
|
|
setCreateIntent(newCreateIntent);
|
|
}
|
|
|
|
// Handle symlink resolution
|
|
const handleSymlinkClick = async (file: FileItem) => {
|
|
if (!currentHost || !sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Ensure SSH connection is valid
|
|
let currentSessionId = sshSessionId;
|
|
try {
|
|
const status = await getSSHStatus(currentSessionId);
|
|
if (!status.connected) {
|
|
const result = await connectSSH(currentSessionId, {
|
|
hostId: currentHost.id,
|
|
host: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
authType: currentHost.authType,
|
|
password: currentHost.password,
|
|
key: currentHost.key,
|
|
keyPassword: currentHost.keyPassword,
|
|
credentialId: currentHost.credentialId,
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new Error(t("fileManager.failedToReconnectSSH"));
|
|
}
|
|
}
|
|
} catch (sessionErr) {
|
|
throw sessionErr;
|
|
}
|
|
|
|
const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path);
|
|
|
|
if (symlinkInfo.type === "directory") {
|
|
// If symlink points to directory, navigate to it
|
|
setCurrentPath(symlinkInfo.target);
|
|
} else if (symlinkInfo.type === "file") {
|
|
// If symlink points to file, open file
|
|
// Calculate window position (slightly offset)
|
|
const windowCount = Date.now() % 10;
|
|
const offsetX = 120 + windowCount * 30;
|
|
const offsetY = 120 + windowCount * 30;
|
|
|
|
// Create target file object
|
|
const targetFile: FileItem = {
|
|
...file,
|
|
path: symlinkInfo.target,
|
|
};
|
|
|
|
// Create window component factory function
|
|
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,
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("fileManager.failedToResolveSymlink"),
|
|
);
|
|
}
|
|
};
|
|
|
|
async function handleFileOpen(file: FileItem, editMode: boolean = false) {
|
|
if (file.type === "directory") {
|
|
setCurrentPath(file.path);
|
|
} else if (file.type === "link") {
|
|
// Handle symlinks
|
|
await handleSymlinkClick(file);
|
|
} else {
|
|
// Open file in new window
|
|
if (!sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
// Record to recent access for regular files
|
|
await recordRecentFile(file);
|
|
|
|
// Calculate window position (slightly offset)
|
|
const windowCount = Date.now() % 10; // Simple offset calculation
|
|
const offsetX = 120 + windowCount * 30;
|
|
const offsetY = 120 + windowCount * 30;
|
|
|
|
const windowTitle = file.name; // Remove mode identifier, controlled internally by FileViewer
|
|
|
|
// Create window component factory function
|
|
const createWindowComponent = (windowId: string) => (
|
|
<FileWindow
|
|
windowId={windowId}
|
|
file={file}
|
|
sshSessionId={sshSessionId}
|
|
sshHost={currentHost}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
onFileNotFound={handleFileNotFound}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
title: windowTitle,
|
|
x: offsetX,
|
|
y: offsetY,
|
|
width: 800,
|
|
height: 600,
|
|
isMaximized: false,
|
|
isMinimized: false,
|
|
component: createWindowComponent,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Dedicated file editing function
|
|
function handleFileEdit(file: FileItem) {
|
|
handleFileOpen(file, true);
|
|
}
|
|
|
|
// Dedicated file viewing function (read-only)
|
|
function handleFileView(file: FileItem) {
|
|
handleFileOpen(file, false);
|
|
}
|
|
|
|
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
|
|
event.preventDefault();
|
|
|
|
// If right-clicked file is already in selection list, use all selected files
|
|
// If right-clicked file is not in selection list, use only this file
|
|
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,
|
|
});
|
|
}
|
|
|
|
function handleCopyFiles(files: FileItem[]) {
|
|
setClipboard({ files, operation: "copy" });
|
|
toast.success(
|
|
t("fileManager.filesCopiedToClipboard", { count: files.length }),
|
|
);
|
|
}
|
|
|
|
function handleCutFiles(files: FileItem[]) {
|
|
setClipboard({ files, operation: "cut" });
|
|
toast.success(
|
|
t("fileManager.filesCutToClipboard", { count: files.length }),
|
|
);
|
|
}
|
|
|
|
async function handlePasteFiles() {
|
|
if (!clipboard || !sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
const { files, operation } = clipboard;
|
|
|
|
// Handle copy and cut operations
|
|
let successCount = 0;
|
|
const copiedItems: string[] = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
if (operation === "copy") {
|
|
// Copy operation: call copy API
|
|
const result = await copySSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
currentPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
copiedItems.push(result.uniqueName || file.name);
|
|
successCount++;
|
|
} else {
|
|
// Cut operation: move files to target directory
|
|
const targetPath = currentPath.endsWith("/")
|
|
? `${currentPath}${file.name}`
|
|
: `${currentPath}/${file.name}`;
|
|
|
|
// Only move when target path differs from original path
|
|
if (file.path !== targetPath) {
|
|
// Use dedicated moveSSHItem API for cross-directory movement
|
|
await moveSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
targetPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
successCount++;
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`Failed to ${operation} file ${file.name}:`, error);
|
|
toast.error(
|
|
t("fileManager.operationFailed", { operation: operation === "copy" ? t("fileManager.copy") : t("fileManager.move"), name: file.name, error: error.message }),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Record undo history
|
|
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]); // Keep max 10 undo records
|
|
} else if (operation === "cut") {
|
|
// Cut operation: record move info, can be moved back to original position on undo
|
|
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, // Reuse copiedFiles field to store move info
|
|
targetDirectory: currentPath,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
|
}
|
|
}
|
|
|
|
// Show success message
|
|
if (successCount > 0) {
|
|
const operationText = operation === "copy" ? t("fileManager.copy") : t("fileManager.move");
|
|
if (operation === "copy" && copiedItems.length > 0) {
|
|
// Show detailed copy info, including renamed files
|
|
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 }));
|
|
}
|
|
}
|
|
|
|
// Refresh file list
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
|
|
// Clear clipboard (after cut operation, copy operation retains clipboard content)
|
|
if (operation === "cut") {
|
|
setClipboard(null);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`);
|
|
}
|
|
}
|
|
|
|
async function handleUndo() {
|
|
if (undoHistory.length === 0) {
|
|
toast.info(t("fileManager.noUndoableActions"));
|
|
return;
|
|
}
|
|
|
|
const lastAction = undoHistory[undoHistory.length - 1];
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
// Execute undo logic based on different operation types
|
|
switch (lastAction.type) {
|
|
case "copy":
|
|
// Undo copy operation: delete copied target files
|
|
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++;
|
|
} catch (error: any) {
|
|
console.error(
|
|
`Failed to delete copied file ${copiedFile.targetName}:`,
|
|
error,
|
|
);
|
|
toast.error(
|
|
t("fileManager.deleteCopiedFileFailed", { name: copiedFile.targetName, error: error.message }),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
// Remove last undo record
|
|
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":
|
|
// Undo cut operation: move files back to original position
|
|
if (lastAction.data.copiedFiles) {
|
|
let successCount = 0;
|
|
for (const movedFile of lastAction.data.copiedFiles) {
|
|
try {
|
|
// Move file from current position back to original position
|
|
await moveSSHItem(
|
|
sshSessionId!,
|
|
movedFile.targetPath, // Current position (target path)
|
|
movedFile.originalPath, // Move back to original position
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
successCount++;
|
|
} catch (error: any) {
|
|
console.error(
|
|
`Failed to move back file ${movedFile.targetName}:`,
|
|
error,
|
|
);
|
|
toast.error(
|
|
t("fileManager.moveBackFileFailed", { name: movedFile.targetName, error: error.message }),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
// Remove last undo record
|
|
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":
|
|
// Delete operation cannot be truly undone (file already deleted from server)
|
|
toast.info(t("fileManager.undoDeleteNotSupported"));
|
|
// Still remove history record as user already knows this limitation
|
|
setUndoHistory((prev) => prev.slice(0, -1));
|
|
return;
|
|
|
|
default:
|
|
toast.error(t("fileManager.undoTypeNotSupported"));
|
|
return;
|
|
}
|
|
|
|
// Refresh file list
|
|
handleRefreshDirectory();
|
|
} catch (error: any) {
|
|
toast.error(`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`);
|
|
console.error("Undo failed:", error);
|
|
}
|
|
}
|
|
|
|
function handleRenameFile(file: FileItem) {
|
|
setEditingFile(file);
|
|
}
|
|
|
|
// Ensure SSH connection is valid - simplified version, prevent concurrent reconnection
|
|
async function ensureSSHConnection() {
|
|
if (!sshSessionId || !currentHost || isReconnecting) return;
|
|
|
|
try {
|
|
const status = await getSSHStatus(sshSessionId);
|
|
|
|
if (!status.connected && !isReconnecting) {
|
|
setIsReconnecting(true);
|
|
console.log("SSH disconnected, reconnecting...");
|
|
|
|
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,
|
|
});
|
|
|
|
console.log("SSH reconnection successful");
|
|
}
|
|
} catch (error) {
|
|
console.log("SSH reconnection failed:", error);
|
|
// Close the tab when SSH reconnection fails
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
throw error;
|
|
} finally {
|
|
setIsReconnecting(false);
|
|
}
|
|
}
|
|
|
|
// Linus-style creation confirmation: pure creation, no mixed logic
|
|
async function handleConfirmCreate(name: string) {
|
|
if (!createIntent || !sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
console.log(`Creating ${createIntent.type}:`, name);
|
|
|
|
if (createIntent.type === "file") {
|
|
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 }));
|
|
}
|
|
|
|
setCreateIntent(null); // Clear intent
|
|
handleRefreshDirectory();
|
|
} catch (error: any) {
|
|
console.error("Create failed:", error);
|
|
toast.error(t("fileManager.failedToCreateItem"));
|
|
}
|
|
}
|
|
|
|
// Linus-style cancel: zero side effects
|
|
function handleCancelCreate() {
|
|
setCreateIntent(null); // Just that simple!
|
|
console.log("Create cancelled - no side effects");
|
|
}
|
|
|
|
// Pure rename confirmation: only handle real files
|
|
async function handleRenameConfirm(file: FileItem, newName: string) {
|
|
if (!sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
console.log("Renaming existing item:", {
|
|
from: file.path,
|
|
to: newName,
|
|
});
|
|
|
|
await renameSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
newName,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
|
|
toast.success(t("fileManager.itemRenamedSuccessfully", { name: newName }));
|
|
setEditingFile(null);
|
|
handleRefreshDirectory();
|
|
} catch (error: any) {
|
|
console.error("Rename failed:", error);
|
|
toast.error(t("fileManager.failedToRenameItem"));
|
|
}
|
|
}
|
|
|
|
// Start editing file name
|
|
function handleStartEdit(file: FileItem) {
|
|
setEditingFile(file);
|
|
}
|
|
|
|
// Linus-style cancel edit: pure cancel, no side effects
|
|
function handleCancelEdit() {
|
|
setEditingFile(null); // Simple and elegant
|
|
console.log("Edit cancelled - no side effects");
|
|
}
|
|
|
|
// Generate unique name (handle name conflicts)
|
|
function generateUniqueName(
|
|
baseName: string,
|
|
type: "file" | "directory",
|
|
): string {
|
|
const existingNames = files.map((f) => f.name.toLowerCase());
|
|
let candidateName = baseName;
|
|
let counter = 1;
|
|
|
|
// If name already exists, try adding number suffix
|
|
while (existingNames.includes(candidateName.toLowerCase())) {
|
|
if (type === "file" && baseName.includes(".")) {
|
|
// For files, add number between filename and extension
|
|
const lastDotIndex = baseName.lastIndexOf(".");
|
|
const nameWithoutExt = baseName.substring(0, lastDotIndex);
|
|
const extension = baseName.substring(lastDotIndex);
|
|
candidateName = `${nameWithoutExt}${counter}${extension}`;
|
|
} else {
|
|
// For folders or files without extension, add number directly
|
|
candidateName = `${baseName}${counter}`;
|
|
}
|
|
counter++;
|
|
}
|
|
|
|
console.log(`Generated unique name: ${baseName} -> ${candidateName}`);
|
|
return candidateName;
|
|
}
|
|
|
|
// Drag handling: file/folder drag to folder = move operation
|
|
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}`;
|
|
|
|
// Only move when target path differs from original path
|
|
if (file.path !== targetPath) {
|
|
await moveSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
targetPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
movedItems.push(file.name);
|
|
successCount++;
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`Failed to move file ${file.name}:`, error);
|
|
toast.error(t("fileManager.moveFileFailed", { name: file.name }) + ": " + error.message);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
// Record undo history
|
|
const movedFiles = draggedFiles
|
|
.slice(0, successCount)
|
|
.map((file, index) => {
|
|
const targetPath = targetFolder.path.endsWith("/")
|
|
? `${targetFolder.path}${file.name}`
|
|
: `${targetFolder.path}/${file.name}`;
|
|
return {
|
|
originalPath: file.path,
|
|
targetPath: targetPath,
|
|
targetName: file.name,
|
|
};
|
|
});
|
|
|
|
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(); // Clear selection state
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Drag move operation failed:", error);
|
|
toast.error(t("fileManager.moveOperationFailed") + ": " + error.message);
|
|
}
|
|
}
|
|
|
|
// Drag handling: file drag to file = diff comparison operation
|
|
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;
|
|
}
|
|
|
|
// Use dedicated DiffWindow for file comparison
|
|
console.log("Opening diff comparison:", file1.name, "vs", file2.name);
|
|
|
|
// Calculate window position
|
|
const offsetX = 100;
|
|
const offsetY = 80;
|
|
|
|
// Create diff window
|
|
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({
|
|
id: windowId,
|
|
type: "diff",
|
|
title: t("fileManager.fileComparison", { file1: file1.name, file2: file2.name }),
|
|
isMaximized: false,
|
|
component: createWindowComponent,
|
|
zIndex: Date.now(),
|
|
});
|
|
|
|
toast.success(t("fileManager.comparingFiles", { file1: file1.name, file2: file2.name }));
|
|
}
|
|
|
|
// Drag to desktop handler function
|
|
async function handleDragToDesktop(files: FileItem[]) {
|
|
if (!currentHost || !sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Prefer new system-level drag approach
|
|
if (systemDrag.isFileSystemAPISupported) {
|
|
await systemDrag.handleDragToSystem(files, {
|
|
enableToast: true,
|
|
onSuccess: () => {
|
|
console.log("System-level drag successful");
|
|
},
|
|
onError: (error) => {
|
|
console.error("System-level drag failed:", error);
|
|
},
|
|
});
|
|
} else {
|
|
// Fallback to Electron approach
|
|
if (files.length === 1) {
|
|
await dragToDesktop.dragFileToDesktop(files[0]);
|
|
} else if (files.length > 1) {
|
|
await dragToDesktop.dragFilesToDesktop(files);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Drag to desktop failed:", error);
|
|
toast.error(t("fileManager.dragFailed") + ": " + (error.message || t("fileManager.unknownError")));
|
|
}
|
|
}
|
|
|
|
// Open terminal handler function
|
|
function handleOpenTerminal(path: string) {
|
|
if (!currentHost) {
|
|
toast.error(t("fileManager.noHostSelected"));
|
|
return;
|
|
}
|
|
|
|
// Create terminal window
|
|
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}
|
|
/>
|
|
);
|
|
|
|
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 }),
|
|
);
|
|
}
|
|
|
|
// Run executable file handler function
|
|
function handleRunExecutable(file: FileItem) {
|
|
if (!currentHost) {
|
|
toast.error(t("fileManager.noHostSelected"));
|
|
return;
|
|
}
|
|
|
|
if (file.type !== "file" || !file.executable) {
|
|
toast.error(t("fileManager.onlyRunExecutableFiles"));
|
|
return;
|
|
}
|
|
|
|
// Get file directory
|
|
const fileDir = file.path.substring(0, file.path.lastIndexOf("/"));
|
|
const fileName = file.name;
|
|
const executeCmd = `./${fileName}`;
|
|
|
|
// Create terminal window for execution
|
|
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} // Auto-execute command
|
|
/>
|
|
);
|
|
|
|
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 }));
|
|
}
|
|
|
|
// Load pinned files list
|
|
async function loadPinnedFiles() {
|
|
if (!currentHost?.id) return;
|
|
|
|
try {
|
|
const pinnedData = await getPinnedFiles(currentHost.id);
|
|
const pinnedPaths = new Set(pinnedData.map((item: any) => item.path));
|
|
setPinnedFiles(pinnedPaths);
|
|
} catch (error) {
|
|
console.error("Failed to load pinned files:", error);
|
|
}
|
|
}
|
|
|
|
// PIN file
|
|
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); // Trigger sidebar refresh
|
|
toast.success(t("fileManager.filePinnedSuccessfully", { name: file.name }));
|
|
} catch (error) {
|
|
console.error("Failed to pin file:", error);
|
|
toast.error(t("fileManager.pinFileFailed"));
|
|
}
|
|
}
|
|
|
|
// UNPIN file
|
|
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); // Trigger sidebar refresh
|
|
toast.success(t("fileManager.fileUnpinnedSuccessfully", { name: file.name }));
|
|
} catch (error) {
|
|
console.error("Failed to unpin file:", error);
|
|
toast.error(t("fileManager.unpinFileFailed"));
|
|
}
|
|
}
|
|
|
|
// Add folder shortcut
|
|
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); // Trigger sidebar refresh
|
|
toast.success(t("fileManager.shortcutAddedSuccessfully", { name: folderName }));
|
|
} catch (error) {
|
|
console.error("Failed to add shortcut:", error);
|
|
toast.error(t("fileManager.addShortcutFailed"));
|
|
}
|
|
}
|
|
|
|
// Check if file is pinned
|
|
function isPinnedFile(file: FileItem): boolean {
|
|
return pinnedFiles.has(file.path);
|
|
}
|
|
|
|
// Record recently accessed file
|
|
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); // Trigger sidebar refresh
|
|
} catch (error) {
|
|
console.error("Failed to record recent file:", error);
|
|
}
|
|
}
|
|
|
|
// Handle sidebar file opening
|
|
async function handleSidebarFileOpen(sidebarItem: SidebarItem) {
|
|
// Convert SidebarItem to FileItem format
|
|
const file: FileItem = {
|
|
name: sidebarItem.name,
|
|
path: sidebarItem.path,
|
|
type: "file", // Both recent and pinned are file types
|
|
};
|
|
|
|
// Call regular file opening handler
|
|
await handleFileOpen(file);
|
|
}
|
|
|
|
// Handle file not found - cleanup from recent and pinned lists
|
|
async function handleFileNotFound(file: FileItem) {
|
|
if (!currentHost) return;
|
|
|
|
try {
|
|
// Remove from recent files
|
|
await removeRecentFile(currentHost.id, file.path);
|
|
|
|
// Remove from pinned files
|
|
await removePinnedFile(currentHost.id, file.path);
|
|
|
|
// Trigger sidebar refresh to update the UI
|
|
setSidebarRefreshTrigger(prev => prev + 1);
|
|
|
|
console.log(`Cleaned up missing file from recent/pinned lists: ${file.path}`);
|
|
} catch (error) {
|
|
console.error("Failed to cleanup missing file:", error);
|
|
}
|
|
}
|
|
|
|
|
|
// Clear createIntent when path changes
|
|
useEffect(() => {
|
|
setCreateIntent(null);
|
|
}, [currentPath]);
|
|
|
|
// Load pinned files list (when host or connection changes)
|
|
useEffect(() => {
|
|
if (currentHost?.id) {
|
|
loadPinnedFiles();
|
|
}
|
|
}, [currentHost?.id]);
|
|
|
|
// Linus-style data separation: only filter real files
|
|
const filteredFiles = files.filter((file) =>
|
|
file.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
);
|
|
|
|
|
|
if (!currentHost) {
|
|
return (
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-dark-bg">
|
|
{/* Toolbar */}
|
|
<div className="flex-shrink-0 border-b border-dark-border">
|
|
<div className="flex items-center justify-between p-3">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="font-semibold text-white">{currentHost.name}</h2>
|
|
<span className="text-sm text-muted-foreground">
|
|
{currentHost.ip}:{currentHost.port}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t("fileManager.searchFiles")}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border"
|
|
/>
|
|
</div>
|
|
|
|
{/* View toggle */}
|
|
<div className="flex border border-dark-border rounded-md">
|
|
<Button
|
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setViewMode("grid")}
|
|
className="rounded-r-none h-9"
|
|
>
|
|
<Grid3X3 className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === "list" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setViewMode("list")}
|
|
className="rounded-l-none h-9"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
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();
|
|
}}
|
|
className="h-9"
|
|
>
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
{t("fileManager.upload")}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCreateNewFolder}
|
|
className="h-9"
|
|
>
|
|
<FolderPlus className="w-4 h-4 mr-2" />
|
|
{t("fileManager.newFolder")}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCreateNewFile}
|
|
className="h-9"
|
|
>
|
|
<FilePlus className="w-4 h-4 mr-2" />
|
|
{t("fileManager.newFile")}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefreshDirectory}
|
|
className="h-9"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content area */}
|
|
<div className="flex-1 flex" {...dragHandlers}>
|
|
{/* Left sidebar */}
|
|
<div className="w-64 flex-shrink-0 h-full">
|
|
<FileManagerSidebar
|
|
currentHost={currentHost}
|
|
currentPath={currentPath}
|
|
onPathChange={setCurrentPath}
|
|
onLoadDirectory={loadDirectory}
|
|
onFileOpen={handleSidebarFileOpen}
|
|
sshSessionId={sshSessionId}
|
|
refreshTrigger={sidebarRefreshTrigger}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right file grid */}
|
|
<div className="flex-1 relative">
|
|
<FileManagerGrid
|
|
files={filteredFiles}
|
|
selectedFiles={selectedFiles}
|
|
onFileSelect={() => {}} // No longer need this callback, use onSelectionChange
|
|
onFileOpen={handleFileOpen}
|
|
onSelectionChange={setSelection}
|
|
currentPath={currentPath}
|
|
isLoading={isLoading}
|
|
onPathChange={setCurrentPath}
|
|
onRefresh={handleRefreshDirectory}
|
|
onUpload={handleFilesDropped}
|
|
onDownload={(files) => files.forEach(handleDownloadFile)}
|
|
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}
|
|
/>
|
|
|
|
{/* Right-click menu */}
|
|
<FileManagerContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
files={contextMenu.files}
|
|
isVisible={contextMenu.isVisible}
|
|
onClose={() =>
|
|
setContextMenu((prev) => ({ ...prev, isVisible: false }))
|
|
}
|
|
onDownload={(files) => files.forEach(handleDownloadFile)}
|
|
onRename={handleRenameFile}
|
|
onCopy={handleCopyFiles}
|
|
onCut={handleCutFiles}
|
|
onPaste={handlePasteFiles}
|
|
onDelete={handleDeleteFiles}
|
|
onUpload={() => {
|
|
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();
|
|
}}
|
|
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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main export component, wrapped with WindowManager
|
|
export function FileManager({
|
|
initialHost,
|
|
onClose,
|
|
}: FileManagerProps) {
|
|
return (
|
|
<WindowManager>
|
|
<FileManagerContent initialHost={initialHost} onClose={onClose} />
|
|
</WindowManager>
|
|
);
|
|
}
|