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
+106
-73
@@ -1,30 +1,27 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Braces,
|
||||
Clock,
|
||||
Fingerprint,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
LayoutTemplate,
|
||||
Network,
|
||||
Play,
|
||||
Plug,
|
||||
Radar,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
Usb,
|
||||
User,
|
||||
Workflow,
|
||||
Zap,
|
||||
Presentation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
/**
|
||||
* The one list of navigation destinations.
|
||||
@@ -42,7 +39,7 @@ export interface RailItemDef {
|
||||
icon: LucideIcon;
|
||||
/** i18n key; every label goes through t() so nothing is hardcoded English. */
|
||||
labelKey: string;
|
||||
/** Tab-opening entries (network_graph) rather than sidebar panels. */
|
||||
/** Tab-opening entries rather than sidebar panels. */
|
||||
kind?: "tab";
|
||||
/** Always-available destinations that users cannot hide. */
|
||||
alwaysVisible?: boolean;
|
||||
@@ -62,6 +59,16 @@ export interface RailItemDef {
|
||||
rightDockable?: boolean;
|
||||
/** Desktop app only. Hidden in the browser build, including its toggle. */
|
||||
electronOnly?: boolean;
|
||||
/** False keeps it out of the Navigation visibility toggles. */
|
||||
hideable?: boolean;
|
||||
/** Registered but not shown, e.g. while its feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/** Places a registered item after this id instead of at the end. */
|
||||
after?: string;
|
||||
/** Tie-break among registered items. */
|
||||
order?: number;
|
||||
/** Set for items a plugin registered. */
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
@@ -139,28 +146,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
},
|
||||
{ id: "fleets", icon: Boxes, labelKey: "nav.fleets", separatorAfter: true },
|
||||
{
|
||||
id: "tailscale",
|
||||
icon: Radar,
|
||||
labelKey: "nav.tailscale",
|
||||
separatorAfter: true,
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
icon: Workflow,
|
||||
labelKey: "nav.automations",
|
||||
separatorAfter: true,
|
||||
promotable: true,
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
icon: Sparkles,
|
||||
labelKey: "nav.ai",
|
||||
separatorAfter: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
icon: Clock,
|
||||
@@ -183,12 +168,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
labelKey: "nav.splitScreen",
|
||||
separatorAfter: true,
|
||||
},
|
||||
{
|
||||
id: "workspaces",
|
||||
icon: LayoutTemplate,
|
||||
labelKey: "nav.workspaces",
|
||||
separatorAfter: true,
|
||||
},
|
||||
{
|
||||
id: "local-terminal",
|
||||
icon: TerminalSquare,
|
||||
@@ -197,44 +176,87 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
separatorAfter: true,
|
||||
electronOnly: true,
|
||||
},
|
||||
{
|
||||
id: "network_graph",
|
||||
icon: Network,
|
||||
labelKey: "nav.networkGraph",
|
||||
kind: "tab",
|
||||
separatorAfter: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Runtime-registered rail items, for a future plugin loader to add
|
||||
* destinations that don't exist at build time. Keyed by id so a plugin can
|
||||
* cleanly unregister its own entries without touching anyone else's.
|
||||
* Rail items registered by plugins at runtime. Reactive, because a plugin can
|
||||
* be enabled or disabled while the app is open and the rail, the mobile bar
|
||||
* and the Navigation toggles all have to follow.
|
||||
*/
|
||||
const registeredRailItems = new Map<string, RailItemDef>();
|
||||
const registeredRailItems = createRegistry<RailItemDef>();
|
||||
|
||||
export function registerRailItem(def: RailItemDef): void {
|
||||
registeredRailItems.set(def.id, def);
|
||||
export function registerRailItem(def: RailItemDef): () => void {
|
||||
return registeredRailItems.register(def);
|
||||
}
|
||||
|
||||
export function unregisterRailItem(id: string): void {
|
||||
registeredRailItems.delete(id);
|
||||
registeredRailItems.unregister(id);
|
||||
}
|
||||
|
||||
export const subscribeToRailItems = registeredRailItems.subscribe;
|
||||
|
||||
/** Registered items, hidden ones included. */
|
||||
export const listRegisteredRailItems = registeredRailItems.list;
|
||||
|
||||
/**
|
||||
* Core items in their fixed order, with each registered item placed after
|
||||
* the item its `after` names, or at the end. Hidden items are left out.
|
||||
*/
|
||||
function mergedRailItems(): RailItemDef[] {
|
||||
const merged = [...RAIL_ITEMS];
|
||||
const plugins = [...registeredRailItems.list()]
|
||||
.filter((item) => !item.hidden)
|
||||
.sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id),
|
||||
);
|
||||
for (const item of plugins) {
|
||||
let at = item.after
|
||||
? merged.findIndex((existing) => existing.id === item.after)
|
||||
: -1;
|
||||
if (at < 0) {
|
||||
merged.push(item);
|
||||
continue;
|
||||
}
|
||||
// Items sharing an anchor keep their own order after it.
|
||||
while (
|
||||
at + 1 < merged.length &&
|
||||
merged[at + 1].pluginId &&
|
||||
merged[at + 1].after === item.after
|
||||
) {
|
||||
at++;
|
||||
}
|
||||
merged.splice(at + 1, 0, item);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rail items available in the current build. Electron-only destinations are
|
||||
* dropped in the browser build so they never reach the rail, the mobile bar,
|
||||
* or the visibility toggles. Runtime-registered items are appended after the
|
||||
* built-in list.
|
||||
* or the visibility toggles.
|
||||
*/
|
||||
export function visibleRailItems(): RailItemDef[] {
|
||||
const electron = isElectron();
|
||||
return [
|
||||
...RAIL_ITEMS.filter((item) => !item.electronOnly || electron),
|
||||
...[...registeredRailItems.values()].filter(
|
||||
(item) => !item.electronOnly || electron,
|
||||
),
|
||||
];
|
||||
return mergedRailItems().filter((item) => !item.electronOnly || electron);
|
||||
}
|
||||
|
||||
let railSnapshot: RailItemDef[] | null = null;
|
||||
registeredRailItems.subscribe(() => {
|
||||
railSnapshot = null;
|
||||
});
|
||||
|
||||
function railItemsSnapshot(): RailItemDef[] {
|
||||
if (!railSnapshot) railSnapshot = visibleRailItems();
|
||||
return railSnapshot;
|
||||
}
|
||||
|
||||
/** visibleRailItems() as a hook, re-rendering when plugins change it. */
|
||||
export function useRailItems(): RailItemDef[] {
|
||||
return useSyncExternalStore(
|
||||
registeredRailItems.subscribe,
|
||||
railItemsSnapshot,
|
||||
railItemsSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,29 +269,40 @@ export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
||||
];
|
||||
|
||||
/** Ids that may be opened in the right dock. */
|
||||
export const RIGHT_DOCKABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.rightDockable)
|
||||
.map((item) => item.id);
|
||||
export function rightDockableIds(): string[] {
|
||||
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.rightDockable)
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
/** Ids that may be opened as a full-width tab. */
|
||||
export const PROMOTABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.promotable)
|
||||
.map((item) => item.id);
|
||||
export function promotableIds(): string[] {
|
||||
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.promotable)
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
/** Ids a user is allowed to hide, mirroring HideableRailView. */
|
||||
export const HIDEABLE_RAIL_IDS = RAIL_ITEMS.filter(
|
||||
(item) => !item.alwaysVisible,
|
||||
).map((item) => item.id);
|
||||
/** Ids a user is allowed to hide from Appearance > Sidebar > Navigation. */
|
||||
export function hideableRailIds(): string[] {
|
||||
return visibleRailItems()
|
||||
.filter((item) => !item.alwaysVisible && item.hideable !== false)
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
const LABEL_KEYS: Record<string, string> = Object.fromEntries(
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].map((item) => [
|
||||
item.id,
|
||||
item.labelKey,
|
||||
]),
|
||||
);
|
||||
/** Whether a rail view is one of core's own, rather than a plugin's. */
|
||||
export function isCoreRailView(id: string): boolean {
|
||||
return [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].some((item) => item.id === id);
|
||||
}
|
||||
|
||||
/** Translated label for any rail destination, including registered ones. */
|
||||
export function railItemLabel(id: string, t: (key: string) => string): string {
|
||||
const key = LABEL_KEYS[id] ?? registeredRailItems.get(id)?.labelKey;
|
||||
const key =
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].find((item) => item.id === id)
|
||||
?.labelKey ?? registeredRailItems.get(id)?.labelKey;
|
||||
return key ? t(key) : id;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetRegisteredRailItems(): void {
|
||||
registeredRailItems.reset();
|
||||
}
|
||||
Reference in new issue
Block a user