chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+18 -7
View File
@@ -48,7 +48,6 @@
"shareTitle": "Shared",
"importExport": "Import / Export",
"importFailed": "Failed to import snippets",
"nameLabel": "Name",
"namePlaceholder": "Snippet name",
"descriptionLabel": "Description",
@@ -61,20 +60,17 @@
"commandPlaceholder": "Command to run, e.g. systemctl status $HOST",
"noteLabel": "Note",
"notePlaceholder": "Text to paste into the terminal",
"createSnippetTitle": "Create snippet",
"createSnippetDescription": "Save a command or note you can run or paste into a terminal.",
"createSnippetButton": "Create snippet",
"createSuccess": "Snippet created",
"createFailed": "Failed to create snippet",
"editSnippetTitle": "Edit snippet",
"editSnippetDescription": "Change this snippet's name, content or folder.",
"saveSnippetButton": "Save snippet",
"updateSuccess": "Snippet updated",
"updateFailed": "Failed to update snippet",
"deleteFailed": "Failed to delete snippet",
"createFolderTitle": "Create folder",
"createFolderButton": "Create folder",
"folderCreateSuccess": "Folder created",
@@ -88,11 +84,26 @@
"folderIconLabel": "Icon",
"folderDeleteSuccess": "Folder deleted",
"folderDeleteFailed": "Failed to delete folder",
"confirmRunMessage": "Run \"{{name}}\"?",
"confirmRunButton": "Run",
"runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)",
"pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)",
"foldersCollapsedLabel": "Collapse folders by default"
"foldersCollapsedLabel": "Collapse folders by default",
"admin": {
"tabTitle": "Snippets",
"count": "{{count}} snippets",
"add": "Add Snippet",
"namePlaceholder": "Snippet name",
"contentPlaceholder": "Command content",
"folderPlaceholder": "Folder (optional)",
"empty": "This user has no snippets",
"deleteConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?",
"deleted": "Snippet deleted",
"deleteFailed": "Failed to delete snippet",
"requiredFields": "Snippet name and content are required",
"saved": "Snippet saved",
"saveFailed": "Failed to save snippet",
"saving": "Saving...",
"save": "Save"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useState } from "react";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
usePluginApi,
useTranslation,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, useConfirmation } from "@termix/plugin-sdk/ui";
// Core's admin routes act on another user's data when this header names them.
export const ADMIN_TARGET_USER_HEADER = "X-Admin-Target-User";
export function adminOptions(targetUserId: string) {
return { headers: { [ADMIN_TARGET_USER_HEADER]: targetUserId } };
}
interface ManagedSnippet {
id: number;
name: string;
content: string;
folder?: string | null;
}
export function mapSnippets(res: unknown): ManagedSnippet[] {
const list = Array.isArray(res)
? res
: ((res as { snippets?: unknown[] })?.snippets ?? []);
return (list as Record<string, unknown>[]).map((s) => ({
id: Number(s.id),
name: String(s.name ?? ""),
content: String(s.content ?? ""),
folder: (s.folder as string | null) ?? null,
}));
}
function errorMessage(error: unknown, fallback: string): string {
return (
(error as { response?: { data?: { error?: string } } })?.response?.data
?.error || fallback
);
}
async function load(api: PluginApiClient, userId: string) {
return mapSnippets((await api.get("/", adminOptions(userId))).data);
}
/** The Snippets tab of the admin "manage user" panel. */
export function AdminUserSnippets({
user,
}: {
user: { id: string; username: string };
}) {
const { t } = useTranslation();
const api = usePluginApi();
const { confirmWithToast } = useConfirmation();
const [snippets, setSnippets] = useState<ManagedSnippet[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<ManagedSnippet | "new" | null>(null);
const [form, setForm] = useState({ name: "", content: "", folder: "" });
const [saving, setSaving] = useState(false);
const reload = useCallback(() => {
setLoading(true);
load(api, user.id)
.then(setSnippets)
.catch(() => {})
.finally(() => setLoading(false));
}, [api, user.id]);
useEffect(() => {
reload();
}, [reload]);
async function save() {
if (!form.name.trim() || !form.content.trim()) {
toast.error(t("admin.requiredFields"));
return;
}
setSaving(true);
try {
const payload = {
name: form.name.trim(),
content: form.content,
folder: form.folder.trim() || null,
};
if (editing && editing !== "new") {
await api.put(`/${editing.id}`, payload, adminOptions(user.id));
} else {
await api.post("/", payload, adminOptions(user.id));
}
setEditing(null);
reload();
toast.success(t("admin.saved"));
} catch (error) {
toast.error(errorMessage(error, t("admin.saveFailed")));
} finally {
setSaving(false);
}
}
function remove(snippet: ManagedSnippet) {
confirmWithToast(
t("admin.deleteConfirm", { name: snippet.name, username: user.username }),
async () => {
try {
await api.delete(`/${snippet.id}`, adminOptions(user.id));
setSnippets((prev) => prev.filter((s) => s.id !== snippet.id));
toast.success(t("admin.deleted"));
} catch (error) {
toast.error(errorMessage(error, t("admin.deleteFailed")));
}
},
);
}
return (
<div className="flex flex-col">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.count", { count: snippets.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={reload}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setForm({ name: "", content: "", folder: "" });
setEditing("new");
}}
>
<Plus className="size-3" />
{t("admin.add")}
</Button>
</div>
</div>
{editing && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<Input
className="h-8 text-xs"
placeholder={t("admin.namePlaceholder")}
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
/>
<textarea
rows={4}
placeholder={t("admin.contentPlaceholder")}
value={form.content}
onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
<Input
className="h-8 text-xs"
placeholder={t("admin.folderPlaceholder")}
value={form.folder}
onChange={(e) => setForm((p) => ({ ...p, folder: e.target.value }))}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setEditing(null)}
>
{t("cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={save}
>
{saving ? t("admin.saving") : t("admin.save")}
</Button>
</div>
</div>
)}
{!loading && snippets.length === 0 && !editing && (
<span className="text-xs text-muted-foreground py-3">
{t("admin.empty")}
</span>
)}
{snippets.map((snippet) => (
<div
key={snippet.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[180px]">
{snippet.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate max-w-[220px]">
{snippet.content}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setForm({
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? "",
});
setEditing(snippet);
}}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => remove(snippet)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
);
}
+44 -4
View File
@@ -3,6 +3,11 @@ import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import { SnippetsPanel } from "./SnippetsPanel";
import { createSnippetsApi } from "./snippets-api";
import { resolveSnippetContent, hasSnippetInputs } from "./snippet-variables";
import {
AdminUserSnippets,
adminOptions,
mapSnippets,
} from "./AdminUserSnippets";
export function activate(app: TermixApp): void {
app.registerRailItem({
@@ -10,6 +15,8 @@ export function activate(app: TermixApp): void {
icon: Play,
titleKey: "nav.snippets",
permission: "view",
// The most approachable power feature, so Simple keeps it.
simplePreset: true,
});
app.registerPanel("snippets", (props: PanelProps) => (
@@ -33,9 +40,37 @@ export function activate(app: TermixApp): void {
icon: Play,
});
// Resolves a snippet by id for core's terminal (startup snippet, custom
// keybindings), so core never imports snippet code or data directly.
app.registerAction("snippets.resolveForTerminal", (async (
// The admin "manage user" panel's Snippets tab.
app.registerSlotContribution("admin.userTabs", {
actionId: "snippets.adminUserTab",
titleKey: "admin.tabTitle",
kind: "component",
component: AdminUserSnippets as never,
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;
@@ -63,7 +98,12 @@ export function activate(app: TermixApp): void {
content: resolveSnippetContent(snippet.content, host, inputValues ?? {}),
isNote: snippet.isNote,
};
}) as never);
};
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.
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const TARGET = { headers: { "X-Admin-Target-User": "u2" } };
function fakeApi(rows: unknown[]) {
const api = {
get: vi.fn(async () => ({ data: rows })),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
};
return api as typeof api & PluginApiClient;
}
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("snippet.list", () => {
it("lists the user's own snippets", async () => {
const api = fakeApi([{ id: 1, name: "a", content: "ls", isNote: true }]);
rendered = await renderWithApp(plugin, { manifest, locales, api });
const rows = await rendered.app.invokeAction("snippet.list");
expect(api.get).toHaveBeenCalledWith("/");
expect(rows).toEqual([
{ id: 1, name: "a", content: "ls", folder: null, isNote: true },
]);
});
it("lists another user's snippets for an admin", async () => {
const api = fakeApi([]);
rendered = await renderWithApp(plugin, { manifest, locales, api });
await rendered.app.invokeAction("snippet.list", { targetUserId: "u2" });
expect(api.get).toHaveBeenCalledWith("/", TARGET);
});
});
describe("admin user snippets tab", () => {
it("fills the admin.userTabs slot", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.slot("admin.userTabs")).toEqual([
"snippets.adminUserTab",
]);
});
it("creates a snippet for the target user", async () => {
const api = fakeApi([]);
rendered = await renderWithApp(plugin, { manifest, locales, api });
rendered.renderSlot("admin.userTabs", {
user: { id: "u2", username: "bob" },
});
fireEvent.click(await screen.findByText("Add Snippet"));
fireEvent.change(screen.getByPlaceholderText("Snippet name"), {
target: { value: "restart" },
});
fireEvent.change(screen.getByPlaceholderText("Command content"), {
target: { value: "systemctl restart nginx" },
});
fireEvent.click(screen.getByText("Save"));
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith(
"/",
{ name: "restart", content: "systemctl restart nginx", folder: null },
TARGET,
),
);
});
});