mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -13,26 +13,56 @@
|
|||||||
* tabUtils.tsx (railView === "ai", case "ai"), the same way the terminal's
|
* tabUtils.tsx (railView === "ai", case "ai"), the same way the terminal's
|
||||||
* tab is baked into Terminal.tsx rather than going through the generic tab
|
* tab is baked into Terminal.tsx rather than going through the generic tab
|
||||||
* registry. What gates it is isTabTypeAvailable("ai") in
|
* registry. What gates it is isTabTypeAvailable("ai") in
|
||||||
* src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map, which is
|
* src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map.
|
||||||
* consulted directly by AppShell.tsx, CommandPalette.tsx and HostItem.tsx --
|
|
||||||
* the same live mechanism ssh-terminal's "terminal" entry already uses.
|
|
||||||
*
|
*
|
||||||
* register()/unregister() exist for parity with the other first-party
|
* What this file DOES contribute is the terminal toolbar's AI button, through
|
||||||
* plugins' frontend halves. Nothing invokes them yet: there is no frontend
|
* the action registry: ai.openWithContext, declared in manifest.json and
|
||||||
* plugin loader (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx),
|
* gated on ai.services.use. The button used to be hardwired into
|
||||||
* and the "ai" surface does not need registerRailItem/registerTabComponent
|
* TerminalToolbar.tsx behind a showAiAssistant prop.
|
||||||
* in the first place, since it is gated through BUILT_IN_TABS_BY_PLUGIN
|
*
|
||||||
* instead.
|
* There is still no frontend plugin loader, so nothing imports this file yet
|
||||||
|
* (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx). Until one exists,
|
||||||
|
* pluginLoader.ts performs the same registration itself when the ai plugin is
|
||||||
|
* enabled. This file is the version that runs once a loader lands, and the two
|
||||||
|
* must be kept in step.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const id = "ai";
|
export const id = "ai";
|
||||||
export const tabId = "ai";
|
export const tabId = "ai";
|
||||||
|
export const actionId = "ai.openWithContext";
|
||||||
|
|
||||||
export async function register() {
|
/** Asks the focused terminal to open its AI panel seeded with `context`. */
|
||||||
// Nothing to register: "ai" is a built-in rail-view/tab gated through
|
export function openWithContext(context) {
|
||||||
// isTabTypeAvailable, not a runtime-registered surface. See the header.
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("termix:ai:openWithContext", {
|
||||||
|
detail: { context: typeof context === "string" ? context : "" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unregister() {
|
export async function register({
|
||||||
// Nothing to unregister; see register().
|
registerAction,
|
||||||
|
registerSlotContribution,
|
||||||
|
icons,
|
||||||
|
}) {
|
||||||
|
registerAction(actionId, openWithContext, {
|
||||||
|
permission: "ai.services.use",
|
||||||
|
pluginId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSlotContribution("terminal.toolbar", {
|
||||||
|
actionId,
|
||||||
|
titleKey: "ai.assistant",
|
||||||
|
icon: icons.Bot,
|
||||||
|
kind: "button",
|
||||||
|
pluginId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregister({
|
||||||
|
unregisterAction,
|
||||||
|
unregisterSlotContribution,
|
||||||
|
}) {
|
||||||
|
unregisterSlotContribution("terminal.toolbar", actionId);
|
||||||
|
unregisterAction(actionId);
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,18 @@
|
|||||||
"defaultForRole": {
|
"defaultForRole": {
|
||||||
"admin": ["ai.services.use"]
|
"admin": ["ai.services.use"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "ai.openWithContext",
|
||||||
|
"titleKey": "ai.assistant",
|
||||||
|
"handler": "openWithContext",
|
||||||
|
"icon": "Bot",
|
||||||
|
"permission": "ai.services.use",
|
||||||
|
"slot": "terminal.toolbar",
|
||||||
|
"kind": "button"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"sidecars": []
|
"sidecars": []
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,12 @@
|
|||||||
|
|
||||||
export const id = "ssh-terminal";
|
export const id = "ssh-terminal";
|
||||||
export const tabId = "terminal";
|
export const tabId = "terminal";
|
||||||
|
export const toolbarSlotId = "terminal.toolbar";
|
||||||
|
|
||||||
export async function register({
|
export async function register({
|
||||||
registerRailItem,
|
registerRailItem,
|
||||||
registerTabComponent,
|
registerTabComponent,
|
||||||
|
declareActionSlot,
|
||||||
icons,
|
icons,
|
||||||
}) {
|
}) {
|
||||||
registerTabComponent(tabId, () =>
|
registerTabComponent(tabId, () =>
|
||||||
@@ -30,12 +32,18 @@ export async function register({
|
|||||||
labelKey: "nav.terminal",
|
labelKey: "nav.terminal",
|
||||||
kind: "tab",
|
kind: "tab",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Offers other plugins a place in the terminal toolbar. The terminal does
|
||||||
|
// not know or care who fills it.
|
||||||
|
declareActionSlot({ id: toolbarSlotId, accepts: ["button"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unregister({
|
export async function unregister({
|
||||||
unregisterRailItem,
|
unregisterRailItem,
|
||||||
unregisterTabComponent,
|
unregisterTabComponent,
|
||||||
|
undeclareActionSlot,
|
||||||
}) {
|
}) {
|
||||||
unregisterRailItem(tabId);
|
unregisterRailItem(tabId);
|
||||||
unregisterTabComponent(tabId);
|
unregisterTabComponent(tabId);
|
||||||
|
undeclareActionSlot(toolbarSlotId);
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,12 @@
|
|||||||
"icon": "SquareTerminal",
|
"icon": "SquareTerminal",
|
||||||
"openFrom": ["rail", "host-context-menu", "palette"]
|
"openFrom": ["rail", "host-context-menu", "palette"]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"actionSlots": [
|
||||||
|
{
|
||||||
|
"id": "terminal.toolbar",
|
||||||
|
"accepts": ["button"]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sidecars": []
|
"sidecars": []
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-bad-action",
|
||||||
|
"name": "Sample Bad Action",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An action gated by a permission the plugin never declares.",
|
||||||
|
"author": {
|
||||||
|
"name": "Jane Doe"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"category": "Productivity",
|
||||||
|
"engine": {
|
||||||
|
"termix": ">=2.9.0",
|
||||||
|
"api": "1"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"backend": false,
|
||||||
|
"frontend": true,
|
||||||
|
"electron": false,
|
||||||
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
},
|
||||||
|
"permissions": ["ui.tab"],
|
||||||
|
"contributes": {
|
||||||
|
"permissionGroup": {
|
||||||
|
"group": "samplebadaction",
|
||||||
|
"permissions": ["samplebadaction.use"]
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "samplebadaction.open",
|
||||||
|
"titleKey": "sampleBadAction.open",
|
||||||
|
"handler": "open",
|
||||||
|
"permission": "samplebadaction.never_declared"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sidecars": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-actions",
|
||||||
|
"name": "Sample Actions",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A plugin that contributes a UI action and offers a slot of its own.",
|
||||||
|
"author": {
|
||||||
|
"name": "Jane Doe"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"category": "Productivity",
|
||||||
|
"engine": {
|
||||||
|
"termix": ">=2.9.0",
|
||||||
|
"api": "1"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"backend": false,
|
||||||
|
"frontend": true,
|
||||||
|
"electron": false,
|
||||||
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
},
|
||||||
|
"permissions": ["ui.tab"],
|
||||||
|
"contributes": {
|
||||||
|
"permissionGroup": {
|
||||||
|
"group": "sampleactions",
|
||||||
|
"permissions": ["sampleactions.use"]
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "sampleactions.openWithContext",
|
||||||
|
"titleKey": "sampleActions.open",
|
||||||
|
"handler": "openWithContext",
|
||||||
|
"icon": "Sparkles",
|
||||||
|
"permission": "sampleactions.use",
|
||||||
|
"slot": "terminal.toolbar",
|
||||||
|
"kind": "button"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sampleactions.ungated",
|
||||||
|
"titleKey": "sampleActions.ungated",
|
||||||
|
"handler": "ungated"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actionSlots": [
|
||||||
|
{
|
||||||
|
"id": "sampleactions.panel",
|
||||||
|
"accepts": ["button"],
|
||||||
|
"descriptionKey": "sampleActions.panelDescription"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sidecars": []
|
||||||
|
}
|
||||||
@@ -207,6 +207,63 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[a-z0-9-]+$",
|
"pattern": "^[a-z0-9-]+$",
|
||||||
"description": "Mounted at /plugin-api/<id>/<apiPrefix>/*."
|
"description": "Mounted at /plugin-api/<id>/<apiPrefix>/*."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Named frontend actions this plugin exposes for other plugins to invoke.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "titleKey", "handler"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
|
||||||
|
"description": "Dotted, globally unique action name, e.g. \"ai.openWithContext\"."
|
||||||
|
},
|
||||||
|
"titleKey": { "type": "string", "minLength": 1 },
|
||||||
|
"handler": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$",
|
||||||
|
"description": "Name of the frontend export implementing this action. The runtime binding is registerAction(id, fn)."
|
||||||
|
},
|
||||||
|
"icon": { "type": "string", "minLength": 1 },
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Role permission a user needs. Must also appear in contributes.permissionGroup.permissions."
|
||||||
|
},
|
||||||
|
"slot": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
|
||||||
|
"description": "Slot this action is intended for. Advisory; binding happens at runtime."
|
||||||
|
},
|
||||||
|
"kind": { "type": "string", "enum": ["button"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actionSlots": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Places this plugin offers other plugins to contribute a UI element.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "accepts"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
|
||||||
|
"description": "Dotted slot name, e.g. \"terminal.toolbar\"."
|
||||||
|
},
|
||||||
|
"accepts": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "enum": ["button"] },
|
||||||
|
"description": "Contribution kinds this slot will render. Checked in the frontend at registration time."
|
||||||
|
},
|
||||||
|
"descriptionKey": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ const ID_PATTERN = /^[a-z0-9-]+$/;
|
|||||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||||
|
// Dotted like a service name, but segments may be camelCase: these name a
|
||||||
|
// frontend function ("ai.openWithContext"), not a lowercase service contract.
|
||||||
|
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
||||||
|
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
||||||
|
const ACTION_CONTRIBUTION_KINDS = ["button"];
|
||||||
|
|
||||||
function loadSchema() {
|
function loadSchema() {
|
||||||
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
|
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
|
||||||
@@ -145,6 +150,20 @@ function validateManifest(manifest, schema) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same rule for UI actions: a permission the catalog never sees is one no
|
||||||
|
// admin can grant, so the action would be invisible rather than denied.
|
||||||
|
const actions = manifest.contributes?.actions;
|
||||||
|
if (Array.isArray(actions)) {
|
||||||
|
for (const action of actions) {
|
||||||
|
if (!action || !action.permission) continue;
|
||||||
|
if (!Array.isArray(declared) || !declared.includes(action.permission)) {
|
||||||
|
errors.push(
|
||||||
|
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +324,130 @@ function validateContributes(contributes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("actions" in contributes) {
|
||||||
|
errors.push(...validateActions(contributes.actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("actionSlots" in contributes) {
|
||||||
|
errors.push(...validateActionSlots(contributes.actionSlots));
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateActions(actions) {
|
||||||
|
if (!Array.isArray(actions)) {
|
||||||
|
return ['Field "contributes.actions" must be an array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
actions.forEach((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(`contributes.actions[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(entry.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].id is a duplicate: "${entry.id}"`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
seen.add(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) {
|
||||||
|
errors.push(`contributes.actions[${index}].titleKey is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof entry.handler !== "string" ||
|
||||||
|
!HANDLER_PATTERN.test(entry.handler)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("icon" in entry && typeof entry.icon !== "string") {
|
||||||
|
errors.push(`contributes.actions[${index}].icon must be a string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("permission" in entry && typeof entry.permission !== "string") {
|
||||||
|
errors.push(`contributes.actions[${index}].permission must be a string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"slot" in entry &&
|
||||||
|
(typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateActionSlots(slots) {
|
||||||
|
if (!Array.isArray(slots)) {
|
||||||
|
return ['Field "contributes.actionSlots" must be an array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
slots.forEach((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(`contributes.actionSlots[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(entry.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
seen.add(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (const kind of entry.accepts) {
|
||||||
|
if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].descriptionKey must be a string`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ describe("validate-plugin-manifest.cjs", () => {
|
|||||||
expect(output).toContain("sampleprovider.undeclared");
|
expect(output).toContain("sampleprovider.undeclared");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a manifest declaring actions and actionSlots", () => {
|
||||||
|
const { status, output } = runValidator("valid-actions.json");
|
||||||
|
expect(status).toBe(0);
|
||||||
|
expect(output).toContain("Valid plugin manifest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an action gated by an undeclared permission", () => {
|
||||||
|
const { status, output } = runValidator("invalid-action-permission.json");
|
||||||
|
expect(status).not.toBe(0);
|
||||||
|
expect(output).toContain("samplebadaction.never_declared");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a manifest with an unknown permission", () => {
|
it("rejects a manifest with an unknown permission", () => {
|
||||||
const { status, output } = runValidator("invalid-unknown-permission.json");
|
const { status, output } = runValidator("invalid-unknown-permission.json");
|
||||||
expect(status).not.toBe(0);
|
expect(status).not.toBe(0);
|
||||||
|
|||||||
@@ -1242,6 +1242,48 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/permissions/me:
|
||||||
|
* get:
|
||||||
|
* summary: Get the current user's effective role permissions
|
||||||
|
* description: >
|
||||||
|
* Returns the permission strings granted to the authenticated user
|
||||||
|
* through their roles, plus whether they are an admin. The frontend uses
|
||||||
|
* this to hide UI a user could not use. It is not a security boundary:
|
||||||
|
* every protected route checks again server side.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The current user's permissions.
|
||||||
|
* 401:
|
||||||
|
* description: Not authenticated.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/permissions/me",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const userId = req.userId!;
|
||||||
|
try {
|
||||||
|
// isAdmin travels with the list because hasPermission() falls back to it
|
||||||
|
// on the deny path. Without it the UI would hide things from an admin
|
||||||
|
// that the server would allow.
|
||||||
|
const [permissions, isAdmin] = await Promise.all([
|
||||||
|
permissionManager.getUserPermissions(userId),
|
||||||
|
permissionManager.isAdmin(userId),
|
||||||
|
]);
|
||||||
|
res.json({ permissions, isAdmin });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get current user permissions", error, {
|
||||||
|
operation: "get_my_permissions",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to get permissions" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/roles/{id}:
|
* /rbac/roles/{id}:
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ export const PLUGIN_CATEGORIES = [
|
|||||||
|
|
||||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contribution kinds a UI action slot can accept. Closed on purpose: a
|
||||||
|
* free-form list would validate nothing. Adding a kind is a one-line edit
|
||||||
|
* here and in the two other validators.
|
||||||
|
*/
|
||||||
|
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
|
||||||
|
|
||||||
|
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
|
||||||
|
|
||||||
const REQUIRED_TOP_LEVEL = [
|
const REQUIRED_TOP_LEVEL = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
@@ -72,6 +81,14 @@ const API_VERSION_PATTERN = /^[0-9]+$/;
|
|||||||
const API_PREFIX_PATTERN = /^[a-z0-9-]+$/;
|
const API_PREFIX_PATTERN = /^[a-z0-9-]+$/;
|
||||||
/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */
|
/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */
|
||||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||||
|
/**
|
||||||
|
* Action and slot ids are dotted like service names, but each segment may be
|
||||||
|
* camelCase: these name a frontend function ("ai.openWithContext"), not a
|
||||||
|
* lowercase service contract, so SERVICE_PATTERN is too strict to reuse.
|
||||||
|
*/
|
||||||
|
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
||||||
|
/** A handler names a JS export, so it follows identifier rules, not dotted ones. */
|
||||||
|
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The plugin SDK major version this build implements. A manifest asking for a
|
* The plugin SDK major version this build implements. A manifest asking for a
|
||||||
@@ -114,6 +131,10 @@ export interface PluginManifest {
|
|||||||
settingsPanel?: { titleKey: string };
|
settingsPanel?: { titleKey: string };
|
||||||
dashboardCards?: Array<{ id: string; titleKey: string }>;
|
dashboardCards?: Array<{ id: string; titleKey: string }>;
|
||||||
apiPrefix?: string;
|
apiPrefix?: string;
|
||||||
|
/** Named frontend actions this plugin exposes for other plugins to invoke. */
|
||||||
|
actions?: PluginActionContribution[];
|
||||||
|
/** Places this plugin offers other plugins to contribute a UI element. */
|
||||||
|
actionSlots?: PluginActionSlot[];
|
||||||
};
|
};
|
||||||
dependencies?: { plugins?: Record<string, string> };
|
dependencies?: { plugins?: Record<string, string> };
|
||||||
/**
|
/**
|
||||||
@@ -141,6 +162,49 @@ export interface PluginServiceRequire {
|
|||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named frontend action, declared here and bound at runtime.
|
||||||
|
*
|
||||||
|
* The split matters: this manifest entry is inert JSON the server can read and
|
||||||
|
* audit without running any plugin code, while the function itself is supplied
|
||||||
|
* in the browser by registerAction(id, fn). `handler` records which frontend
|
||||||
|
* export is meant to end up under `id`, so the contract is readable from the
|
||||||
|
* manifest alone and a future frontend loader can wire module[handler]
|
||||||
|
* automatically. Today nothing reads `handler` at runtime.
|
||||||
|
*/
|
||||||
|
export interface PluginActionContribution {
|
||||||
|
/** Dotted and globally unique, e.g. "ai.openWithContext". */
|
||||||
|
id: string;
|
||||||
|
titleKey: string;
|
||||||
|
/** Name of the frontend export implementing this action. */
|
||||||
|
handler: string;
|
||||||
|
/** Lucide icon name, same convention as contributes.tabs[].icon. */
|
||||||
|
icon?: string;
|
||||||
|
/**
|
||||||
|
* Role permission the acting user needs before the action is offered.
|
||||||
|
* Optional: an ungated action renders for everyone. When set, parseManifest
|
||||||
|
* requires it to be declared in this plugin's own permissionGroup.
|
||||||
|
*/
|
||||||
|
permission?: string;
|
||||||
|
/** Slot this action is intended for. Advisory; binding happens at runtime. */
|
||||||
|
slot?: string;
|
||||||
|
kind?: ActionContributionKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A place this plugin offers other plugins to contribute a UI element.
|
||||||
|
*
|
||||||
|
* A contribution's `kind` is checked against `accepts` in the frontend at
|
||||||
|
* registration time, not here: the slot is usually declared by a different
|
||||||
|
* plugin, and a manifest is validated on its own.
|
||||||
|
*/
|
||||||
|
export interface PluginActionSlot {
|
||||||
|
/** Dotted, e.g. "terminal.toolbar". */
|
||||||
|
id: string;
|
||||||
|
accepts: ActionContributionKind[];
|
||||||
|
descriptionKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateManifest(manifest: unknown): string[] {
|
export function validateManifest(manifest: unknown): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
@@ -414,6 +478,133 @@ function validateContributes(contributes: Record<string, unknown>): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("actions" in contributes) {
|
||||||
|
errors.push(...validateActions(contributes.actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("actionSlots" in contributes) {
|
||||||
|
errors.push(...validateActionSlots(contributes.actionSlots));
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateActions(actions: unknown): string[] {
|
||||||
|
if (!Array.isArray(actions)) {
|
||||||
|
return ['Field "contributes.actions" must be an array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
actions.forEach((entry: unknown, index: number) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(`contributes.actions[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(e.id)) {
|
||||||
|
errors.push(`contributes.actions[${index}].id is a duplicate: "${e.id}"`);
|
||||||
|
} else {
|
||||||
|
seen.add(e.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof e.titleKey !== "string" || e.titleKey.length === 0) {
|
||||||
|
errors.push(`contributes.actions[${index}].titleKey is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof e.handler !== "string" || !HANDLER_PATTERN.test(e.handler)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${String(e.handler)}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("icon" in e && (typeof e.icon !== "string" || e.icon.length === 0)) {
|
||||||
|
errors.push(`contributes.actions[${index}].icon must be a string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"permission" in e &&
|
||||||
|
(typeof e.permission !== "string" || e.permission.length === 0)
|
||||||
|
) {
|
||||||
|
errors.push(`contributes.actions[${index}].permission must be a string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"slot" in e &&
|
||||||
|
(typeof e.slot !== "string" || !ACTION_ID_PATTERN.test(e.slot))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${String(e.slot)}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("kind" in e && !ACTION_CONTRIBUTION_KINDS.includes(e.kind as never)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${String(e.kind)}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateActionSlots(slots: unknown): string[] {
|
||||||
|
if (!Array.isArray(slots)) {
|
||||||
|
return ['Field "contributes.actionSlots" must be an array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
slots.forEach((entry: unknown, index: number) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(`contributes.actionSlots[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(e.id)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].id is a duplicate: "${e.id}"`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
seen.add(e.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(e.accepts) || e.accepts.length === 0) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (const kind of e.accepts) {
|
||||||
|
if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].accepts has unknown value: "${String(kind)}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"descriptionKey" in e &&
|
||||||
|
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.actionSlots[${index}].descriptionKey must be a string`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +644,19 @@ export function parseManifest(raw: unknown): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same rule for UI actions, for the same reason: a permission the catalog
|
||||||
|
// never sees is one no admin can grant, so the action would be invisible
|
||||||
|
// forever rather than merely denied.
|
||||||
|
for (const action of manifest.contributes?.actions ?? []) {
|
||||||
|
if (action.permission && !declaredPermissions.includes(action.permission)) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refused outright rather than ignored: a plugin that asked to run in-process
|
// Refused outright rather than ignored: a plugin that asked to run in-process
|
||||||
// and was quietly downgraded to a worker would fail later in a confusing way,
|
// and was quietly downgraded to a worker would fail later in a confusing way,
|
||||||
// and the manifest would still claim reach it does not have.
|
// and the manifest would still claim reach it does not have.
|
||||||
|
|||||||
@@ -188,7 +188,173 @@ describe("plugin manifest validation", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("actions / actionSlots", () => {
|
||||||
|
function withActions(contributes: Record<string, unknown>) {
|
||||||
|
return validateManifest(validManifest({ contributes }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts a valid actions and actionSlots pair", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "ai.openWithContext",
|
||||||
|
titleKey: "ai.assistant",
|
||||||
|
handler: "openWithContext",
|
||||||
|
icon: "Bot",
|
||||||
|
slot: "terminal.toolbar",
|
||||||
|
kind: "button",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actionSlots: [{ id: "terminal.toolbar", accepts: ["button"] }],
|
||||||
|
});
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a camelCase segment in an action id", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "ai.openWithContext",
|
||||||
|
titleKey: "k",
|
||||||
|
handler: "openWithContext",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an action id that is not dotted", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actions: [{ id: "notdotted", titleKey: "k", handler: "h" }],
|
||||||
|
});
|
||||||
|
expect(errors.some((e) => e.includes("actions[0].id must match"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate action id", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actions: [
|
||||||
|
{ id: "a.one", titleKey: "k", handler: "h" },
|
||||||
|
{ id: "a.one", titleKey: "k", handler: "h" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('actions[1].id is a duplicate: "a.one"')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires titleKey and a valid handler", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actions: [{ id: "a.one", handler: "not a identifier" }],
|
||||||
|
});
|
||||||
|
expect(errors.some((e) => e.includes("actions[0].titleKey"))).toBe(true);
|
||||||
|
expect(errors.some((e) => e.includes("actions[0].handler"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown contribution kind", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actions: [
|
||||||
|
{ id: "a.one", titleKey: "k", handler: "h", kind: "dropdown" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes("actions[0].kind must be one of")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown value in a slot's accepts", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actionSlots: [{ id: "terminal.toolbar", accepts: ["menu"] }],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
errors.some((e) =>
|
||||||
|
e.includes('actionSlots[0].accepts has unknown value: "menu"'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty accepts array", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actionSlots: [{ id: "terminal.toolbar", accepts: [] }],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
errors.some((e) =>
|
||||||
|
e.includes("actionSlots[0].accepts must be a non-empty array"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate slot id", () => {
|
||||||
|
const errors = withActions({
|
||||||
|
actionSlots: [
|
||||||
|
{ id: "terminal.toolbar", accepts: ["button"] },
|
||||||
|
{ id: "terminal.toolbar", accepts: ["button"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes("actionSlots[1].id is a duplicate")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseManifest", () => {
|
describe("parseManifest", () => {
|
||||||
|
it("refuses an action gated by an undeclared permission", () => {
|
||||||
|
const { manifest, errors } = parseManifest(
|
||||||
|
validManifest({
|
||||||
|
contributes: {
|
||||||
|
permissionGroup: { group: "ai", permissions: ["ai.use"] },
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "ai.openWithContext",
|
||||||
|
titleKey: "k",
|
||||||
|
handler: "h",
|
||||||
|
permission: "ai.services.use",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(manifest).toBeUndefined();
|
||||||
|
expect(errors[0]).toContain(
|
||||||
|
'Action "ai.openWithContext" is gated by "ai.services.use"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an action gated by a declared permission", () => {
|
||||||
|
const { manifest, errors } = parseManifest(
|
||||||
|
validManifest({
|
||||||
|
contributes: {
|
||||||
|
permissionGroup: {
|
||||||
|
group: "ai",
|
||||||
|
permissions: ["ai.services.use"],
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "ai.openWithContext",
|
||||||
|
titleKey: "k",
|
||||||
|
handler: "h",
|
||||||
|
permission: "ai.services.use",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(manifest?.contributes?.actions?.[0].id).toBe("ai.openWithContext");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an ungated action with no permissionGroup at all", () => {
|
||||||
|
const { errors } = parseManifest(
|
||||||
|
validManifest({
|
||||||
|
contributes: {
|
||||||
|
actions: [{ id: "a.one", titleKey: "k", handler: "h" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("narrows a valid manifest", () => {
|
it("narrows a valid manifest", () => {
|
||||||
const { manifest, errors } = parseManifest(validManifest());
|
const { manifest, errors } = parseManifest(validManifest());
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
||||||
|
import { resetPermissionsCache } from "@/hooks/use-permissions";
|
||||||
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
||||||
import { AppRail, type RailView } from "@/sidebar/AppRail";
|
import { AppRail, type RailView } from "@/sidebar/AppRail";
|
||||||
import {
|
import {
|
||||||
@@ -967,6 +968,8 @@ export function AppShell({
|
|||||||
const manual =
|
const manual =
|
||||||
event instanceof CustomEvent &&
|
event instanceof CustomEvent &&
|
||||||
(event.detail as { manual?: boolean } | undefined)?.manual === true;
|
(event.detail as { manual?: boolean } | undefined)?.manual === true;
|
||||||
|
// Drop the cached grants so the next user never inherits them.
|
||||||
|
resetPermissionsCache();
|
||||||
onLogout(manual ? { manual: true } : undefined);
|
onLogout(manual ? { manual: true } : undefined);
|
||||||
};
|
};
|
||||||
window.addEventListener("termix:logout", handle);
|
window.addEventListener("termix:logout", handle);
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export async function updateRole(
|
|||||||
): Promise<{ role: Role }> {
|
): Promise<{ role: Role }> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
|
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
|
||||||
|
if (roleData.permissions) {
|
||||||
|
// The editing admin may hold this role, so re-read our own grants
|
||||||
|
// rather than leaving gated UI stale until the next reload.
|
||||||
|
const { notifyPermissionsChanged } =
|
||||||
|
await import("@/hooks/use-permissions");
|
||||||
|
notifyPermissionsChanged();
|
||||||
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update role");
|
throw handleApiError(error, "update role");
|
||||||
@@ -277,6 +284,21 @@ export async function getPermissionsCatalog(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MyPermissions {
|
||||||
|
permissions: string[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current user's own grants, for hiding UI they could not use. */
|
||||||
|
export async function getMyPermissions(): Promise<MyPermissions> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.get("/rbac/permissions/me");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSharedHosts(): Promise<{
|
export async function getSharedHosts(): Promise<{
|
||||||
sharedHosts: Array<{
|
sharedHosts: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
resolveTerminalFontFamily,
|
resolveTerminalFontFamily,
|
||||||
} from "@/lib/terminal-themes.ts";
|
} from "@/lib/terminal-themes.ts";
|
||||||
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
|
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
|
||||||
|
import { getTerminalBufferText } from "./terminal-buffer-text.ts";
|
||||||
import { useTheme } from "@/components/theme-provider.tsx";
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||||
import { getMacLineNavigationSequence } from "@/lib/mac-line-navigation";
|
import { getMacLineNavigationSequence } from "@/lib/mac-line-navigation";
|
||||||
@@ -463,6 +464,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const [autosuggestionStyle, setAutosuggestionStyle] =
|
const [autosuggestionStyle, setAutosuggestionStyle] =
|
||||||
useState<React.CSSProperties>({});
|
useState<React.CSSProperties>({});
|
||||||
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
|
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
|
||||||
|
const [aiAssistantContext, setAiAssistantContext] = useState("");
|
||||||
const { userEnabled: aiAssistantEnabledForUser } = useAiAvailability();
|
const { userEnabled: aiAssistantEnabledForUser } = useAiAvailability();
|
||||||
const isAiAssistantAvailable =
|
const isAiAssistantAvailable =
|
||||||
aiAssistantEnabledForUser && host?.enableAiAssistant === true;
|
aiAssistantEnabledForUser && host?.enableAiAssistant === true;
|
||||||
@@ -733,6 +735,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
setAiAssistantOpen((open) => !open);
|
setAiAssistantOpen((open) => !open);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A plugin-contributed action (the toolbar's AI button) asks for the panel
|
||||||
|
// this way, so the contribution needs no reference into the terminal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFocusedPane) return;
|
||||||
|
const open = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ context?: string }>).detail;
|
||||||
|
setAiAssistantContext(detail?.context ?? "");
|
||||||
|
setAiAssistantOpen(true);
|
||||||
|
};
|
||||||
|
window.addEventListener("termix:ai:openWithContext", open);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("termix:ai:openWithContext", open);
|
||||||
|
}, [isFocusedPane]);
|
||||||
|
|
||||||
const closeAiAssistant = useCallback(() => {
|
const closeAiAssistant = useCallback(() => {
|
||||||
setAiAssistantOpen(false);
|
setAiAssistantOpen(false);
|
||||||
setTimeout(() => terminal?.focus(), 50);
|
setTimeout(() => terminal?.focus(), 50);
|
||||||
@@ -3844,8 +3860,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isFocused={isFocusedPane}
|
isFocused={isFocusedPane}
|
||||||
showAiAssistant={isAiAssistantAvailable}
|
actionsEnabled={isAiAssistantAvailable}
|
||||||
onToggleAiAssistant={toggleAiAssistant}
|
getBufferText={() => getTerminalBufferText(terminal)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3854,6 +3870,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
|
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
|
||||||
hostId={hostConfig.id}
|
hostId={hostConfig.id}
|
||||||
activeTab={`terminal:${hostConfig.name || hostConfig.ip}`}
|
activeTab={`terminal:${hostConfig.name || hostConfig.ip}`}
|
||||||
|
initialContext={aiAssistantContext}
|
||||||
onClose={closeAiAssistant}
|
onClose={closeAiAssistant}
|
||||||
onRunInTerminal={handleRunCommandInTerminal}
|
onRunInTerminal={handleRunCommandInTerminal}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface TerminalAiPanelProps {
|
|||||||
hostLabel: string;
|
hostLabel: string;
|
||||||
hostId: number;
|
hostId: number;
|
||||||
activeTab?: string | null;
|
activeTab?: string | null;
|
||||||
|
/** Terminal output to open with, from a contributed action. */
|
||||||
|
initialContext?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRunInTerminal: (command: string) => void;
|
onRunInTerminal: (command: string) => void;
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,7 @@ export function TerminalAiPanel({
|
|||||||
hostLabel,
|
hostLabel,
|
||||||
hostId,
|
hostId,
|
||||||
activeTab,
|
activeTab,
|
||||||
|
initialContext,
|
||||||
onClose,
|
onClose,
|
||||||
onRunInTerminal,
|
onRunInTerminal,
|
||||||
}: TerminalAiPanelProps) {
|
}: TerminalAiPanelProps) {
|
||||||
@@ -73,6 +76,17 @@ export function TerminalAiPanel({
|
|||||||
>({});
|
>({});
|
||||||
const conversationIdRef = useRef<number | null>(null);
|
const conversationIdRef = useRef<number | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Prefilled rather than sent: the user sees exactly what the terminal is
|
||||||
|
// about to hand the model, and can trim it or add a question first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialContext) return;
|
||||||
|
setInput((current) =>
|
||||||
|
current
|
||||||
|
? current
|
||||||
|
: t("ai.terminalContextPrefill", { output: initialContext }),
|
||||||
|
);
|
||||||
|
}, [initialContext, t]);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const [position, setPosition] = useState<ToolbarPosition>(
|
const [position, setPosition] = useState<ToolbarPosition>(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Bot,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
@@ -26,6 +25,8 @@ import {
|
|||||||
} from "@/components/select";
|
} from "@/components/select";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ActionSlot } from "@/shell/ActionSlot";
|
||||||
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
|
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,9 @@ type SelectedToolbarDensity = ToolbarDensity;
|
|||||||
|
|
||||||
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
|
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
|
||||||
|
|
||||||
|
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
|
||||||
|
const TOOLBAR_SLOT_ID = "terminal.toolbar";
|
||||||
|
|
||||||
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
|
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
|
||||||
{ value: "icon" },
|
{ value: "icon" },
|
||||||
{ value: "labeled" },
|
{ value: "labeled" },
|
||||||
@@ -120,8 +124,14 @@ interface TerminalToolbarProps {
|
|||||||
onOpenTab?: (type: TabType) => void;
|
onOpenTab?: (type: TabType) => void;
|
||||||
onOpenFiles?: () => void;
|
onOpenFiles?: () => void;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
showAiAssistant?: boolean;
|
/**
|
||||||
onToggleAiAssistant?: () => void;
|
* Whether this host offers the toolbar's contributed actions at all. The
|
||||||
|
* slot itself is generic; the host-level AI flag is not something a
|
||||||
|
* contributing plugin can know about.
|
||||||
|
*/
|
||||||
|
actionsEnabled?: boolean;
|
||||||
|
/** Read at click time by contributions that want the visible scrollback. */
|
||||||
|
getBufferText?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||||
@@ -136,10 +146,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
onOpenTab,
|
onOpenTab,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
isFocused,
|
isFocused,
|
||||||
showAiAssistant,
|
actionsEnabled = true,
|
||||||
onToggleAiAssistant,
|
getBufferText,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Drives both the measurement copy and the separator, so the toolbar sizes
|
||||||
|
// itself correctly whether or not anything contributed.
|
||||||
|
const slotContributions = useActionSlot(TOOLBAR_SLOT_ID);
|
||||||
const [density, setDensity] =
|
const [density, setDensity] =
|
||||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||||
const [responsiveDensity, setResponsiveDensity] =
|
const [responsiveDensity, setResponsiveDensity] =
|
||||||
@@ -261,7 +274,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isMobile,
|
isMobile,
|
||||||
isTmuxAttached,
|
isTmuxAttached,
|
||||||
showAiAssistant,
|
actionsEnabled,
|
||||||
|
slotContributions,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -745,12 +759,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
<ClipboardPaste className="size-4" />
|
<ClipboardPaste className="size-4" />
|
||||||
{density !== "icon" && t("terminalToolbar.paste")}
|
{density !== "icon" && t("terminalToolbar.paste")}
|
||||||
</span>
|
</span>
|
||||||
{showAiAssistant && (
|
{actionsEnabled &&
|
||||||
<span className={CONTROL}>
|
slotContributions.map((contribution) => (
|
||||||
<Bot className="size-4" />
|
<span key={contribution.actionId} className={CONTROL}>
|
||||||
{density !== "icon" && t("ai.assistant")}
|
{contribution.icon && <contribution.icon className="size-4" />}
|
||||||
</span>
|
{density !== "icon" && t(contribution.titleKey)}
|
||||||
)}
|
</span>
|
||||||
|
))}
|
||||||
<span className={cn(CONTROL, "h-8 px-2")}>
|
<span className={cn(CONTROL, "h-8 px-2")}>
|
||||||
<LayoutGrid className="size-4" />
|
<LayoutGrid className="size-4" />
|
||||||
<ChevronDown className="size-4" />
|
<ChevronDown className="size-4" />
|
||||||
@@ -832,19 +847,16 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
>
|
>
|
||||||
{imageButtons}
|
{imageButtons}
|
||||||
</div>
|
</div>
|
||||||
{showAiAssistant && (
|
{actionsEnabled && slotContributions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className={SEPARATOR} />
|
<div className={SEPARATOR} />
|
||||||
<button
|
<ActionSlot
|
||||||
type="button"
|
slotId={TOOLBAR_SLOT_ID}
|
||||||
className={CONTROL}
|
className={CONTROL}
|
||||||
aria-label={t("ai.assistant")}
|
enabled={actionsEnabled}
|
||||||
title={t("ai.assistant")}
|
hideLabels={effectiveDensity === "icon"}
|
||||||
onClick={onToggleAiAssistant}
|
context={() => [getBufferText?.() ?? ""]}
|
||||||
>
|
/>
|
||||||
<Bot className="size-4 shrink-0" />
|
|
||||||
{effectiveDensity !== "icon" && t("ai.assistant")}
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={SEPARATOR} />
|
<div className={SEPARATOR} />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Terminal } from "@xterm/xterm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much scrollback to hand out by default. Bounded on purpose: a
|
||||||
|
* long-running session holds megabytes, and nothing consuming this wants all
|
||||||
|
* of it.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_BUFFER_CONTEXT_LINES = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the tail of a terminal's buffer to plain text.
|
||||||
|
*
|
||||||
|
* Wrapped rows are joined back onto the line they continue. xterm splits one
|
||||||
|
* logical line across several buffer rows when it wraps, so emitting a newline
|
||||||
|
* per row would chop long commands in half.
|
||||||
|
*/
|
||||||
|
export function getTerminalBufferText(
|
||||||
|
terminal: Terminal | null | undefined,
|
||||||
|
maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES,
|
||||||
|
): string {
|
||||||
|
if (!terminal || maxLines <= 0) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = terminal.buffer.active;
|
||||||
|
const end = buffer.baseY + terminal.rows;
|
||||||
|
const start = Math.max(0, end - maxLines);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const line = buffer.getLine(i);
|
||||||
|
if (!line) continue;
|
||||||
|
const text = line.translateToString(true);
|
||||||
|
if (line.isWrapped && lines.length > 0) {
|
||||||
|
lines[lines.length - 1] += text;
|
||||||
|
} else {
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||||
|
return lines.join("\n");
|
||||||
|
} catch {
|
||||||
|
// Called from a click handler. A disposed terminal returns nothing
|
||||||
|
// rather than breaking the click.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
|
import {
|
||||||
|
getSlotContributions,
|
||||||
|
subscribeToActionRegistry,
|
||||||
|
getActionPermission,
|
||||||
|
type SlotContribution,
|
||||||
|
} from "@/shell/action-registry";
|
||||||
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contributions in a slot that the current user may actually see.
|
||||||
|
*
|
||||||
|
* A slot owner calls this without knowing which plugins contributed, or
|
||||||
|
* whether any did. Contributions the user lacks the permission for are
|
||||||
|
* dropped entirely rather than disabled, which is how admin-gated UI already
|
||||||
|
* behaves elsewhere in the app.
|
||||||
|
*/
|
||||||
|
export function useActionSlot(slotId: string): SlotContribution[] {
|
||||||
|
const contributions = useSyncExternalStore(
|
||||||
|
subscribeToActionRegistry,
|
||||||
|
() => getSlotContributions(slotId),
|
||||||
|
() => getSlotContributions(slotId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { has, loaded } = usePermissions();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
// Render nothing until the grants are known. A button that appears and
|
||||||
|
// then vanishes is worse than one that appears a moment late.
|
||||||
|
if (!loaded) return [];
|
||||||
|
return contributions.filter((contribution) => {
|
||||||
|
const permission =
|
||||||
|
getActionPermission(contribution.actionId) ?? undefined;
|
||||||
|
return !permission || has(permission);
|
||||||
|
});
|
||||||
|
}, [contributions, has, loaded]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { matchesPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export interface PermissionsState {
|
||||||
|
permissions: string[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
/** False until the call answers, so gated UI does not flash in or out. */
|
||||||
|
loaded: boolean;
|
||||||
|
has: (permission: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when a user's grants may have changed, so every surface re-reads them. */
|
||||||
|
export const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
|
||||||
|
|
||||||
|
interface CachedPermissions {
|
||||||
|
permissions: string[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared across every hook instance. A terminal per split pane would otherwise
|
||||||
|
* fire one identical request each.
|
||||||
|
*/
|
||||||
|
let cache: CachedPermissions | null = null;
|
||||||
|
let inFlight: Promise<CachedPermissions> | null = null;
|
||||||
|
|
||||||
|
function load(): Promise<CachedPermissions> {
|
||||||
|
if (cache) return Promise.resolve(cache);
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
|
||||||
|
inFlight = import("@/api/rbac-api")
|
||||||
|
.then(({ getMyPermissions }) => getMyPermissions())
|
||||||
|
.then((result) => {
|
||||||
|
cache = {
|
||||||
|
permissions: result.permissions ?? [],
|
||||||
|
isAdmin: !!result.isAdmin,
|
||||||
|
};
|
||||||
|
return cache;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Deny by default. A failed lookup hides gated UI rather than showing
|
||||||
|
// something the server would refuse anyway.
|
||||||
|
cache = { permissions: [], isAdmin: false };
|
||||||
|
return cache;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyPermissionsChanged(): void {
|
||||||
|
cache = null;
|
||||||
|
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current user's grants.
|
||||||
|
*
|
||||||
|
* Freshness is eventually consistent: the backend caches per user with a TTL
|
||||||
|
* and this caches until notifyPermissionsChanged fires, so a revoke can leave
|
||||||
|
* a stale button on screen briefly. That is acceptable because the button is
|
||||||
|
* not the boundary - clicking it hits a route that checks again and refuses.
|
||||||
|
*/
|
||||||
|
export function usePermissions(): PermissionsState {
|
||||||
|
const [state, setState] = useState<CachedPermissions & { loaded: boolean }>(
|
||||||
|
() =>
|
||||||
|
cache
|
||||||
|
? { ...cache, loaded: true }
|
||||||
|
: { permissions: [], isAdmin: false, loaded: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
load().then((result) => {
|
||||||
|
if (!cancelled) setState({ ...result, loaded: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
window.addEventListener(PERMISSIONS_CHANGED_EVENT, refresh);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener(PERMISSIONS_CHANGED_EVENT, refresh);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const has = useCallback(
|
||||||
|
(permission: string) =>
|
||||||
|
matchesPermission(state.permissions, state.isAdmin, permission),
|
||||||
|
[state.permissions, state.isAdmin],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...state, has };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function resetPermissionsCache(): void {
|
||||||
|
cache = null;
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Frontend copy of the permission matching rules.
|
||||||
|
*
|
||||||
|
* This mirrors PermissionManager.hasPermission in
|
||||||
|
* src/backend/utils/permission-manager.ts and has to stay in step with it. It
|
||||||
|
* is a second implementation of one rule, which is a real cost, accepted here
|
||||||
|
* only because this copy is cosmetic: it decides what UI to show, never what
|
||||||
|
* a request is allowed to do. The server checks again on every route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a granted permission set covers the one required.
|
||||||
|
*
|
||||||
|
* Order matches the backend: the "*" superuser grant, an exact match, then
|
||||||
|
* progressively shorter dotted wildcards, then admin last.
|
||||||
|
*/
|
||||||
|
export function matchesPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
isAdmin: boolean,
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes("*")) return true;
|
||||||
|
if (granted.includes(required)) return true;
|
||||||
|
|
||||||
|
const parts = required.split(".");
|
||||||
|
for (let i = parts.length; i > 0; i--) {
|
||||||
|
if (granted.includes(parts.slice(0, i).join(".") + ".*")) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAdmin;
|
||||||
|
}
|
||||||
@@ -5048,7 +5048,8 @@
|
|||||||
"terminalPanelPlaceholder": "Ask about this host...",
|
"terminalPanelPlaceholder": "Ask about this host...",
|
||||||
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
|
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
|
||||||
"runsInOpenTerminal": "Runs in your open terminal session.",
|
"runsInOpenTerminal": "Runs in your open terminal session.",
|
||||||
"runInTerminal": "Run"
|
"runInTerminal": "Run",
|
||||||
|
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n"
|
||||||
},
|
},
|
||||||
"macros": {
|
"macros": {
|
||||||
"title": "Macros",
|
"title": "Macros",
|
||||||
|
|||||||
@@ -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.
|
* bigger regression risk to a working terminal than gating the entry points.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
import { unregisterRailItem } from "@/sidebar/rail-items";
|
import { unregisterRailItem } from "@/sidebar/rail-items";
|
||||||
import { unregisterTabComponent } from "@/shell/tabUtils";
|
import { unregisterTabComponent } from "@/shell/tabUtils";
|
||||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
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
|
* 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 pluginEnabled = new Map<string, boolean>();
|
||||||
let disabledTabTypes = new Set<string>();
|
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 {
|
export function applyPluginState(plugins: PluginSummary[]): void {
|
||||||
const nextEnabled = new Map<string, boolean>();
|
const nextEnabled = new Map<string, boolean>();
|
||||||
const nextDisabledTabs = new Set<string>();
|
const nextDisabledTabs = new Set<string>();
|
||||||
@@ -65,6 +117,7 @@ export function applyPluginState(plugins: PluginSummary[]): void {
|
|||||||
|
|
||||||
pluginEnabled = nextEnabled;
|
pluginEnabled = nextEnabled;
|
||||||
disabledTabTypes = nextDisabledTabs;
|
disabledTabTypes = nextDisabledTabs;
|
||||||
|
applyFirstPartyActions(nextEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
||||||
@@ -92,4 +145,6 @@ export function isPluginEnabled(pluginId: string): boolean {
|
|||||||
export function resetPluginState(): void {
|
export function resetPluginState(): void {
|
||||||
pluginEnabled = new Map();
|
pluginEnabled = new Map();
|
||||||
disabledTabTypes = new Set();
|
disabledTabTypes = new Set();
|
||||||
|
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
|
||||||
|
unregisterAction(AI_OPEN_WITH_CONTEXT);
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,16 @@ vi.mock("@/sidebar/tree/HostItem/HostItem", () => hostActionsApi);
|
|||||||
vi.mock("@/hooks/use-mobile", () => ({
|
vi.mock("@/hooks/use-mobile", () => ({
|
||||||
useIsMobile: () => mobileApi.isMobile,
|
useIsMobile: () => mobileApi.isMobile,
|
||||||
}));
|
}));
|
||||||
|
const perms = vi.hoisted(() => ({ granted: [] as string[] }));
|
||||||
|
vi.mock("@/hooks/use-permissions", () => ({
|
||||||
|
usePermissions: () => ({
|
||||||
|
permissions: perms.granted,
|
||||||
|
isAdmin: false,
|
||||||
|
loaded: true,
|
||||||
|
has: (permission: string) => perms.granted.includes(permission),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", () => ({
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string) =>
|
t: (key: string) =>
|
||||||
@@ -54,11 +64,18 @@ vi.mock("react-i18next", () => ({
|
|||||||
"terminalToolbar.imageActionFailed": "Image action failed",
|
"terminalToolbar.imageActionFailed": "Image action failed",
|
||||||
"terminalToolbar.retry": "Retry",
|
"terminalToolbar.retry": "Retry",
|
||||||
"terminalToolbar.dismissError": "Dismiss image error",
|
"terminalToolbar.dismissError": "Dismiss image error",
|
||||||
|
"ai.assistant": "AI Assistant",
|
||||||
})[key] ?? key,
|
})[key] ?? key,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TerminalToolbar } from "@/features/terminal/TerminalToolbar";
|
import { TerminalToolbar } from "@/features/terminal/TerminalToolbar";
|
||||||
|
import {
|
||||||
|
declareActionSlot,
|
||||||
|
registerAction,
|
||||||
|
registerSlotContribution,
|
||||||
|
resetActionRegistry,
|
||||||
|
} from "@/shell/action-registry";
|
||||||
import {
|
import {
|
||||||
clampToolbarPosition,
|
clampToolbarPosition,
|
||||||
getResponsiveToolbarDensity,
|
getResponsiveToolbarDensity,
|
||||||
@@ -96,6 +113,8 @@ beforeEach(() => {
|
|||||||
value: 1024,
|
value: 1024,
|
||||||
});
|
});
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
perms.granted = [];
|
||||||
|
resetActionRegistry();
|
||||||
hostActionsApi.getSshActions.mockReturnValue([]);
|
hostActionsApi.getSshActions.mockReturnValue([]);
|
||||||
api.startMetricsPolling.mockResolvedValue({ viewerSessionId: "viewer" });
|
api.startMetricsPolling.mockResolvedValue({ viewerSessionId: "viewer" });
|
||||||
api.getServerMetricsById.mockResolvedValue(null);
|
api.getServerMetricsById.mockResolvedValue(null);
|
||||||
@@ -891,4 +910,67 @@ describe("TerminalToolbar Phase 1", () => {
|
|||||||
expect(api.stopMetricsPolling).toHaveBeenCalledWith(7, "late-viewer"),
|
expect(api.stopMetricsPolling).toHaveBeenCalledWith(7, "late-viewer"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("contributed toolbar actions", () => {
|
||||||
|
const ACTION = "ai.openWithContext";
|
||||||
|
|
||||||
|
function contribute() {
|
||||||
|
const handler = vi.fn();
|
||||||
|
declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] });
|
||||||
|
registerAction(ACTION, handler, { permission: "ai.services.use" });
|
||||||
|
registerSlotContribution("terminal.toolbar", {
|
||||||
|
actionId: ACTION,
|
||||||
|
titleKey: "ai.assistant",
|
||||||
|
});
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows no contributed button and no stray separator when nothing contributed", () => {
|
||||||
|
declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] });
|
||||||
|
const { container } = renderToolbar();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||||
|
const separatorsWithout = container.querySelectorAll(".w-px").length;
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
contribute();
|
||||||
|
perms.granted = ["ai.services.use"];
|
||||||
|
const withButton = renderToolbar().container;
|
||||||
|
|
||||||
|
expect(withButton.querySelectorAll(".w-px").length).toBe(
|
||||||
|
separatorsWithout + 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the contributed button when the user lacks the permission", () => {
|
||||||
|
contribute();
|
||||||
|
renderToolbar();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes the action with the terminal buffer text when permitted", async () => {
|
||||||
|
const handler = contribute();
|
||||||
|
perms.granted = ["ai.services.use"];
|
||||||
|
const getBufferText = vi.fn(() => "$ whoami\nroot");
|
||||||
|
|
||||||
|
renderToolbar({ getBufferText });
|
||||||
|
|
||||||
|
expect(getBufferText).not.toHaveBeenCalled();
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "AI Assistant" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith("$ whoami\nroot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the slot when the host has contributed actions disabled", () => {
|
||||||
|
contribute();
|
||||||
|
perms.granted = ["ai.services.use"];
|
||||||
|
|
||||||
|
renderToolbar({ actionsEnabled: false });
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Terminal } from "@xterm/xterm";
|
||||||
|
import { getTerminalBufferText } from "@/features/terminal/terminal-buffer-text";
|
||||||
|
|
||||||
|
interface FakeLine {
|
||||||
|
text: string;
|
||||||
|
wrapped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal stand-in for the parts of xterm this reads. */
|
||||||
|
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
baseY: Math.max(0, lines.length - rows),
|
||||||
|
getLine: (i: number) => {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line) return undefined;
|
||||||
|
return {
|
||||||
|
isWrapped: !!line.wrapped,
|
||||||
|
translateToString: () => line.text,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getTerminalBufferText", () => {
|
||||||
|
it("returns an empty string for a missing terminal", () => {
|
||||||
|
expect(getTerminalBufferText(null)).toBe("");
|
||||||
|
expect(getTerminalBufferText(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins buffer rows with newlines", () => {
|
||||||
|
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
|
||||||
|
|
||||||
|
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins a wrapped row onto the line it continues", () => {
|
||||||
|
const terminal = fakeTerminal([
|
||||||
|
{ text: "$ echo start" },
|
||||||
|
{ text: "a-very-long-command " },
|
||||||
|
{ text: "that-wrapped", wrapped: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getTerminalBufferText(terminal)).toBe(
|
||||||
|
"$ echo start\na-very-long-command that-wrapped",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops trailing blank lines", () => {
|
||||||
|
const terminal = fakeTerminal([
|
||||||
|
{ text: "output" },
|
||||||
|
{ text: "" },
|
||||||
|
{ text: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getTerminalBufferText(terminal)).toBe("output");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps blank lines in the middle", () => {
|
||||||
|
const terminal = fakeTerminal([
|
||||||
|
{ text: "one" },
|
||||||
|
{ text: "" },
|
||||||
|
{ text: "two" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the result to maxLines, keeping the tail", () => {
|
||||||
|
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
|
||||||
|
const terminal = fakeTerminal(lines, 50);
|
||||||
|
|
||||||
|
const result = getTerminalBufferText(terminal, 3);
|
||||||
|
|
||||||
|
expect(result).toBe("line 47\nline 48\nline 49");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string for a non-positive maxLines", () => {
|
||||||
|
const terminal = fakeTerminal([{ text: "anything" }]);
|
||||||
|
|
||||||
|
expect(getTerminalBufferText(terminal, 0)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string when the buffer throws", () => {
|
||||||
|
const disposed = {
|
||||||
|
rows: 1,
|
||||||
|
get buffer(): never {
|
||||||
|
throw new Error("terminal disposed");
|
||||||
|
},
|
||||||
|
} as unknown as Terminal;
|
||||||
|
|
||||||
|
expect(getTerminalBufferText(disposed)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { matchesPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These cases mirror PermissionManager.hasPermission on the backend. If that
|
||||||
|
* changes, this file is the tripwire.
|
||||||
|
*/
|
||||||
|
describe("matchesPermission", () => {
|
||||||
|
it("grants on an exact match", () => {
|
||||||
|
expect(
|
||||||
|
matchesPermission(["ai.services.use"], false, "ai.services.use"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants on the superuser wildcard", () => {
|
||||||
|
expect(matchesPermission(["*"], false, "anything.at.all")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants on each dotted wildcard level", () => {
|
||||||
|
expect(matchesPermission(["ai.services.*"], false, "ai.services.use")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(matchesPermission(["ai.*"], false, "ai.services.use")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants to an admin with no explicit permissions", () => {
|
||||||
|
expect(matchesPermission([], true, "ai.services.use")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies a non-admin without a matching grant", () => {
|
||||||
|
expect(matchesPermission([], false, "ai.services.use")).toBe(false);
|
||||||
|
expect(matchesPermission(["hosts.view"], false, "ai.services.use")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a prefix as a wildcard without the star", () => {
|
||||||
|
expect(matchesPermission(["ai.services"], false, "ai.services.use")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a sibling wildcard grant an unrelated permission", () => {
|
||||||
|
expect(matchesPermission(["hosts.*"], false, "ai.services.use")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a single-segment permission", () => {
|
||||||
|
expect(matchesPermission(["admin"], false, "admin")).toBe(true);
|
||||||
|
// "admin.*" covers bare "admin" too: the backend builds the same
|
||||||
|
// single-segment wildcard, so the frontend must agree.
|
||||||
|
expect(matchesPermission(["admin.*"], false, "admin")).toBe(true);
|
||||||
|
expect(matchesPermission(["other"], false, "admin")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* The three states a permission-gated slot contribution can be in, plus the
|
||||||
|
* reactivity that makes a slot different from the rail/tab registries.
|
||||||
|
*
|
||||||
|
* Follows the pattern of sidebar/plugin-extension-seam.test.tsx: register into
|
||||||
|
* the real registry, assert through a real render, then clean up. The point is
|
||||||
|
* that states (a) "nothing contributed" and (b) "contributed but not
|
||||||
|
* permitted" are indistinguishable in the DOM.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({
|
||||||
|
permissions: [] as string[],
|
||||||
|
isAdmin: false,
|
||||||
|
loaded: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-permissions", async () => {
|
||||||
|
const { matchesPermission } = await import("@/lib/permissions");
|
||||||
|
return {
|
||||||
|
usePermissions: () => ({
|
||||||
|
permissions: perms.permissions,
|
||||||
|
isAdmin: perms.isAdmin,
|
||||||
|
loaded: perms.loaded,
|
||||||
|
has: (permission: string) =>
|
||||||
|
matchesPermission(perms.permissions, perms.isAdmin, permission),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { ActionSlot } from "@/shell/ActionSlot";
|
||||||
|
import {
|
||||||
|
declareActionSlot,
|
||||||
|
registerAction,
|
||||||
|
registerSlotContribution,
|
||||||
|
resetActionRegistry,
|
||||||
|
getSlotContributions,
|
||||||
|
unregisterSlotContribution,
|
||||||
|
} from "@/shell/action-registry";
|
||||||
|
|
||||||
|
const SLOT = "terminal.toolbar";
|
||||||
|
const ACTION = "ai.openWithContext";
|
||||||
|
|
||||||
|
function contributeAi(handler = vi.fn()) {
|
||||||
|
registerAction(ACTION, handler, { permission: "ai.services.use" });
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: ACTION,
|
||||||
|
titleKey: "ai.assistant",
|
||||||
|
icon: Bot,
|
||||||
|
});
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ActionSlot", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
perms.permissions = [];
|
||||||
|
perms.isAdmin = false;
|
||||||
|
perms.loaded = true;
|
||||||
|
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetActionRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when no plugin has contributed", () => {
|
||||||
|
const { container } = render(<ActionSlot slotId={SLOT} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when the user lacks the permission", () => {
|
||||||
|
const handler = contributeAi();
|
||||||
|
|
||||||
|
const { container } = render(<ActionSlot slotId={SLOT} />);
|
||||||
|
|
||||||
|
// Identical to the no-contribution case: no placeholder, and nothing
|
||||||
|
// merely disabled or greyed out that hints at a feature out of reach.
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
expect(screen.queryByText("ai.assistant")).toBeNull();
|
||||||
|
expect(container.querySelector("[disabled]")).toBeNull();
|
||||||
|
expect(container.querySelector("[aria-disabled]")).toBeNull();
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the button and invokes the action when permitted", async () => {
|
||||||
|
const handler = contributeAi();
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} context={() => ["buffer text"]} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "ai.assistant" });
|
||||||
|
await act(async () => {
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler).toHaveBeenCalledWith("buffer text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads its context at click time, not on render", async () => {
|
||||||
|
const handler = contributeAi();
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
const context = vi.fn(() => ["later"]);
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} context={context} />);
|
||||||
|
expect(context).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("button", { name: "ai.assistant" }).click();
|
||||||
|
});
|
||||||
|
expect(context).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler).toHaveBeenCalledWith("later");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appears when a contribution is registered after mount", () => {
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} />);
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
|
||||||
|
// No re-render from the test: useSyncExternalStore has to do this. A
|
||||||
|
// plain Map like the rail/tab registries would fail here.
|
||||||
|
act(() => {
|
||||||
|
contributeAi();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disappears again when the contribution is removed", () => {
|
||||||
|
contributeAi();
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} />);
|
||||||
|
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
unregisterSlotContribution(SLOT, ACTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a wildcard-granted contribution", () => {
|
||||||
|
contributeAi();
|
||||||
|
perms.permissions = ["ai.*"];
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders for an admin with no explicit grants", () => {
|
||||||
|
contributeAi();
|
||||||
|
perms.isAdmin = true;
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an ungated contribution for everyone", () => {
|
||||||
|
registerAction("demo.ping", vi.fn());
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: "demo.ping",
|
||||||
|
titleKey: "demo.ping",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ActionSlot slotId={SLOT} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "demo.ping" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing while permissions are still loading", () => {
|
||||||
|
contributeAi();
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
perms.loaded = false;
|
||||||
|
|
||||||
|
const { container } = render(<ActionSlot slotId={SLOT} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when the owner disables the slot", () => {
|
||||||
|
contributeAi();
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
|
||||||
|
const { container } = render(<ActionSlot slotId={SLOT} enabled={false} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the slot owner render contributions its own way", () => {
|
||||||
|
contributeAi();
|
||||||
|
perms.permissions = ["ai.services.use"];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ActionSlot
|
||||||
|
slotId={SLOT}
|
||||||
|
renderItem={(contribution, invoke) => (
|
||||||
|
<span data-testid="custom" onClick={invoke}>
|
||||||
|
{contribution.titleKey}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("custom")).toHaveTextContent("ai.assistant");
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("action registry", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
resetActionRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a contribution whose kind the slot does not accept", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||||
|
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: "demo.menu",
|
||||||
|
titleKey: "demo.menu",
|
||||||
|
kind: "menu-item",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSlotContributions(SLOT)).toHaveLength(0);
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a contribution registered before its slot is declared", () => {
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: ACTION,
|
||||||
|
titleKey: "ai.assistant",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSlotContributions(SLOT)).toHaveLength(1);
|
||||||
|
|
||||||
|
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||||
|
expect(getSlotContributions(SLOT)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a stable snapshot reference between mutations", () => {
|
||||||
|
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: ACTION,
|
||||||
|
titleKey: "ai.assistant",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard against useSyncExternalStore's infinite re-render loop.
|
||||||
|
expect(getSlotContributions(SLOT)).toBe(getSlotContributions(SLOT));
|
||||||
|
|
||||||
|
const before = getSlotContributions(SLOT);
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: "demo.other",
|
||||||
|
titleKey: "demo.other",
|
||||||
|
});
|
||||||
|
expect(getSlotContributions(SLOT)).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by order then action id", () => {
|
||||||
|
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||||
|
registerSlotContribution(SLOT, { actionId: "z.last", titleKey: "k" });
|
||||||
|
registerSlotContribution(SLOT, { actionId: "a.first", titleKey: "k" });
|
||||||
|
registerSlotContribution(SLOT, {
|
||||||
|
actionId: "m.leading",
|
||||||
|
titleKey: "k",
|
||||||
|
order: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSlotContributions(SLOT).map((c) => c.actionId)).toEqual([
|
||||||
|
"m.leading",
|
||||||
|
"a.first",
|
||||||
|
"z.last",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves quietly when a contribution points at a missing action", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const { invokeAction } = await import("@/shell/action-registry");
|
||||||
|
|
||||||
|
await expect(invokeAction("nobody.home")).resolves.toBeUndefined();
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user