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

@@ -4,7 +4,6 @@ import {
SectionCard,
SettingRow,
FakeSwitch,
Select2,
} from "@termix/plugin-sdk/ui";
import { useEffect, useState } from "react";
import {
@@ -12,33 +11,24 @@ import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
HardDrive,
LayoutDashboard,
Plus,
Server,
Trash2,
Zap,
} from "lucide-react";
import { HardDrive, LayoutDashboard, Plus, Server, Trash2 } from "lucide-react";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
const PLUGIN_ID = "host-metrics";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
type QuickActionRow = { name: string; snippetId: string };
/**
* The Host Metrics section of the host editor. Its values are this plugin's
* host settings, carried on form.pluginSettings and saved by the editor after
* the host itself. Quick actions are a core host field.
* the host itself.
*/
export function HostStatsTab({
form,
setField,
updateForm,
host,
snippets,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
@@ -49,8 +39,6 @@ export function HostStatsTab({
];
const settings = readHostMetricsSettings(bag);
const { excludedMounts, monitoredMounts } = settings;
const quickActions = (form.quickActions ?? []) as QuickActionRow[];
const snippetOptions = (snippets ?? []) as { id: number; name: string }[];
const api = usePluginApi();
const patch = (values: Record<string, unknown>) =>
@@ -288,84 +276,6 @@ export function HostStatsTab({
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.quickActionsLabel")}
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={() =>
setField("quickActions", [
...quickActions,
{ name: "", snippetId: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</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("hosts.noQuickActions")}</span>
</div>
)}
{quickActions.map((a, i) => (
<div
key={i}
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("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
/>
<Select2
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
};
setField("quickActions", updated);
}}
>
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
{snippetOptions.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</Select2>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField(
"quickActions",
quickActions.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
);
}
@@ -1,7 +1,6 @@
import {
Separator,
Button,
useConfirmation,
ComponentSlot,
TOTPDialog,
useTabsSafe,
ConnectionLogProvider,
@@ -11,7 +10,6 @@ import {
CardGridCanvas,
ColumnCountStepper,
type GridCardCatalogEntry,
SnippetVariablesDialog,
} from "@termix/plugin-sdk/ui";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
@@ -23,33 +21,12 @@ import type { ServerMetrics } from "../shared/metrics.js";
import React from "react";
import { useHostMetricsApi } from "./host-metrics-api";
interface Snippet {
id: number;
name: string;
content: string;
}
function errorText(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return typeof error === "string" ? error : "";
}
// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for
// whether a snippet still has $INPUT_n placeholders once host variables are
// substituted, so the quick-action dialog knows to ask before running.
const SNIPPET_INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function hasSnippetInputs(content: string): boolean {
SNIPPET_INPUT_PATTERN.lastIndex = 0;
return SNIPPET_INPUT_PATTERN.test(content);
}
import {
useTranslation,
useSlotContributions,
useConnectionRetry,
useHost,
useHostStatus,
invokeAction,
logActivity,
usePluginUiPreferences,
} from "@termix/plugin-sdk/frontend";
@@ -70,11 +47,6 @@ import { metricsChangeKey } from "./metrics-change-key";
const HISTORY_LEN = 30;
interface QuickAction {
name: string;
snippetId: number;
}
type ConnectionLogPayload = Parameters<
ReturnType<typeof useConnectionLog>["addLog"]
>[0];
@@ -87,7 +59,6 @@ interface HostConfig {
name: string;
ip: string;
username: string;
quickActions?: QuickAction[];
statusCheckEnabled?: boolean;
pluginSettings?: Record<string, Record<string, unknown>>;
authType?: string;
@@ -127,9 +98,6 @@ function HostMetricsInner({
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
@@ -139,15 +107,9 @@ function HostMetricsInner({
null,
);
const [editMode, setEditMode] = React.useState(false);
const [runningAction, setRunningAction] = React.useState<{
action: QuickAction;
snippet: Snippet;
} | null>(null);
const { confirmWithToast } = useConfirmation();
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const snippetsCacheRef = React.useRef(new Map<number, Snippet>());
const statsConfig = React.useMemo(() => {
const settings = readHostMetricsSettings(
@@ -532,102 +494,6 @@ function HostMetricsInner({
}
};
async function executeQuickAction(
action: QuickAction,
inputValues: Record<string, string>,
) {
if (!currentHostConfig) return;
setExecutingActions((prev) => new Set(prev).add(action.snippetId));
toast.loading(
t("hostMetrics.executingQuickAction", { name: action.name }),
{
id: `quick-action-${action.snippetId}`,
},
);
try {
const result = (await invokeAction(
"snippets.execute",
action.snippetId,
currentHostConfig.id,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
)) as { success: boolean; output?: string; error?: string } | undefined;
if (!result) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: t("hostMetrics.snippetsUnavailable"),
duration: 5000,
});
} else if (result.success) {
toast.success(
t("hostMetrics.quickActionSuccess", { name: action.name }),
{
id: `quick-action-${action.snippetId}`,
description: result.output?.substring(0, 200),
duration: 5000,
},
);
} else {
toast.error(t("hostMetrics.quickActionFailed", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: result.error || result.output,
duration: 5000,
});
}
} catch (error) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: errorText(error),
duration: 5000,
});
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}
function runQuickActionWithConfirm(
action: QuickAction,
inputValues: Record<string, string>,
) {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
const run = () => void executeQuickAction(action, inputValues);
if (!shouldConfirm) {
run();
return;
}
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", { name: action.name }),
run,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
}
async function runQuickAction(action: QuickAction) {
if (!currentHostConfig) return;
try {
let snippet = snippetsCacheRef.current.get(action.snippetId);
if (!snippet) {
snippet =
((await invokeAction("snippets.get", action.snippetId)) as
Snippet | null | undefined) ?? undefined;
if (snippet) snippetsCacheRef.current.set(snippet.id, snippet);
}
if (snippet && hasSnippetInputs(snippet.content)) {
setRunningAction({ action, snippet });
return;
}
} catch {
// fall through and run with no inputs if the snippet lookup fails
}
runQuickActionWithConfirm(action, {});
}
const showCards =
metricsEnabled && metricsRetry.status === "connected" && metrics;
const showOffline =
@@ -653,43 +519,13 @@ function HostMetricsInner({
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
</div>
<div className="flex items-center gap-0">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<>
<div className="mr-3 flex flex-wrap gap-2">
{currentHostConfig.quickActions.map(
(action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="h-8 text-xs font-semibold"
disabled={isExecuting}
onClick={() => void runQuickAction(action)}
>
{isExecuting ? (
<>
<RefreshCw className="mr-1 size-3 animate-spin" />
{action.name}
</>
) : (
action.name
)}
</Button>
);
},
)}
</div>
<Separator
orientation="vertical"
className="mx-3 h-8"
/>
</>
)}
<ComponentSlot
slotId="host-metrics.toolbar"
props={{
hostId: currentHostConfig?.id,
host: currentHostConfig,
}}
/>
{editMode && (
<>
<ColumnCountStepper
@@ -806,32 +642,6 @@ function HostMetricsInner({
onCancel={handleTOTPCancel}
backgroundColor="var(--bg-canvas)"
/>
{runningAction && (
<SnippetVariablesDialog
snippet={
runningAction.snippet as Parameters<
typeof SnippetVariablesDialog
>[0]["snippet"]
}
host={
currentHostConfig
? {
ip: currentHostConfig.ip,
username: currentHostConfig.username,
port: currentHostConfig.port,
name: currentHostConfig.name,
}
: null
}
onCancel={() => setRunningAction(null)}
onConfirm={(_resolvedContent, inputValues) => {
const action = runningAction.action;
setRunningAction(null);
runQuickActionWithConfirm(action, inputValues);
}}
/>
)}
</div>
);
}
@@ -140,4 +140,10 @@ export async function activate(app: TermixApp): Promise<void> {
id: "host-metrics.managers",
accepts: ["component"],
});
// Buttons before the toolbar's own controls, drawn with { hostId, host }.
// The snippets plugin puts a host's quick actions here.
app.declareActionSlot({
id: "host-metrics.toolbar",
accepts: ["component"],
});
}