Files
Termix/src/ui/sidebar/HostExportDialog.tsx
T

348 lines
11 KiB
TypeScript

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<string>;
}) {
const { t } = useTranslation();
const [scope, setScope] = useState<"all" | "selected">("all");
const [withCredentials, setWithCredentials] = useState(false);
const [groups, setGroups] = useState<Set<FieldGroup>>(
() => new Set(DEFAULT_GROUPS),
);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const [raw, setRaw] = useState<ExportPayload | null>(null);
const [loading, setLoading] = useState(false);
const cache = useRef<Partial<Record<"share" | "full", ExportPayload>>>({});
const prevOpen = useRef(false);
const exportableHosts = useMemo(
() =>
hosts.filter(
(h) =>
!isFolder(h as unknown as Parameters<typeof isFolder>[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<string>();
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<string, unknown>)),
),
);
} 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 (
<InlineView
open={open}
onOpenChange={(next) => !next && onClose()}
title={t("hosts.export.title")}
width="wide"
footer={
<FormFooter
onCancel={onClose}
cancelLabel={t("hosts.export.cancel")}
onSave={() => void handleExport()}
saveLabel={t("hosts.export.confirm")}
disabled={!canExport}
/>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
</InlineView>
);
}