Files
Termix/src/ui/sidebar/HostsPanel.tsx
T
2026-07-13 23:00:16 +08:00

1051 lines
38 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
Check,
ChevronsDownUp,
ChevronsUpDown,
Download,
Filter,
FolderPlus,
Group,
ListChecks,
MoreHorizontal,
Plus,
RefreshCw,
Search,
Server,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { ProxmoxDiscoverDialog } from "@/components/proxmox/ProxmoxDiscoverDialog";
import { Button } from "@/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
getSSHHosts,
bulkImportSSHHosts,
importSSHConfigHosts,
exportAllSSHHosts,
} from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
import {
resolveHostSortPreferences,
sortHostTree,
type SortKey,
} from "@/sidebar/host-sort";
type FilterState = {
status: ("online" | "offline" | "pinned")[];
authType: ("password" | "key" | "credential" | "none" | "opkssh")[];
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
tags: string[];
};
const DEFAULT_FILTERS: FilterState = {
status: [],
authType: [],
protocol: [],
features: [],
tags: [],
};
type GroupKey = "folder" | "tag" | "status" | "protocol" | "auth";
function flattenHosts(folder: HostFolder): Host[] {
const out: Host[] = [];
for (const child of folder.children) {
if (isFolder(child)) out.push(...flattenHosts(child));
else out.push(child);
}
return out;
}
function hostGroupNames(host: Host, key: GroupKey): string[] {
switch (key) {
case "tag":
return host.tags && host.tags.length > 0 ? host.tags : ["__none__"];
case "status":
return [host.online ? "online" : "offline"];
case "protocol": {
const protos: string[] = [];
if (host.enableSsh) protos.push("ssh");
if (host.enableRdp) protos.push("rdp");
if (host.enableVnc) protos.push("vnc");
if (host.enableTelnet) protos.push("telnet");
return protos.length > 0 ? protos : ["__none__"];
}
case "auth":
return [host.authType || "none"];
default:
return ["__none__"];
}
}
function groupHosts(
tree: HostFolder,
key: GroupKey,
labelFor: (key: GroupKey, group: string) => string,
): HostFolder {
if (key === "folder") return tree;
const hosts = flattenHosts(tree);
const groups = new Map<string, Host[]>();
for (const host of hosts) {
for (const name of hostGroupNames(host, key)) {
if (!groups.has(name)) groups.set(name, []);
groups.get(name)!.push(host);
}
}
const children: (Host | HostFolder)[] = [...groups.entries()]
.sort((a, b) => labelFor(key, a[0]).localeCompare(labelFor(key, b[0])))
.map(([group, members]) => ({
name: labelFor(key, group),
path: `__group__:${key}:${group}`,
children: members,
}));
return { name: "root", children };
}
function hostPassesFilters(host: Host, filters: FilterState): boolean {
if (filters.status.length > 0) {
const ok =
(filters.status.includes("online") && host.online) ||
(filters.status.includes("offline") && !host.online) ||
(filters.status.includes("pinned") && !!host.pin);
if (!ok) return false;
}
if (filters.authType.length > 0) {
if (
!filters.authType.includes(
host.authType as FilterState["authType"][number],
)
)
return false;
}
if (filters.protocol.length > 0) {
const ok =
(filters.protocol.includes("ssh") && host.enableSsh) ||
(filters.protocol.includes("rdp") && host.enableRdp) ||
(filters.protocol.includes("vnc") && host.enableVnc) ||
(filters.protocol.includes("telnet") && host.enableTelnet);
if (!ok) return false;
}
if (filters.features.length > 0) {
const ok =
(filters.features.includes("terminal") && host.enableTerminal) ||
(filters.features.includes("fileManager") && host.enableFileManager) ||
(filters.features.includes("tunnel") && host.enableTunnel) ||
(filters.features.includes("docker") && host.enableDocker);
if (!ok) return false;
}
if (filters.tags.length > 0) {
const ok = filters.tags.some((tag) => host.tags?.includes(tag));
if (!ok) return false;
}
return true;
}
function applyFilters(folder: HostFolder, filters: FilterState): HostFolder {
const active = Object.values(filters).some((arr) => arr.length > 0);
if (!active) return folder;
const filteredChildren = folder.children
.map((child) => {
if (isFolder(child)) return applyFilters(child, filters);
return hostPassesFilters(child, filters) ? child : null;
})
.filter((child): child is Host | HostFolder => {
if (child === null) return false;
if (isFolder(child)) return child.children.length > 0;
return true;
});
return { ...folder, children: filteredChildren };
}
export function HostsPanel({
onOpenTab,
onEditHost,
hostTree,
loading,
onEditingChange,
active = true,
}: {
onOpenTab: (host: Host, type: TabType) => void;
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
loading?: boolean;
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
const { t } = useTranslation();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [rawHosts, setRawHosts] = useState<SSHHostWithStatus[]>([]);
const [shareModalHost, setShareModalHost] = useState<Host | null>(null);
const [proxmoxDialogOpen, setProxmoxDialogOpen] = useState(false);
const [proxmoxHostId, setProxmoxHostId] = useState<number | undefined>(
undefined,
);
const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState<
number | null
>(null);
const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState<
string | undefined
>(undefined);
const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState<
string | undefined
>(undefined);
const [sortKey, setSortKey] = useState<SortKey>(() => {
return resolveHostSortPreferences(
localStorage.getItem("hostSortKey"),
localStorage.getItem("hostPinnedFirst"),
).sortKey;
});
const [pinnedFirst, setPinnedFirst] = useState(() => {
return resolveHostSortPreferences(
localStorage.getItem("hostSortKey"),
localStorage.getItem("hostPinnedFirst"),
).pinnedFirst;
});
const [groupKey, setGroupKey] = useState<GroupKey>(
() => (localStorage.getItem("hostGroupKey") as GroupKey) ?? "folder",
);
const [filterState, setFilterState] = useState<FilterState>(() => {
try {
const saved = localStorage.getItem("hostFilterState");
return saved ? (JSON.parse(saved) as FilterState) : DEFAULT_FILTERS;
} catch {
return DEFAULT_FILTERS;
}
});
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
const fileInputRef = useRef<HTMLInputElement>(null);
const sshConfigInputRef = useRef<HTMLInputElement>(null);
const importOverwriteRef = useRef(false);
const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))];
function handleSortChange(key: SortKey) {
setSortKey(key);
localStorage.setItem("hostSortKey", key);
}
function handlePinnedFirstChange(enabled: boolean) {
setPinnedFirst(enabled);
localStorage.setItem("hostPinnedFirst", String(enabled));
}
function handleGroupChange(key: GroupKey) {
setGroupKey(key);
localStorage.setItem("hostGroupKey", key);
}
function groupLabel(key: GroupKey, group: string): string {
if (group === "__none__") return t("hosts.groupUngrouped");
if (key === "status")
return group === "online"
? t("hosts.filterOnline")
: t("hosts.filterOffline");
if (key === "protocol") return group.toUpperCase();
if (key === "auth")
return t(
`hosts.filterAuth${group.charAt(0).toUpperCase() + group.slice(1)}`,
);
return group;
}
function handleFilterToggle<K extends keyof FilterState>(
group: K,
value: FilterState[K][number],
) {
setFilterState((prev) => {
const arr = prev[group] as string[];
const next = arr.includes(value as string)
? arr.filter((v) => v !== value)
: [...arr, value as string];
const updated = { ...prev, [group]: next };
localStorage.setItem("hostFilterState", JSON.stringify(updated));
return updated as FilterState;
});
}
function handleFilterClear() {
setFilterState(DEFAULT_FILTERS);
localStorage.setItem("hostFilterState", JSON.stringify(DEFAULT_FILTERS));
}
useEffect(() => {
getSSHHosts()
.then(setRawHosts)
.catch(() => {});
}, []);
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
function toggleSelectionMode() {
setSelectionMode((v) => !v);
}
async function handleRefresh() {
setRefreshing(true);
try {
const hosts = await getSSHHosts();
setRawHosts(hosts);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
} catch {
// best-effort
} finally {
setRefreshing(false);
}
}
async function handleExportHosts(share = false) {
try {
const result = await exportAllSSHHosts(
share ? { share: true } : undefined,
);
const data = JSON.stringify(result, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = share ? "termix-hosts-share.json" : "termix-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(
t(share ? "hosts.hostsShareExported" : "hosts.hostsExported"),
);
} catch {
toast.error(t("hosts.exportFailed"));
}
}
function handleDownloadSample() {
const sample = JSON.stringify(
{
hosts: [
{
name: "Web Server (Production)",
ip: "192.168.1.100",
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
tags: ["web", "production", "nginx"],
pin: true,
notes: "Main production web server running Nginx",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
defaultPath: "/var/www",
},
{
name: "Database Server",
ip: "192.168.1.101",
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
tags: ["database", "production", "postgresql"],
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
enableDocker: false,
},
],
},
null,
2,
);
const blob = new Blob([sample], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "termix-hosts-sample.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={hostSearch}
onChange={(e) => setHostSearch(e.target.value)}
placeholder={t("hosts.searchHosts")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{hostSearch && (
<button
onClick={() => setHostSearch("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
try {
const text = await file.text();
const parsed = JSON.parse(text);
const hostsArray = Array.isArray(parsed)
? parsed
: (parsed.hosts ?? []);
const credentialsArray =
!Array.isArray(parsed) && Array.isArray(parsed.credentials)
? parsed.credentials
: undefined;
if (!Array.isArray(hostsArray) || hostsArray.length === 0) {
toast.error("No hosts found in file");
return;
}
if (hostsArray.length > 100) {
toast.error("Cannot import more than 100 hosts at once");
return;
}
const normalized = hostsArray.map(
(h: Record<string, unknown>) => ({
...h,
port: h.port ?? h.sshPort ?? 22,
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
enableTelnet:
h.enableTelnet ?? h.connectionType === "telnet",
}),
);
const result = await bulkImportSSHHosts(
normalized,
importOverwriteRef.current,
credentialsArray,
);
const hosts = await getSSHHosts();
setRawHosts(hosts);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
const msg = [
result.success ? `${result.success} imported` : null,
result.updated ? `${result.updated} updated` : null,
result.failed ? `${result.failed} failed` : null,
]
.filter(Boolean)
.join(", ");
toast.success(`Import complete: ${msg}`);
} catch (err: unknown) {
toast.error(
err instanceof Error ? err.message : "Failed to import hosts",
);
}
}}
/>
<input
ref={sshConfigInputRef}
type="file"
accept=".conf,.config,*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
try {
const text = await file.text();
const result = await importSSHConfigHosts(
text,
importOverwriteRef.current,
);
const hosts = await getSSHHosts();
setRawHosts(hosts);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
const msg = [
result.success ? `${result.success} imported` : null,
result.updated ? `${result.updated} updated` : null,
result.failed ? `${result.failed} failed` : null,
]
.filter(Boolean)
.join(", ");
toast.success(`${t("hosts.importSSHConfig")}: ${msg}`);
} catch (err: unknown) {
toast.error(
err instanceof Error
? err.message
: "Failed to import SSH config",
);
}
}}
/>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
title={t("hosts.refreshBtn2")}
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={`size-3.5 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
title={t("hosts.importExportBtn")}
>
<Upload className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
fileInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSkipExisting")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = true;
fileInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importOverwrite")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
sshConfigInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSSHConfig")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxmoxHostId(undefined);
setProxmoxDialogOpen(true);
}}
disabled={
!rawHosts.some(
(h) => !isFolder(h) && (h as any).enableProxmox,
)
}
>
<Server className="size-3.5 mr-2" />
{t("hosts.proxmoxImportTitle")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleExportHosts(false)}
disabled={rawHosts.length === 0}
>
<Download className="size-3.5 mr-2" />
{t("hosts.exportAll")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportHosts(true)}
disabled={rawHosts.length === 0}
>
<Download className="size-3.5 mr-2" />
{t("hosts.exportForSharing")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownloadSample}>
<Download className="size-3.5 mr-2" />
{t("hosts.downloadSample")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
title={
selectionMode
? t("hosts.exitSelectionTitle")
: t("hosts.selectHosts")
}
onClick={toggleSelectionMode}
className={`flex items-center justify-center size-7 rounded-sm shrink-0 transition-colors ${selectionMode ? "text-accent-brand bg-accent-brand/10 border border-accent-brand/30" : "text-muted-foreground/60 hover:text-foreground hover:bg-muted/60 border border-transparent"}`}
>
<ListChecks className="size-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${sortKey !== "default" || pinnedFirst ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("hosts.sortHosts")}
>
<ArrowUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[160px]"
>
<DropdownMenuItem
onClick={() => handleSortChange("default")}
className="flex items-center gap-1.5"
>
{sortKey === "default" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t("hosts.sortDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(["name-asc", "name-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`hosts.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["ip-asc", "ip-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(`hosts.sort${key === "ip-asc" ? "IpAsc" : "IpDesc"}`)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["status-online", "status-offline"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
key === "status-online"
? "hosts.sortOnlineFirst"
: "hosts.sortOfflineFirst",
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={pinnedFirst}
onCheckedChange={handlePinnedFirstChange}
onSelect={(event) => event.preventDefault()}
>
{t("hosts.sortPinnedFirst")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("hosts.filterHosts")}
>
<Filter className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
{filterActive && (
<>
<DropdownMenuItem
onClick={handleFilterClear}
className="flex items-center gap-1.5 text-accent-brand"
>
<X className="size-3 shrink-0" />
{t("hosts.filterClearAll")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>
{t("hosts.filterStatusGroup")}
</DropdownMenuLabel>
{(["online", "offline", "pinned"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.status.includes(val)}
onCheckedChange={() => handleFilterToggle("status", val)}
onSelect={(e) => e.preventDefault()}
>
{t(
`hosts.filter${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("hosts.filterAuthGroup")}
</DropdownMenuLabel>
{(
["password", "key", "credential", "none", "opkssh"] as const
).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.authType.includes(val)}
onCheckedChange={() => handleFilterToggle("authType", val)}
onSelect={(e) => e.preventDefault()}
>
{t(
`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("hosts.filterProtocolGroup")}
</DropdownMenuLabel>
{(
[
["ssh", "Ssh"],
["rdp", "Rdp"],
["vnc", "Vnc"],
["telnet", "Telnet"],
] as const
).map(([val, key]) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.protocol.includes(val)}
onCheckedChange={() => handleFilterToggle("protocol", val)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterProtocol${key}`)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("hosts.filterFeaturesGroup")}
</DropdownMenuLabel>
{(
[
["terminal", "Terminal"],
["fileManager", "FileManager"],
["tunnel", "Tunnel"],
["docker", "Docker"],
] as const
).map(([val, key]) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.features.includes(val)}
onCheckedChange={() => handleFilterToggle("features", val)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterFeature${key}`)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("hosts.filterTagsGroup")}
</DropdownMenuLabel>
{allTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={filterState.tags.includes(tag)}
onCheckedChange={() => handleFilterToggle("tags", tag)}
onSelect={(e) => e.preventDefault()}
>
{tag}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${groupKey !== "folder" || selectionMode ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("hosts.moreActions")}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2">
<Group className="size-3.5 shrink-0" />
{t("hosts.groupBy")}
{groupKey !== "folder" && (
<span className="ml-auto text-accent-brand">
{t(
`hosts.GroupBy${groupKey.charAt(0).toUpperCase() + groupKey.slice(1)}`,
)}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="text-xs min-w-[150px]">
{(
[
["folder", "GroupByFolder"],
["tag", "GroupByTag"],
["status", "GroupByStatus"],
["protocol", "GroupByProtocol"],
["auth", "GroupByAuth"],
] as const
).map(([key, label]) => (
<DropdownMenuItem
key={key}
onClick={() => handleGroupChange(key)}
className="flex items-center gap-1.5"
>
{groupKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(`hosts.${label}`)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
window.dispatchEvent(new CustomEvent("hosts:create-folder"))
}
>
<FolderPlus className="size-3.5 mr-2" />
{t("hosts.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
window.dispatchEvent(new CustomEvent("hosts:expand-all"))
}
>
<ChevronsUpDown className="size-3.5 mr-2" />
{t("hosts.expandAll")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
window.dispatchEvent(new CustomEvent("hosts:collapse-all"))
}
>
<ChevronsDownUp className="size-3.5 mr-2" />
{t("hosts.collapseAll")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={toggleSelectionMode}
className={selectionMode ? "text-accent-brand" : ""}
>
<ListChecks className="size-3.5 mr-2" />
{selectionMode
? t("hosts.exitSelectionTitle")
: t("hosts.selectHosts")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2">
<Upload className="size-3.5 shrink-0" />
{t("hosts.importExportBtn")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="text-xs min-w-[170px]">
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
fileInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSkipExisting")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = true;
fileInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importOverwrite")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
sshConfigInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSSHConfig")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportHosts(false)}
disabled={rawHosts.length === 0}
>
<Download className="size-3.5 mr-2" />
{t("hosts.exportAll")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportHosts(true)}
disabled={rawHosts.length === 0}
>
<Download className="size-3.5 mr-2" />
{t("hosts.exportForSharing")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownloadSample}>
<Download className="size-3.5 mr-2" />
{t("hosts.downloadSample")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={() =>
window.dispatchEvent(new CustomEvent("host-manager:add-host"))
}
title={t("hosts.addHost")}
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
>
<Plus className="size-3 shrink-0" />
{t("hosts.addHost")}
</button>
</div>
</div>
)}
<div
className={`flex flex-col flex-1 min-h-0 ${managerEditing ? "hidden" : ""}`}
>
<SidebarTree
children={
hostTree
? groupHosts(
applyFilters(
sortHostTree(hostTree, sortKey, pinnedFirst),
filterState,
),
groupKey,
groupLabel,
).children
: []
}
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={(host) => setShareModalHost(host)}
onProxmoxDiscover={(host) => {
const cfg = host.proxmoxConfig;
setProxmoxHostId(Number(host.id));
setProxmoxDefaultCredentialId(cfg?.defaultCredentialId ?? null);
setProxmoxDefaultAuthType(cfg?.defaultAuthType ?? undefined);
setProxmoxDefaultUsername(undefined);
setProxmoxDialogOpen(true);
}}
query={hostSearch.trim().toLowerCase()}
selectionMode={selectionMode}
onToggleSelectionMode={toggleSelectionMode}
loading={loading}
/>
</div>
<div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
>
<HostManager onEditingChange={handleEditingChange} active={active} />
</div>
<HostShareModal
open={shareModalHost !== null}
onClose={() => setShareModalHost(null)}
host={shareModalHost}
/>
<ProxmoxDiscoverDialog
open={proxmoxDialogOpen}
onClose={() => {
setProxmoxDialogOpen(false);
setProxmoxHostId(undefined);
}}
hosts={rawHosts}
onHostsChanged={setRawHosts}
preselectedHostId={proxmoxHostId}
defaultCredentialId={proxmoxDefaultCredentialId}
defaultAuthType={proxmoxDefaultAuthType}
defaultUsername={proxmoxDefaultUsername}
/>
</div>
);
}