mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user