mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -22,7 +22,10 @@ import {
|
||||
} from "@/components/select";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
|
||||
import { getSettingsComponent } from "./settings-components";
|
||||
import {
|
||||
getSettingsComponent,
|
||||
useSettingsComponents,
|
||||
} from "./settings-components";
|
||||
|
||||
export interface SettingsFieldProps {
|
||||
pluginId: string;
|
||||
@@ -46,6 +49,8 @@ export function SettingsFieldRow({
|
||||
disabled = false,
|
||||
}: SettingsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
// A custom component appears once its plugin's frontend has registered it.
|
||||
useSettingsComponents();
|
||||
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
|
||||
|
||||
// A custom field draws its own row: it has no single control to put in the
|
||||
|
||||
@@ -24,7 +24,6 @@ import { Separator } from "@/components/separator";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { PluginSettingsPage } from "./PluginSettingsPage";
|
||||
import { registerLegacySettingsComponents } from "./legacy-settings-components";
|
||||
|
||||
const UserProfilePanel = lazy(() =>
|
||||
import("@/sidebar/UserProfilePanel").then((m) => ({
|
||||
@@ -87,12 +86,6 @@ export function SettingsScreen({
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
|
||||
// Registered here rather than at module scope so a custom field's component
|
||||
// exists before any page that renders one. A7 moves this into the loader.
|
||||
useEffect(() => {
|
||||
registerLegacySettingsComponents();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* The tailnet status shown on the Tailscale settings page.
|
||||
*
|
||||
* Confirms the saved key actually works, which is the question anyone has just
|
||||
* after entering one. Connecting to a device stays in the Tailscale rail panel;
|
||||
* a settings page is for configuring, not for operating.
|
||||
*
|
||||
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
|
||||
* bundle register it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import type { SettingsComponentProps } from "../settings-components";
|
||||
import { getTailscaleDevices } from "../../../../plugins/tailscale/src/frontend/tailscale-api";
|
||||
|
||||
type Status =
|
||||
| { kind: "loading" }
|
||||
| { kind: "no-key" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ok"; count: number };
|
||||
|
||||
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<Status>({ kind: "loading" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!running) {
|
||||
setStatus({ kind: "no-key" });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: "loading" });
|
||||
try {
|
||||
const result = await getTailscaleDevices();
|
||||
if (!result.hasApiKey) {
|
||||
setStatus({ kind: "no-key" });
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
setStatus({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
|
||||
} catch {
|
||||
setStatus({ kind: "error" });
|
||||
}
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const label = (key: string) => t(pluginKey("tailscale", key));
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label("settings.devices.label")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{status.kind === "loading" && label("settings.devices.checking")}
|
||||
{status.kind === "no-key" && label("settings.devices.noKey")}
|
||||
{status.kind === "error" && label("settings.devices.error")}
|
||||
{status.kind === "ok" &&
|
||||
t(pluginKey("tailscale", "settings.devices.count"), {
|
||||
count: status.count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs shrink-0"
|
||||
disabled={!running || status.kind === "loading"}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{status.kind === "loading" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Registers the host editor's "Plugins" tab.
|
||||
*
|
||||
* Goes through the existing registerHostEditorTab seam rather than adding a
|
||||
* case to HostTabId, so the tab genuinely appears and disappears with the
|
||||
* plugins that fill it. Nothing is registered when no enabled plugin declares
|
||||
* Goes through the host editor section registry rather than a case in the
|
||||
* editor, so the tab genuinely appears and disappears with the plugins that
|
||||
* fill it. Nothing is registered when no enabled plugin declares
|
||||
* host settings, which is what stops an empty tab from showing up.
|
||||
*
|
||||
* Re-run whenever the plugin list changes: enabling a plugin from the settings
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
registerHostEditorTab,
|
||||
unregisterHostEditorTab,
|
||||
registerHostEditorSection,
|
||||
unregisterHostEditorSection,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
@@ -42,14 +42,17 @@ export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
|
||||
});
|
||||
|
||||
if (contributors.length === 0) {
|
||||
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
|
||||
return false;
|
||||
}
|
||||
|
||||
registerHostEditorTab({
|
||||
registerHostEditorSection({
|
||||
id: HOST_PLUGINS_TAB_ID,
|
||||
// In the main strip, so hosts without SSH reach it too.
|
||||
group: "top",
|
||||
order: 100,
|
||||
labelKey: "settings.pluginsGroupLabel",
|
||||
icon: <Puzzle className="size-3" />,
|
||||
icon: Puzzle,
|
||||
component: ({ form, setField }) => (
|
||||
<HostPluginSections
|
||||
plugins={contributors}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Core-side registration of plugin settings components.
|
||||
*
|
||||
* TEMPORARY. A `type: "custom"` settings field names a component the plugin's
|
||||
* own frontend bundle should register through `app.registerSettingsComponent`.
|
||||
* There is no frontend plugin loader yet, so core registers the handful that
|
||||
* exist, the same way pluginLoader.ts mirrors the first-party action
|
||||
* contributions.
|
||||
*
|
||||
* A7 deletes this file. Each plugin registers its own component from its
|
||||
* bundle, and nothing in core names a plugin id.
|
||||
*
|
||||
* Only register what is needed now. Everything else waits for its own step:
|
||||
* the AI providers list stays where it is until B18.
|
||||
*/
|
||||
|
||||
import { registerSettingsComponent } from "./settings-components.js";
|
||||
import { TailscaleDevicesStatus } from "./components/TailscaleDevicesStatus.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerLegacySettingsComponents(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
registerSettingsComponent("tailscale", "devices", TailscaleDevicesStatus);
|
||||
}
|
||||
@@ -5,14 +5,12 @@
|
||||
* manifest so a plugin cannot ship its own form styling. A few are not: a
|
||||
* device browser, a provider list. Those declare a component id here instead,
|
||||
* which keeps the escape hatch narrow and named rather than letting a plugin
|
||||
* hand core arbitrary markup.
|
||||
*
|
||||
* A7 lets a plugin's own frontend bundle register these through
|
||||
* `app.registerSettingsComponent`. Until then core registers them, in
|
||||
* legacy-settings-components.ts.
|
||||
* hand core arbitrary markup. A plugin registers them from its frontend
|
||||
* through `app.registerSettingsComponent`.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
export interface SettingsComponentProps {
|
||||
pluginId: string;
|
||||
@@ -27,7 +25,10 @@ export interface SettingsComponentProps {
|
||||
export type SettingsComponent = ComponentType<SettingsComponentProps>;
|
||||
|
||||
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
|
||||
const registry = new Map<string, SettingsComponent>();
|
||||
const registry = createRegistry<{
|
||||
id: string;
|
||||
component: SettingsComponent;
|
||||
}>();
|
||||
|
||||
function registryKey(pluginId: string, componentId: string): string {
|
||||
return `${pluginId}:${componentId}`;
|
||||
@@ -38,11 +39,10 @@ export function registerSettingsComponent(
|
||||
componentId: string,
|
||||
component: SettingsComponent,
|
||||
): () => void {
|
||||
const key = registryKey(pluginId, componentId);
|
||||
registry.set(key, component);
|
||||
return () => {
|
||||
if (registry.get(key) === component) registry.delete(key);
|
||||
};
|
||||
return registry.register({
|
||||
id: registryKey(pluginId, componentId),
|
||||
component,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettingsComponent(
|
||||
@@ -50,16 +50,24 @@ export function getSettingsComponent(
|
||||
componentId: string | undefined,
|
||||
): SettingsComponent | undefined {
|
||||
if (!componentId) return undefined;
|
||||
return registry.get(registryKey(pluginId, componentId));
|
||||
return registry.get(registryKey(pluginId, componentId))?.component;
|
||||
}
|
||||
|
||||
/** Registered "<pluginId>:<componentId>" keys. */
|
||||
export function listSettingsComponents(): string[] {
|
||||
return registry.list().map((entry) => entry.id);
|
||||
}
|
||||
|
||||
/** Re-renders when components are registered, e.g. after a plugin loads. */
|
||||
export const useSettingsComponents = registry.useList;
|
||||
|
||||
export function unregisterSettingsComponents(pluginId: string): void {
|
||||
for (const key of [...registry.keys()]) {
|
||||
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
|
||||
for (const entry of registry.list()) {
|
||||
if (entry.id.startsWith(`${pluginId}:`)) registry.unregister(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetSettingsComponents(): void {
|
||||
registry.clear();
|
||||
registry.reset();
|
||||
}
|
||||
Reference in new issue
Block a user