import { InlineView } from "@/components/surface/surface-scope"; import { FormFooter } from "@/components/list-kit"; import { getErrorMessage } from "../lib/error-message.js"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Search } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/button"; import { Checkbox } from "@/components/checkbox"; import { Input } from "@/components/input"; import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios"; import { isFolder } from "@/sidebar/SidebarTree"; import { buildExportPayload, pluginSecretKeys, hostKey, maskSecrets, type ExportPayload, type FieldGroup, } from "@/sidebar/host-export-payload"; const GROUPS: { key: FieldGroup; label: string }[] = [ { key: "connection", label: "groupConnection" }, { key: "notes", label: "groupNotes" }, { key: "tags", label: "groupTags" }, { key: "proxy", label: "groupProxy" }, { key: "jumpHosts", label: "groupJumpHosts" }, { key: "featureFlags", label: "groupFeatureFlags" }, { key: "advanced", label: "groupAdvanced" }, ]; const DEFAULT_GROUPS: FieldGroup[] = [ "connection", "notes", "tags", "proxy", "jumpHosts", "featureFlags", "advanced", ]; const PREVIEW_HOST_LIMIT = 20; export function HostExportDialog({ open, onClose, hosts, preselectedHostIds, }: { open: boolean; onClose: () => void; hosts: SSHHostWithStatus[]; preselectedHostIds?: Set; }) { const { t } = useTranslation(); const [scope, setScope] = useState<"all" | "selected">("all"); const [withCredentials, setWithCredentials] = useState(false); const [groups, setGroups] = useState>( () => new Set(DEFAULT_GROUPS), ); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [search, setSearch] = useState(""); const [raw, setRaw] = useState(null); const [loading, setLoading] = useState(false); const cache = useRef>>({}); const prevOpen = useRef(false); const exportableHosts = useMemo( () => hosts.filter( (h) => !isFolder(h as unknown as Parameters[0]) && !h.isShared, ), [hosts], ); useEffect(() => { const justOpened = open && !prevOpen.current; prevOpen.current = open; if (!justOpened) return; cache.current = {}; setRaw(null); setSearch(""); setWithCredentials(false); setGroups(new Set(DEFAULT_GROUPS)); const preselected = preselectedHostIds ?? new Set(); if (preselected.size > 0) { setScope("selected"); setSelectedKeys( new Set( exportableHosts .filter((h) => preselected.has(String(h.id))) .map((h) => hostKey(h as unknown as Record)), ), ); } else { setScope("all"); setSelectedKeys(new Set()); } }, [open, preselectedHostIds, exportableHosts]); useEffect(() => { if (!open) return; const mode = withCredentials ? "full" : "share"; const cached = cache.current[mode]; if (cached) { setRaw(cached); return; } let cancelled = false; setLoading(true); (withCredentials ? exportAllSSHHosts() : exportAllSSHHosts({ share: true })) .then((result) => { if (cancelled) return; cache.current[mode] = result as unknown as ExportPayload; setRaw(result as unknown as ExportPayload); }) .catch((err: unknown) => { if (cancelled) return; setRaw(null); const message = getErrorMessage(err, ""); toast.error(message || t("hosts.export.fetchFailed")); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [open, withCredentials, t]); const { records: pluginRecords } = usePluginStore(); const pluginSecrets = useMemo( () => pluginSecretKeys( [...pluginRecords.values()].map((record) => record.summary), ), [pluginRecords], ); const payload = useMemo(() => { if (!raw) return null; return buildExportPayload( raw, scope === "all" ? null : selectedKeys, groups, withCredentials, pluginSecrets, ); }, [raw, scope, selectedKeys, groups, withCredentials, pluginSecrets]); const preview = useMemo(() => { if (!payload) return ""; const masked = maskSecrets(payload, pluginSecrets); if (masked.hosts.length <= PREVIEW_HOST_LIMIT) { return JSON.stringify(masked, null, 2); } const text = JSON.stringify( { ...masked, hosts: masked.hosts.slice(0, PREVIEW_HOST_LIMIT) }, null, 2, ); return ( text + "\n" + t("hosts.export.moreHosts", { count: payload.hosts.length - PREVIEW_HOST_LIMIT, }) ); }, [payload, pluginSecrets, t]); function toggleGroup(group: FieldGroup) { setGroups((prev) => { const next = new Set(prev); if (next.has(group)) next.delete(group); else next.add(group); return next; }); } function toggleHost(key: string) { setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } function handleExport() { if (!payload) return; const data = JSON.stringify(payload, 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 = withCredentials ? "termix-hosts.json" : "termix-hosts-share.json"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(t("hosts.hostsExported")); onClose(); } const visibleHosts = exportableHosts.filter((h) => (h.name ?? "").toLowerCase().includes(search.trim().toLowerCase()), ); const count = payload?.hosts.length ?? 0; const canExport = !loading && count > 0; return ( !next && onClose()} title={t("hosts.export.title")} width="wide" footer={ void handleExport()} saveLabel={t("hosts.export.confirm")} disabled={!canExport} /> } >
{/* Scope + host picker */}
{t("hosts.export.scope")}
setSearch(e.target.value)} disabled={scope === "all"} />
{visibleHosts.map((host) => { const key = hostKey(host as unknown as Record); return ( ); })}
{/* Field groups */}
{t("hosts.export.include")}
{GROUPS.filter((g) => g.key !== "connection").map((group) => ( ))}
{/* Preview */}
{t("hosts.export.preview")}
            {preview}
          
{count === 0 ? ( t("hosts.export.noneSelected") ) : ( {t("hosts.export.summary", { selected: count, total: exportableHosts.length, })} {withCredentials ? t("hosts.export.credentialsIncluded") : t("hosts.export.credentialsExcluded")} )}
); }