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

+37
View File
@@ -0,0 +1,37 @@
import type { ComponentType } from "react";
import { FolderOpen, Network } from "lucide-react";
import { tabTypeForActivity } from "@/shell/tab-registry";
export interface ActivityTarget {
icon: ComponentType<{ className?: string }> | undefined;
/** Tab type the entry reopens. */
tab: string;
labelKey: string | undefined;
}
/** Recent-activity types core itself records. */
const CORE_ACTIVITY: Record<string, ActivityTarget> = {
file_manager: {
icon: FolderOpen,
tab: "files",
labelKey: "dashboard.activityFileManager",
},
tunnel: {
icon: Network,
tab: "tunnel",
labelKey: "dashboard.activityTunnel",
},
};
/**
* What a recent-activity entry opens and how it is labelled. Plugin tabs
* claim their activity types through the registry, so an entry whose plugin
* is gone simply has no target.
*/
export function activityTarget(type: string): ActivityTarget | undefined {
const core = CORE_ACTIVITY[type];
if (core) return core;
const def = tabTypeForActivity(type);
if (!def) return undefined;
return { icon: def.icon, tab: def.id, labelKey: def.titleKey };
}
+26 -30
View File
@@ -1,40 +1,36 @@
import type { Host, TabType } from "@/types/ui-types";
import {
defaultConnectAction,
hostActionsFor,
listHostActions,
} from "@/sidebar/host-contributions";
type ConnectionTabType = "terminal" | "rdp" | "vnc" | "telnet";
function isConnectionTabType(type: TabType): type is ConnectionTabType {
return (
type === "terminal" || type === "rdp" || type === "vnc" || type === "telnet"
);
}
function isConnectionEnabled(host: Host, type: ConnectionTabType): boolean {
switch (type) {
case "terminal":
return host.enableSsh;
case "rdp":
return host.enableRdp;
case "vnc":
return host.enableVnc;
case "telnet":
return host.enableTelnet;
}
}
export function getDefaultConnectionTab(host: Host): ConnectionTabType {
if (host.enableSsh) return "terminal";
if (host.enableRdp) return "rdp";
if (host.enableVnc) return "vnc";
if (host.enableTelnet) return "telnet";
return "terminal";
/**
* Which tab a click on a host opens. The ways to connect (SSH terminal, RDP,
* VNC, Telnet) are host actions that plugins register, so the answer is the
* highest-priority connect action the host allows.
*/
export function getDefaultConnectionTab(host: Host): TabType {
return defaultConnectAction(listHostActions(), host)?.tabType ?? "terminal";
}
/**
* The preferred tab if the host allows it. A connect tab type the host has
* switched off falls back to the host's default; any other type is taken as
* asked.
*/
export function resolveHostTabType(
host: Host,
preferredType?: TabType,
): TabType {
if (!preferredType) return getDefaultConnectionTab(host);
if (!isConnectionTabType(preferredType)) return preferredType;
if (isConnectionEnabled(host, preferredType)) return preferredType;
return getDefaultConnectionTab(host);
const all = listHostActions();
const isConnectType = all.some(
(action) => action.kind === "connect" && action.tabType === preferredType,
);
if (!isConnectType) return preferredType;
const allowed = hostActionsFor(all, host, "connect").some(
(action) => action.tabType === preferredType,
);
return allowed ? preferredType : getDefaultConnectionTab(host);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
/** The shell's Host in the SSHHost shape the connection surfaces take. */
export function hostToSSHHost(h: Host): SSHHost {
return {
id: parseInt(h.id, 10),
name: h.name,
ip: h.ip,
port: h.port,
username: h.username,
folder: h.folder ?? "",
tags: h.tags ?? [],
pin: h.pin ?? false,
authType: h.authType,
password: h.password,
hasPassword: h.hasPassword,
key: h.key,
keyPassword: h.keyPassword,
hasKey: h.hasKey,
hasKeyPassword: h.hasKeyPassword,
keyType: h.keyType,
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
terminalConfig: h.terminalConfig,
hasSudoPassword: h.hasSudoPassword,
enableTerminal: h.enableTerminal ?? false,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableAiAssistant: h.enableAiAssistant ?? false,
dockerConfig: h.dockerConfig ?? null,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: h.webUiConfig ?? { endpoints: [] },
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
showDockerInSidebar: true,
showServerStatsInSidebar: true,
defaultPath: h.defaultPath ?? "",
tunnelConnections: [],
connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null,
isShared: h.isShared ?? false,
// Carries the host's identity to a delegated backend. Without it the
// remote side resolves our local row id against its own table.
syncId: h.syncId ?? null,
createdAt: "",
updatedAt: "",
} as unknown as SSHHost;
}
+15 -7
View File
@@ -2,12 +2,20 @@
* Where a plugin's i18n keys resolve from.
*
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
* it never has to know where the strings live. Today they live in core
* en.json under `plugins.<pluginId>.`, because the frontend does not load
* plugin locale bundles yet. When it does, that subtree moves into
* plugins/<id>/locales/en.json as the plugin's own namespace and only this
* function changes; no manifest is touched.
* it never has to know where the strings live. They live in the plugin's own
* locales/en.json, loaded into an i18next namespace named after the plugin,
* so the qualified key is "<pluginId>:<key>". A key that already names a
* namespace is left alone. Missing keys fall back to core strings.
*/
export function pluginKey(pluginId: string, key: string): string {
return `plugins.${pluginId}.${key}`;
export function pluginKey(pluginId: string, key: string): string;
export function pluginKey(
pluginId: string,
key: string | undefined,
): string | undefined;
export function pluginKey(
pluginId: string,
key: string | undefined,
): string | undefined {
if (!key) return key;
return key.includes(":") ? key : `${pluginId}:${key}`;
}
+81
View File
@@ -0,0 +1,81 @@
import { useSyncExternalStore } from "react";
/**
* A keyed, reactive list of contributions.
*
* Plugins register and unregister at runtime, so anything the shell renders
* from a registry has to re-render when it changes. The snapshot array is
* cached until the next change because useSyncExternalStore compares by
* identity and loops forever on a fresh array.
*/
export interface Registry<T extends { id: string }> {
/** Adds or replaces by id. The disposer only removes this exact entry. */
register: (item: T) => () => void;
unregister: (id: string) => void;
get: (id: string) => T | undefined;
list: () => T[];
subscribe: (listener: () => void) => () => void;
/** React hook over list(). */
useList: () => T[];
reset: () => void;
}
export function createRegistry<T extends { id: string }>(
sort?: (a: T, b: T) => number,
): Registry<T> {
const items = new Map<string, T>();
const listeners = new Set<() => void>();
let snapshot: T[] | null = null;
const emit = () => {
snapshot = null;
for (const listener of listeners) listener();
};
const list = () => {
if (!snapshot) {
snapshot = [...items.values()];
if (sort) snapshot.sort(sort);
}
return snapshot;
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
return {
register(item) {
items.set(item.id, item);
emit();
return () => {
if (items.get(item.id) === item) {
items.delete(item.id);
emit();
}
};
},
unregister(id) {
if (items.delete(id)) emit();
},
get: (id) => items.get(id),
list,
subscribe,
useList: () => useSyncExternalStore(subscribe, list, list),
reset() {
items.clear();
emit();
},
};
}
/** Orders by `order`, then id, so plugin contributions sort stably. */
export function byOrderThenId<T extends { id: string; order?: number }>(
a: T,
b: T,
): number {
return (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id);
}
-6
View File
@@ -36,12 +36,6 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
description: "Feed of recent connection events",
defaultEnabled: true,
},
{
id: "network_graph",
label: "Network Graph",
description: "Visual map of host network topology",
defaultEnabled: false,
},
{
id: "service_links",
label: "Service Links",