mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
e00fa2733b
Updates all code references, IPC channels, CSS classes, and storage keys from "nebula" to "netcatty" to align with new branding and application identity. Ensures consistency across frontend, Electron backend, global types, and package metadata.
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.netcatty?.openSftp) throw new Error("SFTP bridge unavailable");
|
|
const sftpId = await window.netcatty.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.netcatty.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.netcatty?.closeSftp) {
|
|
try {
|
|
await window.netcatty.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.netcatty.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.netcatty.writeSftpBinaryWithProgress) {
|
|
await window.netcatty.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.netcatty.writeSftpBinary) {
|
|
// Fallback to non-progress API
|
|
await window.netcatty.writeSftpBinary(sftpId, fullPath, arrayBuffer);
|
|
} else {
|
|
// Fallback: read as text (works for text files)
|
|
const text = await file.text();
|
|
await window.netcatty.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.netcatty.deleteSftp) {
|
|
await window.netcatty.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.netcatty.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.netcatty.deleteSftp) {
|
|
await window.netcatty.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;
|