feat(snippets): bring back sharing, target hosts and drag reorder

This commit is contained in:
LukeGus committed 2026-09-28 20:34:51 -05:00
1 parent 796db8f0a8
commit 1fd89acd90
16 files changed
+1376 -53

No files matched your search

+5
View File
@@ -1293,6 +1293,9 @@ export interface MockContextOptions {
permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles serve. See FakeContextOptions. */
shareableUsers?: PluginShareableUser[];
shareableRoles?: PluginShareableRole[];
/** Other plugins' services. See FakeContextOptions. */
services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
@@ -1361,6 +1364,8 @@ export function createMockCtx(
router: options.router,
permissions: options.permissions,
hosts: options.hosts,
shareableUsers: options.shareableUsers,
shareableRoles: options.shareableRoles,
sshHosts: options.sshHosts,
services: options.services,
protocolTargets: options.protocolTargets,
+47 -1
View File
@@ -149,5 +149,51 @@
"clearSearch": "Clear search",
"noSearchResults": "No snippets match your search",
"emptyTitle": "No snippets yet",
"emptyDescription": "Save commands you run often, then run them in a terminal with one click."
"emptyDescription": "Save commands you run often, then run them in a terminal with one click.",
"shareSnippet": "Share snippet",
"shareFolder": "Share folder",
"shareExpiry": "Expires",
"shareExpiryOptions": {
"h0": "Never",
"h1": "In 1 hour",
"h24": "In 1 day",
"h168": "In 7 days",
"h720": "In 30 days"
},
"shareButton": "Share",
"shareSuccess": "Shared with {{count}}",
"shareFailed": "Failed to share",
"shareLoadFailed": "Failed to load sharing details",
"currentAccess": "Current access",
"notSharedYet": "Not shared with anyone yet",
"revokeAccess": "Revoke",
"revokeSuccess": "Access revoked",
"revokeFailed": "Failed to revoke access",
"close": "Close",
"targetHostsLabel": "Target hosts",
"targetHostsHint": "Pick hosts to run this command on directly, without a terminal open. Leave empty to run it in the active terminal.",
"clearTargetHosts": "Clear",
"noHostsAvailable": "No hosts available",
"searchHosts": "Search hosts",
"runOnTargets": "Run on target hosts",
"executionResultTitle": "Results for \"{{name}}\"",
"executionResultDescription": "Output from each target host.",
"executionSuccess": "Success",
"executionFailed": "Failed",
"directRunSuccess": "Ran \"{{name}}\" on {{count}} host(s)",
"directRunPartialFail": "\"{{name}}\" failed on some hosts",
"reorderFailed": "Failed to reorder snippets",
"dropToRemoveFromFolder": "Drop here to move out of the folder",
"shareSnippetTitle": "Share \"{{name}}\"",
"shareFolderTitle": "Share folder \"{{name}}\"",
"shareWith": "Share with",
"usersTab": "Users",
"rolesTab": "Roles",
"shareSearchPlaceholder": "Search users or roles",
"shareNoMatches": "No matches",
"shareWithCount": "Share with {{count}}",
"shareFolderHint": "Shares every snippet in this folder. Snippets added later are not shared automatically.",
"shareFolderSummary": "Shared {{count}} snippet(s) in this folder",
"grantedBy": "Granted by",
"expired": "Expired"
}
+1
View File
@@ -19,6 +19,7 @@
"network:serve",
"ssh:connect",
"credentials:use",
"hosts:write",
"ui:surface",
"events:core"
],
+62
View File
@@ -926,6 +926,68 @@ export function registerSnippetRoutes(
// Sharing (moved from core's /rbac/snippet* routes)
/**
* @openapi
* /plugin-api/snippets/share-targets/users:
* get:
* summary: Users the caller may share snippets with
* tags:
* - Snippets
* responses:
* 200:
* description: Users list.
* 500:
* description: Failed to list users.
*/
router.get(
"/share-targets/users",
ctx.rbac.require("share") as never,
async (_req: Request, res: Response) => {
try {
res.json({ users: await ctx.hosts.listUsers() });
} catch (err) {
logError(
ctx,
"Failed to list share target users",
err,
"snippet_share_targets_users_failed",
);
res.status(500).json({ error: "Failed to list users" });
}
},
);
/**
* @openapi
* /plugin-api/snippets/share-targets/roles:
* get:
* summary: Non-system roles the caller may share snippets with
* tags:
* - Snippets
* responses:
* 200:
* description: Roles list.
* 500:
* description: Failed to list roles.
*/
router.get(
"/share-targets/roles",
ctx.rbac.require("share") as never,
async (_req: Request, res: Response) => {
try {
res.json({ roles: await ctx.hosts.listRoles() });
} catch (err) {
logError(
ctx,
"Failed to list share target roles",
err,
"snippet_share_targets_roles_failed",
);
res.status(500).json({ error: "Failed to list roles" });
}
},
);
/**
* @openapi
* /plugin-api/snippets/{id}/share:
@@ -0,0 +1,85 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, Server } from "lucide-react";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
export interface HostExecutionResult {
hostId: number;
hostLabel: string;
/** Null while the command is still running on this host. */
success: boolean | null;
output: string;
error?: string;
}
export function ExecutionResultsDialog({
snippetName,
results,
onClose,
}: {
snippetName: string;
results: HostExecutionResult[];
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("executionResultTitle", { name: snippetName })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("executionResultDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col border border-border max-h-96 overflow-y-auto">
{results.map((result) => (
<div
key={result.hostId}
className="flex flex-col gap-1.5 p-2.5 border-b border-border/40 last:border-b-0"
>
<div className="flex items-center gap-2">
<Server className="size-3 shrink-0 text-muted-foreground" />
<span className="text-xs font-semibold truncate flex-1">
{result.hostLabel}
</span>
{result.success === null ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : (
<span
className={`text-[9px] px-1 py-px border uppercase tracking-wider shrink-0 ${
result.success
? "border-green-500/30 bg-green-500/10 text-green-500"
: "border-destructive/30 bg-destructive/10 text-destructive"
}`}
>
{t(result.success ? "executionSuccess" : "executionFailed")}
</span>
)}
</div>
{(result.output || result.error) && (
<pre className="text-[11px] bg-muted/30 p-2 overflow-x-auto whitespace-pre-wrap font-mono text-muted-foreground max-h-40">
{result.error || result.output}
</pre>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+105 -4
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ArrowLeft, FileText } from "lucide-react";
import { useMemo, useState } from "react";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import { ArrowLeft, Check, FileText, Search, Server, Zap } from "lucide-react";
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
import type { Snippet, SnippetFolder } from "./types";
import { parseHostFilter, type Snippet, type SnippetFolder } from "./types";
export interface SnippetFormValues {
name: string;
@@ -10,6 +10,7 @@ export interface SnippetFormValues {
description: string;
folder: string;
isNote: boolean;
hostIds: number[];
}
const labelClass =
@@ -36,7 +37,10 @@ export function SnippetEditor({
description: snippet?.description ?? "",
folder: snippet?.folder ?? defaultFolder ?? "",
isNote: snippet?.isNote ?? false,
hostIds: parseHostFilter(snippet?.hostFilter),
}));
const { hosts } = useHosts();
const [hostSearch, setHostSearch] = useState("");
const [saving, setSaving] = useState(false);
const set = <K extends keyof SnippetFormValues>(
@@ -53,6 +57,29 @@ export function SnippetEditor({
]),
).sort((a, b) => a.localeCompare(b));
const hostQuery = hostSearch.trim().toLowerCase();
const visibleHosts = useMemo(
() =>
hosts
.filter(
(host) =>
!hostQuery ||
host.name.toLowerCase().includes(hostQuery) ||
host.ip.toLowerCase().includes(hostQuery),
)
.sort((a, b) => a.name.localeCompare(b.name)),
[hosts, hostQuery],
);
function toggleHost(id: number) {
set(
"hostIds",
values.hostIds.includes(id)
? values.hostIds.filter((h) => h !== id)
: [...values.hostIds, id],
);
}
async function handleSave() {
if (!canSave) return;
setSaving(true);
@@ -168,6 +195,80 @@ export function SnippetEditor({
</div>
</SectionCard>
{!values.isNote && (
<SectionCard
title={t("targetHostsLabel")}
icon={<Zap className="size-3.5" />}
action={
values.hostIds.length > 0 ? (
<button
type="button"
onClick={() => set("hostIds", [])}
className="text-[10px] text-accent-brand hover:underline"
>
{t("clearTargetHosts")}
</button>
) : undefined
}
>
<div className="flex flex-col gap-2 py-3">
<span className="text-[11px] text-muted-foreground">
{t("targetHostsHint")}
</span>
{hosts.length === 0 ? (
<span className="text-[11px] text-muted-foreground/60">
{t("noHostsAvailable")}
</span>
) : (
<div className="flex flex-col border border-border">
<div className="flex items-center gap-2 px-2.5 h-7 border-b border-border bg-muted/40">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={hostSearch}
onChange={(e) => setHostSearch(e.target.value)}
placeholder={t("searchHosts")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 min-w-0"
/>
</div>
<div className="flex flex-col max-h-48 overflow-y-auto">
{visibleHosts.map((host) => {
const id = Number(host.id);
const selected = values.hostIds.includes(id);
return (
<button
key={host.id}
type="button"
onClick={() => toggleHost(id)}
className={`flex items-center gap-2 px-2.5 py-1.5 text-left border-b border-border/40 last:border-b-0 transition-colors ${
selected
? "bg-accent-brand/[0.07]"
: "hover:bg-muted/40"
}`}
>
<span
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${selected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{selected && (
<Check className="size-2 text-background" />
)}
</span>
<Server className="size-3 shrink-0 text-muted-foreground/60" />
<span className="text-xs font-medium truncate flex-1">
{host.name || host.ip}
</span>
<span className="text-[11px] text-muted-foreground/60 font-mono truncate shrink-0 max-w-[45%]">
{host.ip}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onBack} disabled={saving}>
{t("cancel")}
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
ArrowLeft,
Check,
ListChecks,
Search,
Share2,
Shield,
User,
Users,
X,
} from "lucide-react";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
} from "@termix/plugin-sdk/ui";
import type {
ShareableRole,
ShareableUser,
SnippetShareInput,
SnippetsApi,
} from "./snippets-api";
import { errorMessage, type Snippet, type SnippetAccessEntry } from "./types";
export type ShareTarget =
{ kind: "snippet"; snippet: Snippet } | { kind: "folder"; name: string };
const EXPIRY_HOURS = [0, 1, 24, 168, 720] as const;
const DOCS_URL = "https://docs.termix.site/features/authentication/rbac";
export function SnippetShareView({
target,
client,
onBack,
}: {
target: ShareTarget;
client: SnippetsApi;
onBack: () => void;
}) {
const { t } = useTranslation();
const [users, setUsers] = useState<ShareableUser[]>([]);
const [roles, setRoles] = useState<ShareableRole[]>([]);
const [access, setAccess] = useState<SnippetAccessEntry[]>([]);
const [tab, setTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [selectedRoles, setSelectedRoles] = useState<Set<number>>(new Set());
const [expiryHours, setExpiryHours] = useState<number>(0);
const [loadError, setLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [folderSummary, setFolderSummary] = useState<number | null>(null);
const snippetId = target.kind === "snippet" ? target.snippet.id : null;
const selectedCount = selectedUsers.size + selectedRoles.size;
const loadAccess = useCallback(async () => {
if (snippetId === null) return;
const list = await client.getAccess(snippetId);
setAccess(Array.isArray(list) ? list : []);
}, [client, snippetId]);
useEffect(() => {
let cancelled = false;
Promise.all([
client.shareTargetUsers(),
client.shareTargetRoles(),
loadAccess(),
])
.then(([usersRes, rolesRes]) => {
if (cancelled) return;
setUsers(usersRes.users ?? []);
setRoles(rolesRes.roles ?? []);
})
.catch(() => {
if (!cancelled) setLoadError(true);
});
return () => {
cancelled = true;
};
}, [client, loadAccess]);
const query = search.trim().toLowerCase();
const filteredUsers = useMemo(
() => users.filter((u) => u.username.toLowerCase().includes(query)),
[users, query],
);
const filteredRoles = useMemo(
() =>
roles.filter((r) =>
(r.displayName || r.name).toLowerCase().includes(query),
),
[roles, query],
);
function toggle<T>(set: Set<T>, value: T): Set<T> {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
}
async function handleShare() {
if (selectedCount === 0) return;
const expiresAt =
expiryHours > 0
? new Date(Date.now() + expiryHours * 3600_000).toISOString()
: null;
const bodies: SnippetShareInput[] = [
...[...selectedUsers].map((id) => ({
targetType: "user" as const,
targetUserId: id,
expiresAt,
})),
...[...selectedRoles].map((id) => ({
targetType: "role" as const,
targetRoleId: id,
expiresAt,
})),
];
setSubmitting(true);
try {
if (target.kind === "snippet") {
for (const body of bodies) await client.share(target.snippet.id, body);
await loadAccess();
} else {
let shared = 0;
for (const body of bodies) {
const result = await client.shareFolder(target.name, body);
shared = Math.max(shared, result.snippetsShared ?? 0);
}
setFolderSummary(shared);
}
toast.success(t("shareSuccess", { count: selectedCount }));
setSelectedUsers(new Set());
setSelectedRoles(new Set());
} catch (err) {
toast.error(errorMessage(err, t("shareFailed")));
} finally {
setSubmitting(false);
}
}
async function handleRevoke(entry: SnippetAccessEntry) {
if (snippetId === null) return;
try {
await client.revokeAccess(snippetId, entry.id);
setAccess((prev) => prev.filter((a) => a.id !== entry.id));
toast.success(t("revokeSuccess"));
} catch (err) {
toast.error(errorMessage(err, t("revokeFailed")));
}
}
const rowClass = (selected: boolean) =>
`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${selected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`;
const checkbox = (selected: boolean) => (
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${selected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{selected && <Check className="size-2.5 text-background" />}
</div>
);
return (
<div className="flex flex-col flex-1 min-h-0">
<button
onClick={onBack}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{target.kind === "snippet"
? t("shareSnippetTitle", { name: target.snippet.name })
: t("shareFolderTitle", { name: target.name })}
</span>
</button>
{loadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("shareLoadFailed")}</div>
</div>
)}
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("shareWith")}
</div>
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("docsLink")}
</a>
</div>
<div className="flex gap-1.5">
{(["user", "role"] as const).map((key) => {
const count =
key === "user" ? selectedUsers.size : selectedRoles.size;
return (
<button
key={key}
onClick={() => setTab(key)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${tab === key ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{key === "user" ? (
<User className="size-3 shrink-0" />
) : (
<Shield className="size-3 shrink-0" />
)}
{t(key === "user" ? "usersTab" : "rolesTab")}
{count > 0 && <span>({count})</span>}
</button>
);
})}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("shareSearchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{tab === "user" &&
(filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("shareNoMatches")}
</div>
) : (
filteredUsers.map((user) => {
const selected = selectedUsers.has(user.id);
return (
<button
key={user.id}
onClick={() =>
setSelectedUsers((prev) => toggle(prev, user.id))
}
className={rowClass(selected)}
>
{checkbox(selected)}
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
))}
{tab === "role" &&
(filteredRoles.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("shareNoMatches")}
</div>
) : (
filteredRoles.map((role) => {
const selected = selectedRoles.has(role.id);
return (
<button
key={role.id}
onClick={() =>
setSelectedRoles((prev) => toggle(prev, role.id))
}
className={rowClass(selected)}
>
{checkbox(selected)}
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
</span>
</button>
);
})
))}
</div>
<div className="flex items-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("shareExpiry")}
</span>
<span className="h-8 flex items-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`shareExpiryOptions.h${expiryHours}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{EXPIRY_HOURS.map((hours) => (
<DropdownMenuItem
key={hours}
onClick={() => setExpiryHours(hours)}
>
{expiryHours === hours ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`shareExpiryOptions.h${hours}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={selectedCount === 0 || submitting}
onClick={() => void handleShare()}
>
<Share2 className="size-3.5 mr-1.5" />
{selectedCount > 0
? t("shareWithCount", { count: selectedCount })
: t("shareButton")}
</Button>
</div>
{target.kind === "folder" && (
<p className="text-[11px] text-muted-foreground leading-snug">
{t("shareFolderHint")}
</p>
)}
</div>
{target.kind === "folder" && folderSummary !== null && (
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 text-xs text-muted-foreground">
<ListChecks className="size-3.5 shrink-0" />
{t("shareFolderSummary", { count: folderSummary })}
</div>
)}
{target.kind === "snippet" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<ListChecks className="size-3.5" />
{t("currentAccess")}
{access.length > 0 && (
<span className="text-muted-foreground/40">
({access.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{access.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("notSharedYet")}
</div>
)}
{access.map((entry) => {
const expired =
entry.expiresAt && new Date(entry.expiresAt) < new Date();
return (
<div
key={entry.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{entry.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{entry.username ??
entry.roleDisplayName ??
entry.roleName ??
entry.userId ??
entry.roleId}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={() => void handleRevoke(entry)}
>
{t("revokeAccess")}
</Button>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("grantedBy")}:{" "}
<span className="text-foreground/70">
{entry.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("shareExpiry")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("expired")}
</span>
) : (
<span className="text-foreground/70">
{entry.expiresAt
? new Date(entry.expiresAt).toLocaleString()
: t("shareExpiryOptions.h0")}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+167 -31
View File
@@ -5,14 +5,17 @@ import {
ClipboardPaste,
Copy,
FolderInput,
GripVertical,
MoreHorizontal,
Pencil,
Play,
Plus,
Share2,
StickyNote,
Terminal,
Trash2,
Users,
Zap,
} from "lucide-react";
import {
DropdownMenu,
@@ -30,42 +33,93 @@ import type { Snippet, SnippetFolder } from "./types";
const trayButtonClass =
"flex items-center justify-center size-[22.75px] text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
export type DropPosition = "above" | "below";
export function SnippetRow({
snippet,
stripeIndex,
showCommand,
folderNames,
targetHostNames,
canEdit,
canDelete,
canShare,
draggable,
dragActive,
isDragging,
dropIndicator,
onRun,
onCopy,
onEdit,
onShare,
onMove,
onDelete,
onDragStart,
onDragEnd,
onDragOverRow,
onDropRow,
}: {
snippet: Snippet;
stripeIndex: number;
showCommand: boolean;
folderNames: string[];
/** Hosts a command snippet runs on directly, instead of the active terminal. */
targetHostNames: string[];
canEdit: boolean;
canDelete: boolean;
canShare: boolean;
draggable: boolean;
/** True while any snippet is being dragged, so this row is a drop target. */
dragActive: boolean;
isDragging: boolean;
dropIndicator: DropPosition | null;
onRun: () => void;
onCopy: () => void;
onEdit: () => void;
onShare: () => void;
onMove: (folder: string | null) => void;
onDelete: () => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOverRow: (position: DropPosition) => void;
onDropRow: () => void;
}) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const owned = !snippet.isShared;
const editable = canEdit && owned;
const deletable = canDelete && owned;
const shareable = canShare && owned;
const hasTargets = !snippet.isNote && targetHostNames.length > 0;
const TypeIcon = snippet.isNote ? StickyNote : Terminal;
const RunIcon = snippet.isNote ? ClipboardPaste : Play;
const runLabel = t(snippet.isNote ? "pasteToTerminal" : "run");
const RunIcon = hasTargets ? Zap : snippet.isNote ? ClipboardPaste : Play;
const runLabel = t(
hasTargets ? "runOnTargets" : snippet.isNote ? "pasteToTerminal" : "run",
);
return (
<div
draggable={draggable}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
onDragStart();
}}
onDragEnd={onDragEnd}
onDragOver={(e) => {
if (!dragActive) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onDragOverRow(
e.clientY < rect.top + rect.height / 2 ? "above" : "below",
);
}}
onDrop={(e) => {
if (!dragActive) return;
e.preventDefault();
e.stopPropagation();
onDropRow();
}}
onDoubleClick={onRun}
onContextMenu={(e) => {
e.preventDefault();
@@ -73,12 +127,24 @@ export function SnippetRow({
}}
title={t("doubleClickToRun")}
className={`group relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 ${
draggable ? "cursor-grab active:cursor-grabbing" : ""
} ${
menuOpen ? "bg-muted/50" : stripeIndex % 2 === 1 ? "bg-muted/15" : ""
}`}
} ${isDragging ? "opacity-40" : ""}`}
>
{dropIndicator && (
<div
className={`absolute left-0 right-0 h-0.5 bg-accent-brand z-10 pointer-events-none ${dropIndicator === "above" ? "-top-px" : "-bottom-px"}`}
/>
)}
<div
className={`w-[3px] shrink-0 ${snippet.isNote ? "bg-muted-foreground/30" : "bg-accent-brand/60"}`}
/>
{draggable && (
<div className="flex items-center justify-center w-3.5 shrink-0 -mr-1.5 text-muted-foreground/30 group-hover:text-muted-foreground/70 transition-colors">
<GripVertical className="size-3" />
</div>
)}
<div className="flex flex-col flex-1 min-w-0 pl-[8.75px] pr-[7px] py-[7px] gap-[3.5px]">
<div className="flex items-center gap-1.5 min-w-0">
<TypeIcon className="size-3 shrink-0 text-muted-foreground/60" />
@@ -107,6 +173,24 @@ export function SnippetRow({
{snippet.content.split("\n")[0]}
</span>
)}
{hasTargets && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden">
<Zap className="size-2.5 shrink-0 text-accent-brand/70" />
{targetHostNames.slice(0, 3).map((name) => (
<span
key={name}
className="text-[9px] px-1.5 py-[1px] bg-accent-brand/10 text-accent-brand shrink-0 leading-[1.4] truncate max-w-28"
>
{name}
</span>
))}
{targetHostNames.length > 3 && (
<span className="text-[9px] text-muted-foreground/50 shrink-0">
+{targetHostNames.length - 3}
</span>
)}
</div>
)}
</div>
<div
@@ -146,6 +230,18 @@ export function SnippetRow({
<Pencil className="size-3.5" />
</button>
)}
{shareable && (
<button
title={t("shareSnippet")}
onClick={(e) => {
e.stopPropagation();
onShare();
}}
className={trayButtonClass}
>
<Share2 className="size-3.5" />
</button>
)}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -166,35 +262,41 @@ export function SnippetRow({
{t("copyToClipboard")}
</DropdownMenuItem>
{editable && (
<>
<DropdownMenuItem onClick={onEdit}>
<Pencil className="size-3.5 mr-2" />
{t("editSnippetTitle")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="size-3.5 mr-2" />
{t("moveToFolder")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="text-xs max-w-72">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="size-3.5 mr-2" />
{t("editSnippetTitle")}
</DropdownMenuItem>
)}
{shareable && (
<DropdownMenuItem onClick={onShare}>
<Share2 className="size-3.5 mr-2" />
{t("shareSnippet")}
</DropdownMenuItem>
)}
{editable && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="size-3.5 mr-2" />
{t("moveToFolder")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="text-xs max-w-72">
<DropdownMenuItem
disabled={!snippet.folder}
onClick={() => onMove(null)}
>
{t("noFolder")}
</DropdownMenuItem>
{folderNames.map((name) => (
<DropdownMenuItem
disabled={!snippet.folder}
onClick={() => onMove(null)}
key={name}
disabled={snippet.folder === name}
onClick={() => onMove(name)}
>
{t("noFolder")}
<span className="truncate">{name}</span>
</DropdownMenuItem>
{folderNames.map((name) => (
<DropdownMenuItem
key={name}
disabled={snippet.folder === name}
onClick={() => onMove(name)}
>
<span className="truncate">{name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{deletable && (
<>
@@ -224,10 +326,14 @@ export function SnippetFolderRow({
canCreate,
canEdit,
canDelete,
canShare,
acceptsDrop,
onToggle,
onAddSnippet,
onEdit,
onShare,
onDelete,
onDropSnippet,
children,
}: {
name: string;
@@ -239,14 +345,20 @@ export function SnippetFolderRow({
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
canShare: boolean;
/** True while a snippet is being dragged that could move into this folder. */
acceptsDrop: boolean;
onToggle: () => void;
onAddSnippet: () => void;
onEdit: () => void;
onShare: () => void;
onDelete: () => void;
onDropSnippet: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
const hasActions = canCreate || canEdit || canDelete;
const [dragOver, setDragOver] = useState(false);
const hasActions = canCreate || canEdit || canDelete || canShare;
return (
<div className="border-b border-border/40">
@@ -260,9 +372,27 @@ export function SnippetFolderRow({
onToggle();
}
}}
onDragOver={(e) => {
if (!acceptsDrop) return;
e.preventDefault();
setDragOver(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (!acceptsDrop) return;
e.preventDefault();
setDragOver(false);
onDropSnippet();
}}
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 cursor-pointer select-none transition-colors ${
open ? "bg-muted/40" : "hover:bg-muted/30"
} ${stripeIndex % 2 === 1 && !open ? "bg-muted/[0.08]" : ""}`}
} ${stripeIndex % 2 === 1 && !open ? "bg-muted/[0.08]" : ""} ${
dragOver && acceptsDrop
? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10"
: ""
}`}
>
<ChevronRight
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${open ? "rotate-90" : ""}`}
@@ -302,6 +432,12 @@ export function SnippetFolderRow({
{t("editFolderTitle")}
</DropdownMenuItem>
)}
{canShare && count > 0 && (
<DropdownMenuItem onClick={onShare}>
<Share2 className="size-3.5 mr-2" />
{t("shareFolder")}
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
+181 -8
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useHosts,
usePermission,
usePluginApi,
useSettings,
@@ -31,7 +32,8 @@ import {
useConfirmation,
} from "@termix/plugin-sdk/ui";
import { createSnippetsApi } from "./snippets-api";
import { useSnippetRunner } from "./use-snippet-runner";
import { useSnippetRunner, type TargetHost } from "./use-snippet-runner";
import { SnippetShareView, type ShareTarget } from "./SnippetShareView";
import { SnippetEditor, type SnippetFormValues } from "./SnippetEditor";
import { SnippetSettings } from "./SnippetSettings";
import { readSnippetSettings } from "./settings";
@@ -39,15 +41,21 @@ import {
SnippetFolderDialog,
type FolderFormValues,
} from "./SnippetFolderDialog";
import { SnippetFolderRow, SnippetRow } from "./SnippetTree";
import { errorMessage, type Snippet, type SnippetFolder } from "./types";
import { SnippetFolderRow, SnippetRow, type DropPosition } from "./SnippetTree";
import {
errorMessage,
parseHostFilter,
type Snippet,
type SnippetFolder,
} from "./types";
const DOCS_URL = "https://docs.termix.site/features/terminal/snippets";
type View =
| { kind: "list" }
| { kind: "edit"; snippet: Snippet | null; folder?: string }
| { kind: "settings" };
| { kind: "settings" }
| { kind: "share"; target: ShareTarget };
interface FolderGroup {
name: string;
@@ -71,11 +79,15 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
const canCreate = usePermission("create");
const canEdit = usePermission("edit");
const canDelete = usePermission("delete");
const canShare = usePermission("share");
const settings = useSettings("user");
const display = readSnippetSettings(settings.values);
const { runOnActive, dialog: runnerDialog } = useSnippetRunner(
display.confirmExecution,
);
const {
runOnActive,
runOnHosts,
dialog: runnerDialog,
} = useSnippetRunner(display.confirmExecution);
const { hosts } = useHosts();
const { confirmWithToast } = useConfirmation();
const [snippets, setSnippets] = useState<Snippet[]>([]);
@@ -92,9 +104,37 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
/** The name being edited, set even when the folder has no metadata row. */
editName: string | null;
} | null>(null);
const [dragged, setDragged] = useState<Snippet | null>(null);
const [dropTarget, setDropTarget] = useState<{
id: number;
position: DropPosition;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const importOverwriteRef = useRef(false);
const hostsById = useMemo(
() => new Map(hosts.map((host) => [Number(host.id), host])),
[hosts],
);
function targetHostsOf(snippet: Snippet): TargetHost[] {
if (snippet.isNote) return [];
return parseHostFilter(snippet.hostFilter).flatMap((id) => {
const host = hostsById.get(id);
return host
? [
{
id,
name: host.name,
ip: host.ip,
username: host.username,
port: host.port,
},
]
: [];
});
}
const load = useCallback(async () => {
if (!canView) {
setLoading(false);
@@ -186,6 +226,8 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
description: values.description.trim() || null,
folder: values.folder || null,
isNote: values.isNote,
hostFilter:
!values.isNote && values.hostIds.length > 0 ? values.hostIds : null,
};
const editing = view.snippet;
try {
@@ -210,6 +252,69 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
}
}
function handleRun(snippet: Snippet) {
const targets = targetHostsOf(snippet);
if (targets.length > 0) runOnHosts(snippet, targets);
else runOnActive(snippet, null);
}
function endDrag() {
setDragged(null);
setDropTarget(null);
}
// Drops the dragged snippet next to a row, taking that row's folder.
async function handleDropOnRow(target: Snippet) {
const source = dragged;
const position = dropTarget?.position ?? "below";
endDrag();
if (!source || source.id === target.id) return;
const folder = target.folder ?? null;
const group = snippets.filter(
(s) => (s.folder ?? null) === folder && s.id !== source.id,
);
const index = group.findIndex((s) => s.id === target.id);
group.splice(position === "above" ? index : index + 1, 0, {
...source,
folder,
});
await saveOrder(group, folder);
}
async function handleDropOnFolder(folder: string) {
const source = dragged;
endDrag();
if (!source || source.folder === folder) return;
const group = [
...snippets.filter((s) => s.folder === folder && s.id !== source.id),
{ ...source, folder },
];
setFolderOverrides((prev) => new Map(prev).set(folder, true));
await saveOrder(group, folder);
}
async function saveOrder(group: Snippet[], folder: string | null) {
const ordered = group.map((s, order) => ({ ...s, order }));
const byId = new Map(ordered.map((s) => [s.id, s]));
setSnippets((prev) => {
const rest = prev.filter((s) => !byId.has(s.id));
return [...rest, ...ordered];
});
try {
await client.reorder(
ordered.map((s) => ({
id: s.id,
order: s.order,
folder: folder ?? "",
})),
);
} catch (err) {
toast.error(errorMessage(err, t("reorderFailed")));
await load();
}
}
function handleDeleteSnippet(snippet: Snippet) {
void confirmWithToast(
t("deleteSnippetConfirm", { name: snippet.name }),
@@ -381,6 +486,18 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
);
}
if (view.kind === "share") {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<SnippetShareView
target={view.target}
client={client}
onBack={() => setView({ kind: "list" })}
/>
</div>
);
}
if (view.kind === "settings") {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
@@ -400,13 +517,38 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
stripeIndex={stripe++}
showCommand={display.showCommands}
folderNames={folderNames}
targetHostNames={targetHostsOf(snippet).map(
(host) => host.name || host.ip || String(host.id),
)}
canEdit={canEdit}
canDelete={canDelete}
onRun={() => runOnActive(snippet, null)}
canShare={canShare}
draggable={canEdit && !snippet.isShared && !query}
dragActive={dragged !== null}
isDragging={dragged?.id === snippet.id}
dropIndicator={
dropTarget?.id === snippet.id && dragged?.id !== snippet.id
? dropTarget.position
: null
}
onRun={() => handleRun(snippet)}
onCopy={() => void handleCopy(snippet)}
onEdit={() => setView({ kind: "edit", snippet })}
onShare={() =>
setView({ kind: "share", target: { kind: "snippet", snippet } })
}
onMove={(folder) => void handleMoveSnippet(snippet, folder)}
onDelete={() => handleDeleteSnippet(snippet)}
onDragStart={() => setDragged(snippet)}
onDragEnd={endDrag}
onDragOverRow={(position) =>
setDropTarget((prev) =>
prev?.id === snippet.id && prev.position === position
? prev
: { id: snippet.id, position },
)
}
onDropRow={() => void handleDropOnRow(snippet)}
/>
);
@@ -586,6 +728,10 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
canCreate={canCreate}
canEdit={canEdit}
canDelete={canDelete}
canShare={canShare}
acceptsDrop={
dragged !== null && dragged.folder !== group.name
}
onToggle={() => toggleFolder(group.name)}
onAddSnippet={() =>
setView({
@@ -600,13 +746,40 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
editName: group.name,
})
}
onShare={() =>
setView({
kind: "share",
target: { kind: "folder", name: group.name },
})
}
onDelete={() => handleDeleteFolder(group.name)}
onDropSnippet={() => void handleDropOnFolder(group.name)}
>
{open && group.snippets.map(renderSnippet)}
</SnippetFolderRow>
);
})}
{rootSnippets.map(renderSnippet)}
{dragged?.folder && (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const source = dragged;
endDrag();
void saveOrder(
[
...snippets.filter((s) => !s.folder),
{ ...source, folder: null },
],
null,
);
}}
className="m-2 px-3 py-3 text-center text-[11px] text-muted-foreground border border-dashed border-border"
>
{t("dropToRemoveFromFolder")}
</div>
)}
</>
)}
</div>
@@ -33,6 +33,17 @@ export interface SnippetExecutionResult {
error?: string;
}
export interface ShareableUser {
id: string;
username: string;
}
export interface ShareableRole {
id: number;
name: string;
displayName: string | null;
}
export interface SnippetShareInput {
targetType: "user" | "role";
targetUserId?: string;
@@ -118,6 +129,10 @@ export function createSnippetsApi(api: PluginApiClient) {
data(
api.delete<{ success: boolean }>(`/${snippetId}/access/${accessId}`),
),
shareTargetUsers: () =>
data(api.get<{ users: ShareableUser[] }>("/share-targets/users")),
shareTargetRoles: () =>
data(api.get<{ roles: ShareableRole[] }>("/share-targets/roles")),
listShared: () => data(api.get<{ sharedSnippets: Snippet[] }>("/shared")),
};
}
+13
View File
@@ -52,6 +52,19 @@ export interface SnippetAccessEntry {
createdAt: string;
}
/** The host ids a command snippet targets, from its stored JSON filter. */
export function parseHostFilter(raw: string | null | undefined): number[] {
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((id): id is number => typeof id === "number")
: [];
} catch {
return [];
}
}
export function errorMessage(error: unknown, fallback: string): string {
if (
error &&
@@ -1,5 +1,9 @@
import { useCallback, useState } from "react";
import { useTranslation, invokeAction } from "@termix/plugin-sdk/frontend";
import { useCallback, useMemo, useState } from "react";
import {
invokeAction,
usePluginApi,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import {
@@ -7,7 +11,20 @@ import {
resolveSnippetContent,
type SnippetHostContext,
} from "./snippet-variables";
import type { Snippet } from "./types";
import { createSnippetsApi } from "./snippets-api";
import {
ExecutionResultsDialog,
type HostExecutionResult,
} from "./ExecutionResultsDialog";
import { errorMessage, type Snippet } from "./types";
export interface TargetHost {
id: number;
name?: string;
ip?: string;
username?: string;
port?: number;
}
export interface RunTarget {
sessionId: string;
@@ -20,10 +37,17 @@ export interface RunTarget {
* present, and gates on the confirm-before-running setting. Sends through
* the ssh-terminal plugin's terminal.sendToActive/sendToSession actions
* rather than touching a terminal ref directly, since core's tab state is
* not part of the plugin surface.
* not part of the plugin surface. runOnHosts instead runs a command over a
* fresh SSH connection to each of the snippet's target hosts.
*/
export function useSnippetRunner(confirmExecution = false) {
const { t } = useTranslation();
const api = usePluginApi();
const client = useMemo(() => createSnippetsApi(api), [api]);
const [execution, setExecution] = useState<{
snippetName: string;
results: HostExecutionResult[];
} | null>(null);
const [runningSnippet, setRunningSnippet] = useState<{
snippet: Snippet;
host: SnippetHostContext | null;
@@ -149,7 +173,91 @@ export function useSnippetRunner(confirmExecution = false) {
[handleConfirmRun, t],
);
const dialog = runningSnippet ? (
const runOnHosts = useCallback(
(snippet: Snippet, hosts: TargetHost[]) => {
if (hosts.length === 0) return;
const execute = (inputValues: Record<string, string>) => {
const label = (host: TargetHost) =>
host.name || host.ip || String(host.id);
setExecution({
snippetName: snippet.name,
results: hosts.map((host) => ({
hostId: host.id,
hostLabel: label(host),
success: null,
output: "",
})),
});
const update = (result: HostExecutionResult) =>
setExecution((prev) =>
prev
? {
...prev,
results: prev.results.map((r) =>
r.hostId === result.hostId ? result : r,
),
}
: prev,
);
const inputs =
Object.keys(inputValues).length > 0 ? inputValues : undefined;
void Promise.all(
hosts.map(async (host) => {
try {
const result = await client.execute(snippet.id, host.id, inputs);
update({ hostId: host.id, hostLabel: label(host), ...result });
return result.success;
} catch (err) {
update({
hostId: host.id,
hostLabel: label(host),
success: false,
output: "",
error: errorMessage(err, t("executionFailed")),
});
return false;
}
}),
).then((outcomes) => {
if (outcomes.every(Boolean)) {
toast.success(
t("directRunSuccess", {
name: snippet.name,
count: hosts.length,
}),
);
} else {
toast.error(t("directRunPartialFail", { name: snippet.name }));
}
});
};
const runWithInputs = (inputValues: Record<string, string>) =>
handleConfirmRun(snippet, () => execute(inputValues));
if (hasSnippetInputs(snippet.content)) {
const first = hosts[0];
setRunningSnippet({
snippet,
host: {
ip: first.ip,
username: first.username,
port: first.port,
name: first.name,
},
onConfirm: (_resolvedContent, inputValues) => {
setRunningSnippet(null);
runWithInputs(inputValues);
},
});
} else {
runWithInputs({});
}
},
[client, handleConfirmRun, t],
);
const variablesDialog = runningSnippet ? (
<SnippetVariablesDialog
snippet={runningSnippet.snippet as never}
host={runningSnippet.host}
@@ -158,5 +266,18 @@ export function useSnippetRunner(confirmExecution = false) {
/>
) : null;
return { runSnippet, runOnActive, handleConfirmRun, dialog };
const dialog = (
<>
{variablesDialog}
{execution && (
<ExecutionResultsDialog
snippetName={execution.snippetName}
results={execution.results}
onClose={() => setExecution(null)}
/>
)}
</>
);
return { runSnippet, runOnActive, runOnHosts, handleConfirmRun, dialog };
}
@@ -30,6 +30,8 @@ export async function startServer(
permissions?: string[];
users?: string[];
capabilities?: string[];
shareableUsers?: { id: string; username: string }[];
shareableRoles?: { id: number; name: string; displayName: string }[];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
@@ -45,6 +47,8 @@ export async function startServer(
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
db: db.database,
shareableUsers: options.shareableUsers,
shareableRoles: options.shareableRoles,
router: () => (router = express.Router()),
permissions: options.permissions ?? [
"snippets.view",
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from "vitest";
import { manifest, startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("share target routes", () => {
it("lists users and roles to share with", async () => {
server = await startServer({
shareableUsers: [{ id: "user-2", username: "bob" }],
shareableRoles: [{ id: 3, name: "ops", displayName: "Ops" }],
});
const users = await server.request("GET", "/share-targets/users");
expect(users.status).toBe(200);
expect(users.body).toEqual({ users: [{ id: "user-2", username: "bob" }] });
const roles = await server.request("GET", "/share-targets/roles");
expect(roles.status).toBe(200);
expect(roles.body).toEqual({
roles: [{ id: 3, name: "ops", displayName: "Ops" }],
});
});
it("needs the share permission", async () => {
server = await startServer({ permissions: ["snippets.view"] });
expect((await server.request("GET", "/share-targets/users")).status).toBe(
403,
);
});
it("fails without the hosts:write capability", async () => {
server = await startServer({
capabilities: manifest.capabilities.filter((c) => c !== "hosts:write"),
});
expect((await server.request("GET", "/share-targets/roles")).status).toBe(
500,
);
});
});
@@ -16,6 +16,11 @@ const PERMISSIONS = [
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
];
const HOSTS = [
{ id: "5", name: "web-1", ip: "10.0.0.5", port: 22, username: "root" },
];
function snippet(overrides: Record<string, unknown>) {
@@ -37,10 +42,17 @@ function snippet(overrides: Record<string, unknown>) {
function fakeApi(snippets: unknown[], folders: unknown[] = []) {
const api = {
get: vi.fn(async (path: string) => ({
data: path === "/folders" ? folders : snippets,
get: vi.fn(async (path: string) => {
if (path === "/folders") return { data: folders };
if (path === "/share-targets/users")
return { data: { users: [{ id: "u2", username: "bob" }] } };
if (path === "/share-targets/roles") return { data: { roles: [] } };
if (path.endsWith("/access")) return { data: [] };
return { data: snippets };
}),
post: vi.fn(async (path: string) => ({
data: path === "/execute" ? { success: true, output: "up 3 days" } : {},
})),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
@@ -61,6 +73,7 @@ async function renderPanel(api: PluginApiClient) {
locales,
api,
permissions: PERMISSIONS,
hosts: HOSTS as never,
});
rendered.renderPanel("snippets", { active: true });
}
@@ -121,6 +134,7 @@ describe("snippets panel", () => {
description: null,
folder: null,
isNote: false,
hostFilter: null,
}),
);
expect(await screen.findByPlaceholderText(/search snippets/i)).toBeTruthy();
@@ -136,4 +150,63 @@ describe("snippets panel", () => {
fireEvent.click(screen.getByText(locales.backToSnippets));
expect(await screen.findByText(locales.emptyTitle)).toBeTruthy();
});
it("shares a snippet with a user", async () => {
const api = fakeApi([snippet({})]);
await renderPanel(api);
await screen.findByText("List files");
fireEvent.click(screen.getByTitle(locales.shareSnippet));
fireEvent.click(await screen.findByText("bob"));
fireEvent.click(screen.getByRole("button", { name: /share with 1/i }));
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith("/1/share", {
targetType: "user",
targetUserId: "u2",
expiresAt: null,
}),
);
expect(await screen.findByText(locales.notSharedYet)).toBeTruthy();
fireEvent.click(screen.getByText(/share "list files"/i));
expect(await screen.findByText("List files")).toBeTruthy();
});
it("runs a snippet with target hosts on those hosts", async () => {
const api = fakeApi([snippet({ hostFilter: "[5]" })]);
await renderPanel(api);
expect(await screen.findByText("web-1")).toBeTruthy();
fireEvent.click(screen.getByTitle(locales.runOnTargets));
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith("/execute", {
snippetId: 1,
hostId: 5,
inputValues: undefined,
}),
);
expect(await screen.findByText("up 3 days")).toBeTruthy();
});
it("reorders snippets by dragging", async () => {
const api = fakeApi([
snippet({ id: 1, name: "First", order: 0 }),
snippet({ id: 2, name: "Second", order: 1 }),
]);
await renderPanel(api);
const row = (name: string) =>
screen.getByText(name).closest("[draggable]") as HTMLElement;
await screen.findByText("First");
// jsdom has no layout, so a drop always lands below the target row.
fireEvent.dragStart(row("First"), {
dataTransfer: { effectAllowed: "" },
});
fireEvent.dragOver(row("Second"));
fireEvent.drop(row("Second"));
await waitFor(() =>
expect(api.put).toHaveBeenCalledWith("/reorder", {
snippets: [
{ id: 2, order: 0, folder: "" },
{ id: 1, order: 1, folder: "" },
],
}),
);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { parseHostFilter } from "../../src/frontend/types";
describe("parseHostFilter", () => {
it("reads a stored list of host ids", () => {
expect(parseHostFilter("[1,5]")).toEqual([1, 5]);
});
it("treats empty, malformed or non-numeric values as no targets", () => {
expect(parseHostFilter(null)).toEqual([]);
expect(parseHostFilter("")).toEqual([]);
expect(parseHostFilter("not json")).toEqual([]);
expect(parseHostFilter('{"a":1}')).toEqual([]);
expect(parseHostFilter('[1,"x",2]')).toEqual([1, 2]);
});
});