mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
+141
-137
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user