feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

+1 -1
View File
@@ -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[];
/**
+121 -15
View File
@@ -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);
}
+180 -1
View File
@@ -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;
+7
View File
@@ -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[];
/**
+1
View File
@@ -137,6 +137,7 @@ const offline = () =>
getHostPassword: async () => null,
patchOpenTab: async () => {},
getCustomKeybindings: async () => [],
runKeybindingAction: () => false,
getClientPreference: () => undefined,
setClientPreference: () => {},
listHosts: async () => [],