mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: rebuild workspaces as the reference plugin
This commit is contained in:
1 parent
d774df8b41
commit
f217d8db89
79 files changed
+32995
-2184
No files matched your search
@@ -50,6 +50,12 @@ function load(): Promise<CachedPermissions> {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/** The same answer usePermissions().has gives, for code outside a component. */
|
||||
export async function hasPermission(permission: string): Promise<boolean> {
|
||||
const { permissions, isAdmin } = await load();
|
||||
return matchesPermission(permissions, isAdmin, permission);
|
||||
}
|
||||
|
||||
export function notifyPermissionsChanged(): void {
|
||||
cache = null;
|
||||
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
||||
|
||||
@@ -43,6 +43,8 @@ import { shell, tabsApi } from "./shell-bridge";
|
||||
import { withPluginScope } from "./scope";
|
||||
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { hasPermission } from "@/hooks/use-permissions";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface PluginAppHandle {
|
||||
app: TermixApp;
|
||||
@@ -118,6 +120,9 @@ export function createPluginApp(
|
||||
after: item.after,
|
||||
order: item.order,
|
||||
pluginId,
|
||||
permission: item.permission
|
||||
? resolvePluginPermission(pluginId, item.permission)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -339,6 +344,20 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||
i18n.t(
|
||||
pluginKey(pluginId, key),
|
||||
options as Record<string, unknown>,
|
||||
)) as TermixApp["t"],
|
||||
hasPermission: async (permission) => {
|
||||
try {
|
||||
return await hasPermission(
|
||||
resolvePluginPermission(pluginId, permission),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
api: pluginHostBridge.getApi(pluginId),
|
||||
wsUrl: (path, options) =>
|
||||
pluginWsUrl(pluginId, path, options as never) as ReturnType<
|
||||
|
||||
@@ -56,6 +56,15 @@ export function resolvePluginPermission(
|
||||
|
||||
const apiClients = new Map<string, ReturnType<typeof createPluginApi>>();
|
||||
|
||||
/** Test seam: renderWithApp's `api` option stands in for the real client. */
|
||||
export function setPluginApiForTesting(
|
||||
pluginId: string,
|
||||
client: ReturnType<typeof createPluginApi> | null,
|
||||
): void {
|
||||
if (client) apiClients.set(pluginId, client);
|
||||
else apiClients.delete(pluginId);
|
||||
}
|
||||
|
||||
function getApi(pluginId: string) {
|
||||
let client = apiClients.get(pluginId);
|
||||
if (!client) {
|
||||
|
||||
@@ -58,6 +58,9 @@ export { TOTPDialog, type MFAPromptMode } from "@/ssh/dialogs/TOTPDialog";
|
||||
export { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog";
|
||||
export { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog";
|
||||
|
||||
// Design tokens: the colour swatches folders, workspaces and tags pick from.
|
||||
export { FOLDER_COLORS } from "@/lib/theme";
|
||||
|
||||
// Shell context
|
||||
export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
||||
|
||||
|
||||
@@ -44,9 +44,18 @@ import { getSlotContributions, listActions } from "@/shell/action-registry";
|
||||
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
import { createPluginApp } from "./app";
|
||||
import { installPluginHostBridge } from "./bridge";
|
||||
import { installPluginHostBridge, setPluginApiForTesting } from "./bridge";
|
||||
import { setPluginSummaries, setFrontendState } from "./plugin-store";
|
||||
import { setShellCallbacks, setShellHosts } from "./shell-bridge";
|
||||
import {
|
||||
notifyShellReady,
|
||||
resetShellBridge,
|
||||
setShellCallbacks,
|
||||
setShellHosts,
|
||||
setShellLayoutProvider,
|
||||
} from "./shell-bridge";
|
||||
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
|
||||
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
|
||||
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
||||
const record =
|
||||
@@ -78,7 +87,7 @@ function wrap(element: ReactElement): HTMLElement {
|
||||
).container;
|
||||
}
|
||||
|
||||
function missing(kind: string, id: string): never {
|
||||
function missing(kind: string, id: string | number): never {
|
||||
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
|
||||
}
|
||||
|
||||
@@ -101,12 +110,45 @@ export async function renderPlugin(
|
||||
} as PluginManifest;
|
||||
|
||||
installPluginHostBridge();
|
||||
resetShellBridge();
|
||||
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
|
||||
setShellHosts((options.hosts ?? []) as unknown as Host[]);
|
||||
const hosts = (options.hosts ?? []) as unknown as Host[];
|
||||
setShellHosts(hosts);
|
||||
const shellCalls: ShellCall[] = [];
|
||||
const shell = recordingShell(shellCalls);
|
||||
setShellCallbacks(shell);
|
||||
|
||||
// Stands in for AppShell's layout: applying one runs the shell's own
|
||||
// restore rules and records which tabs it would open.
|
||||
let layout = options.layout ?? null;
|
||||
const opened: Array<{ type: string; hostId?: number; label: string }> = [];
|
||||
setShellLayoutProvider({
|
||||
getLayout: () => layout,
|
||||
applyLayout: async (next, applyOptions) => {
|
||||
shellCalls.push({ method: "applyLayout", args: [next, applyOptions] });
|
||||
layout = next;
|
||||
opened.length = 0;
|
||||
const skipped: string[] = [];
|
||||
const tabs = (next.tabs ?? []) as WorkspaceTabSnapshot[];
|
||||
for (const snapshot of tabs) {
|
||||
const target = resolveLayoutTabTarget(snapshot, hosts);
|
||||
if (target.kind === "skip") {
|
||||
skipped.push(snapshot.hostNameSnapshot || snapshot.label);
|
||||
continue;
|
||||
}
|
||||
opened.push({
|
||||
type: snapshot.type,
|
||||
hostId:
|
||||
"host" in target && target.host
|
||||
? Number(target.host.id)
|
||||
: undefined,
|
||||
label: snapshot.customLabel ?? snapshot.label,
|
||||
});
|
||||
}
|
||||
return { skipped };
|
||||
},
|
||||
});
|
||||
|
||||
const summary: PluginSummary = {
|
||||
id: pluginId,
|
||||
name: manifest.name,
|
||||
@@ -128,11 +170,16 @@ export async function renderPlugin(
|
||||
);
|
||||
}
|
||||
|
||||
setPluginApiForTesting(
|
||||
pluginId,
|
||||
(options.api as Parameters<typeof setPluginApiForTesting>[1]) ?? null,
|
||||
);
|
||||
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
|
||||
guest: options.guest,
|
||||
});
|
||||
await plugin.activate(handle.app);
|
||||
setFrontendState(pluginId, "active");
|
||||
if (options.ready) notifyShellReady();
|
||||
|
||||
const mine = <T extends { pluginId?: string }>(items: T[]) =>
|
||||
items.filter((item) => item.pluginId === pluginId);
|
||||
@@ -154,6 +201,7 @@ export async function renderPlugin(
|
||||
mine(listRegisteredRailItems()).map((item) => ({
|
||||
id: item.id,
|
||||
hidden: item.hidden,
|
||||
permission: item.permission,
|
||||
})),
|
||||
tabs: () => mine(listTabTypes()).map((def) => def.id),
|
||||
panels: () => mine(listPanels()).map((panel) => panel.id),
|
||||
@@ -195,6 +243,28 @@ export async function renderPlugin(
|
||||
);
|
||||
},
|
||||
|
||||
openedTabs: () => opened.map((tab) => ({ ...tab })),
|
||||
|
||||
renderOpenedTab(index) {
|
||||
const tab = opened[index] ?? missing("an opened tab at index", index);
|
||||
const def = getTabType(tab.type);
|
||||
if (!def) {
|
||||
// What tabUtils renders for a tab no running plugin registered.
|
||||
return wrap(<PluginViewPlaceholder kind="tab" viewId={tab.type} />);
|
||||
}
|
||||
const Component = def.component;
|
||||
return wrap(
|
||||
<Component
|
||||
tab={tabRecord(tab.type)}
|
||||
label={tab.label}
|
||||
isVisible
|
||||
isFocusedPane
|
||||
handleRef={null}
|
||||
shell={shell}
|
||||
/>,
|
||||
);
|
||||
},
|
||||
|
||||
renderPanel(id, props = {}) {
|
||||
const panel = getPanel(id) ?? missing("a panel", id);
|
||||
const Component = panel.component;
|
||||
@@ -260,6 +330,7 @@ export async function renderPlugin(
|
||||
} finally {
|
||||
handle.dispose();
|
||||
setFrontendState(pluginId, "inactive");
|
||||
setPluginApiForTesting(pluginId, null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,8 +45,34 @@ const CORE_SINGLETON = new Set([
|
||||
"ssh-tools",
|
||||
]);
|
||||
|
||||
/** Core tab types that are never part of a saved arrangement. */
|
||||
const CORE_UNSAVED = new Set([
|
||||
"dashboard",
|
||||
"local-terminal",
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"split-screen",
|
||||
"sftp",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A tab type no running plugin has registered: its plugin is disabled,
|
||||
* failed, still loading or not installed. The shell shows a placeholder for
|
||||
* it, and a layout keeps it so nothing is lost when the plugin comes back.
|
||||
*/
|
||||
export function isUnregisteredPluginTabType(type: string): boolean {
|
||||
return (
|
||||
!CORE_UNSAVED.has(type) &&
|
||||
!CORE_CAPTURABLE.has(type) &&
|
||||
type !== "serial" &&
|
||||
!getTabType(type)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCapturableTabType(type: string): boolean {
|
||||
if (CORE_CAPTURABLE.has(type)) return true;
|
||||
if (isUnregisteredPluginTabType(type)) return true;
|
||||
const def = getTabType(type);
|
||||
return !!def && def.inLayouts !== false;
|
||||
}
|
||||
@@ -142,6 +168,12 @@ export function resolveLayoutTabTarget(
|
||||
return { kind: "singleton", host };
|
||||
}
|
||||
|
||||
// Reopened as a placeholder rather than dropped, so applying a workspace
|
||||
// while a plugin is off does not quietly lose its tabs.
|
||||
if (!host && isUnregisteredPluginTabType(snapshot.type)) {
|
||||
return { kind: "singleton" };
|
||||
}
|
||||
|
||||
return host ? { kind: "host", host } : { kind: "skip" };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, visibleRailItems } from "./rail-items";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -52,12 +52,13 @@ type RailItem =
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(
|
||||
items: RailItemDef[],
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
hidden: Set<string>,
|
||||
): RailItem[] {
|
||||
const all: RailItem[] = [];
|
||||
for (const item of visibleRailItems()) {
|
||||
for (const item of items) {
|
||||
const Icon = item.icon;
|
||||
if (item.kind === "tab") {
|
||||
all.push({
|
||||
@@ -192,8 +193,8 @@ export function AppRail({
|
||||
};
|
||||
}, [menuPos]);
|
||||
|
||||
// Plugins add and remove rail items at runtime.
|
||||
useRailItems();
|
||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||
const railItems = useRailItems();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -246,7 +247,12 @@ export function AppRail({
|
||||
...hiddenTabs,
|
||||
...(isRemoteSyncConnected ? [] : ["termix-id"]),
|
||||
]);
|
||||
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems,
|
||||
splitMode,
|
||||
t,
|
||||
effectiveHiddenTabs,
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Presentation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
@@ -69,6 +70,8 @@ export interface RailItemDef {
|
||||
order?: number;
|
||||
/** Set for items a plugin registered. */
|
||||
pluginId?: string;
|
||||
/** Role permission the user needs to see it at all, as a full id. */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
@@ -250,13 +253,37 @@ function railItemsSnapshot(): RailItemDef[] {
|
||||
return railSnapshot;
|
||||
}
|
||||
|
||||
/** visibleRailItems() as a hook, re-rendering when plugins change it. */
|
||||
/**
|
||||
* Drops items gated on a permission the user lacks. Until permissions load,
|
||||
* gated items stay hidden rather than flashing in and out.
|
||||
*/
|
||||
export function permittedRailItems(
|
||||
items: RailItemDef[],
|
||||
permissions: { has: (permission: string) => boolean; loaded: boolean },
|
||||
): RailItemDef[] {
|
||||
if (!items.some((item) => item.permission)) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
!item.permission ||
|
||||
(permissions.loaded && permissions.has(item.permission)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* visibleRailItems() as a hook, re-rendering when plugins change it, without
|
||||
* the items the user's permissions hide.
|
||||
*/
|
||||
export function useRailItems(): RailItemDef[] {
|
||||
return useSyncExternalStore(
|
||||
const items = useSyncExternalStore(
|
||||
registeredRailItems.subscribe,
|
||||
railItemsSnapshot,
|
||||
railItemsSnapshot,
|
||||
);
|
||||
const { has, loaded } = usePermissions();
|
||||
return useMemo(
|
||||
() => permittedRailItems(items, { has, loaded }),
|
||||
[items, has, loaded],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -358,11 +358,49 @@ describe("plugin tab types in layouts", () => {
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("leaves out a plugin tab type nobody registered", () => {
|
||||
it("keeps a plugin tab type nobody registered, so a disabled plugin loses nothing", () => {
|
||||
const { snapshots } = buildWorkspaceTabSnapshots([
|
||||
makeTab({ id: "x", type: "some-plugin-tab" }),
|
||||
makeTab({ id: "x", type: "some-plugin-tab", data: { a: 1 } }),
|
||||
makeTab({ id: "d", type: "dashboard" }),
|
||||
makeTab({ id: "h", type: "host-manager" }),
|
||||
]);
|
||||
expect(snapshots).toEqual([]);
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["some-plugin-tab"]);
|
||||
expect(snapshotData(snapshots[0])).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("reopens a hostless tab of a missing plugin as a placeholder", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{ slotId: "s", type: "some-plugin-tab", label: "Gone" },
|
||||
[],
|
||||
),
|
||||
).toEqual({ kind: "singleton" });
|
||||
});
|
||||
|
||||
it("reopens a missing plugin's host tab while its host exists", () => {
|
||||
const host = makeHost({ syncId: "sync-1" });
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{
|
||||
slotId: "s",
|
||||
type: "some-plugin-tab",
|
||||
label: "Gone",
|
||||
hostSyncId: "sync-1",
|
||||
},
|
||||
[host],
|
||||
),
|
||||
).toEqual({ kind: "host", host });
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{
|
||||
slotId: "s",
|
||||
type: "some-plugin-tab",
|
||||
label: "Gone",
|
||||
hostSyncId: "sync-deleted",
|
||||
},
|
||||
[host],
|
||||
),
|
||||
).toEqual({ kind: "skip" });
|
||||
});
|
||||
|
||||
it("carries a tab's plugin data and reads the older fleetId field", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
hideableRailIds,
|
||||
permittedRailItems,
|
||||
promotableIds,
|
||||
RAIL_ITEMS,
|
||||
RAIL_UTILITY_ITEMS,
|
||||
@@ -230,3 +231,39 @@ describe("railItemLabel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permittedRailItems", () => {
|
||||
const items = [
|
||||
{ id: "open", icon: Boxes, labelKey: "nav.hosts" },
|
||||
{
|
||||
id: "gated",
|
||||
icon: Boxes,
|
||||
labelKey: "nav.hosts",
|
||||
permission: "demo.use",
|
||||
},
|
||||
];
|
||||
|
||||
it("hides an item gated on a permission the user lacks", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: true,
|
||||
has: () => false,
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
|
||||
it("shows it once the user holds the permission", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: true,
|
||||
has: (permission) => permission === "demo.use",
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open", "gated"]);
|
||||
});
|
||||
|
||||
it("keeps gated items hidden until permissions have loaded", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: false,
|
||||
has: () => true,
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user