mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
5a687667b5
Refactors code for consistent indentation, spacing, and style. Enhances readability and maintainability by aligning formatting. No logic changes; only visual and stylistic improvements applied.
1354 lines
45 KiB
TypeScript
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;
|