mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +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[];
|
permissions?: string[];
|
||||||
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
|
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
|
||||||
hosts?: PluginHostSummary[];
|
hosts?: PluginHostSummary[];
|
||||||
|
/** Users and roles ctx.hosts.listUsers/listRoles serve. See FakeContextOptions. */
|
||||||
|
shareableUsers?: PluginShareableUser[];
|
||||||
|
shareableRoles?: PluginShareableRole[];
|
||||||
/** Other plugins' services. See FakeContextOptions. */
|
/** Other plugins' services. See FakeContextOptions. */
|
||||||
services?: Record<string, object>;
|
services?: Record<string, object>;
|
||||||
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
|
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
|
||||||
@@ -1361,6 +1364,8 @@ export function createMockCtx(
|
|||||||
router: options.router,
|
router: options.router,
|
||||||
permissions: options.permissions,
|
permissions: options.permissions,
|
||||||
hosts: options.hosts,
|
hosts: options.hosts,
|
||||||
|
shareableUsers: options.shareableUsers,
|
||||||
|
shareableRoles: options.shareableRoles,
|
||||||
sshHosts: options.sshHosts,
|
sshHosts: options.sshHosts,
|
||||||
services: options.services,
|
services: options.services,
|
||||||
protocolTargets: options.protocolTargets,
|
protocolTargets: options.protocolTargets,
|
||||||
|
|||||||
@@ -149,5 +149,51 @@
|
|||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
"noSearchResults": "No snippets match your search",
|
"noSearchResults": "No snippets match your search",
|
||||||
"emptyTitle": "No snippets yet",
|
"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",
|
"network:serve",
|
||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"credentials:use",
|
"credentials:use",
|
||||||
|
"hosts:write",
|
||||||
"ui:surface",
|
"ui:surface",
|
||||||
"events:core"
|
"events:core"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -926,6 +926,68 @@ export function registerSnippetRoutes(
|
|||||||
|
|
||||||
// Sharing (moved from core's /rbac/snippet* routes)
|
// 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
|
* @openapi
|
||||||
* /plugin-api/snippets/{id}/share:
|
* /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 { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { ArrowLeft, FileText } from "lucide-react";
|
import { ArrowLeft, Check, FileText, Search, Server, Zap } from "lucide-react";
|
||||||
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
|
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 {
|
export interface SnippetFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,6 +10,7 @@ export interface SnippetFormValues {
|
|||||||
description: string;
|
description: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
isNote: boolean;
|
isNote: boolean;
|
||||||
|
hostIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelClass =
|
const labelClass =
|
||||||
@@ -36,7 +37,10 @@ export function SnippetEditor({
|
|||||||
description: snippet?.description ?? "",
|
description: snippet?.description ?? "",
|
||||||
folder: snippet?.folder ?? defaultFolder ?? "",
|
folder: snippet?.folder ?? defaultFolder ?? "",
|
||||||
isNote: snippet?.isNote ?? false,
|
isNote: snippet?.isNote ?? false,
|
||||||
|
hostIds: parseHostFilter(snippet?.hostFilter),
|
||||||
}));
|
}));
|
||||||
|
const { hosts } = useHosts();
|
||||||
|
const [hostSearch, setHostSearch] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const set = <K extends keyof SnippetFormValues>(
|
const set = <K extends keyof SnippetFormValues>(
|
||||||
@@ -53,6 +57,29 @@ export function SnippetEditor({
|
|||||||
]),
|
]),
|
||||||
).sort((a, b) => a.localeCompare(b));
|
).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() {
|
async function handleSave() {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -168,6 +195,80 @@ export function SnippetEditor({
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</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">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="ghost" onClick={onBack} disabled={saving}>
|
<Button variant="ghost" onClick={onBack} disabled={saving}>
|
||||||
{t("cancel")}
|
{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,
|
ClipboardPaste,
|
||||||
Copy,
|
Copy,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
|
GripVertical,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Share2,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -30,42 +33,93 @@ import type { Snippet, SnippetFolder } from "./types";
|
|||||||
const trayButtonClass =
|
const trayButtonClass =
|
||||||
"flex items-center justify-center size-[22.75px] text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
|
"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({
|
export function SnippetRow({
|
||||||
snippet,
|
snippet,
|
||||||
stripeIndex,
|
stripeIndex,
|
||||||
showCommand,
|
showCommand,
|
||||||
folderNames,
|
folderNames,
|
||||||
|
targetHostNames,
|
||||||
canEdit,
|
canEdit,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
canShare,
|
||||||
|
draggable,
|
||||||
|
dragActive,
|
||||||
|
isDragging,
|
||||||
|
dropIndicator,
|
||||||
onRun,
|
onRun,
|
||||||
onCopy,
|
onCopy,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onShare,
|
||||||
onMove,
|
onMove,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOverRow,
|
||||||
|
onDropRow,
|
||||||
}: {
|
}: {
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
stripeIndex: number;
|
stripeIndex: number;
|
||||||
showCommand: boolean;
|
showCommand: boolean;
|
||||||
folderNames: string[];
|
folderNames: string[];
|
||||||
|
/** Hosts a command snippet runs on directly, instead of the active terminal. */
|
||||||
|
targetHostNames: string[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canDelete: 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;
|
onRun: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onShare: () => void;
|
||||||
onMove: (folder: string | null) => void;
|
onMove: (folder: string | null) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onDragOverRow: (position: DropPosition) => void;
|
||||||
|
onDropRow: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const owned = !snippet.isShared;
|
const owned = !snippet.isShared;
|
||||||
const editable = canEdit && owned;
|
const editable = canEdit && owned;
|
||||||
const deletable = canDelete && owned;
|
const deletable = canDelete && owned;
|
||||||
|
const shareable = canShare && owned;
|
||||||
|
const hasTargets = !snippet.isNote && targetHostNames.length > 0;
|
||||||
const TypeIcon = snippet.isNote ? StickyNote : Terminal;
|
const TypeIcon = snippet.isNote ? StickyNote : Terminal;
|
||||||
const RunIcon = snippet.isNote ? ClipboardPaste : Play;
|
const RunIcon = hasTargets ? Zap : snippet.isNote ? ClipboardPaste : Play;
|
||||||
const runLabel = t(snippet.isNote ? "pasteToTerminal" : "run");
|
const runLabel = t(
|
||||||
|
hasTargets ? "runOnTargets" : snippet.isNote ? "pasteToTerminal" : "run",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onDoubleClick={onRun}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -73,12 +127,24 @@ export function SnippetRow({
|
|||||||
}}
|
}}
|
||||||
title={t("doubleClickToRun")}
|
title={t("doubleClickToRun")}
|
||||||
className={`group relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 ${
|
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" : ""
|
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
|
<div
|
||||||
className={`w-[3px] shrink-0 ${snippet.isNote ? "bg-muted-foreground/30" : "bg-accent-brand/60"}`}
|
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 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">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<TypeIcon className="size-3 shrink-0 text-muted-foreground/60" />
|
<TypeIcon className="size-3 shrink-0 text-muted-foreground/60" />
|
||||||
@@ -107,6 +173,24 @@ export function SnippetRow({
|
|||||||
{snippet.content.split("\n")[0]}
|
{snippet.content.split("\n")[0]}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -146,6 +230,18 @@ export function SnippetRow({
|
|||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{shareable && (
|
||||||
|
<button
|
||||||
|
title={t("shareSnippet")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare();
|
||||||
|
}}
|
||||||
|
className={trayButtonClass}
|
||||||
|
>
|
||||||
|
<Share2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -166,35 +262,41 @@ export function SnippetRow({
|
|||||||
{t("copyToClipboard")}
|
{t("copyToClipboard")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{editable && (
|
{editable && (
|
||||||
<>
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
<DropdownMenuItem onClick={onEdit}>
|
<Pencil className="size-3.5 mr-2" />
|
||||||
<Pencil className="size-3.5 mr-2" />
|
{t("editSnippetTitle")}
|
||||||
{t("editSnippetTitle")}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
<DropdownMenuSub>
|
{shareable && (
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuItem onClick={onShare}>
|
||||||
<FolderInput className="size-3.5 mr-2" />
|
<Share2 className="size-3.5 mr-2" />
|
||||||
{t("moveToFolder")}
|
{t("shareSnippet")}
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSubContent className="text-xs max-w-72">
|
)}
|
||||||
|
{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
|
<DropdownMenuItem
|
||||||
disabled={!snippet.folder}
|
key={name}
|
||||||
onClick={() => onMove(null)}
|
disabled={snippet.folder === name}
|
||||||
|
onClick={() => onMove(name)}
|
||||||
>
|
>
|
||||||
{t("noFolder")}
|
<span className="truncate">{name}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{folderNames.map((name) => (
|
))}
|
||||||
<DropdownMenuItem
|
</DropdownMenuSubContent>
|
||||||
key={name}
|
</DropdownMenuSub>
|
||||||
disabled={snippet.folder === name}
|
|
||||||
onClick={() => onMove(name)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{name}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{deletable && (
|
{deletable && (
|
||||||
<>
|
<>
|
||||||
@@ -224,10 +326,14 @@ export function SnippetFolderRow({
|
|||||||
canCreate,
|
canCreate,
|
||||||
canEdit,
|
canEdit,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
canShare,
|
||||||
|
acceptsDrop,
|
||||||
onToggle,
|
onToggle,
|
||||||
onAddSnippet,
|
onAddSnippet,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onShare,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDropSnippet,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -239,14 +345,20 @@ export function SnippetFolderRow({
|
|||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canShare: boolean;
|
||||||
|
/** True while a snippet is being dragged that could move into this folder. */
|
||||||
|
acceptsDrop: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onAddSnippet: () => void;
|
onAddSnippet: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onShare: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDropSnippet: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasActions = canCreate || canEdit || canDelete;
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const hasActions = canCreate || canEdit || canDelete || canShare;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border/40">
|
<div className="border-b border-border/40">
|
||||||
@@ -260,9 +372,27 @@ export function SnippetFolderRow({
|
|||||||
onToggle();
|
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 ${
|
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"
|
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
|
<ChevronRight
|
||||||
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${open ? "rotate-90" : ""}`}
|
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${open ? "rotate-90" : ""}`}
|
||||||
@@ -302,6 +432,12 @@ export function SnippetFolderRow({
|
|||||||
{t("editFolderTitle")}
|
{t("editFolderTitle")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{canShare && count > 0 && (
|
||||||
|
<DropdownMenuItem onClick={onShare}>
|
||||||
|
<Share2 className="size-3.5 mr-2" />
|
||||||
|
{t("shareFolder")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
useHosts,
|
||||||
usePermission,
|
usePermission,
|
||||||
usePluginApi,
|
usePluginApi,
|
||||||
useSettings,
|
useSettings,
|
||||||
@@ -31,7 +32,8 @@ import {
|
|||||||
useConfirmation,
|
useConfirmation,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { createSnippetsApi } from "./snippets-api";
|
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 { SnippetEditor, type SnippetFormValues } from "./SnippetEditor";
|
||||||
import { SnippetSettings } from "./SnippetSettings";
|
import { SnippetSettings } from "./SnippetSettings";
|
||||||
import { readSnippetSettings } from "./settings";
|
import { readSnippetSettings } from "./settings";
|
||||||
@@ -39,15 +41,21 @@ import {
|
|||||||
SnippetFolderDialog,
|
SnippetFolderDialog,
|
||||||
type FolderFormValues,
|
type FolderFormValues,
|
||||||
} from "./SnippetFolderDialog";
|
} from "./SnippetFolderDialog";
|
||||||
import { SnippetFolderRow, SnippetRow } from "./SnippetTree";
|
import { SnippetFolderRow, SnippetRow, type DropPosition } from "./SnippetTree";
|
||||||
import { errorMessage, type Snippet, type SnippetFolder } from "./types";
|
import {
|
||||||
|
errorMessage,
|
||||||
|
parseHostFilter,
|
||||||
|
type Snippet,
|
||||||
|
type SnippetFolder,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const DOCS_URL = "https://docs.termix.site/features/terminal/snippets";
|
const DOCS_URL = "https://docs.termix.site/features/terminal/snippets";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| { kind: "list" }
|
| { kind: "list" }
|
||||||
| { kind: "edit"; snippet: Snippet | null; folder?: string }
|
| { kind: "edit"; snippet: Snippet | null; folder?: string }
|
||||||
| { kind: "settings" };
|
| { kind: "settings" }
|
||||||
|
| { kind: "share"; target: ShareTarget };
|
||||||
|
|
||||||
interface FolderGroup {
|
interface FolderGroup {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,11 +79,15 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
|
|||||||
const canCreate = usePermission("create");
|
const canCreate = usePermission("create");
|
||||||
const canEdit = usePermission("edit");
|
const canEdit = usePermission("edit");
|
||||||
const canDelete = usePermission("delete");
|
const canDelete = usePermission("delete");
|
||||||
|
const canShare = usePermission("share");
|
||||||
const settings = useSettings("user");
|
const settings = useSettings("user");
|
||||||
const display = readSnippetSettings(settings.values);
|
const display = readSnippetSettings(settings.values);
|
||||||
const { runOnActive, dialog: runnerDialog } = useSnippetRunner(
|
const {
|
||||||
display.confirmExecution,
|
runOnActive,
|
||||||
);
|
runOnHosts,
|
||||||
|
dialog: runnerDialog,
|
||||||
|
} = useSnippetRunner(display.confirmExecution);
|
||||||
|
const { hosts } = useHosts();
|
||||||
const { confirmWithToast } = useConfirmation();
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
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. */
|
/** The name being edited, set even when the folder has no metadata row. */
|
||||||
editName: string | null;
|
editName: string | null;
|
||||||
} | null>(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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const importOverwriteRef = useRef(false);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!canView) {
|
if (!canView) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -186,6 +226,8 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
|
|||||||
description: values.description.trim() || null,
|
description: values.description.trim() || null,
|
||||||
folder: values.folder || null,
|
folder: values.folder || null,
|
||||||
isNote: values.isNote,
|
isNote: values.isNote,
|
||||||
|
hostFilter:
|
||||||
|
!values.isNote && values.hostIds.length > 0 ? values.hostIds : null,
|
||||||
};
|
};
|
||||||
const editing = view.snippet;
|
const editing = view.snippet;
|
||||||
try {
|
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) {
|
function handleDeleteSnippet(snippet: Snippet) {
|
||||||
void confirmWithToast(
|
void confirmWithToast(
|
||||||
t("deleteSnippetConfirm", { name: snippet.name }),
|
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") {
|
if (view.kind === "settings") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -400,13 +517,38 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
|
|||||||
stripeIndex={stripe++}
|
stripeIndex={stripe++}
|
||||||
showCommand={display.showCommands}
|
showCommand={display.showCommands}
|
||||||
folderNames={folderNames}
|
folderNames={folderNames}
|
||||||
|
targetHostNames={targetHostsOf(snippet).map(
|
||||||
|
(host) => host.name || host.ip || String(host.id),
|
||||||
|
)}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
canDelete={canDelete}
|
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)}
|
onCopy={() => void handleCopy(snippet)}
|
||||||
onEdit={() => setView({ kind: "edit", snippet })}
|
onEdit={() => setView({ kind: "edit", snippet })}
|
||||||
|
onShare={() =>
|
||||||
|
setView({ kind: "share", target: { kind: "snippet", snippet } })
|
||||||
|
}
|
||||||
onMove={(folder) => void handleMoveSnippet(snippet, folder)}
|
onMove={(folder) => void handleMoveSnippet(snippet, folder)}
|
||||||
onDelete={() => handleDeleteSnippet(snippet)}
|
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}
|
canCreate={canCreate}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
|
canShare={canShare}
|
||||||
|
acceptsDrop={
|
||||||
|
dragged !== null && dragged.folder !== group.name
|
||||||
|
}
|
||||||
onToggle={() => toggleFolder(group.name)}
|
onToggle={() => toggleFolder(group.name)}
|
||||||
onAddSnippet={() =>
|
onAddSnippet={() =>
|
||||||
setView({
|
setView({
|
||||||
@@ -600,13 +746,40 @@ export function SnippetsPanel({ active: _active }: PanelProps) {
|
|||||||
editName: group.name,
|
editName: group.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onShare={() =>
|
||||||
|
setView({
|
||||||
|
kind: "share",
|
||||||
|
target: { kind: "folder", name: group.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
onDelete={() => handleDeleteFolder(group.name)}
|
onDelete={() => handleDeleteFolder(group.name)}
|
||||||
|
onDropSnippet={() => void handleDropOnFolder(group.name)}
|
||||||
>
|
>
|
||||||
{open && group.snippets.map(renderSnippet)}
|
{open && group.snippets.map(renderSnippet)}
|
||||||
</SnippetFolderRow>
|
</SnippetFolderRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{rootSnippets.map(renderSnippet)}
|
{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>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export interface SnippetExecutionResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareableUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareableRole {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnippetShareInput {
|
export interface SnippetShareInput {
|
||||||
targetType: "user" | "role";
|
targetType: "user" | "role";
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
@@ -118,6 +129,10 @@ export function createSnippetsApi(api: PluginApiClient) {
|
|||||||
data(
|
data(
|
||||||
api.delete<{ success: boolean }>(`/${snippetId}/access/${accessId}`),
|
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")),
|
listShared: () => data(api.get<{ sharedSnippets: Snippet[] }>("/shared")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ export interface SnippetAccessEntry {
|
|||||||
createdAt: string;
|
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 {
|
export function errorMessage(error: unknown, fallback: string): string {
|
||||||
if (
|
if (
|
||||||
error &&
|
error &&
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation, invokeAction } from "@termix/plugin-sdk/frontend";
|
import {
|
||||||
|
invokeAction,
|
||||||
|
usePluginApi,
|
||||||
|
useTranslation,
|
||||||
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
|
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +11,20 @@ import {
|
|||||||
resolveSnippetContent,
|
resolveSnippetContent,
|
||||||
type SnippetHostContext,
|
type SnippetHostContext,
|
||||||
} from "./snippet-variables";
|
} 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 {
|
export interface RunTarget {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -20,10 +37,17 @@ export interface RunTarget {
|
|||||||
* present, and gates on the confirm-before-running setting. Sends through
|
* present, and gates on the confirm-before-running setting. Sends through
|
||||||
* the ssh-terminal plugin's terminal.sendToActive/sendToSession actions
|
* the ssh-terminal plugin's terminal.sendToActive/sendToSession actions
|
||||||
* rather than touching a terminal ref directly, since core's tab state is
|
* 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) {
|
export function useSnippetRunner(confirmExecution = false) {
|
||||||
const { t } = useTranslation();
|
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<{
|
const [runningSnippet, setRunningSnippet] = useState<{
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
host: SnippetHostContext | null;
|
host: SnippetHostContext | null;
|
||||||
@@ -149,7 +173,91 @@ export function useSnippetRunner(confirmExecution = false) {
|
|||||||
[handleConfirmRun, t],
|
[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
|
<SnippetVariablesDialog
|
||||||
snippet={runningSnippet.snippet as never}
|
snippet={runningSnippet.snippet as never}
|
||||||
host={runningSnippet.host}
|
host={runningSnippet.host}
|
||||||
@@ -158,5 +266,18 @@ export function useSnippetRunner(confirmExecution = false) {
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : 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[];
|
permissions?: string[];
|
||||||
users?: string[];
|
users?: string[];
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
shareableUsers?: { id: string; username: string }[];
|
||||||
|
shareableRoles?: { id: number; name: string; displayName: string }[];
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<TestServer> {
|
): Promise<TestServer> {
|
||||||
const db = await createTestDb(pluginDir);
|
const db = await createTestDb(pluginDir);
|
||||||
@@ -45,6 +47,8 @@ export async function startServer(
|
|||||||
manifest,
|
manifest,
|
||||||
capabilities: options.capabilities ?? manifest.capabilities,
|
capabilities: options.capabilities ?? manifest.capabilities,
|
||||||
db: db.database,
|
db: db.database,
|
||||||
|
shareableUsers: options.shareableUsers,
|
||||||
|
shareableRoles: options.shareableRoles,
|
||||||
router: () => (router = express.Router()),
|
router: () => (router = express.Router()),
|
||||||
permissions: options.permissions ?? [
|
permissions: options.permissions ?? [
|
||||||
"snippets.view",
|
"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.create",
|
||||||
"snippets.edit",
|
"snippets.edit",
|
||||||
"snippets.delete",
|
"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>) {
|
function snippet(overrides: Record<string, unknown>) {
|
||||||
@@ -37,10 +42,17 @@ function snippet(overrides: Record<string, unknown>) {
|
|||||||
|
|
||||||
function fakeApi(snippets: unknown[], folders: unknown[] = []) {
|
function fakeApi(snippets: unknown[], folders: unknown[] = []) {
|
||||||
const api = {
|
const api = {
|
||||||
get: vi.fn(async (path: string) => ({
|
get: vi.fn(async (path: string) => {
|
||||||
data: path === "/folders" ? folders : snippets,
|
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: {} })),
|
put: vi.fn(async () => ({ data: {} })),
|
||||||
patch: vi.fn(async () => ({ data: {} })),
|
patch: vi.fn(async () => ({ data: {} })),
|
||||||
delete: vi.fn(async () => ({ data: {} })),
|
delete: vi.fn(async () => ({ data: {} })),
|
||||||
@@ -61,6 +73,7 @@ async function renderPanel(api: PluginApiClient) {
|
|||||||
locales,
|
locales,
|
||||||
api,
|
api,
|
||||||
permissions: PERMISSIONS,
|
permissions: PERMISSIONS,
|
||||||
|
hosts: HOSTS as never,
|
||||||
});
|
});
|
||||||
rendered.renderPanel("snippets", { active: true });
|
rendered.renderPanel("snippets", { active: true });
|
||||||
}
|
}
|
||||||
@@ -121,6 +134,7 @@ describe("snippets panel", () => {
|
|||||||
description: null,
|
description: null,
|
||||||
folder: null,
|
folder: null,
|
||||||
isNote: false,
|
isNote: false,
|
||||||
|
hostFilter: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(await screen.findByPlaceholderText(/search snippets/i)).toBeTruthy();
|
expect(await screen.findByPlaceholderText(/search snippets/i)).toBeTruthy();
|
||||||
@@ -136,4 +150,63 @@ describe("snippets panel", () => {
|
|||||||
fireEvent.click(screen.getByText(locales.backToSnippets));
|
fireEvent.click(screen.getByText(locales.backToSnippets));
|
||||||
expect(await screen.findByText(locales.emptyTitle)).toBeTruthy();
|
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