mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal plugin offers and the ai plugin fills, gated on ai.services.use.
This commit is contained in:
1 parent
225f618337
commit
2d6c1cdecb
29 files changed
+1971
-40
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { invokeAction, type SlotContribution } from "@/shell/action-registry";
|
||||
|
||||
interface ActionSlotProps {
|
||||
slotId: string;
|
||||
/**
|
||||
* Arguments for the action, read at click time rather than on render. A
|
||||
* getter keeps expensive context (a serialized terminal buffer, say) off the
|
||||
* render path.
|
||||
*/
|
||||
context?: () => unknown[];
|
||||
/** Applied to the default button so a slot owner can match its own controls. */
|
||||
className?: string;
|
||||
/** Hides the whole slot. For a surface that is contextually unavailable. */
|
||||
enabled?: boolean;
|
||||
/** Renders a contribution the owner's own way instead of as a button. */
|
||||
renderItem?: (
|
||||
contribution: SlotContribution,
|
||||
invoke: () => void,
|
||||
) => React.ReactNode;
|
||||
/** Icon-only, for toolbars that collapse their labels. */
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders whatever plugins have contributed to a named slot.
|
||||
*
|
||||
* Renders null when there is nothing to show, which covers both "no plugin
|
||||
* contributed" and "the user may not see what was contributed". Those two
|
||||
* cases are deliberately identical in the DOM: no placeholder, no disabled
|
||||
* button, nothing hinting at a feature the user cannot reach.
|
||||
*/
|
||||
export function ActionSlot({
|
||||
slotId,
|
||||
context,
|
||||
className,
|
||||
enabled = true,
|
||||
renderItem,
|
||||
hideLabels,
|
||||
}: ActionSlotProps) {
|
||||
const { t } = useTranslation();
|
||||
const contributions = useActionSlot(slotId);
|
||||
|
||||
if (!enabled || contributions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{contributions.map((contribution) => {
|
||||
const invoke = () => {
|
||||
void invokeAction(contribution.actionId, ...(context?.() ?? []));
|
||||
};
|
||||
|
||||
if (renderItem) {
|
||||
return (
|
||||
<React.Fragment key={contribution.actionId}>
|
||||
{renderItem(contribution, invoke)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = contribution.icon;
|
||||
const label = t(contribution.titleKey);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={contribution.actionId}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={invoke}
|
||||
>
|
||||
{Icon && <Icon className="size-4 shrink-0" />}
|
||||
{!hideLabels && label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Runtime registry for plugin-contributed UI actions and the slots they fill.
|
||||
*
|
||||
* This is the frontend sibling of src/backend/plugins/service-registry.ts, and
|
||||
* the difference between them is the thing to be clear about:
|
||||
*
|
||||
* - The backend registry's proxy IS a boundary. It checks the acting user's
|
||||
* RBAC permission on every call before the provider ever runs.
|
||||
* - This one is NOT. It runs in the browser, where anyone can call
|
||||
* invokeAction from the console. The permission attached to an action
|
||||
* decides whether a button is DRAWN, nothing more.
|
||||
*
|
||||
* Real enforcement stays where it already is: the backend route the handler
|
||||
* eventually calls. Treating this file as a security boundary is the easy
|
||||
* mistake to make when reading the two side by side, so it is said plainly
|
||||
* here rather than left to be inferred.
|
||||
*
|
||||
* Unlike the rail and tab registries (rail-items.ts, tabUtils.tsx), which are
|
||||
* plain Maps read during render, this one is reactive. A slot renders live and
|
||||
* has to react when a plugin is enabled or disabled at runtime, so it carries
|
||||
* subscribe/getSnapshot plumbing for useSyncExternalStore.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Args are unknown[] because the registry never inspects them: a slot owner
|
||||
* passes whatever its surface offers (the terminal passes its buffer text) and
|
||||
* the contributing plugin knows what to expect. Handlers are written with
|
||||
* concrete parameter types, so registerAction accepts those too.
|
||||
*/
|
||||
export type ActionHandler = (...args: never[]) => unknown;
|
||||
|
||||
export interface RegisteredAction {
|
||||
id: string;
|
||||
handler: ActionHandler;
|
||||
/** Role permission a user needs before this action is offered. */
|
||||
permission?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
export interface ActionSlotDefinition {
|
||||
id: string;
|
||||
/** Contribution kinds this slot will render. */
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
export interface SlotContribution {
|
||||
/** Action invoked when this contribution is activated. */
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
icon?: LucideIcon;
|
||||
/** Checked against the slot's accepts. Defaults to "button". */
|
||||
kind?: string;
|
||||
pluginId?: string;
|
||||
/** Lower sorts first. Ties break on actionId so ordering is stable. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
const actions = new Map<string, RegisteredAction>();
|
||||
const slots = new Map<string, ActionSlotDefinition>();
|
||||
/** slotId -> actionId -> contribution. */
|
||||
const contributions = new Map<string, Map<string, SlotContribution>>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Cached per-slot arrays.
|
||||
*
|
||||
* Required, not an optimization: useSyncExternalStore compares snapshots by
|
||||
* identity, so building a fresh array per call makes React throw "The result
|
||||
* of getSnapshot should be cached" and re-render forever.
|
||||
*/
|
||||
const snapshots = new Map<string, SlotContribution[]>();
|
||||
|
||||
const EMPTY: SlotContribution[] = [];
|
||||
|
||||
function emit(): void {
|
||||
snapshots.clear();
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function registerAction(
|
||||
id: string,
|
||||
handler: ActionHandler,
|
||||
options: { permission?: string; pluginId?: string } = {},
|
||||
): void {
|
||||
actions.set(id, { id, handler, ...options });
|
||||
emit();
|
||||
}
|
||||
|
||||
export function unregisterAction(id: string): void {
|
||||
if (actions.delete(id)) emit();
|
||||
}
|
||||
|
||||
/** The permission gating an action, if it declared one. */
|
||||
export function getActionPermission(id: string): string | undefined {
|
||||
return actions.get(id)?.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action. Resolves quietly when the id is unknown: a contribution can
|
||||
* outlive the plugin that registered its handler, and a click should not throw
|
||||
* into the React event handler because of it.
|
||||
*/
|
||||
export async function invokeAction(
|
||||
id: string,
|
||||
...args: unknown[]
|
||||
): Promise<unknown> {
|
||||
const action = actions.get(id);
|
||||
if (!action) {
|
||||
console.warn(`[actions] no handler registered for "${id}"`);
|
||||
return undefined;
|
||||
}
|
||||
return (action.handler as (...a: unknown[]) => unknown)(...args);
|
||||
}
|
||||
|
||||
export function declareActionSlot(slot: ActionSlotDefinition): void {
|
||||
slots.set(slot.id, slot);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function undeclareActionSlot(slotId: string): void {
|
||||
if (slots.delete(slotId)) emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a contribution to a slot.
|
||||
*
|
||||
* A contribution for a slot nobody has declared yet is kept rather than
|
||||
* dropped: plugin activation order is not guaranteed, and the slot owner may
|
||||
* register after the contributor. It becomes visible once the slot appears.
|
||||
*/
|
||||
export function registerSlotContribution(
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
): void {
|
||||
const slot = slots.get(slotId);
|
||||
const kind = contribution.kind ?? "button";
|
||||
|
||||
if (slot && !slot.accepts.includes(kind)) {
|
||||
console.warn(
|
||||
`[actions] slot "${slotId}" does not accept "${kind}" (accepts: ${slot.accepts.join(", ")})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let forSlot = contributions.get(slotId);
|
||||
if (!forSlot) {
|
||||
forSlot = new Map();
|
||||
contributions.set(slotId, forSlot);
|
||||
}
|
||||
forSlot.set(contribution.actionId, { ...contribution, kind });
|
||||
emit();
|
||||
}
|
||||
|
||||
export function unregisterSlotContribution(
|
||||
slotId: string,
|
||||
actionId: string,
|
||||
): void {
|
||||
const forSlot = contributions.get(slotId);
|
||||
if (!forSlot?.delete(actionId)) return;
|
||||
if (forSlot.size === 0) contributions.delete(slotId);
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contributions for a slot, sorted and referentially stable between changes.
|
||||
* Unfiltered: callers that render go through useActionSlot, which drops the
|
||||
* ones the current user may not see.
|
||||
*/
|
||||
export function getSlotContributions(slotId: string): SlotContribution[] {
|
||||
const cached = snapshots.get(slotId);
|
||||
if (cached) return cached;
|
||||
|
||||
const forSlot = contributions.get(slotId);
|
||||
if (!forSlot || forSlot.size === 0) {
|
||||
snapshots.set(slotId, EMPTY);
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const sorted = [...forSlot.values()].sort(
|
||||
(a, b) =>
|
||||
(a.order ?? 0) - (b.order ?? 0) || a.actionId.localeCompare(b.actionId),
|
||||
);
|
||||
snapshots.set(slotId, sorted);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function subscribeToActionRegistry(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam, mirroring resetPluginState(). */
|
||||
export function resetActionRegistry(): void {
|
||||
actions.clear();
|
||||
slots.clear();
|
||||
contributions.clear();
|
||||
snapshots.clear();
|
||||
emit();
|
||||
}
|
||||
@@ -15,9 +15,17 @@
|
||||
* bigger regression risk to a working terminal than gating the entry points.
|
||||
*/
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { unregisterRailItem } from "@/sidebar/rail-items";
|
||||
import { unregisterTabComponent } from "@/shell/tabUtils";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
unregisterAction,
|
||||
unregisterSlotContribution,
|
||||
} from "@/shell/action-registry";
|
||||
|
||||
/**
|
||||
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
|
||||
@@ -38,6 +46,50 @@ const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
||||
let pluginEnabled = new Map<string, boolean>();
|
||||
let disabledTabTypes = new Set<string>();
|
||||
|
||||
const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
|
||||
const AI_OPEN_WITH_CONTEXT = "ai.openWithContext";
|
||||
|
||||
/**
|
||||
* Applies the UI action contributions the first-party plugins declare.
|
||||
*
|
||||
* These live in plugins/*_/frontend/index.mjs, but nothing imports those yet:
|
||||
* there is no frontend plugin loader. Until there is, the registration is
|
||||
* mirrored here so the contributed UI is actually reachable in the running
|
||||
* app rather than only in tests. The two must be kept in step, and this block
|
||||
* goes away when a real loader lands.
|
||||
*/
|
||||
function applyFirstPartyActions(enabled: Map<string, boolean>): void {
|
||||
if (enabled.get("ssh-terminal") !== false) {
|
||||
declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
|
||||
}
|
||||
|
||||
if (enabled.get("ai") === false) {
|
||||
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
|
||||
unregisterAction(AI_OPEN_WITH_CONTEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
registerAction(
|
||||
AI_OPEN_WITH_CONTEXT,
|
||||
(context: unknown) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:ai:openWithContext", {
|
||||
detail: { context: typeof context === "string" ? context : "" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ permission: "ai.services.use", pluginId: "ai" },
|
||||
);
|
||||
|
||||
registerSlotContribution(TERMINAL_TOOLBAR_SLOT, {
|
||||
actionId: AI_OPEN_WITH_CONTEXT,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
kind: "button",
|
||||
pluginId: "ai",
|
||||
});
|
||||
}
|
||||
|
||||
export function applyPluginState(plugins: PluginSummary[]): void {
|
||||
const nextEnabled = new Map<string, boolean>();
|
||||
const nextDisabledTabs = new Set<string>();
|
||||
@@ -65,6 +117,7 @@ export function applyPluginState(plugins: PluginSummary[]): void {
|
||||
|
||||
pluginEnabled = nextEnabled;
|
||||
disabledTabTypes = nextDisabledTabs;
|
||||
applyFirstPartyActions(nextEnabled);
|
||||
}
|
||||
|
||||
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
||||
@@ -92,4 +145,6 @@ export function isPluginEnabled(pluginId: string): boolean {
|
||||
export function resetPluginState(): void {
|
||||
pluginEnabled = new Map();
|
||||
disabledTabTypes = new Set();
|
||||
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
|
||||
unregisterAction(AI_OPEN_WITH_CONTEXT);
|
||||
}
|
||||
Reference in new issue
Block a user