Files
Netcatty/components/SFTPModal.tsx
T
LAPTOP-O016UC3M\Qi Chen 5a687667b5 Improves code consistency and formatting across components
Refactors code for consistent indentation, spacing, and style.
Enhances readability and maintainability by aligning formatting.
No logic changes; only visual and stylistic improvements applied.
2025-12-11 17:08:03 +08:00

1354 lines
45 KiB
TypeScript

import {
ArrowUp,
ChevronRight,
Database,
Download,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Home,
Key,
Loader2,
Lock,
Plus,
RefreshCw,
Settings,
Terminal,
Trash2,
Upload,
X,
} from "lucide-react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "../lib/utils";
import { Host, RemoteFile } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "./ui/context-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
// Comprehensive file icon helper
const getFileIcon = (fileName: string, isDirectory: boolean) => {
if (isDirectory)
return (
<Folder
size={18}
fill="currentColor"
fillOpacity={0.2}
className="text-blue-400"
/>
);
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const iconClass = "text-muted-foreground";
// Documents
if (["doc", "docx", "rtf", "odt"].includes(ext))
return <FileText size={18} className="text-blue-500" />;
if (["xls", "xlsx", "csv", "ods"].includes(ext))
return <FileSpreadsheet size={18} className="text-green-500" />;
if (["ppt", "pptx", "odp"].includes(ext))
return <FileType size={18} className="text-orange-500" />;
if (["pdf"].includes(ext))
return <FileText size={18} className="text-red-500" />;
// Code/Scripts
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
return <FileCode size={18} className="text-yellow-500" />;
if (["py", "pyc", "pyw"].includes(ext))
return <FileCode size={18} className="text-blue-400" />;
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
return <Terminal size={18} className="text-green-400" />;
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
return <FileCode size={18} className="text-blue-600" />;
if (["java", "class", "jar"].includes(ext))
return <FileCode size={18} className="text-orange-600" />;
if (["go"].includes(ext))
return <FileCode size={18} className="text-cyan-500" />;
if (["rs"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["rb"].includes(ext))
return <FileCode size={18} className="text-red-400" />;
if (["php"].includes(ext))
return <FileCode size={18} className="text-purple-500" />;
if (["html", "htm", "xhtml"].includes(ext))
return <Globe size={18} className="text-orange-500" />;
if (["css", "scss", "sass", "less"].includes(ext))
return <FileCode size={18} className="text-blue-500" />;
if (["vue", "svelte"].includes(ext))
return <FileCode size={18} className="text-green-500" />;
// Config/Data
if (["json", "json5"].includes(ext))
return <FileCode size={18} className="text-yellow-600" />;
if (["xml", "xsl", "xslt"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["yml", "yaml"].includes(ext))
return <Settings size={18} className="text-pink-400" />;
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
return <Settings size={18} className="text-gray-400" />;
if (["env"].includes(ext))
return <Lock size={18} className="text-yellow-500" />;
if (["sql", "sqlite", "db"].includes(ext))
return <Database size={18} className="text-blue-400" />;
// Images
if (
[
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"tif",
"heic",
"heif",
"avif",
].includes(ext)
)
return <FileImage size={18} className="text-purple-400" />;
// Videos
if (
[
"mp4",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
"mpg",
].includes(ext)
)
return <FileVideo size={18} className="text-pink-500" />;
// Audio
if (
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
ext,
)
)
return <FileAudio size={18} className="text-green-400" />;
// Archives
if (
[
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"tbz2",
"lz",
"lzma",
"cab",
"iso",
"dmg",
].includes(ext)
)
return <FileArchive size={18} className="text-amber-500" />;
// Executables
if (["exe", "msi", "app", "deb", "rpm", "apk", "ipa"].includes(ext))
return <File size={18} className="text-red-400" />;
if (["dll", "so", "dylib"].includes(ext))
return <File size={18} className="text-gray-500" />;
// Keys/Certs
if (["pem", "crt", "cer", "key", "pub", "ppk"].includes(ext))
return <Key size={18} className="text-yellow-400" />;
// Text/Markdown
if (["md", "markdown", "mdx"].includes(ext))
return <FileText size={18} className="text-gray-400" />;
if (["txt", "log", "text"].includes(ext))
return <FileText size={18} className={iconClass} />;
// Default
return <File size={18} className={iconClass} />;
};
// Format bytes with appropriate unit (B, KB, MB, GB)
const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
// Format date as YYYY-MM-DD HH:mm:ss in local timezone
const formatDate = (dateStr: string | number | undefined): string => {
if (!dateStr) return "--";
const date =
typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
interface SFTPModalProps {
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
};
open: boolean;
onClose: () => void;
}
// Sort configuration
type SortField = "name" | "size" | "modified";
type SortOrder = "asc" | "desc";
// Transfer task type for modal
interface UploadTask {
id: string;
fileName: string;
status: "pending" | "uploading" | "completed" | "failed";
progress: number;
totalBytes: number;
transferredBytes: number;
speed: number; // bytes per second
startTime: number;
error?: string;
}
const SFTPModal: React.FC<SFTPModalProps> = ({
host,
credentials,
open,
onClose,
}) => {
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const sftpIdRef = useRef<string | null>(null);
const initializedRef = useRef(false);
const navigatingRef = useRef(false);
const lastSelectedIndexRef = useRef<number | null>(null);
// Sorting state
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
// Column widths (percentages)
const [columnWidths, setColumnWidths] = useState({
name: 45,
size: 15,
modified: 25,
actions: 15,
});
const resizingRef = useRef<{
field: string;
startX: number;
startWidth: number;
} | null>(null);
// Editable path state
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const ensureSftp = async () => {
if (sftpIdRef.current) return sftpIdRef.current;
if (!window.nebula?.openSftp) throw new Error("SFTP bridge unavailable");
const sftpId = await window.nebula.openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
});
sftpIdRef.current = sftpId;
return sftpId;
};
const loadFiles = useCallback(async (path: string) => {
try {
setError(null);
const sftpId = await ensureSftp();
setLoading(true);
const list = await window.nebula.listSftp(sftpId, path);
setFiles(list);
setSelectedFiles(new Set());
} catch (e) {
console.error("Failed to load files", e);
setError(e instanceof Error ? e.message : "Failed to load directory");
setFiles([]);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- ensureSftp is defined inline, not a dependency
}, []);
const closeSftp = async () => {
if (sftpIdRef.current && window.nebula?.closeSftp) {
try {
await window.nebula.closeSftp(sftpIdRef.current);
} catch {
// Silently ignore close errors - connection may already be closed
}
}
sftpIdRef.current = null;
};
useEffect(() => {
return () => {
// Cleanup on unmount
closeSftp();
};
}, []);
useEffect(() => {
if (open) {
if (!initializedRef.current) {
initializedRef.current = true;
setCurrentPath("/");
}
loadFiles(currentPath);
} else {
closeSftp();
initializedRef.current = false;
}
}, [open, currentPath, loadFiles]);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
if (navigatingRef.current) return;
navigatingRef.current = true;
setCurrentPath(path);
// Reset lock after a short delay
setTimeout(() => {
navigatingRef.current = false;
}, 300);
}, []);
const handleUp = () => {
if (currentPath === "/") return;
const parent = currentPath.split("/").slice(0, -1).join("/") || "/";
setCurrentPath(parent);
};
const handleDownload = async (file: RemoteFile) => {
try {
const sftpId = await ensureSftp();
const fullPath =
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`;
setLoading(true);
const content = await window.nebula.readSftp(sftpId, fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
setError(e instanceof Error ? e.message : "Download failed");
} finally {
setLoading(false);
}
};
const handleUploadFile = async (
file: File,
taskId: string,
): Promise<boolean> => {
const sftpId = await ensureSftp();
const startTime = Date.now();
// Update task to uploading with start time
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: "uploading" as const,
totalBytes: file.size,
startTime,
speed: 0,
}
: t,
),
);
try {
const arrayBuffer = await file.arrayBuffer();
const fullPath =
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`;
// Use real-time progress API if available
if (window.nebula.writeSftpBinaryWithProgress) {
await window.nebula.writeSftpBinaryWithProgress(
sftpId,
fullPath,
arrayBuffer,
taskId,
// Real-time progress callback
(transferred: number, total: number, speed: number) => {
const progress =
total > 0 ? Math.round((transferred / total) * 100) : 0;
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId && t.status === "uploading"
? {
...t,
transferredBytes: transferred,
progress,
speed,
}
: t,
),
);
},
// Complete callback
() => {
const totalTime = (Date.now() - startTime) / 1000;
const finalSpeed = totalTime > 0 ? file.size / totalTime : 0;
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: "completed" as const,
progress: 100,
transferredBytes: file.size,
speed: finalSpeed,
}
: t,
),
);
},
// Error callback
(error: string) => {
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: "failed" as const,
error,
}
: t,
),
);
},
);
return true;
} else if (window.nebula.writeSftpBinary) {
// Fallback to non-progress API
await window.nebula.writeSftpBinary(sftpId, fullPath, arrayBuffer);
} else {
// Fallback: read as text (works for text files)
const text = await file.text();
await window.nebula.writeSftp(sftpId, fullPath, text);
}
// Calculate final speed (for fallback methods)
const totalTime = (Date.now() - startTime) / 1000;
const finalSpeed = totalTime > 0 ? file.size / totalTime : 0;
// Update task to completed
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: "completed" as const,
progress: 100,
transferredBytes: file.size,
speed: finalSpeed,
}
: t,
),
);
return true;
} catch (e) {
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: "failed" as const,
error: e instanceof Error ? e.message : "Upload failed",
}
: t,
),
);
return false;
}
};
const handleUploadMultiple = async (fileList: FileList) => {
if (fileList.length === 0) return;
// Create all tasks upfront
const newTasks: UploadTask[] = Array.from(fileList).map((file) => ({
id: crypto.randomUUID(),
fileName: file.name,
status: "pending" as const,
progress: 0,
totalBytes: file.size,
transferredBytes: 0,
speed: 0,
startTime: 0,
}));
setUploadTasks((prev) => [...prev, ...newTasks]);
setUploading(true);
// Upload files sequentially
const filesToUpload = Array.from(fileList);
for (let i = 0; i < filesToUpload.length; i++) {
await handleUploadFile(filesToUpload[i], newTasks[i].id);
}
setUploading(false);
await loadFiles(currentPath);
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks((prev) => prev.filter((t) => t.status !== "completed"));
}, 3000);
};
const handleDelete = async (file: RemoteFile) => {
if (!confirm(`Delete "${file.name}"?`)) return;
try {
const sftpId = await ensureSftp();
const fullPath =
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`;
// Use deleteSftp which handles both files and directories
if (window.nebula.deleteSftp) {
await window.nebula.deleteSftp(sftpId, fullPath);
}
await loadFiles(currentPath);
} catch (e) {
setError(e instanceof Error ? e.message : "Delete failed");
}
};
const handleCreateFolder = async () => {
const folderName = prompt("New folder name?");
if (!folderName) return;
try {
const sftpId = await ensureSftp();
const fullPath =
currentPath === "/" ? `/${folderName}` : `${currentPath}/${folderName}`;
await window.nebula.mkdirSftp(sftpId, fullPath);
await loadFiles(currentPath);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create folder");
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleUploadMultiple(e.target.files);
}
// Reset input so same file can be selected again
e.target.value = "";
};
// Drag and Drop handlers
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleUploadMultiple(e.dataTransfer.files);
}
};
const handleClose = async () => {
await closeSftp();
setIsEditingPath(false);
onClose();
};
// Sorted files
const sortedFiles = useMemo(() => {
return [...files].sort((a, b) => {
// Directories always first
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size": {
const sizeA =
typeof a.size === "number"
? a.size
: parseInt(String(a.size), 10) || 0;
const sizeB =
typeof b.size === "number"
? b.size
: parseInt(String(b.size), 10) || 0;
cmp = sizeA - sizeB;
break;
}
case "modified": {
const dateA = new Date(a.lastModified || 0).getTime();
const dateB = new Date(b.lastModified || 0).getTime();
cmp = dateA - dateB;
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
}, [files, sortField, sortOrder]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
// Column resize handlers
const handleResizeStart = (field: string, e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field as keyof typeof columnWidths],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
// Path editing handlers
const handlePathDoubleClick = () => {
setEditingPathValue(currentPath);
setIsEditingPath(true);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = () => {
const newPath = editingPathValue.trim() || "/";
setIsEditingPath(false);
if (newPath !== currentPath) {
handleNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
};
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handlePathSubmit();
} else if (e.key === "Escape") {
setIsEditingPath(false);
}
};
// Breadcrumbs
const breadcrumbs =
currentPath === "/" ? [] : currentPath.split("/").filter(Boolean);
const handleFileClick = (
file: RemoteFile,
index: number,
e: React.MouseEvent,
) => {
if (file.type === "directory") {
// Double click to enter directory is handled by onDoubleClick
// Single click just selects
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
// Shift-click: range select
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd click: toggle selection
setSelectedFiles((prev) => {
const next = new Set(prev);
// Don't select directories
return next;
});
}
return;
}
// For files
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
// Shift-click: range select
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd click: toggle selection
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(file.name)) {
next.delete(file.name);
} else {
next.add(file.name);
}
return next;
});
lastSelectedIndexRef.current = index;
} else {
// Normal click: single select
setSelectedFiles(new Set([file.name]));
lastSelectedIndexRef.current = index;
}
};
const handleFileDoubleClick = (file: RemoteFile) => {
if (file.type === "directory") {
handleNavigate(
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`,
);
} else {
handleDownload(file);
}
};
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
const fileNames = Array.from(selectedFiles);
if (
!confirm(
`Delete ${fileNames.length} item${fileNames.length > 1 ? "s" : ""}?`,
)
)
return;
try {
const sftpId = await ensureSftp();
for (const fileName of fileNames) {
const fullPath =
currentPath === "/" ? `/${fileName}` : `${currentPath}/${fileName}`;
if (window.nebula.deleteSftp) {
await window.nebula.deleteSftp(sftpId, fullPath);
}
}
await loadFiles(currentPath);
setSelectedFiles(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : "Delete failed");
}
};
const handleDownloadSelected = async () => {
if (selectedFiles.size === 0) return;
for (const fileName of selectedFiles) {
const file = files.find((f) => f.name === fileName);
if (file && file.type === "file") {
await handleDownload(file);
}
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
{/* Header */}
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
{host.label}
</DialogTitle>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
</div>
</DialogHeader>
{/* Toolbar */}
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleUp}
disabled={currentPath === "/"}
>
<ArrowUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setCurrentPath("/")}
>
<Home size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => loadFiles(currentPath)}
>
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
</Button>
{/* Editable Breadcrumbs */}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title="Double-click to edit path"
>
<button
className="text-muted-foreground hover:text-foreground px-1"
onClick={() => setCurrentPath("/")}
>
/
</button>
{breadcrumbs.map((part, idx) => (
<React.Fragment key={idx}>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className="text-muted-foreground hover:text-foreground truncate px-1"
onClick={() =>
setCurrentPath(
"/" + breadcrumbs.slice(0, idx + 1).join("/"),
)
}
>
{part}
</button>
</React.Fragment>
))}
</div>
)}
</div>
{/* Action buttons moved here */}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => inputRef.current?.click()}
disabled={uploading}
>
<Upload size={14} className="mr-1.5" /> Upload
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={handleCreateFolder}
>
<Plus size={14} className="mr-1.5" /> New Folder
</Button>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={handleFileSelect}
multiple
/>
</div>
</div>
{/* Error display */}
{error && (
<div className="px-4 py-2 bg-destructive/10 text-destructive text-xs border-b border-destructive/20 flex items-center justify-between">
<span>{error}</span>
<button className="ml-2 underline" onClick={() => setError(null)}>
Dismiss
</button>
</div>
)}
{/* File List */}
<div
className={cn(
"flex-1 overflow-y-auto relative",
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{dragActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
<Upload size={32} />
<span>Drop files to upload</span>
</div>
</div>
)}
{loading && files.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{files.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
<div className="text-sm font-medium">Empty Directory</div>
<div className="text-xs mt-1">
Drag and drop files here to upload
</div>
</div>
)}
{/* Table Header with sortable columns and resize handles */}
{files.length > 0 && (
<div
className="sticky top-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>Name</span>
{sortField === "name" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("size")}
>
<span>Size</span>
{sortField === "size" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>Modified</span>
{sortField === "modified" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div className="text-right">Actions</div>
</div>
)}
{/* File rows */}
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="divide-y divide-border/30">
{sortedFiles.map((file, idx) => (
<ContextMenu key={idx}>
<ContextMenuTrigger>
<div
className={cn(
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
selectedFiles.has(file.name) && "bg-primary/10",
)}
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}}
onClick={(e) => handleFileClick(file, idx, e)}
onDoubleClick={() => handleFileDoubleClick(file)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0">
{getFileIcon(file.name, file.type === "directory")}
</div>
<span className="truncate font-medium">
{file.name}
</span>
</div>
<div className="text-xs text-muted-foreground">
{file.type === "directory"
? "--"
: formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified)}
</div>
<div className="flex items-center justify-end gap-1">
{file.type === "file" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
title="Download"
>
<Download size={14} />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
title="Delete"
>
<Trash2 size={14} />
</Button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{file.type === "directory" && (
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
Open
</ContextMenuItem>
)}
{file.type === "file" && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" /> Download
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => handleDelete(file)}
>
<Trash2 size={14} className="mr-2" /> Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> New folder
</ContextMenuItem>
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> Upload files
</ContextMenuItem>
<ContextMenuItem onClick={() => loadFiles(currentPath)}>
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
</ContextMenuItem>
{selectedFiles.size > 0 && (
<>
<ContextMenuItem onClick={handleDownloadSelected}>
<Download className="h-4 w-4 mr-2" /> Download selected (
{selectedFiles.size})
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 className="h-4 w-4 mr-2" /> Delete selected (
{selectedFiles.size})
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
{/* Upload progress at bottom */}
{uploadTasks.length > 0 && (
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
<div className="max-h-40 overflow-y-auto">
{uploadTasks.map((task) => {
// Format speed
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
if (bytesPerSec >= 1024)
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${Math.round(bytesPerSec)} B/s`;
};
// Format bytes
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
// Remaining time
const remainingBytes = task.totalBytes - task.transferredBytes;
const remainingTime =
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: "";
return (
<div
key={task.id}
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0">
{task.status === "uploading" && (
<Loader2
size={14}
className="animate-spin text-primary"
/>
)}
{task.status === "pending" && (
<Upload
size={14}
className="text-muted-foreground animate-pulse"
/>
)}
{task.status === "completed" && (
<Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<X size={14} className="text-destructive" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{task.fileName}
</span>
{task.status === "uploading" && task.speed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(task.speed)}
</span>
)}
{task.status === "uploading" && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" ||
task.status === "pending") && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-150",
task.status === "pending"
? "bg-muted-foreground/50 animate-pulse w-full"
: "bg-primary",
)}
style={{
width:
task.status === "uploading"
? `${task.progress}%`
: undefined,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
{task.status === "uploading"
? `${task.progress}%`
: "..."}
</span>
</div>
)}
{task.status === "uploading" && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} /{" "}
{formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
Completed • {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "failed" && task.error && (
<div className="text-[10px] text-destructive truncate mt-0.5">
{task.error}
</div>
)}
</div>
<div className="text-[10px] text-muted-foreground shrink-0">
{task.status === "pending" && "Waiting..."}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{files.length} items
{selectedFiles.size > 0 && (
<>
<span className="mx-2">•</span>
<span className="text-primary">
{selectedFiles.size} selected
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
onClick={handleDownloadSelected}
>
<Download size={10} className="mr-1" /> Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 size={10} className="mr-1" /> Delete
</Button>
</>
)}
</span>
<span>
{loading ? "Loading..." : uploading ? "Uploading..." : "Ready"}
</span>
</div>
</DialogContent>
</Dialog>
);
};
export default SFTPModal;