feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+141 -137
View File
@@ -1,33 +1,19 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import {
Activity,
Box,
Folder,
KeyRound,
Monitor,
MousePointerClick,
Network,
Server,
Settings,
SquareTerminal,
Terminal,
Globe,
} from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
export type HostTabId =
| "general"
| "ssh"
| "terminal"
| "tunnels"
| "docker"
| "web-ui"
| "proxmox"
| "files"
| "host-metrics"
| "rdp"
| "vnc"
| "telnet";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
export type CoreHostTabId =
"general" | "ssh" | "terminal" | "tunnels" | "files";
export type HostTabId = CoreHostTabId | (string & {});
export type CredentialTabId = "general" | "auth";
type HostTab = {
@@ -41,139 +27,157 @@ type CredentialTab = {
icon: ReactNode;
};
export const SSH_GROUP_TABS = new Set<HostTabId>([
"ssh",
"terminal",
"tunnels",
"docker",
"web-ui",
"proxmox",
"files",
"host-metrics",
]);
/**
* A plugin-contributed host editor tab. `component` receives the same
* {form, setField} pair every built-in tab body gets (see HostDockerTab in
* HostEditorFeatureTabs.tsx for the shape a plugin's own tab component
* should match).
*/
export type HostEditorTabComponent = ComponentType<{
form: unknown;
export interface HostEditorSectionRenderProps {
// The editor form is a plain object. Sections read the fields they own.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
}>;
interface RegisteredHostEditorTab {
id: string;
labelKey: string;
icon: ReactNode;
component: HostEditorTabComponent;
/** Applies several fields at once, against the latest form. */
updateForm: (
patch: (form: Record<string, unknown>) => Record<string, unknown>,
) => void;
host?: unknown;
credentials?: unknown[];
snippets?: unknown[];
protocols: Record<string, boolean>;
}
/**
* Runtime registry for host editor tabs that don't exist in the built-in
* HostTabId union. A plugin's frontend registers into this from its own
* register() (see plugins/*\/frontend/index.mjs), the same pattern
* rail-items.ts and tabUtils.tsx already use for their seams.
* A host editor tab contributed at runtime. "top" tabs sit in the main strip
* beside General and SSH; "ssh" tabs sit in the SSH group's second strip.
*/
const registeredHostEditorTabs = new Map<string, RegisteredHostEditorTab>();
export function registerHostEditorTab(def: RegisteredHostEditorTab): void {
registeredHostEditorTabs.set(def.id, def);
export interface HostEditorSectionDef {
id: string;
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */
order?: number;
/** Whether to offer the tab for these protocols. Defaults to always. */
visible?: (protocols: Record<string, boolean>) => boolean;
component: ComponentType<HostEditorSectionRenderProps>;
}
export function unregisterHostEditorTab(id: string): void {
registeredHostEditorTabs.delete(id);
const sections = createRegistry<HostEditorSectionDef>(byOrderThenId);
export const registerHostEditorSection = sections.register;
export const unregisterHostEditorSection = sections.unregister;
export const getHostEditorSection = sections.get;
export const hostEditorSectionList = sections.list;
export const useHostEditorSections = sections.useList;
export const resetHostEditorSections = sections.reset;
const CORE_SSH_GROUP = new Set<string>(["ssh", "terminal", "tunnels", "files"]);
/** Whether a tab lives in the SSH group's second strip. */
export function isSshGroupTab(id: string): boolean {
return CORE_SSH_GROUP.has(id) || sections.get(id)?.group === "ssh";
}
export function getRegisteredHostEditorTab(
id: string,
): RegisteredHostEditorTab | undefined {
return registeredHostEditorTabs.get(id);
function sectionVisible(
section: HostEditorSectionDef,
protocols: Record<string, boolean>,
): boolean {
if (!section.visible) return true;
try {
return section.visible(protocols);
} catch {
return false;
}
}
export function registeredHostEditorTabList(): RegisteredHostEditorTab[] {
return [...registeredHostEditorTabs.values()];
function mergeByOrder(
core: (HostTab & { order: number })[],
group: "top" | "ssh",
t: (key: string) => string,
protocols: Record<string, boolean> | undefined,
): HostTab[] {
const registered = sections
.list()
.filter(
(section) =>
section.group === group &&
(!protocols || sectionVisible(section, protocols)),
)
.map((section) => {
const Icon = section.icon;
return {
id: section.id,
label: t(section.labelKey),
icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000,
};
});
return [...core, ...registered]
.sort((a, b) => a.order - b.order)
.map(({ order: _order, ...tab }) => tab);
}
export function makeHostTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
},
{
id: "rdp",
label: t("hosts.tabRdp"),
icon: <Monitor className="size-3" />,
},
{
id: "vnc",
label: t("hosts.tabVnc"),
icon: <MousePointerClick className="size-3" />,
},
{
id: "telnet",
label: t("hosts.tabTelnet"),
icon: <Terminal className="size-3" />,
},
];
/**
* The main strip. With `protocols`, registered tabs are filtered by their
* `visible`; without, every registered top tab is listed.
*/
export function makeHostTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
): HostTab[] {
return mergeByOrder(
[
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
order: 0,
},
{
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
order: 10,
},
],
"top",
t,
protocols,
);
}
export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "ssh",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "terminal",
label: t("hosts.tabTerminal"),
icon: <SquareTerminal className="size-3" />,
},
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
},
{
id: "docker",
label: t("hosts.tabDocker"),
icon: <Box className="size-3" />,
},
{
id: "web-ui",
label: t("hosts.tabWebUi"),
icon: <Globe className="size-3" />,
},
{
id: "proxmox",
label: t("hosts.tabProxmox"),
icon: <Server className="size-3" />,
},
{
id: "files",
label: t("hosts.tabFiles"),
icon: <Folder className="size-3" />,
},
{
id: "host-metrics",
label: t("hosts.tabHostMetrics"),
icon: <Activity className="size-3" />,
},
...registeredHostEditorTabList().map((tab) => ({
id: tab.id as HostTabId,
label: t(tab.labelKey),
icon: tab.icon,
})),
];
export function makeHostSshSubTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
): HostTab[] {
return mergeByOrder(
[
{
id: "ssh",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
order: 0,
},
{
id: "terminal",
label: t("hosts.tabTerminal"),
icon: <SquareTerminal className="size-3" />,
order: 10,
},
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
order: 20,
},
{
id: "files",
label: t("hosts.tabFiles"),
icon: <Folder className="size-3" />,
order: 60,
},
],
"ssh",
t,
protocols,
);
}
export function makeCredentialTabs(