mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -64,7 +64,13 @@ import {
|
||||
import { shell, shellHost, tabsApi } from "./shell-bridge";
|
||||
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
|
||||
import {
|
||||
withPluginContext,
|
||||
withPluginScope,
|
||||
withIconBoundary,
|
||||
guardCallback,
|
||||
} from "./scope";
|
||||
import { registerLayout as registerLayoutComponent } from "@/look/layout-registry";
|
||||
import {
|
||||
scopeHostArgs,
|
||||
scopeHostCallback,
|
||||
@@ -157,7 +163,9 @@ export function createPluginApp(
|
||||
? "contributes.tabs"
|
||||
: kind === "panel"
|
||||
? "contributes.panels or contributes.tabs"
|
||||
: "contributes.dashboardCards";
|
||||
: kind === "layout"
|
||||
? "contributes.layouts"
|
||||
: "contributes.dashboardCards";
|
||||
throw new Error(
|
||||
`${pluginId}: ${what} "${id}" is not declared in manifest ${field}`,
|
||||
);
|
||||
@@ -578,6 +586,21 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
registerLayout(layout) {
|
||||
requireDeclared("layout", layout.id, "layout");
|
||||
return track(
|
||||
registerLayoutComponent({
|
||||
id: layout.id,
|
||||
pluginId,
|
||||
component: withPluginContext(pluginId, layout.component),
|
||||
mobileComponent: layout.mobileComponent
|
||||
? withPluginContext(pluginId, layout.mobileComponent)
|
||||
: undefined,
|
||||
defaultTheme: layout.defaultTheme,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||
i18n.t(
|
||||
pluginKey(pluginId, key),
|
||||
|
||||
@@ -48,6 +48,8 @@ import { invokeAction } from "@/shell/action-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { usePluginComponent } from "./component-registry";
|
||||
import { themeMode, useThemes } from "@/look/theme-registry";
|
||||
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
||||
import {
|
||||
getLiveHostStatus,
|
||||
useOptionalHostStatusEntry,
|
||||
@@ -337,15 +339,13 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
|
||||
useTheme() {
|
||||
const { theme } = useTheme();
|
||||
if (theme === "light") return { theme: "light" };
|
||||
if ((theme as string) === "system" && typeof window !== "undefined") {
|
||||
return {
|
||||
theme: window.matchMedia?.("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark",
|
||||
};
|
||||
}
|
||||
return { theme: "dark" };
|
||||
// Re-render when plugin themes arrive, since they carry their mode.
|
||||
useThemes();
|
||||
return { theme: themeMode(theme) };
|
||||
},
|
||||
|
||||
useShell() {
|
||||
return usePluginShell(usePluginScope());
|
||||
},
|
||||
|
||||
toast: {
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
setPluginSummaries,
|
||||
} from "./plugin-store";
|
||||
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
|
||||
import {
|
||||
chosenLayoutId,
|
||||
loadLook,
|
||||
safeLayoutRequested,
|
||||
} from "@/look/look-store";
|
||||
import { BUILTIN_THEMES, setPluginThemes } from "@/look/theme-registry";
|
||||
import { setPluginLayouts } from "@/look/layout-registry";
|
||||
|
||||
/**
|
||||
* Loads plugin frontends into the running shell.
|
||||
@@ -55,14 +62,22 @@ let queue: Promise<void> = Promise.resolve();
|
||||
let deps: PluginLoaderDeps | null = null;
|
||||
let started = false;
|
||||
let guestMode = false;
|
||||
/** Signed in with the full runtime: only the chosen layout's code loads. */
|
||||
let gateLayouts = false;
|
||||
|
||||
function assetUrlWithVersion(
|
||||
pluginId: string,
|
||||
file: string,
|
||||
version: string | null | undefined,
|
||||
): string {
|
||||
const query = version ? `?v=${encodeURIComponent(version)}` : "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(pluginId)}/${file}${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assetUrl(summary: PluginSummary, file: string): string {
|
||||
const version = summary.assetVersion
|
||||
? `?v=${encodeURIComponent(summary.assetVersion)}`
|
||||
: "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`,
|
||||
);
|
||||
return assetUrlWithVersion(summary.id, file, summary.assetVersion);
|
||||
}
|
||||
|
||||
/** Absolute URL, so import() resolves it against the page, not this module. */
|
||||
@@ -286,11 +301,75 @@ function namespaceVersion(summary: PluginSummary): string {
|
||||
return `${summary.version}|${summary.assetVersion ?? ""}`;
|
||||
}
|
||||
|
||||
/** Puts every running plugin's themes and every enabled plugin's layouts on offer. */
|
||||
function syncLooks(summaries: PluginSummary[]): void {
|
||||
const fallback = (mode: "light" | "dark") =>
|
||||
BUILTIN_THEMES.find((theme) => theme.id === mode)!.swatch;
|
||||
setPluginThemes(
|
||||
summaries.filter(serverServesAssets).flatMap((summary) =>
|
||||
summary.themeVersion
|
||||
? (summary.contributes?.themes ?? []).map((theme) => ({
|
||||
id: theme.id,
|
||||
label: theme.label,
|
||||
mode: theme.mode,
|
||||
swatch: theme.swatch ?? fallback(theme.mode),
|
||||
pluginId: summary.id,
|
||||
cssHref: assetUrlWithVersion(
|
||||
summary.id,
|
||||
`_themes/${theme.id}.css`,
|
||||
summary.themeVersion,
|
||||
),
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
);
|
||||
setPluginLayouts(
|
||||
summaries
|
||||
.filter((summary) => summary.enabled)
|
||||
.flatMap((summary) =>
|
||||
(summary.contributes?.layouts ?? []).map((layout) => ({
|
||||
id: layout.id,
|
||||
label: layout.label,
|
||||
description: layout.description,
|
||||
defaultTheme: layout.defaultTheme,
|
||||
pluginId: summary.id,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout plugin's code runs only while one of its layouts is the one
|
||||
* shown, and never in the repair view, so a broken layout cannot break the
|
||||
* way out of it.
|
||||
*/
|
||||
async function withoutUnchosenLayouts(
|
||||
summaries: PluginSummary[],
|
||||
): Promise<PluginSummary[]> {
|
||||
if (!gateLayouts) return summaries;
|
||||
await loadLook();
|
||||
const chosen = safeLayoutRequested() ? null : chosenLayoutId();
|
||||
return summaries.map((summary) => {
|
||||
const layouts = summary.contributes?.layouts ?? [];
|
||||
if (layouts.length === 0) return summary;
|
||||
if (layouts.some((layout) => layout.id === chosen)) return summary;
|
||||
if (
|
||||
summary.frontend &&
|
||||
getPluginRecord(summary.id)?.frontend !== "inactive"
|
||||
)
|
||||
setFrontendState(summary.id, "inactive");
|
||||
return { ...summary, frontend: false };
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||
setPluginSummaries(summaries);
|
||||
syncLooks(summaries);
|
||||
await loadNamespaces(summaries.filter(serverServesAssets));
|
||||
|
||||
const { order, blocked } = orderForActivation(summaries);
|
||||
const { order, blocked } = orderForActivation(
|
||||
await withoutUnchosenLayouts(summaries),
|
||||
);
|
||||
const wanted = new Map(order.map((summary) => [summary.id, summary]));
|
||||
|
||||
// Dependents go first, so nothing outlives what it depends on.
|
||||
@@ -393,6 +472,12 @@ export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Tries a plugin's frontend again even though this bundle failed before. */
|
||||
export function retryPluginFrontend(pluginId: string): Promise<void> {
|
||||
failedVersions.delete(pluginId);
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
|
||||
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
|
||||
@@ -503,6 +588,7 @@ export function startPluginRuntime(
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
gateLayouts = true;
|
||||
if (!started && typeof window !== "undefined") {
|
||||
started = true;
|
||||
lastFocusSync = Date.now();
|
||||
@@ -514,6 +600,7 @@ export function startPluginRuntime(
|
||||
|
||||
/** Deactivates every plugin, on logout. */
|
||||
export function stopPluginRuntime(): Promise<void> {
|
||||
gateLayouts = false;
|
||||
if (started && typeof window !== "undefined") {
|
||||
started = false;
|
||||
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
||||
|
||||
@@ -114,6 +114,28 @@ export function withPluginScope<P extends object>(
|
||||
return Scoped;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin id for the hooks without the error boundary, for a component
|
||||
* whose caller handles a crash itself (a layout, which the repair view takes
|
||||
* over from).
|
||||
*/
|
||||
export function withPluginContext<P extends object>(
|
||||
pluginId: string,
|
||||
Inner: ComponentType<P>,
|
||||
): ComponentType<P> {
|
||||
function Scoped(props: P) {
|
||||
return (
|
||||
<PluginScopeContext.Provider value={pluginId}>
|
||||
<Suspense fallback={null}>
|
||||
<Inner {...props} />
|
||||
</Suspense>
|
||||
</PluginScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`;
|
||||
return Scoped;
|
||||
}
|
||||
|
||||
/** Renders nothing when a plugin's icon throws, instead of a crash notice. */
|
||||
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
|
||||
state = { error: false };
|
||||
|
||||
@@ -152,6 +152,9 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
||||
|
||||
// Slots: a plugin can offer places for other plugins to fill.
|
||||
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||
// For layouts: where tabs show, and the confirm a tab close asks with.
|
||||
export { TabStage } from "@/shell/stage/TabStage";
|
||||
export { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
|
||||
|
||||
// A plugin's manifest host settings, drawn inside its own host editor section.
|
||||
export {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getPluginStoreState, type PluginRecord } from "./plugin-store";
|
||||
* because a disabled plugin never runs its frontend and so never registers
|
||||
* anything, yet a saved tab or card of its still has to say whose it is.
|
||||
*/
|
||||
export type ViewKind = "tab" | "panel" | "card";
|
||||
export type ViewKind = "tab" | "panel" | "card" | "layout";
|
||||
|
||||
export type ViewStatus =
|
||||
/** Registered by a running plugin. */
|
||||
@@ -31,6 +31,9 @@ function declares(record: PluginRecord, kind: ViewKind, id: string): boolean {
|
||||
!!contributes.tabs?.some((tab) => tab.id === id)
|
||||
);
|
||||
}
|
||||
if (kind === "layout") {
|
||||
return !!contributes.layouts?.some((layout) => layout.id === id);
|
||||
}
|
||||
return !!contributes.dashboardCards?.some((card) => card.id === id);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user