mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: port the demo redesign with Manage and Settings tabs
This commit is contained in:
1 parent
f8151cb885
commit
35c954b79a
128 files changed
+13262
-8143
No files matched your search
@@ -0,0 +1,256 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GroupHeading, PANEL } from "@/components/panel-layout";
|
||||
import { TabStrip } from "@/components/tab-strip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EditorNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
/** Marks a section holding something that needs fixing. */
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorNavBand {
|
||||
id: string;
|
||||
label: string;
|
||||
items: EditorNavItem[];
|
||||
}
|
||||
|
||||
/** Which section is in view, and a way to jump to one. */
|
||||
export function useScrollSpy(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
ids: string[],
|
||||
): { active: string; scrollTo: (id: string) => void } {
|
||||
const [active, setActive] = useState(ids[0] ?? "");
|
||||
const idsKey = ids.join("|");
|
||||
// A jump scrolls past every section between; the spy waits it out.
|
||||
const lockUntil = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
if (Date.now() < lockUntil.current) return;
|
||||
const top = container.getBoundingClientRect().top;
|
||||
let current = ids[0] ?? "";
|
||||
for (const id of ids) {
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-section="${id}"]`,
|
||||
);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top - top <= 24) current = id;
|
||||
}
|
||||
if (
|
||||
container.scrollHeight > container.clientHeight &&
|
||||
container.scrollTop + container.clientHeight >=
|
||||
container.scrollHeight - 8
|
||||
) {
|
||||
current = ids[ids.length - 1] ?? current;
|
||||
}
|
||||
setActive(current);
|
||||
};
|
||||
onScroll();
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerRef, idsKey]);
|
||||
|
||||
return {
|
||||
active: ids.includes(active) ? active : (ids[0] ?? ""),
|
||||
scrollTo: (id: string) => {
|
||||
const container = containerRef.current;
|
||||
const el = container?.querySelector<HTMLElement>(
|
||||
`[data-section="${id}"]`,
|
||||
);
|
||||
if (!container || !el) return;
|
||||
lockUntil.current = Date.now() + 600;
|
||||
setActive(id);
|
||||
container.scrollTo?.({
|
||||
top:
|
||||
el.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top +
|
||||
container.scrollTop -
|
||||
8,
|
||||
behavior: "smooth",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps a section so the nav can find it. */
|
||||
export function EditorSection({
|
||||
id,
|
||||
label,
|
||||
showHeading = true,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
showHeading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section data-section={id} className={`flex flex-col ${PANEL.gap}`}>
|
||||
{showHeading && label && <GroupHeading title={label} className="pt-1" />}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts its children the first time they scroll near view, so a long editor
|
||||
* does not start every section's requests at once.
|
||||
*/
|
||||
export function LazySection({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [shown, setShown] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (shown) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShown(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "400px 0px" },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [shown]);
|
||||
return (
|
||||
<div ref={ref} className={shown ? "contents" : "min-h-24"}>
|
||||
{shown && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An editor laid out like the settings screen: a section nav beside one
|
||||
* scrolling form, and the actions pinned under it.
|
||||
*/
|
||||
export function EditorPane({
|
||||
bands,
|
||||
banner,
|
||||
footer,
|
||||
errorCount = 0,
|
||||
revealRef,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
bands: EditorNavBand[];
|
||||
/** Filled with a function that scrolls a section into view. */
|
||||
revealRef?: React.MutableRefObject<((id: string) => void) | null>;
|
||||
/** Sits above the form, inside the scroll area. */
|
||||
banner?: ReactNode;
|
||||
/** Usually Cancel and Save, pinned at the bottom. */
|
||||
footer?: ReactNode;
|
||||
/** Shown beside the footer so a problem in a hidden section is not missed. */
|
||||
errorCount?: number;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const ids = bands.flatMap((band) => band.items.map((item) => item.id));
|
||||
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
||||
const showNav = ids.length > 1;
|
||||
if (revealRef) revealRef.current = scrollTo;
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-1", className)}>
|
||||
{showNav && (
|
||||
<nav
|
||||
aria-label={t("manage.sections")}
|
||||
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
|
||||
>
|
||||
{bands.map((band) => (
|
||||
<div key={band.id} className="flex flex-col">
|
||||
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
|
||||
{band.items.map((item) => {
|
||||
const on = item.id === active;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={on ? "true" : undefined}
|
||||
onClick={() => scrollTo(item.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
on
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex shrink-0 [&_svg]:size-3.5">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span
|
||||
title={t("manage.sectionNeedsAttention")}
|
||||
className="ml-auto size-1.5 shrink-0 rounded-full bg-destructive"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{showNav && (
|
||||
<div className="shrink-0 border-b border-border px-1 lg:hidden">
|
||||
<TabStrip
|
||||
tabs={bands.flatMap((band) =>
|
||||
band.items.map((item) => ({ id: item.id, label: item.label })),
|
||||
)}
|
||||
activeTab={active}
|
||||
onTabChange={scrollTo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
||||
>
|
||||
<div className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}>
|
||||
{banner}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{(footer || errorCount > 0) && (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t("manage.needsAttention", { count: errorCount })}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">{footer}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PanelSearch, Segmented } from "@/components/panel-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rem } from "@/lib/rem";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import { defaultsRows, type DefaultsRow } from "./defaults-rows";
|
||||
import {
|
||||
allFolderPaths,
|
||||
allParentIds,
|
||||
buildCredentialManageTree,
|
||||
buildHostManageTree,
|
||||
flattenTree,
|
||||
type ManageRow,
|
||||
} from "./manage-tree";
|
||||
import type { ManageMode } from "./manage-requests";
|
||||
import {
|
||||
getStatusClasses,
|
||||
useStatusColorScheme,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
|
||||
function indent(depth: number) {
|
||||
return 6 + depth * 13;
|
||||
}
|
||||
|
||||
/** Rules marking each open ancestor, so deep rows still read as nested. */
|
||||
function Guides({ depth }: { depth: number }) {
|
||||
if (depth === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: depth }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 w-px bg-border"
|
||||
style={{ left: rem(indent(i) + 7) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rowFocus =
|
||||
"focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring";
|
||||
|
||||
/** The list column of the Manage tab. */
|
||||
export function ManageList({
|
||||
editing,
|
||||
mode,
|
||||
onMode,
|
||||
hosts,
|
||||
credentials,
|
||||
canEditInstanceDefaults,
|
||||
isOnline,
|
||||
selectedKey,
|
||||
onPickHost,
|
||||
onPickCredential,
|
||||
onPickDefaults,
|
||||
onAdd,
|
||||
}: {
|
||||
editing: boolean;
|
||||
mode: ManageMode;
|
||||
onMode: (mode: ManageMode) => void;
|
||||
hosts: Host[];
|
||||
credentials: Credential[];
|
||||
canEditInstanceDefaults: boolean;
|
||||
isOnline: (host: Host) => boolean;
|
||||
/** "h:<id>", "c:<id>" or a defaults row key. */
|
||||
selectedKey: string | null;
|
||||
onPickHost: (host: Host) => void;
|
||||
onPickCredential: (credential: Credential) => void;
|
||||
onPickDefaults: (row: DefaultsRow) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
const tree = useMemo(
|
||||
() =>
|
||||
mode === "credentials"
|
||||
? buildCredentialManageTree(credentials)
|
||||
: buildHostManageTree(hosts, isOnline),
|
||||
[mode, hosts, credentials, isOnline],
|
||||
);
|
||||
|
||||
// Everything starts open; closing something is remembered while mounted.
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
setOpen(new Set([...allFolderPaths(tree), ...allParentIds(tree)]));
|
||||
// Rebuilt only when the side changes, so toggles survive an edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
const toggle = (key: string) =>
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const rows = useMemo(
|
||||
() => (mode === "defaults" ? [] : flattenTree(tree, open, needle)),
|
||||
[mode, tree, open, needle],
|
||||
);
|
||||
|
||||
const defaults = useMemo(
|
||||
() =>
|
||||
defaultsRows(hosts, canEditInstanceDefaults, t).filter(
|
||||
(row) => !needle || row.label.toLowerCase().includes(needle),
|
||||
),
|
||||
[hosts, canEditInstanceDefaults, t, needle],
|
||||
);
|
||||
|
||||
const pick = (id: string) => {
|
||||
if (mode === "hosts") {
|
||||
const host = hosts.find((h) => h.id === id);
|
||||
if (host) onPickHost(host);
|
||||
} else {
|
||||
const credential = credentials.find((c) => c.id === id);
|
||||
if (credential) onPickCredential(credential);
|
||||
}
|
||||
};
|
||||
|
||||
const empty = mode === "defaults" ? defaults.length === 0 : rows.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
editing ? "hidden md:flex" : "flex",
|
||||
"w-full shrink-0 flex-col border-r border-border bg-sidebar md:w-72",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={onMode}
|
||||
options={[
|
||||
{ value: "hosts", label: t("nav.hosts"), count: hosts.length },
|
||||
{
|
||||
value: "credentials",
|
||||
label: t("nav.credentials"),
|
||||
count: credentials.length,
|
||||
},
|
||||
{ value: "defaults", label: t("manage.defaults") },
|
||||
]}
|
||||
className="w-full [&>button]:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={
|
||||
mode === "hosts"
|
||||
? t("manage.searchHosts")
|
||||
: mode === "credentials"
|
||||
? t("manage.searchCredentials")
|
||||
: t("manage.searchDefaults")
|
||||
}
|
||||
fill
|
||||
/>
|
||||
{mode !== "defaults" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={
|
||||
mode === "hosts"
|
||||
? t("hosts.addHost")
|
||||
: t("manage.addCredential")
|
||||
}
|
||||
aria-label={
|
||||
mode === "hosts"
|
||||
? t("hosts.addHost")
|
||||
: t("manage.addCredential")
|
||||
}
|
||||
onClick={onAdd}
|
||||
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-1 thin-scrollbar">
|
||||
{empty ? (
|
||||
<EmptyState
|
||||
icon={mode === "credentials" ? KeyRound : Server}
|
||||
title={needle ? t("manage.nothingMatches") : t("manage.nothingYet")}
|
||||
hint={
|
||||
needle
|
||||
? undefined
|
||||
: mode === "hosts"
|
||||
? t("manage.emptyHostsHint")
|
||||
: t("manage.emptyCredentialsHint")
|
||||
}
|
||||
/>
|
||||
) : mode === "defaults" ? (
|
||||
defaults.map((row) => {
|
||||
const Icon =
|
||||
row.level === "admin"
|
||||
? Globe
|
||||
: row.level === "user"
|
||||
? User
|
||||
: Folder;
|
||||
const selected = selectedKey === row.key;
|
||||
return (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
onClick={() => onPickDefaults(row)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 px-2.5 py-1.5 text-left transition-colors",
|
||||
rowFocus,
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{row.label}
|
||||
</span>
|
||||
<SlidersHorizontal className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
rows.map((row) =>
|
||||
row.kind === "folder" ? (
|
||||
<FolderRow
|
||||
key={`f:${row.folder.path}`}
|
||||
row={row}
|
||||
showOnline={mode === "hosts"}
|
||||
open={!!needle || open.has(row.folder.path)}
|
||||
locked={!!needle}
|
||||
onToggle={() => toggle(row.folder.path)}
|
||||
/>
|
||||
) : (
|
||||
<ItemRow
|
||||
key={`i:${row.item.id}`}
|
||||
row={row}
|
||||
isHost={mode === "hosts"}
|
||||
selected={
|
||||
selectedKey ===
|
||||
`${mode === "hosts" ? "h" : "c"}:${row.item.id}`
|
||||
}
|
||||
open={!!needle || open.has(row.item.id)}
|
||||
locked={!!needle}
|
||||
onToggle={() => toggle(row.item.id)}
|
||||
onPick={() => pick(row.item.id)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({
|
||||
row,
|
||||
open,
|
||||
locked,
|
||||
showOnline,
|
||||
onToggle,
|
||||
}: {
|
||||
row: Extract<ManageRow, { kind: "folder" }>;
|
||||
open: boolean;
|
||||
/** Search forces folders open, so the chevron stops responding. */
|
||||
locked: boolean;
|
||||
showOnline: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const Icon = open ? FolderOpen : Folder;
|
||||
return (
|
||||
<div className="relative">
|
||||
<Guides depth={row.depth} />
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={locked ? undefined : onToggle}
|
||||
style={{ paddingLeft: rem(indent(row.depth)) }}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 py-1.5 pr-2.5 text-left transition-colors hover:bg-muted",
|
||||
rowFocus,
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{row.folder.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">
|
||||
{showOnline ? `${row.online}/${row.count}` : row.count}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRow({
|
||||
row,
|
||||
isHost,
|
||||
selected,
|
||||
open,
|
||||
locked,
|
||||
onToggle,
|
||||
onPick,
|
||||
}: {
|
||||
row: Extract<ManageRow, { kind: "item" }>;
|
||||
isHost: boolean;
|
||||
selected: boolean;
|
||||
open: boolean;
|
||||
locked: boolean;
|
||||
onToggle: () => void;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useStatusColorScheme();
|
||||
const nests = row.item.children.length > 0;
|
||||
const Icon = isHost ? Server : KeyRound;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Guides depth={row.depth} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center border-l-2 transition-colors",
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-transparent hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ paddingLeft: rem(indent(row.depth)) }}
|
||||
>
|
||||
{nests ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={locked ? undefined : onToggle}
|
||||
aria-expanded={open}
|
||||
title={open ? t("manage.collapse") : t("manage.expand")}
|
||||
aria-label={open ? t("manage.collapse") : t("manage.expand")}
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center text-muted-foreground hover:text-foreground",
|
||||
rowFocus,
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 py-1.5 pl-1 pr-2.5 text-left transition-colors",
|
||||
rowFocus,
|
||||
selected ? "text-accent-brand" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{row.item.name}
|
||||
</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-muted-foreground">
|
||||
{row.item.note}
|
||||
</span>
|
||||
{isHost && (
|
||||
<span
|
||||
aria-hidden
|
||||
title={row.item.online ? t("common.online") : t("common.offline")}
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
getStatusClasses(!!row.item.online, scheme, "dot"),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, LibraryBig, Plus, Server, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { FakeSwitch } from "@/components/section-card";
|
||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useUiPreference } from "@/contexts/UiPreferencesContext";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
deleteCredential,
|
||||
deleteSSHHost,
|
||||
getCredentialDetails,
|
||||
getCredentials,
|
||||
getSSHHosts,
|
||||
type SSHHostWithStatus,
|
||||
} from "@/main-axios";
|
||||
import { getFolderDefaultsId } from "@/api/host-defaults-api";
|
||||
import { registerTabCloseGuard } from "@/shell/tab-close-guards";
|
||||
import { HostEditor } from "@/sidebar/HostEditor";
|
||||
import { CredentialEditorView } from "@/sidebar/CredentialEditorView";
|
||||
import {
|
||||
HostDefaultsEditorView,
|
||||
hostDefaultsTitle,
|
||||
type HostDefaultsEditorTarget,
|
||||
} from "@/sidebar/host-defaults/HostDefaultsEditorView";
|
||||
import { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import {
|
||||
hostProtocolFlags,
|
||||
type HostProtocols,
|
||||
} from "@/sidebar/host-protocols";
|
||||
import { canDeleteHost } from "@/sidebar/host-permissions";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import { ManageList } from "./ManageList";
|
||||
import type { DefaultsRow } from "./defaults-rows";
|
||||
import {
|
||||
MANAGE_REQUEST_EVENT,
|
||||
takePendingManageRequest,
|
||||
type ManageMode,
|
||||
type ManageRequest,
|
||||
} from "./manage-requests";
|
||||
|
||||
type Editing =
|
||||
| {
|
||||
kind: "host";
|
||||
host: Host | null;
|
||||
draft?: HostDraft;
|
||||
key: number;
|
||||
protocols: HostProtocols;
|
||||
}
|
||||
| { kind: "credential"; credential: Credential | null; key: number }
|
||||
| { kind: "defaults"; target: HostDefaultsEditorTarget; rowKey: string };
|
||||
|
||||
const TAB_ID = "host-manager";
|
||||
|
||||
/** Fetches a credential with its secrets marked as stored, for the editor. */
|
||||
async function loadCredentialForEdit(cred: Credential): Promise<Credential> {
|
||||
try {
|
||||
const full = (await getCredentialDetails(Number(cred.id))) as {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
password?: string;
|
||||
certPublicKey?: string;
|
||||
};
|
||||
return {
|
||||
...cred,
|
||||
value: full.hasKey ? "existing_key" : (full.password ?? ""),
|
||||
password: full.password ?? "",
|
||||
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
|
||||
publicKey: full.certPublicKey ?? cred.publicKey,
|
||||
};
|
||||
} catch {
|
||||
return cred;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts, credentials and host defaults, edited at full width: a list on the
|
||||
* left, the thing you picked on the right.
|
||||
*/
|
||||
export function ManageTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { has } = usePermissions();
|
||||
const { statuses } = useServerStatus();
|
||||
const presetMode = useUiPreference("hostEditor", "mode");
|
||||
|
||||
const [mode, setMode] = useState<ManageMode>("hosts");
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
// Starts from the preset and stays where the user puts it this session.
|
||||
const [advanced, setAdvanced] = useState(presetMode !== "simple");
|
||||
// The host a credential was opened from, so Back returns to it.
|
||||
const [returnHost, setReturnHost] = useState<Host | "new" | null>(null);
|
||||
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const credentialsRef = useRef(credentials);
|
||||
credentialsRef.current = credentials;
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
|
||||
const reloadHosts = useCallback(() => {
|
||||
return getSSHHosts()
|
||||
.then((raw) => setHosts(raw.map(sshHostToHost)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const reloadCredentials = useCallback(() => {
|
||||
return getCredentials()
|
||||
.then((res) => setCredentials(mapCredentials(res)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loaded = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
loaded.current = Promise.all([reloadHosts(), reloadCredentials()]);
|
||||
}, [reloadHosts, reloadCredentials]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.addEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.addEventListener("hosts:refresh", reloadHosts);
|
||||
window.addEventListener("termix:credentials-changed", reloadCredentials);
|
||||
return () => {
|
||||
window.removeEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.removeEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.removeEventListener("hosts:refresh", reloadHosts);
|
||||
window.removeEventListener(
|
||||
"termix:credentials-changed",
|
||||
reloadCredentials,
|
||||
);
|
||||
};
|
||||
}, [reloadHosts, reloadCredentials]);
|
||||
|
||||
/** Asks before throwing away unsaved changes. True means go ahead. */
|
||||
const confirmDiscard = useCallback(async () => {
|
||||
if (!dirtyRef.current) return true;
|
||||
return confirm({
|
||||
title: t("common.unsavedChanges"),
|
||||
description: t("hosts.unsavedChangesDescription"),
|
||||
confirmLabel: t("hosts.discardChanges"),
|
||||
cancelLabel: t("hosts.keepEditing"),
|
||||
});
|
||||
}, [confirm, t]);
|
||||
|
||||
useEffect(
|
||||
() => registerTabCloseGuard(TAB_ID, confirmDiscard),
|
||||
[confirmDiscard],
|
||||
);
|
||||
|
||||
const openHost = useCallback((host: Host | null, draft?: HostDraft) => {
|
||||
setMode("hosts");
|
||||
setReturnHost(null);
|
||||
setDirty(false);
|
||||
setEditing({
|
||||
kind: "host",
|
||||
host,
|
||||
draft,
|
||||
key: Date.now(),
|
||||
protocols: hostProtocolFlags(host),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCredential = useCallback(async (credential: Credential | null) => {
|
||||
setMode("credentials");
|
||||
setDirty(false);
|
||||
const full = credential ? await loadCredentialForEdit(credential) : null;
|
||||
setEditing({ kind: "credential", credential: full, key: Date.now() });
|
||||
}, []);
|
||||
|
||||
const openDefaults = useCallback(
|
||||
async (row: Pick<DefaultsRow, "level" | "folderName">) => {
|
||||
setMode("defaults");
|
||||
setReturnHost(null);
|
||||
setDirty(false);
|
||||
const rowKey =
|
||||
row.level === "folder" ? `folder:${row.folderName}` : row.level;
|
||||
if (row.level === "folder" && row.folderName) {
|
||||
try {
|
||||
const folderId = await getFolderDefaultsId(row.folderName);
|
||||
setEditing({
|
||||
kind: "defaults",
|
||||
rowKey,
|
||||
target: { level: "folder", folderId, folderName: row.folderName },
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("hostDefaults.loadFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setEditing({ kind: "defaults", rowKey, target: { level: row.level } });
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const apply = useCallback(
|
||||
async (request: ManageRequest | null) => {
|
||||
if (!request) return;
|
||||
if (!(await confirmDiscard())) return;
|
||||
await loaded.current;
|
||||
if (request.kind === "host") {
|
||||
const host = request.hostId
|
||||
? (hostsRef.current.find((h) => h.id === request.hostId) ?? null)
|
||||
: null;
|
||||
if (request.hostId && !host) return;
|
||||
openHost(host, request.draft);
|
||||
} else if (request.kind === "credential") {
|
||||
const credential = request.credentialId
|
||||
? (credentialsRef.current.find(
|
||||
(c) => c.id === request.credentialId,
|
||||
) ?? null)
|
||||
: null;
|
||||
void openCredential(credential);
|
||||
} else if (request.kind === "defaults") {
|
||||
void openDefaults(request);
|
||||
} else {
|
||||
setDirty(false);
|
||||
setEditing(null);
|
||||
setMode(request.mode);
|
||||
}
|
||||
},
|
||||
[confirmDiscard, openHost, openCredential, openDefaults],
|
||||
);
|
||||
|
||||
// The request that opened this tab was parked before it mounted.
|
||||
useEffect(() => {
|
||||
void apply(takePendingManageRequest());
|
||||
const onRequest = () => void apply(takePendingManageRequest());
|
||||
window.addEventListener(MANAGE_REQUEST_EVENT, onRequest);
|
||||
return () => window.removeEventListener(MANAGE_REQUEST_EVENT, onRequest);
|
||||
}, [apply]);
|
||||
|
||||
const guarded =
|
||||
<A extends unknown[]>(fn: (...args: A) => void) =>
|
||||
async (...args: A) => {
|
||||
if (await confirmDiscard()) fn(...args);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setDirty(false);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const backFromCredential = () => {
|
||||
if (returnHost) {
|
||||
const host = returnHost;
|
||||
setReturnHost(null);
|
||||
openHost(host === "new" ? null : host);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const editCredentialFromHost = async (credentialId: string) => {
|
||||
const cred = credentials.find((c) => String(c.id) === String(credentialId));
|
||||
if (!cred || editing?.kind !== "host") return;
|
||||
if (!(await confirmDiscard())) return;
|
||||
setReturnHost(editing.host ?? "new");
|
||||
await openCredential(cred);
|
||||
};
|
||||
|
||||
const deleteCurrent = async () => {
|
||||
if (editing?.kind === "host" && editing.host) {
|
||||
const host = editing.host;
|
||||
const ok = await confirm({
|
||||
title: t("hosts.deleteHostConfirm", { name: host.name || host.ip }),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteSSHHost(Number(host.id));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.deletedCount", { count: 1 }));
|
||||
close();
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToDeleteCount", { count: 1 }));
|
||||
}
|
||||
} else if (editing?.kind === "credential" && editing.credential) {
|
||||
const cred = editing.credential;
|
||||
const ok = await confirm({
|
||||
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteCredential(Number(cred.id));
|
||||
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
toast.success(t("credentials.deletedCredential", { name: cred.name }));
|
||||
close();
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isOnline = useCallback(
|
||||
(host: Host) => statuses.get(Number(host.id))?.status === "online",
|
||||
[statuses],
|
||||
);
|
||||
|
||||
const existingFolders = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(
|
||||
credentials.map((c) => c.folder).filter((f): f is string => !!f),
|
||||
),
|
||||
].sort(),
|
||||
[credentials],
|
||||
);
|
||||
|
||||
const selectedKey = !editing
|
||||
? null
|
||||
: editing.kind === "host"
|
||||
? editing.host
|
||||
? `h:${editing.host.id}`
|
||||
: null
|
||||
: editing.kind === "credential"
|
||||
? editing.credential
|
||||
? `c:${editing.credential.id}`
|
||||
: null
|
||||
: editing.rowKey;
|
||||
|
||||
const status = !editing
|
||||
? mode === "hosts"
|
||||
? t("manage.hostCount", { count: hosts.length })
|
||||
: mode === "credentials"
|
||||
? t("manage.credentialCount", { count: credentials.length })
|
||||
: t("manage.defaults")
|
||||
: editing.kind === "host"
|
||||
? editing.host
|
||||
? editing.host.name || editing.host.ip
|
||||
: t("manage.newHost")
|
||||
: editing.kind === "credential"
|
||||
? editing.credential
|
||||
? editing.credential.name
|
||||
: t("manage.newCredential")
|
||||
: hostDefaultsTitle(editing.target, t);
|
||||
|
||||
const canDelete =
|
||||
(editing?.kind === "host" &&
|
||||
!!editing.host &&
|
||||
canDeleteHost(editing.host)) ||
|
||||
(editing?.kind === "credential" && !!editing.credential);
|
||||
|
||||
const renderEditor = () => {
|
||||
if (!editing) return null;
|
||||
if (editing.kind === "defaults") {
|
||||
return (
|
||||
<HostDefaultsEditorView
|
||||
key={editing.rowKey}
|
||||
target={editing.target}
|
||||
onClose={guarded(close)}
|
||||
onDirtyChange={setDirty}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (editing.kind === "credential") {
|
||||
return (
|
||||
<CredentialEditorView
|
||||
key={`c:${editing.credential?.id ?? "new"}:${editing.key}`}
|
||||
credential={editing.credential}
|
||||
existingFolders={existingFolders}
|
||||
saveAsNewHost={returnHost ?? undefined}
|
||||
onBack={backFromCredential}
|
||||
onSave={(saved, options) => {
|
||||
const id = String((saved as { id: number | string }).id);
|
||||
void reloadCredentials();
|
||||
if (options?.assignToHost && returnHost) {
|
||||
const host = returnHost;
|
||||
setReturnHost(null);
|
||||
setDirty(false);
|
||||
setMode("hosts");
|
||||
const next =
|
||||
host === "new" ? null : { ...host, credentialId: id };
|
||||
setEditing({
|
||||
kind: "host",
|
||||
host: next,
|
||||
key: Date.now(),
|
||||
protocols: hostProtocolFlags(next),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (returnHost) {
|
||||
backFromCredential();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HostEditor
|
||||
key={`h:${editing.host?.id ?? "new"}:${editing.key}`}
|
||||
host={editing.host}
|
||||
draft={editing.host ? undefined : editing.draft}
|
||||
advanced={advanced}
|
||||
onBack={guarded(close)}
|
||||
onSave={(saved) => {
|
||||
const updated = sshHostToHost(saved as unknown as SSHHostWithStatus);
|
||||
setHosts((prev) => {
|
||||
const idx = prev.findIndex((h) => h.id === updated.id);
|
||||
if (idx < 0) return [...prev, updated];
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
close();
|
||||
}}
|
||||
protocols={editing.protocols}
|
||||
onProtocolChange={(p) =>
|
||||
setEditing((current) =>
|
||||
current?.kind === "host"
|
||||
? { ...current, protocols: { ...current.protocols, ...p } }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onDirtyChange={setDirty}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
onEditCredential={(id) => void editCredentialFromHost(id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
icon={<LibraryBig className="size-4" />}
|
||||
title={t("nav.manage")}
|
||||
status={status}
|
||||
scroll={false}
|
||||
leading={
|
||||
editing ? (
|
||||
<span className="flex h-full md:hidden">
|
||||
<BackButton
|
||||
onClick={() =>
|
||||
void guarded(
|
||||
editing.kind === "credential" ? backFromCredential : close,
|
||||
)()
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{editing?.kind === "host" && (
|
||||
<label className="flex items-center gap-2 px-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("manage.advanced")}
|
||||
</span>
|
||||
<FakeSwitch checked={advanced} onChange={setAdvanced} />
|
||||
</label>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => void deleteCurrent()}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<ManageList
|
||||
editing={!!editing}
|
||||
mode={mode}
|
||||
onMode={(next) =>
|
||||
void guarded(() => {
|
||||
setMode(next);
|
||||
close();
|
||||
})()
|
||||
}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
canEditInstanceDefaults={has("admin.settings.manage")}
|
||||
isOnline={isOnline}
|
||||
selectedKey={selectedKey}
|
||||
onPickHost={(host) => void guarded(() => openHost(host))()}
|
||||
onPickCredential={(cred) =>
|
||||
void guarded(() => void openCredential(cred))()
|
||||
}
|
||||
onPickDefaults={(row) => void guarded(() => void openDefaults(row))()}
|
||||
onAdd={() =>
|
||||
void guarded(() =>
|
||||
mode === "credentials"
|
||||
? void openCredential(null)
|
||||
: openHost(null),
|
||||
)()
|
||||
}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{renderEditor()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden min-h-0 flex-1 items-center justify-center md:flex">
|
||||
<EmptyState
|
||||
icon={mode === "credentials" ? KeyRound : Server}
|
||||
title={
|
||||
mode === "hosts"
|
||||
? t("manage.pickHost")
|
||||
: mode === "credentials"
|
||||
? t("manage.pickCredential")
|
||||
: t("manage.pickDefaults")
|
||||
}
|
||||
hint={t("manage.pickHint")}
|
||||
action={
|
||||
mode !== "defaults" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
mode === "hosts"
|
||||
? openHost(null)
|
||||
: void openCredential(null)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
{mode === "hosts"
|
||||
? t("hosts.addHost")
|
||||
: t("manage.addCredential")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { HostDefaultsLevel } from "@/types/host-defaults";
|
||||
|
||||
export interface DefaultsRow {
|
||||
key: string;
|
||||
level: HostDefaultsLevel;
|
||||
folderName?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The levels of host defaults a user can open: instance, their own, folders. */
|
||||
export function defaultsRows(
|
||||
hosts: Host[],
|
||||
canEditInstance: boolean,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): DefaultsRow[] {
|
||||
const rows: DefaultsRow[] = [];
|
||||
if (canEditInstance)
|
||||
rows.push({
|
||||
key: "admin",
|
||||
level: "admin",
|
||||
label: t("hostDefaults.titleAdmin"),
|
||||
});
|
||||
rows.push({ key: "user", level: "user", label: t("hostDefaults.titleUser") });
|
||||
const folders = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (!host.folder) continue;
|
||||
let path = "";
|
||||
for (const part of host.folder.split(" / ")) {
|
||||
path = path ? `${path} / ${part}` : part;
|
||||
folders.add(path);
|
||||
}
|
||||
}
|
||||
for (const folder of [...folders].sort((a, b) => a.localeCompare(b))) {
|
||||
rows.push({
|
||||
key: `folder:${folder}`,
|
||||
level: "folder",
|
||||
folderName: folder,
|
||||
label: folder,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
|
||||
|
||||
/** Field keys mapped to an i18n key saying what is wrong. */
|
||||
export type HostFormErrors = Partial<Record<"ip" | "sshPort", string>>;
|
||||
|
||||
function validPort(port: unknown): boolean {
|
||||
const n = Number(port);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
}
|
||||
|
||||
/** What has to be fixed before a host can be saved. */
|
||||
export function validateHostForm(
|
||||
form: Pick<HostEditorForm, "ip" | "sshPort">,
|
||||
protocols: Pick<HostProtocols, "enableSsh">,
|
||||
): HostFormErrors {
|
||||
const errors: HostFormErrors = {};
|
||||
if (!form.ip.trim()) errors.ip = "manage.errorAddressRequired";
|
||||
if (protocols.enableSsh && !validPort(form.sshPort))
|
||||
errors.sshPort = "manage.errorPortRange";
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import type { HostDefaultsLevel } from "@/types/host-defaults";
|
||||
|
||||
export type ManageMode = "hosts" | "credentials" | "defaults";
|
||||
|
||||
/** What to open in the Manage tab. */
|
||||
export type ManageRequest =
|
||||
| { kind: "host"; hostId: string | null; draft?: HostDraft }
|
||||
| { kind: "credential"; credentialId: string | null }
|
||||
| { kind: "defaults"; level: HostDefaultsLevel; folderName?: string }
|
||||
| { kind: "browse"; mode: ManageMode };
|
||||
|
||||
export const MANAGE_REQUEST_EVENT = "manage:request";
|
||||
|
||||
let pending: ManageRequest | null = null;
|
||||
|
||||
/**
|
||||
* Parks a request for the Manage tab and pokes it. The shell opens the tab on
|
||||
* the same event, and the tab claims the parked request once it mounts.
|
||||
*/
|
||||
export function requestManage(request: ManageRequest): void {
|
||||
pending = request;
|
||||
window.dispatchEvent(new Event(MANAGE_REQUEST_EVENT));
|
||||
}
|
||||
|
||||
/** Takes the parked request, leaving none behind. */
|
||||
export function takePendingManageRequest(): ManageRequest | null {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window events older callers fire (the dashboard, folder menus, the
|
||||
* palette), turned into a request. Null for anything else.
|
||||
*/
|
||||
export function requestFromLegacyEvent(
|
||||
name: string,
|
||||
detail?: unknown,
|
||||
): ManageRequest | null {
|
||||
switch (name) {
|
||||
case "host-manager:add-host":
|
||||
return {
|
||||
kind: "host",
|
||||
hostId: null,
|
||||
draft: (detail as HostDraft | undefined) ?? undefined,
|
||||
};
|
||||
case "host-manager:edit-host":
|
||||
return detail ? { kind: "host", hostId: String(detail) } : null;
|
||||
case "host-manager:add-credential":
|
||||
return { kind: "credential", credentialId: null };
|
||||
case "host-manager:show-credentials":
|
||||
return { kind: "browse", mode: "credentials" };
|
||||
case "host-manager:edit-defaults":
|
||||
case "termix:open-host-defaults": {
|
||||
const target = detail as
|
||||
{ level?: HostDefaultsLevel; folderName?: string } | undefined;
|
||||
if (!target?.level) return { kind: "browse", mode: "defaults" };
|
||||
return {
|
||||
kind: "defaults",
|
||||
level: target.level,
|
||||
folderName: target.folderName,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetManageRequests(): void {
|
||||
pending = null;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* The Manage list as a tree: folders from the " / " path, sub-hosts under
|
||||
* their parent host.
|
||||
*/
|
||||
|
||||
export type ManageItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** IP for a host, username for a credential. */
|
||||
note: string;
|
||||
/** Extra search text, e.g. tags. */
|
||||
search: string;
|
||||
online?: boolean;
|
||||
children: ManageItem[];
|
||||
};
|
||||
|
||||
export type ManageFolder = {
|
||||
/** Full " / " path, unique across the tree. */
|
||||
path: string;
|
||||
/** Last segment, what the row shows. */
|
||||
name: string;
|
||||
folders: ManageFolder[];
|
||||
items: ManageItem[];
|
||||
};
|
||||
|
||||
export type ManageRow =
|
||||
| {
|
||||
kind: "folder";
|
||||
depth: number;
|
||||
folder: ManageFolder;
|
||||
count: number;
|
||||
online: number;
|
||||
}
|
||||
| { kind: "item"; depth: number; item: ManageItem; folderPath: string };
|
||||
|
||||
function emptyFolder(path: string, name: string): ManageFolder {
|
||||
return { path, name, folders: [], items: [] };
|
||||
}
|
||||
|
||||
/** Walks to the folder at `path`, creating each missing segment on the way. */
|
||||
function folderAt(
|
||||
root: ManageFolder,
|
||||
index: Map<string, ManageFolder>,
|
||||
path: string,
|
||||
) {
|
||||
if (!path) return root;
|
||||
const existing = index.get(path);
|
||||
if (existing) return existing;
|
||||
|
||||
let current = root;
|
||||
let accumulated = "";
|
||||
for (const part of path.split(" / ")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
accumulated = accumulated ? `${accumulated} / ${trimmed}` : trimmed;
|
||||
let next = index.get(accumulated);
|
||||
if (!next) {
|
||||
next = emptyFolder(accumulated, trimmed);
|
||||
index.set(accumulated, next);
|
||||
current.folders.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function buildHostManageTree(
|
||||
hosts: Host[],
|
||||
isOnline: (host: Host) => boolean = (host) => host.online,
|
||||
): ManageFolder {
|
||||
const root = emptyFolder("", "");
|
||||
const index = new Map<string, ManageFolder>();
|
||||
|
||||
const toItem = (host: Host): ManageItem => ({
|
||||
id: host.id,
|
||||
name: host.name || host.ip,
|
||||
note: host.ip,
|
||||
search: `${host.username} ${(host.tags ?? []).join(" ")}`,
|
||||
online: isOnline(host),
|
||||
children: [],
|
||||
});
|
||||
|
||||
const items = new Map(hosts.map((h) => [h.id, toItem(h)]));
|
||||
|
||||
// A parent that is gone or would close a loop drops the host back to its
|
||||
// folder, the same as buildHostTree.
|
||||
const byId = new Map(hosts.map((h) => [h.id, h]));
|
||||
const isDescendant = (candidate: string, ancestor: string) => {
|
||||
const seen = new Set<string>();
|
||||
let current: string | null | undefined = candidate;
|
||||
while (current) {
|
||||
if (current === ancestor) return true;
|
||||
if (seen.has(current)) return false;
|
||||
seen.add(current);
|
||||
current = byId.get(current)?.parentHostId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const nested = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (!host.parentHostId) continue;
|
||||
const parent = items.get(host.parentHostId);
|
||||
if (!parent) continue;
|
||||
if (isDescendant(host.parentHostId, host.id)) continue;
|
||||
parent.children.push(items.get(host.id)!);
|
||||
nested.add(host.id);
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
if (nested.has(host.id)) continue;
|
||||
folderAt(root, index, host.folder ?? "").items.push(items.get(host.id)!);
|
||||
}
|
||||
return sortFolder(root);
|
||||
}
|
||||
|
||||
export function buildCredentialManageTree(
|
||||
credentials: Credential[],
|
||||
): ManageFolder {
|
||||
const root = emptyFolder("", "");
|
||||
const index = new Map<string, ManageFolder>();
|
||||
|
||||
for (const cred of credentials) {
|
||||
folderAt(root, index, cred.folder ?? "").items.push({
|
||||
id: cred.id,
|
||||
name: cred.name,
|
||||
note: cred.username,
|
||||
search: `${cred.type} ${(cred.tags ?? []).join(" ")}`,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
return sortFolder(root);
|
||||
}
|
||||
|
||||
function sortFolder(folder: ManageFolder): ManageFolder {
|
||||
folder.folders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
folder.items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
folder.folders.forEach(sortFolder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function itemMatches(item: ManageItem, needle: string): boolean {
|
||||
if (`${item.name} ${item.note} ${item.search}`.toLowerCase().includes(needle))
|
||||
return true;
|
||||
return item.children.some((child) => itemMatches(child, needle));
|
||||
}
|
||||
|
||||
/** Total items in a folder and its subfolders, plus how many are online. */
|
||||
export function folderCounts(folder: ManageFolder): {
|
||||
count: number;
|
||||
online: number;
|
||||
} {
|
||||
let count = 0;
|
||||
let online = 0;
|
||||
const walk = (item: ManageItem) => {
|
||||
count++;
|
||||
if (item.online) online++;
|
||||
item.children.forEach(walk);
|
||||
};
|
||||
folder.items.forEach(walk);
|
||||
for (const child of folder.folders) {
|
||||
const sub = folderCounts(child);
|
||||
count += sub.count;
|
||||
online += sub.online;
|
||||
}
|
||||
return { count, online };
|
||||
}
|
||||
|
||||
/** The rows actually drawn. While searching, folders holding a match open on their own. */
|
||||
export function flattenTree(
|
||||
folder: ManageFolder,
|
||||
open: Set<string>,
|
||||
needle: string,
|
||||
depth = 0,
|
||||
rows: ManageRow[] = [],
|
||||
): ManageRow[] {
|
||||
for (const child of folder.folders) {
|
||||
const counts = folderCounts(child);
|
||||
const matching = filterFolder(child, needle);
|
||||
if (!matching) continue;
|
||||
rows.push({
|
||||
kind: "folder",
|
||||
depth,
|
||||
folder: child,
|
||||
count: counts.count,
|
||||
online: counts.online,
|
||||
});
|
||||
if (needle || open.has(child.path))
|
||||
flattenTree(matching, open, needle, depth + 1, rows);
|
||||
}
|
||||
|
||||
for (const item of folder.items) {
|
||||
if (needle && !itemMatches(item, needle)) continue;
|
||||
pushItem(item, folder.path, depth, open, needle, rows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function pushItem(
|
||||
item: ManageItem,
|
||||
folderPath: string,
|
||||
depth: number,
|
||||
open: Set<string>,
|
||||
needle: string,
|
||||
rows: ManageRow[],
|
||||
) {
|
||||
rows.push({ kind: "item", depth, item, folderPath });
|
||||
if (item.children.length === 0) return;
|
||||
if (!needle && !open.has(item.id)) return;
|
||||
for (const child of item.children) {
|
||||
if (needle && !itemMatches(child, needle)) continue;
|
||||
pushItem(child, folderPath, depth + 1, open, needle, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops folders with nothing matching, so search never leaves empty headers. */
|
||||
function filterFolder(
|
||||
folder: ManageFolder,
|
||||
needle: string,
|
||||
): ManageFolder | null {
|
||||
if (!needle) return folder;
|
||||
const folders = folder.folders
|
||||
.map((child) => filterFolder(child, needle))
|
||||
.filter((child): child is ManageFolder => child !== null);
|
||||
const items = folder.items.filter((item) => itemMatches(item, needle));
|
||||
if (folders.length === 0 && items.length === 0) return null;
|
||||
return { ...folder, folders, items };
|
||||
}
|
||||
|
||||
/** Every folder path in the tree, for expand-all and the default open set. */
|
||||
export function allFolderPaths(
|
||||
folder: ManageFolder,
|
||||
out: string[] = [],
|
||||
): string[] {
|
||||
for (const child of folder.folders) {
|
||||
out.push(child.path);
|
||||
allFolderPaths(child, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every parent host id, so sub-hosts can start expanded too. */
|
||||
export function allParentIds(
|
||||
folder: ManageFolder,
|
||||
out: string[] = [],
|
||||
): string[] {
|
||||
const walk = (item: ManageItem) => {
|
||||
if (item.children.length > 0) out.push(item.id);
|
||||
item.children.forEach(walk);
|
||||
};
|
||||
folder.items.forEach(walk);
|
||||
folder.folders.forEach((child) => allParentIds(child, out));
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user