mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export interface SnippetInput {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostContext {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
export function extractSnippetInputs(content: string): SnippetInput[] {
|
||||
const seen = new Map<string, SnippetInput>();
|
||||
let match: RegExpExecArray | null;
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
while ((match = INPUT_PATTERN.exec(content)) !== null) {
|
||||
const digits = match[1] ?? match[3];
|
||||
const key = `INPUT_${digits}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
export function hasSnippetInputs(content: string): boolean {
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
return INPUT_PATTERN.test(content);
|
||||
}
|
||||
|
||||
function replaceVar(
|
||||
content: string,
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
export function resolveSnippetContent(
|
||||
content: string,
|
||||
host: SnippetHostContext | 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user