mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(snippets): bring back sharing, target hosts and drag reorder
This commit is contained in:
1 parent
796db8f0a8
commit
1fd89acd90
16 files changed
+1376
-53
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"network:serve",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"hosts:write",
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user