feat: port the demo redesign with Manage and Settings tabs

This commit is contained in:
LukeGus committed 2026-10-05 16:23:21 -05:00
1 parent f8151cb885
commit 35c954b79a
128 files changed
+13262 -8143

No files matched your search

+256
View File
@@ -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>
);
}
+422
View File
@@ -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>
);
}
+550
View File
@@ -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>
);
}
+43
View File
@@ -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;
}
+21
View File
@@ -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;
}
+73
View File
@@ -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;
}
+256
View File
@@ -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;
}