mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -279,6 +279,51 @@
|
||||
"guest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"keybindingActions": {
|
||||
"type": "array",
|
||||
"description": "Keybinding actions the plugin adds, with the parameters a saved binding carries.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9.-]{0,63}$",
|
||||
"not": {
|
||||
"enum": [
|
||||
"nextTab",
|
||||
"previousTab",
|
||||
"openCommandPalette",
|
||||
"reconnectSession"
|
||||
]
|
||||
}
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$",
|
||||
"not": { "const": "type" }
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": { "enum": ["string", "boolean"] },
|
||||
"required": { "type": "boolean" },
|
||||
"pattern": { "type": "string" },
|
||||
"maxLength": { "type": "integer", "minimum": 1 },
|
||||
"syncEntity": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"syncEntities": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
@@ -840,6 +885,10 @@
|
||||
"additionalProperties": false,
|
||||
"description": "HTTP options for the plugin's /plugin-api/<id>/ router.",
|
||||
"properties": {
|
||||
"adminImpersonation": {
|
||||
"type": "boolean",
|
||||
"description": "An admin may call the plugin's routes on behalf of another user with the X-Admin-Target-User header."
|
||||
},
|
||||
"legacyPaths": {
|
||||
"type": "array",
|
||||
"description": "Old URLs under /<plugin id>/ served by the plugin's router with that prefix removed.",
|
||||
|
||||
@@ -191,7 +191,7 @@ export interface SyncEntityRegistration {
|
||||
singleton?: boolean;
|
||||
/**
|
||||
* Names core uses when its own rows point at this entity without knowing
|
||||
* which plugin provides it, e.g. "commandSnippet" for a host quick action.
|
||||
* which plugin provides it. Nothing in core uses one today.
|
||||
*/
|
||||
answersTo?: readonly string[];
|
||||
/**
|
||||
|
||||
@@ -301,7 +301,8 @@ export interface HostEditorSectionProps {
|
||||
) => void;
|
||||
host?: PluginHostRecord;
|
||||
credentials?: unknown[];
|
||||
snippets?: unknown[];
|
||||
/** Set while an admin edits another user's host from the admin panel. */
|
||||
adminTargetUserId?: string;
|
||||
/** Which connection protocols are switched on for this host. */
|
||||
protocols: Record<string, boolean>;
|
||||
}
|
||||
@@ -408,6 +409,97 @@ export interface PaletteEntryContribution {
|
||||
run: (shell: ShellApi, host?: PluginHostRecord) => void;
|
||||
}
|
||||
|
||||
/** What a palette item's run gets. */
|
||||
export interface PaletteRunContext {
|
||||
/** The command-target tab the user is working in, if any. */
|
||||
targetTab?: PluginTabRecord;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
/** One searchable row in a palette group. */
|
||||
export interface PaletteItem {
|
||||
id: string;
|
||||
/** Already translated; items are data, not keys. */
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: IconComponent;
|
||||
/** Extra words the search matches. */
|
||||
keywords?: string[];
|
||||
/** Acts on the command-target tab, so it is greyed out without one. */
|
||||
needsTarget?: boolean;
|
||||
/** Short text at the row's end, e.g. "Run in terminal". */
|
||||
hint?: string;
|
||||
run: (context: PaletteRunContext) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of items in the command palette, e.g. the user's snippets. `load`
|
||||
* runs each time the palette opens; the palette filters the items by what
|
||||
* the user types.
|
||||
*/
|
||||
export interface PaletteGroupContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
/** Lower sorts first. */
|
||||
order?: number;
|
||||
load: () => PaletteItem[] | Promise<PaletteItem[]>;
|
||||
/** Show the items before anything is typed. Default false. */
|
||||
showWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
/** A keybinding action's parameter editor in Appearance > Keybindings. */
|
||||
export interface KeybindingActionEditorProps {
|
||||
action: KeybindingAction;
|
||||
onChange: (action: KeybindingAction) => void;
|
||||
}
|
||||
|
||||
/** Where a keybinding action runs when the terminal hands it on. */
|
||||
export interface KeybindingRunContext {
|
||||
/** The session the key was pressed in. */
|
||||
sessionId?: string;
|
||||
host?: {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
} | null;
|
||||
/** Writes raw input to that session. */
|
||||
send?: (data: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A keybinding action. `id` is stored as the binding's action.type and must
|
||||
* be declared in contributes.keybindingActions, which is what the server
|
||||
* validates saved bindings against.
|
||||
*/
|
||||
export interface KeybindingActionContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
/**
|
||||
* "session" runs while a session tab such as a terminal has focus (the
|
||||
* default). "global" runs anywhere in the app.
|
||||
*/
|
||||
scope?: "session" | "global";
|
||||
/** Draws the action's parameters in the binding form. */
|
||||
editor?: ComponentType<KeybindingActionEditorProps>;
|
||||
/** Drawn after the action's name in the binding list, e.g. a warning. */
|
||||
summary?: ComponentType<KeybindingActionEditorProps>;
|
||||
/** A plugin translation key when the action cannot be saved yet. */
|
||||
validate?: (action: KeybindingAction) => string | null;
|
||||
/**
|
||||
* Runs it. A terminal hands on every bound action it does not handle
|
||||
* itself; a global action runs from anywhere.
|
||||
*/
|
||||
run?: (action: KeybindingAction, context: KeybindingRunContext) => void;
|
||||
}
|
||||
|
||||
/** A built-in key the user can rebind in Appearance > Keybindings. */
|
||||
export interface KeybindingDefaultContribution {
|
||||
id: string;
|
||||
combo: KeyCombo;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
export interface DashboardCardProps {
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
@@ -627,6 +719,11 @@ export interface TermixApp extends TermixAppInfo {
|
||||
item: HostContextMenuItemContribution,
|
||||
) => Disposer;
|
||||
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
||||
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
||||
registerKeybindingDefault: (
|
||||
binding: KeybindingDefaultContribution,
|
||||
) => Disposer;
|
||||
registerDashboardCard: (card: DashboardCardContribution) => Disposer;
|
||||
registerHomepageWidget: (widget: HomepageWidgetContribution) => Disposer;
|
||||
registerSettingsComponent: (
|
||||
@@ -815,23 +912,16 @@ export interface KeyCombo {
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
export type KeybindingActionType =
|
||||
| "copy"
|
||||
| "paste"
|
||||
| "sendControlCode"
|
||||
| "sendText"
|
||||
| "runSnippet"
|
||||
| "nextTab"
|
||||
| "previousTab"
|
||||
| "openCommandPalette"
|
||||
| "reconnectSession";
|
||||
/**
|
||||
* The shell's own actions (nextTab, previousTab, openCommandPalette,
|
||||
* reconnectSession) or one a plugin declared in contributes.keybindingActions.
|
||||
*/
|
||||
export type KeybindingActionType = string;
|
||||
|
||||
/** A bound action: its type plus the parameters its declaration lists. */
|
||||
export interface KeybindingAction {
|
||||
type: KeybindingActionType;
|
||||
text?: string;
|
||||
controlCode?: string;
|
||||
snippetId?: string;
|
||||
appendEnter?: boolean;
|
||||
[param: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CustomKeybinding {
|
||||
@@ -869,6 +959,15 @@ export interface PluginCoreApi {
|
||||
) => Promise<void>;
|
||||
/** The user's custom keybindings, enabled or not. */
|
||||
getCustomKeybindings: () => Promise<CustomKeybinding[]>;
|
||||
/**
|
||||
* Runs a bound action through whoever registered it (a plugin's
|
||||
* registerKeybindingAction, or the shell for its own). False when nothing
|
||||
* running handles that type.
|
||||
*/
|
||||
runKeybindingAction: (
|
||||
action: KeybindingAction,
|
||||
context?: KeybindingRunContext,
|
||||
) => boolean;
|
||||
/** A browser-side UI preference (a cookie, or the desktop app's store). */
|
||||
getClientPreference: (name: string) => string | undefined;
|
||||
/** Saves a browser-side UI preference where getClientPreference reads it. */
|
||||
@@ -1021,6 +1120,13 @@ export function getCustomKeybindings(): Promise<CustomKeybinding[]> {
|
||||
return requireHost().core.getCustomKeybindings();
|
||||
}
|
||||
|
||||
export function runKeybindingAction(
|
||||
action: KeybindingAction,
|
||||
context?: KeybindingRunContext,
|
||||
): boolean {
|
||||
return requireHost().core.runKeybindingAction(action, context);
|
||||
}
|
||||
|
||||
export function getClientPreference(name: string): string | undefined {
|
||||
return requireHost().core.getClientPreference(name);
|
||||
}
|
||||
|
||||
@@ -344,8 +344,46 @@ export interface PluginContributions {
|
||||
* off and a desktop can list them in its sync settings.
|
||||
*/
|
||||
syncEntities?: string[];
|
||||
/**
|
||||
* Keybinding actions this plugin adds to Appearance > Keybindings, with
|
||||
* the parameters a saved binding carries. Core validates saved bindings
|
||||
* against these without running plugin code, and translates a parameter
|
||||
* naming a sync entity over sync.
|
||||
*/
|
||||
keybindingActions?: PluginKeybindingActionContribution[];
|
||||
}
|
||||
|
||||
/** One parameter a saved keybinding action carries next to its type. */
|
||||
export interface PluginKeybindingParam {
|
||||
type: "string" | "boolean";
|
||||
required?: boolean;
|
||||
/** A regular expression a string value must match. */
|
||||
pattern?: string;
|
||||
maxLength?: number;
|
||||
/**
|
||||
* The value is a local row id of this sync entity, stored as a string.
|
||||
* Sync sends the row's syncId instead.
|
||||
*/
|
||||
syncEntity?: string;
|
||||
}
|
||||
|
||||
export interface PluginKeybindingActionContribution {
|
||||
/**
|
||||
* Stored as the binding's action.type. New actions use
|
||||
* "<plugin id>.<name>"; the bundled ones keep their 2.8 names.
|
||||
*/
|
||||
id: string;
|
||||
params?: Record<string, PluginKeybindingParam>;
|
||||
}
|
||||
|
||||
/** Keybinding action types the shell runs itself; no plugin may declare them. */
|
||||
export const CORE_KEYBINDING_ACTIONS: readonly string[] = [
|
||||
"nextTab",
|
||||
"previousTab",
|
||||
"openCommandPalette",
|
||||
"reconnectSession",
|
||||
];
|
||||
|
||||
/**
|
||||
* How a desktop linked to a server treats this plugin.
|
||||
* - "mirror": follows the server, installed and switched on or off with it.
|
||||
@@ -376,6 +414,12 @@ export interface PluginHttpContribution {
|
||||
* Core routes win a clash.
|
||||
*/
|
||||
legacyRedirects?: PluginLegacyRedirect[];
|
||||
/**
|
||||
* An admin may call this plugin's routes on behalf of another user with
|
||||
* the X-Admin-Target-User header (the admin "manage user" panel). Without
|
||||
* it core refuses the header on every route under /plugin-api/<id>/.
|
||||
*/
|
||||
adminImpersonation?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginLegacyRedirect {
|
||||
@@ -478,6 +522,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"uiPresets",
|
||||
"auth",
|
||||
"syncEntities",
|
||||
"keybindingActions",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -947,6 +992,132 @@ function validateContributes(
|
||||
validateAuthContribution(contributes.auth, errors);
|
||||
validateHttpContribution(contributes.http, pluginId, errors);
|
||||
validateUiPresets(contributes.uiPresets, errors);
|
||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||
}
|
||||
|
||||
const KEYBINDING_ACTION_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/;
|
||||
const KEYBINDING_PARAM_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
|
||||
|
||||
function validateKeybindingParam(
|
||||
param: unknown,
|
||||
at: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (!isPlainObject(param)) {
|
||||
errors.push(`${at} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
param,
|
||||
["type", "required", "pattern", "maxLength", "syncEntity"],
|
||||
at,
|
||||
errors,
|
||||
);
|
||||
if (param.type !== "string" && param.type !== "boolean") {
|
||||
errors.push(`${at}.type must be "string" or "boolean"`);
|
||||
}
|
||||
if (param.required !== undefined && typeof param.required !== "boolean") {
|
||||
errors.push(`${at}.required must be a boolean`);
|
||||
}
|
||||
if (param.pattern !== undefined) {
|
||||
let valid = typeof param.pattern === "string";
|
||||
if (valid) {
|
||||
try {
|
||||
new RegExp(param.pattern as string);
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (!valid) errors.push(`${at}.pattern must be a regular expression`);
|
||||
}
|
||||
if (
|
||||
param.maxLength !== undefined &&
|
||||
(typeof param.maxLength !== "number" ||
|
||||
!Number.isInteger(param.maxLength) ||
|
||||
param.maxLength < 1)
|
||||
) {
|
||||
errors.push(`${at}.maxLength must be a positive integer`);
|
||||
}
|
||||
if (param.syncEntity !== undefined) {
|
||||
if (
|
||||
typeof param.syncEntity !== "string" ||
|
||||
!/^[a-zA-Z][a-zA-Z0-9]*$/.test(param.syncEntity)
|
||||
) {
|
||||
errors.push(`${at}.syncEntity must be a sync entity wire name`);
|
||||
} else if (param.type !== "string") {
|
||||
errors.push(`${at}.syncEntity needs a string parameter`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateKeybindingActions(value: unknown, errors: string[]): void {
|
||||
if (value === undefined) return;
|
||||
const where = "contributes.keybindingActions";
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`Field "${where}" must be an array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
value.forEach((raw, index) => {
|
||||
const at = `${where}[${index}]`;
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${at} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(raw, ["id", "params"], at, errors);
|
||||
if (typeof raw.id !== "string" || !KEYBINDING_ACTION_PATTERN.test(raw.id)) {
|
||||
errors.push(`${at}.id must be letters, digits, dots and dashes`);
|
||||
} else if (CORE_KEYBINDING_ACTIONS.includes(raw.id)) {
|
||||
errors.push(`${at}.id "${raw.id}" is one of the shell's own actions`);
|
||||
} else {
|
||||
if (seen.has(raw.id)) errors.push(`${at}.id duplicates "${raw.id}"`);
|
||||
seen.add(raw.id);
|
||||
}
|
||||
if (raw.params === undefined) return;
|
||||
if (!isPlainObject(raw.params)) {
|
||||
errors.push(`${at}.params must be an object`);
|
||||
return;
|
||||
}
|
||||
for (const [name, param] of Object.entries(raw.params)) {
|
||||
if (!KEYBINDING_PARAM_PATTERN.test(name) || name === "type") {
|
||||
errors.push(`${at}.params.${name} is not a valid parameter name`);
|
||||
}
|
||||
validateKeybindingParam(param, `${at}.params.${name}`, errors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks one saved keybinding action against the parameters its declaration
|
||||
* lists. A parameter the declaration does not list is refused, so a binding
|
||||
* cannot carry arbitrary data under a declared type.
|
||||
*/
|
||||
export function validateKeybindingActionParams(
|
||||
action: Record<string, unknown>,
|
||||
declaration: PluginKeybindingActionContribution,
|
||||
): boolean {
|
||||
const params = declaration.params ?? {};
|
||||
for (const key of Object.keys(action)) {
|
||||
if (key !== "type" && !(key in params) && action[key] != null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const [name, param] of Object.entries(params)) {
|
||||
const value = action[name];
|
||||
if (value === undefined || value === null) {
|
||||
if (param.required) return false;
|
||||
continue;
|
||||
}
|
||||
if (param.type === "boolean") {
|
||||
if (typeof value !== "boolean") return false;
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string") return false;
|
||||
if (param.required && value.length === 0) return false;
|
||||
if (value.length > (param.maxLength ?? 65_536)) return false;
|
||||
if (param.pattern && !new RegExp(param.pattern).test(value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPresetValue(value: unknown): boolean {
|
||||
@@ -1041,10 +1212,18 @@ function validateHttpContribution(
|
||||
}
|
||||
rejectUnknown(
|
||||
value,
|
||||
["legacyPaths", "legacyRedirects"],
|
||||
["legacyPaths", "legacyRedirects", "adminImpersonation"],
|
||||
"contributes.http",
|
||||
errors,
|
||||
);
|
||||
if (
|
||||
value.adminImpersonation !== undefined &&
|
||||
typeof value.adminImpersonation !== "boolean"
|
||||
) {
|
||||
errors.push(
|
||||
'Field "contributes.http.adminImpersonation" must be a boolean',
|
||||
);
|
||||
}
|
||||
validateLegacyRedirects(value.legacyRedirects, errors);
|
||||
const paths = value.legacyPaths;
|
||||
if (paths === undefined) return;
|
||||
|
||||
@@ -2000,7 +2000,14 @@ export interface RenderedPluginApp {
|
||||
actions: () => string[];
|
||||
loginMethods: () => string[];
|
||||
secondFactors: () => string[];
|
||||
paletteGroups: () => string[];
|
||||
keybindingActions: () => string[];
|
||||
keybindingDefaults: () => string[];
|
||||
};
|
||||
/** A registered palette group's items, as the palette loads them on open. */
|
||||
loadPaletteGroup: (
|
||||
id: string,
|
||||
) => Promise<import("./frontend.js").PaletteItem[]>;
|
||||
/** Shell calls made by the plugin's code, applyLayout included. */
|
||||
shellCalls: ShellCall[];
|
||||
/**
|
||||
|
||||
@@ -137,6 +137,7 @@ const offline = () =>
|
||||
getHostPassword: async () => null,
|
||||
patchOpenTab: async () => {},
|
||||
getCustomKeybindings: async () => [],
|
||||
runKeybindingAction: () => false,
|
||||
getClientPreference: () => undefined,
|
||||
setClientPreference: () => {},
|
||||
listHosts: async () => [],
|
||||
|
||||
Reference in new issue
Block a user