mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>) : {};
|
||||
}
|
||||
+6
@@ -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;
|
||||
Reference in new issue
Block a user