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

+2 -52
View File
@@ -4,58 +4,8 @@ export interface SnippetExecutionResult {
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export { resolveSnippetContent as resolveSnippetCommand } from "../shared/variables.js";
export type { SnippetHostContext as SnippetHostVars } from "../shared/variables.js";
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
+117
View File
@@ -0,0 +1,117 @@
import {
PLUGIN_ID,
readQuickActions,
readStartupSnippetId,
} from "../shared/host-settings.js";
/**
* Registered as "snippets.hostImportNormalizer": a 2.8 or early 2.9 export
* carried quick actions as a host field and the startup snippet inside
* terminalConfig. An export that already carries this plugin's
* pluginSettings has had them written by core.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
PLUGIN_ID
];
if (
carried &&
typeof carried === "object" &&
Object.keys(carried).length > 0
) {
return null;
}
const values: Record<string, unknown> = {};
const quickActions = readQuickActions(raw.quickActions);
if (quickActions.length > 0) values.quickActions = quickActions;
let terminalConfig = raw.terminalConfig;
if (typeof terminalConfig === "string") {
try {
terminalConfig = JSON.parse(terminalConfig);
} catch {
terminalConfig = null;
}
}
const startup = readStartupSnippetId(
(terminalConfig as { startupSnippetId?: unknown } | null)?.startupSnippetId,
);
if (startup !== null) values.startupSnippetId = startup;
return Object.keys(values).length > 0 ? values : null;
}
/**
* Registered as "snippets.hostPayloadLegacy": quickActions and
* terminalConfig.startupSnippetId in their 2.8 shape, for clients that
* still read them (Termix-Mobile). Remove once the mobile app reads
* pluginSettings.snippets.
*/
export function hostPayloadLegacy(
values: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {
quickActions: readQuickActions(values.quickActions),
};
const startup = readStartupSnippetId(values.startupSnippetId);
if (startup !== null) out.terminalConfig = { startupSnippetId: startup };
return out;
}
/** What hostSettingsSync needs from the repository. */
export interface SnippetSyncLookup {
findSyncIdById: (id: number) => Promise<string | null>;
findIdBySyncId: (syncId: string) => Promise<number | null>;
}
/**
* Registered as "snippets.hostSettingsSync": both host settings hold local
* snippet ids, so remote sync carries the snippets' sync ids instead, which
* are the same on both sides.
*/
export function createHostSettingsSync(lookup: SnippetSyncLookup) {
const toSyncId = async (id: number) => lookup.findSyncIdById(id);
const toId = async (value: unknown) =>
typeof value === "string" && value ? lookup.findIdBySyncId(value) : null;
return {
exportValue: async (key: string, value: unknown) => {
if (key === "startupSnippetId") {
const id = readStartupSnippetId(value);
return id === null ? null : await toSyncId(id);
}
if (key === "quickActions") {
const actions = readQuickActions(value);
const out = [];
for (const action of actions) {
const syncId = await toSyncId(action.snippetId);
if (syncId) out.push({ name: action.name, snippetId: syncId });
}
return out;
}
return value;
},
importValue: async (key: string, value: unknown) => {
if (key === "startupSnippetId") return await toId(value);
if (key === "quickActions") {
if (!Array.isArray(value)) return [];
const out = [];
for (const entry of value) {
const row = (entry ?? {}) as { name?: unknown; snippetId?: unknown };
const id = await toId(row.snippetId);
if (id !== null) {
out.push({
name: typeof row.name === "string" ? row.name : "",
snippetId: id,
});
}
}
return out;
}
return value;
},
};
}
+28 -4
View File
@@ -3,7 +3,15 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { snippetAccess, snippetFolders, snippets } from "./tables.js";
import { createSnippetRepository } from "./repository.js";
import { registerSnippetRoutes } from "./routes.js";
import { resolveSnippetCommand } from "./execution.js";
import {
extractSnippetInputs,
resolveSnippetContent,
} from "../shared/variables.js";
import {
createHostSettingsSync,
hostImportNormalizer,
hostPayloadLegacy,
} from "./host-import.js";
/** What other plugins get from ctx.services.get("snippets.access", {userId}). */
export interface SnippetsService {
@@ -74,8 +82,24 @@ export async function activate(ctx: PluginContext) {
type: "snippets",
table: snippetsTable,
order: 60,
// Host quick actions and keybindings in core point at snippets by this.
answersTo: ["commandSnippet"],
});
// Host settings that name a snippet by its local id, and the 2.8 host
// fields they came from. Keybindings name one through the runSnippet
// declaration in the manifest, which core translates over sync.
ctx.registry.provide("snippets.hostImportNormalizer", hostImportNormalizer);
ctx.registry.provide("snippets.hostPayloadLegacy", hostPayloadLegacy);
ctx.registry.provide(
"snippets.hostSettingsSync",
createHostSettingsSync(repo),
);
// $HOST and $INPUT_n handling for commands other plugins run (fleets). A
// pure function, so it goes through the registry rather than a service
// gated on snippets.view.
ctx.registry.provide("snippets.variables", {
resolve: resolveSnippetContent,
extractInputs: extractSnippetInputs,
});
// The user row survives a password-reset data wipe, so it never triggers
@@ -133,7 +157,7 @@ export async function activate(ctx: PluginContext) {
if (!userId) return null;
const snippet = await findAccessible(userId, id);
if (!snippet) return null;
return resolveSnippetCommand(snippet.content, vars, inputValues);
return resolveSnippetContent(snippet.content, vars, inputValues);
},
create: async (input) => {
const userId = ctx.currentActor();
@@ -882,6 +882,27 @@ export function createSnippetRepository(
return rows[0] ?? null;
},
/** A snippet's sync id, for a host setting that names it over sync. */
async findSyncIdById(snippetId: number): Promise<string | null> {
const drizzle = await client();
const rows = await drizzle
.select({ syncId: snippets.syncId })
.from(snippets)
.where(eq(snippets.id, snippetId))
.limit(1);
return (rows[0]?.syncId as string | null | undefined) ?? null;
},
async findIdBySyncId(syncId: string): Promise<number | null> {
const drizzle = await client();
const rows = await drizzle
.select({ id: snippets.id })
.from(snippets)
.where(eq(snippets.syncId, syncId))
.limit(1);
return (rows[0]?.id as number | undefined) ?? null;
},
/** A user's role ids, for the sharing filters above. */
async listUserRoleIds(userId: string): Promise<number[]> {
const drizzle = await client();
@@ -0,0 +1,171 @@
import { Plus, Play, Trash2, Zap } from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
import { PLUGIN_ID, readStartupSnippetId } from "../shared/host-settings.js";
import { useSnippetOptions } from "./use-snippet-options";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
/** A quick action row while it is being edited: the snippet may be unset. */
interface QuickActionRow {
name: string;
snippetId: number | null;
}
function readRows(value: unknown): QuickActionRow[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => {
const row = (entry ?? {}) as { name?: unknown; snippetId?: unknown };
const id = Number(row.snippetId);
return {
name: typeof row.name === "string" ? row.name : "",
snippetId: Number.isInteger(id) && id > 0 ? id : null,
};
});
}
/**
* The host editor's Snippets tab: the snippet a terminal runs when it
* connects, and the quick action buttons Host Metrics shows for the host.
* Both are this plugin's host settings, saved with the host.
*/
export function HostSnippetsSection({
form,
updateForm,
adminTargetUserId,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const { options } = useSnippetOptions(adminTargetUserId);
const own =
(form.pluginSettings as PluginSettingsBag | undefined)?.[PLUGIN_ID] ?? {};
const startupSnippetId = readStartupSnippetId(own.startupSnippetId);
const quickActions = readRows(own.quickActions);
const write = (values: Record<string, unknown>) =>
updateForm((current) => {
const all =
(current.pluginSettings as PluginSettingsBag | undefined) ?? {};
return {
...current,
pluginSettings: {
...all,
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
},
};
});
const setQuickActions = (rows: QuickActionRow[]) =>
write({ quickActions: rows });
return (
<>
<SectionCard
title={t("host.startupTitle")}
icon={<Play className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-3">
<p className="text-xs text-muted-foreground">
{t("host.startupDescription")}
</p>
<Select2
aria-label={t("host.startupTitle")}
value={startupSnippetId ?? ""}
onChange={(e) =>
write({
startupSnippetId: e.target.value
? Number(e.target.value)
: null,
})
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("host.none")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
</div>
</SectionCard>
<SectionCard
title={t("host.quickActionsTitle")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setQuickActions([...quickActions, { name: "", snippetId: null }])
}
>
<Plus className="size-3 mr-1" /> {t("host.addQuickAction")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("host.quickActionsDescription")}
</p>
{quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("host.noQuickActions")}</span>
</div>
)}
{quickActions.map((row, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("host.buttonLabel")}
value={row.name}
onChange={(e) => {
const next = [...quickActions];
next[index] = { ...row, name: e.target.value };
setQuickActions(next);
}}
/>
<Select2
aria-label={t("host.selectSnippet")}
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={row.snippetId ?? ""}
onChange={(e) => {
const next = [...quickActions];
next[index] = {
...row,
snippetId: e.target.value ? Number(e.target.value) : null,
};
setQuickActions(next);
}}
>
<option value="">{t("host.selectSnippet")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
<button
type="button"
aria-label={t("host.removeQuickAction")}
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setQuickActions(quickActions.filter((_, i) => i !== index))
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
);
}
@@ -0,0 +1,147 @@
import { useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";
import {
usePermission,
usePluginApi,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Button, Separator } from "@termix/plugin-sdk/ui";
import {
readQuickActions,
snippetHostSettings,
type QuickAction,
} from "../shared/host-settings.js";
import { hasSnippetInputs } from "../shared/variables.js";
import { askForInputs, shouldConfirmExecution } from "./prompt-store";
import { createSnippetsApi } from "./snippets-api";
import { errorMessage } from "./types";
interface ToolbarHost {
id?: number | string;
name?: string;
ip?: string;
username?: string;
port?: number;
pluginSettings?: Record<string, Record<string, unknown>>;
}
/**
* The host's quick actions as buttons in the Host Metrics toolbar (the
* host-metrics.toolbar slot). Each runs its snippet on the host over a fresh
* SSH connection and reports the result in a toast.
*/
export function QuickActionButtons(props: Record<string, unknown>) {
const host = props.host as ToolbarHost | undefined;
const { t } = useTranslation();
const api = usePluginApi();
const client = useMemo(() => createSnippetsApi(api), [api]);
const canView = usePermission("view");
const [running, setRunning] = useState<Set<number>>(new Set());
const actions = readQuickActions(snippetHostSettings(host).quickActions);
const hostId = Number(host?.id);
if (!canView || actions.length === 0 || !Number.isInteger(hostId)) {
return null;
}
const setBusy = (snippetId: number, busy: boolean) =>
setRunning((prev) => {
const next = new Set(prev);
if (busy) next.add(snippetId);
else next.delete(snippetId);
return next;
});
async function execute(
action: QuickAction,
inputValues: Record<string, string>,
) {
const toastId = `quick-action-${hostId}-${action.snippetId}`;
setBusy(action.snippetId, true);
toast.loading(t("quickActions.running", { name: action.name }), {
id: toastId,
});
try {
const result = await client.execute(
action.snippetId,
hostId,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
if (result.success) {
toast.success(t("quickActions.succeeded", { name: action.name }), {
id: toastId,
description: result.output?.substring(0, 200),
duration: 5000,
});
} else {
toast.error(t("quickActions.failed", { name: action.name }), {
id: toastId,
description: result.error || result.output,
duration: 5000,
});
}
} catch (error) {
toast.error(t("quickActions.error", { name: action.name }), {
id: toastId,
description: errorMessage(error, t("executionFailed")),
duration: 5000,
});
} finally {
setBusy(action.snippetId, false);
}
}
async function run(action: QuickAction) {
let inputValues: Record<string, string> = {};
const snippet = await client.get(action.snippetId).catch(() => null);
if (snippet && hasSnippetInputs(snippet.content)) {
const values = await askForInputs(
{ name: action.name || snippet.name, content: snippet.content },
host
? {
ip: host.ip,
username: host.username,
port: host.port,
name: host.name,
}
: null,
);
if (!values) return;
inputValues = values;
}
const go = () => void execute(action, inputValues);
if (!shouldConfirmExecution()) {
go();
return;
}
toast(t("confirmRunMessage", { name: action.name }), {
action: { label: t("confirmRunButton"), onClick: go },
duration: 6000,
});
}
return (
<>
<div className="mr-3 flex flex-wrap gap-2">
{actions.map((action, index) => {
const busy = running.has(action.snippetId);
return (
<Button
key={index}
variant="outline"
size="sm"
className="h-8 text-xs font-semibold"
disabled={busy}
onClick={() => void run(action)}
>
{busy && <RefreshCw className="mr-1 size-3 animate-spin" />}
{action.name}
</Button>
);
})}
</div>
<Separator orientation="vertical" className="mx-3 h-8" />
</>
);
}
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useSettings } from "@termix/plugin-sdk/frontend";
import { SnippetVariablesDialog } from "./SnippetVariablesDialog";
import { readSnippetSettings } from "./settings";
import { setConfirmExecution, usePendingPrompt } from "./prompt-store";
/**
* Always mounted at the shell's root (the shell.overlay slot), so a snippet
* started from the command palette, a keybinding or a host button can ask
* for its inputs after whatever started it has closed.
*/
export function SnippetOverlay() {
const settings = useSettings("user");
const prompt = usePendingPrompt();
const confirmExecution = readSnippetSettings(
settings.values,
).confirmExecution;
useEffect(() => {
setConfirmExecution(confirmExecution);
}, [confirmExecution]);
if (!prompt) return null;
return (
<SnippetVariablesDialog
snippet={prompt.snippet}
host={prompt.host}
onCancel={() => prompt.settle(null)}
onConfirm={(_resolved, values) => prompt.settle(values)}
/>
);
}
@@ -0,0 +1,107 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Input,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@termix/plugin-sdk/ui";
import {
extractSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "../shared/variables.js";
/** Enough of a snippet to fill in its inputs. */
export interface VariablesDialogSnippet {
name: string;
content: string;
}
/**
* Shown before running a snippet that contains $INPUT_n placeholders:
* collects a value per placeholder and previews the fully resolved command
* (host vars and inputs) before handing the result back to the caller.
*/
export function SnippetVariablesDialog({
snippet,
host,
onCancel,
onConfirm,
}: {
snippet: VariablesDialogSnippet;
host: SnippetHostContext | null;
onCancel: () => void;
onConfirm: (
resolvedContent: string,
inputValues: Record<string, string>,
) => void;
}) {
const { t } = useTranslation();
const inputs = useMemo(
() => extractSnippetInputs(snippet.content),
[snippet.content],
);
const [values, setValues] = useState<Record<string, string>>({});
useEffect(() => {
setValues({});
}, [snippet]);
const preview = resolveSnippetContent(snippet.content, host, values);
return (
<Dialog open onOpenChange={(v) => !v && onCancel()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("variablesDialogTitle", { name: snippet.name })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("variablesDialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
{inputs.map((input) => (
<div key={input.key} className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">{input.label}</label>
<Input
autoFocus={inputs[0]?.key === input.key}
value={values[input.key] ?? ""}
onChange={(e) =>
setValues((prev) => ({
...prev,
[input.key]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted-foreground">
{t("variablesPreviewLabel")}
</label>
<span className="text-xs text-muted-foreground font-mono px-2.5 py-2 border border-border bg-muted/20 min-w-0 break-all whitespace-pre-wrap">
{preview}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={onCancel}>
{t("cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => onConfirm(preview, values)}
>
{t("variablesConfirmButton")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+124 -80
View File
@@ -1,15 +1,53 @@
import { Play } from "lucide-react";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import { Clipboard, Play } from "lucide-react";
import type {
PaletteItem,
PanelProps,
PluginHostRecord,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { Snippet } from "./types";
import { SnippetsPanel } from "./SnippetsPanel";
import { createSnippetsApi } from "./snippets-api";
import { resolveSnippetContent, hasSnippetInputs } from "./snippet-variables";
import {
AdminUserSnippets,
adminOptions,
mapSnippets,
} from "./AdminUserSnippets";
extractSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "../shared/variables.js";
import {
readStartupSnippetId,
snippetHostSettings,
} from "../shared/host-settings.js";
import { AdminUserSnippets } from "./AdminUserSnippets";
import { SnippetOverlay } from "./SnippetOverlay";
import { HostSnippetsSection } from "./HostSnippetsSection";
import { QuickActionButtons } from "./QuickActionButtons";
import { resetPrompts } from "./prompt-store";
import { fetchSnippet, runInSession } from "./run-flows";
import {
RUN_SNIPPET_ACTION,
RunSnippetEditor,
RunSnippetSummary,
runSnippetBinding,
validateRunSnippet,
} from "./keybinding";
function hostContext(host: unknown): SnippetHostContext | null {
if (!host || typeof host !== "object") return null;
const record = host as Record<string, unknown>;
return {
ip: typeof record.ip === "string" ? record.ip : undefined,
username: typeof record.username === "string" ? record.username : undefined,
port:
typeof record.port === "number" || typeof record.port === "string"
? record.port
: undefined,
name: typeof record.name === "string" ? record.name : undefined,
};
}
export function activate(app: TermixApp): void {
app.onDispose(resetPrompts);
app.registerRailItem({
id: "snippets",
icon: Play,
@@ -33,6 +71,73 @@ export function activate(app: TermixApp): void {
},
});
// Every snippet in the command palette, run in the terminal the user is
// working in.
app.registerPaletteGroup({
id: "snippets",
titleKey: "nav.snippets",
order: 10,
load: async (): Promise<PaletteItem[]> => {
if (!(await app.hasPermission("view"))) return [];
const snippets: Snippet[] = await createSnippetsApi(app.api)
.list()
.catch(() => []);
return snippets.map((snippet) => ({
id: String(snippet.id),
title: snippet.name,
description: snippet.content,
icon: snippet.isNote ? Clipboard : Play,
keywords: [snippet.description ?? ""],
needsTarget: true,
hint: app.t(snippet.isNote ? "paletteHintPaste" : "paletteHintRun"),
run: ({ targetTab }) => {
if (!targetTab) return;
void runInSession(
app,
snippet,
{ sessionId: targetTab.id },
hostContext(targetTab.host),
{ announce: true },
);
},
}));
},
});
// Keeps the input dialog and the confirm setting alive at the shell root.
app.registerSlotContribution("shell.overlay", {
actionId: "snippets.overlay",
titleKey: "nav.snippets",
kind: "component",
component: SnippetOverlay as never,
});
app.registerKeybindingAction({
id: RUN_SNIPPET_ACTION,
titleKey: "keybindings.runSnippet",
editor: RunSnippetEditor,
summary: RunSnippetSummary,
validate: validateRunSnippet,
run: (action, context) => runSnippetBinding(app, action, context),
});
app.registerHostEditorSection({
id: "snippets",
group: "ssh",
titleKey: "host.tabTitle",
icon: Play,
order: 15,
component: HostSnippetsSection,
});
// Host Metrics shows a host's quick actions as toolbar buttons.
app.registerSlotContribution("host-metrics.toolbar", {
actionId: "snippets.quickActions",
titleKey: "host.quickActionsTitle",
kind: "component",
component: QuickActionButtons,
});
app.registerSlotContribution("onboarding.features", {
actionId: "snippets.feature",
titleKey: "onboarding.feature_snippets",
@@ -49,71 +154,17 @@ export function activate(app: TermixApp): void {
order: 10,
});
// Core's snippet pickers (the host editor's startup snippet, keybindings,
// the command palette) read the list through this. An admin editing
// another user's host passes that user's id.
app.registerAction("snippet.list", (async (options?: {
targetUserId?: string;
}) => {
const response = options?.targetUserId
? await app.api.get("/", adminOptions(options.targetUserId))
: await app.api.get("/");
const raw = response.data as unknown;
const list = Array.isArray(raw)
? raw
: ((raw as { snippets?: unknown[] })?.snippets ?? []);
return (list as Record<string, unknown>[]).map((row) => ({
...mapSnippets([row])[0],
isNote: row.isNote === true,
}));
}) as never);
// Resolves a snippet by id for a terminal (startup snippet, custom
// keybindings, the command palette), so core never imports snippet code.
const resolveForTerminal = async (
snippetId: number,
host: {
ip?: string;
username?: string;
port?: number;
name?: string;
} | null,
inputValues?: Record<string, string>,
// The command a terminal types when it connects to a host: the host's
// startup snippet with its variables filled in, or null.
app.registerAction("snippets.startupCommand", (async (
host: PluginHostRecord | null,
vars?: SnippetHostContext,
) => {
const api = createSnippetsApi(app.api);
const snippet = await api.get(snippetId).catch(() => null);
const id = readStartupSnippetId(snippetHostSettings(host).startupSnippetId);
if (id === null) return null;
const snippet = await fetchSnippet(app, id);
if (!snippet) return null;
if (
hasSnippetInputs(snippet.content) &&
(!inputValues || Object.keys(inputValues).length === 0)
) {
return {
needsInputs: true,
content: snippet.content,
isNote: snippet.isNote,
};
}
return {
needsInputs: false,
content: resolveSnippetContent(snippet.content, host, inputValues ?? {}),
isNote: snippet.isNote,
};
};
app.registerAction("snippet.resolveForTerminal", resolveForTerminal as never);
app.registerAction(
"snippets.resolveForTerminal",
resolveForTerminal as never,
);
// For other plugins' quick actions (host metrics): the snippet's content,
// so the caller can ask for $INPUT_n values, and a run on a host.
app.registerAction("snippets.get", (async (snippetId: number) => {
const snippet = await createSnippetsApi(app.api)
.get(snippetId)
.catch(() => null);
return snippet
? { id: snippet.id, name: snippet.name, content: snippet.content }
: null;
return resolveSnippetContent(snippet.content, vars ?? hostContext(host));
}) as never);
// For pickers in other plugins (the AI assistant's @-mentions): id and name.
@@ -124,14 +175,7 @@ export function activate(app: TermixApp): void {
return snippets.map((snippet) => ({ id: snippet.id, name: snippet.name }));
}) as never);
app.registerAction("snippets.execute", (async (
snippetId: number,
hostId: number,
inputValues?: Record<string, string>,
) =>
createSnippetsApi(app.api).execute(
snippetId,
hostId,
inputValues,
)) as never);
// Variable handling for commands other plugins run (fleets' run box).
app.registerAction("snippets.extractInputs", ((content: string) =>
extractSnippetInputs(typeof content === "string" ? content : "")) as never);
}
@@ -0,0 +1,89 @@
import {
useTranslation,
type KeybindingAction,
type KeybindingActionEditorProps,
type KeybindingRunContext,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { fetchSnippet, runInSession } from "./run-flows";
import { useSnippetOptions } from "./use-snippet-options";
/** Stored as action.type; kept from 2.8 so saved bindings keep working. */
export const RUN_SNIPPET_ACTION = "runSnippet";
function snippetIdOf(action: KeybindingAction): string {
return typeof action.snippetId === "string" ? action.snippetId : "";
}
/** Picks the snippet a "Run snippet" binding runs. */
export function RunSnippetEditor({
action,
onChange,
}: KeybindingActionEditorProps) {
const { t } = useTranslation();
const { options } = useSnippetOptions();
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("keybindings.snippetLabel")}
</label>
<select
aria-label={t("keybindings.snippetLabel")}
value={snippetIdOf(action)}
onChange={(e) =>
onChange({
type: RUN_SNIPPET_ACTION,
snippetId: e.target.value || undefined,
appendEnter: true,
})
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("keybindings.selectSnippet")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={String(snippet.id)}>
{snippet.name}
</option>
))}
</select>
</div>
);
}
/** The bound snippet's name in the binding list, or a warning once it is gone. */
export function RunSnippetSummary({ action }: KeybindingActionEditorProps) {
const { t } = useTranslation();
const { options, loaded } = useSnippetOptions();
if (!loaded) return null;
const snippet = options.find((s) => String(s.id) === snippetIdOf(action));
if (snippet) return <span>({snippet.name})</span>;
return (
<span className="text-destructive">
({t("keybindings.snippetMissing")})
</span>
);
}
export function validateRunSnippet(action: KeybindingAction): string | null {
return /^\d+$/.test(snippetIdOf(action))
? null
: "keybindings.snippetRequired";
}
/** Runs a bound snippet in the terminal the key was pressed in. */
export function runSnippetBinding(
app: Pick<TermixApp, "api" | "t">,
action: KeybindingAction,
context: KeybindingRunContext,
): void {
void fetchSnippet(app, Number(snippetIdOf(action))).then((snippet) => {
if (!snippet) return;
void runInSession(
app,
{ ...snippet, isNote: false },
{ sessionId: context.sessionId, send: context.send },
context.host ?? null,
{ pressEnter: action.appendEnter !== false },
);
});
}
@@ -0,0 +1,72 @@
import { useSyncExternalStore } from "react";
import type { SnippetHostContext } from "../shared/variables.js";
export interface PendingPrompt {
snippet: { name: string; content: string };
host: SnippetHostContext | null;
settle: (values: Record<string, string> | null) => void;
}
let pending: PendingPrompt | null = null;
let confirmExecution = false;
const listeners = new Set<() => void>();
function notify(): void {
for (const listener of [...listeners]) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/**
* Asks for a snippet's $INPUT_n values through the dialog the overlay draws.
* Resolves null when the user cancels. A second request cancels the first.
*/
export function askForInputs(
snippet: { name: string; content: string },
host: SnippetHostContext | null,
): Promise<Record<string, string> | null> {
pending?.settle(null);
return new Promise((resolve) => {
const prompt: PendingPrompt = {
snippet,
host,
settle: (values) => {
if (pending === prompt) {
pending = null;
notify();
}
resolve(values);
},
};
pending = prompt;
notify();
});
}
export function usePendingPrompt(): PendingPrompt | null {
return useSyncExternalStore(
subscribe,
() => pending,
() => pending,
);
}
/** Kept current by the overlay from the user's confirmExecution setting. */
export function setConfirmExecution(value: boolean): void {
confirmExecution = value;
}
export function shouldConfirmExecution(): boolean {
return confirmExecution;
}
/** Cancels anything waiting, for deactivate. */
export function resetPrompts(): void {
pending?.settle(null);
pending = null;
confirmExecution = false;
notify();
}
+121
View File
@@ -0,0 +1,121 @@
import { toast } from "sonner";
import { invokeAction, type TermixApp } from "@termix/plugin-sdk/frontend";
import {
hasSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "../shared/variables.js";
import { askForInputs, shouldConfirmExecution } from "./prompt-store";
import { createSnippetsApi } from "./snippets-api";
export interface RunnableSnippet {
id: number;
name: string;
content: string;
isNote?: boolean;
}
/** Where a run sends its text: a session by id, or a raw writer. */
export interface RunTarget {
sessionId?: string;
send?: (data: string) => void;
}
/**
* The snippet with its variables filled in, asking for $INPUT_n values when
* it has any. Null when the user cancels.
*/
export async function resolveForRun(
snippet: { name: string; content: string },
host: SnippetHostContext | null,
): Promise<string | null> {
if (!hasSnippetInputs(snippet.content)) {
return resolveSnippetContent(snippet.content, host);
}
const values = await askForInputs(snippet, host);
if (!values) return null;
return resolveSnippetContent(snippet.content, host, values);
}
/** Runs `execute` now, or after the user confirms when they asked to. */
export function confirmThenRun(
app: Pick<TermixApp, "t">,
name: string,
execute: () => void,
): void {
if (!shouldConfirmExecution()) {
execute();
return;
}
toast(app.t("confirmRunMessage", { name }), {
action: { label: app.t("confirmRunButton"), onClick: execute },
duration: 6000,
});
}
/**
* Types a snippet into a terminal session: resolves its variables, asks for
* inputs and confirmation as the user's settings say, then sends it. A note
* is pasted without running.
*/
export async function runInSession(
app: Pick<TermixApp, "t">,
snippet: RunnableSnippet,
target: RunTarget,
host: SnippetHostContext | null,
options: { pressEnter?: boolean; announce?: boolean } = {},
): Promise<void> {
const content = await resolveForRun(snippet, host);
if (content === null) return;
const run = !snippet.isNote && options.pressEnter !== false;
const send = async () => {
let sent = true;
if (target.send) {
target.send(content + (run ? "\r" : ""));
} else if (target.sessionId) {
sent =
(await invokeAction(
"terminal.sendToSession",
target.sessionId,
content,
{
run,
},
)) === true;
} else {
sent = false;
}
if (!options.announce) return;
if (!sent) {
toast.error(app.t("noTerminalTabsOpen"));
return;
}
toast.success(
app.t(snippet.isNote ? "pasteSuccess" : "runSuccess", {
name: snippet.name,
count: 1,
}),
);
};
if (snippet.isNote) void send();
else confirmThenRun(app, snippet.name, () => void send());
}
/** One snippet by id, or null when it is gone or unreadable. */
export async function fetchSnippet(
app: Pick<TermixApp, "api">,
id: number,
): Promise<RunnableSnippet | null> {
if (!Number.isInteger(id) || id <= 0) return null;
const snippet = await createSnippetsApi(app.api)
.get(id)
.catch(() => null);
return snippet
? {
id: snippet.id,
name: snippet.name,
content: snippet.content,
isNote: snippet.isNote,
}
: null;
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { usePluginApi } from "@termix/plugin-sdk/frontend";
import { adminOptions } from "./AdminUserSnippets";
export interface SnippetOption {
id: number;
name: string;
}
/**
* The user's snippets as picker options: their own, or the target user's
* while an admin edits that user's host. `loaded` turns true once the list
* came back, so a picker can tell "no snippets" from "not loaded yet".
*/
export function useSnippetOptions(targetUserId?: string): {
options: SnippetOption[];
loaded: boolean;
} {
const api = usePluginApi();
const [state, setState] = useState<{
options: SnippetOption[];
loaded: boolean;
}>({ options: [], loaded: false });
useEffect(() => {
let cancelled = false;
const request = targetUserId
? api.get("/", adminOptions(targetUserId))
: api.get("/");
request
.then((response) => {
const raw = response.data as unknown;
const list = Array.isArray(raw)
? raw
: ((raw as { snippets?: unknown[] })?.snippets ?? []);
const options = (list as Array<{ id?: unknown; name?: unknown }>)
.filter((row) => typeof row.id === "number")
.map((row) => ({
id: row.id as number,
name: typeof row.name === "string" ? row.name : `#${row.id}`,
}));
if (!cancelled) setState({ options, loaded: true });
})
.catch(() => {
if (!cancelled) setState({ options: [], loaded: true });
});
return () => {
cancelled = true;
};
}, [api, targetUserId]);
return state;
}
@@ -5,12 +5,12 @@ import {
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import {
hasSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "./snippet-variables";
} from "../shared/variables.js";
import { SnippetVariablesDialog } from "./SnippetVariablesDialog";
import { createSnippetsApi } from "./snippets-api";
import {
ExecutionResultsDialog,
@@ -259,7 +259,7 @@ export function useSnippetRunner(confirmExecution = false) {
const variablesDialog = runningSnippet ? (
<SnippetVariablesDialog
snippet={runningSnippet.snippet as never}
snippet={runningSnippet.snippet}
host={runningSnippet.host}
onCancel={() => setRunningSnippet(null)}
onConfirm={runningSnippet.onConfirm}
@@ -0,0 +1,48 @@
/**
* The snippets plugin's host settings: the startup snippet a terminal runs
* when it connects, and the quick action buttons Host Metrics shows.
*/
export const PLUGIN_ID = "snippets";
export interface QuickAction {
name: string;
snippetId: number;
}
function snippetId(value: unknown): number | null {
const id = typeof value === "string" ? Number(value) : value;
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
}
/** Quick actions in their stored shape, dropping any without a snippet. */
export function readQuickActions(value: unknown): QuickAction[] {
let list = value;
if (typeof list === "string") {
try {
list = JSON.parse(list);
} catch {
return [];
}
}
if (!Array.isArray(list)) return [];
return list.flatMap((entry): QuickAction[] => {
if (!entry || typeof entry !== "object") return [];
const id = snippetId((entry as { snippetId?: unknown }).snippetId);
if (id === null) return [];
const name = (entry as { name?: unknown }).name;
return [{ name: typeof name === "string" ? name : "", snippetId: id }];
});
}
export function readStartupSnippetId(value: unknown): number | null {
return snippetId(value);
}
/** This plugin's slice of a host record's pluginSettings. */
export function snippetHostSettings(host: unknown): Record<string, unknown> {
const all = (host as { pluginSettings?: Record<string, unknown> } | null)
?.pluginSettings;
const own = all?.[PLUGIN_ID];
return own && typeof own === "object" ? (own as Record<string, unknown>) : {};
}
@@ -1,3 +1,9 @@
/**
* $HOST, $USER, $PORT, $NAME and $INPUT_n in a command. The one copy: the
* frontend, the backend and other plugins (fleets, through the
* "snippets.variables" registry entry and action) all use this.
*/
export interface SnippetInput {
key: string;
label: string;