feat(snippets): redesign panel to match the host list

This commit is contained in:
LukeGus committed 2026-09-28 20:13:08 -05:00
1 parent 278fc7e32a
commit 75c3542ba0
12 files changed
+1520 -613

No files matched your search

+49 -5
View File
@@ -32,6 +32,14 @@
"foldersCollapsed": {
"label": "Collapse folders by default",
"description": "Start each snippet folder collapsed when you open the Snippets panel."
},
"showCommands": {
"label": "Show commands",
"description": "Show the first line of each snippet's command under its name."
},
"confirmExecution": {
"label": "Confirm before running",
"description": "Ask before a command snippet runs in a terminal."
}
},
"cancel": "Cancel",
@@ -45,7 +53,6 @@
"run": "Run",
"pasteToTerminal": "Paste to terminal",
"copySuccess": "Copied to clipboard",
"shareTitle": "Shared",
"importExport": "Import / Export",
"importFailed": "Failed to import snippets",
"nameLabel": "Name",
@@ -61,12 +68,10 @@
"noteLabel": "Note",
"notePlaceholder": "Text to paste into the terminal",
"createSnippetTitle": "Create snippet",
"createSnippetDescription": "Save a command or note you can run or paste into a terminal.",
"createSnippetButton": "Create snippet",
"createSuccess": "Snippet created",
"createFailed": "Failed to create snippet",
"editSnippetTitle": "Edit snippet",
"editSnippetDescription": "Change this snippet's name, content or folder.",
"saveSnippetButton": "Save snippet",
"updateSuccess": "Snippet updated",
"updateFailed": "Failed to update snippet",
@@ -88,7 +93,6 @@
"confirmRunButton": "Run",
"runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)",
"pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)",
"foldersCollapsedLabel": "Collapse folders by default",
"admin": {
"tabTitle": "Snippets",
"count": "{{count}} snippets",
@@ -105,5 +109,45 @@
"saveFailed": "Failed to save snippet",
"saving": "Saving...",
"save": "Save"
}
},
"backToSnippets": "Back to snippets",
"settingsTitle": "Snippet settings",
"settingsDisplayTitle": "Display",
"settingsBehaviorTitle": "Behavior",
"typeCommandHint": "Runs in the active terminal.",
"typeNoteHint": "Pastes into the active terminal without running.",
"variablesHint": "Variables: $HOST, $USER, $PORT, $NAME, ${INPUT_1:Label}",
"doubleClickToRun": "Double click to run",
"sharedBadge": "Shared",
"copyToClipboard": "Copy to clipboard",
"copyFailed": "Failed to copy to clipboard",
"moreOptions": "More options",
"moreActions": "More actions",
"moveToFolder": "Move to folder",
"deleteSnippet": "Delete snippet",
"deleteSnippetConfirm": "Delete snippet \"{{name}}\"?",
"deleteSuccess": "Snippet deleted",
"delete": "Delete",
"folderActions": "Folder actions",
"addSnippetHere": "New snippet here",
"deleteFolder": "Delete folder",
"deleteFolderConfirm": "Delete folder \"{{name}}\"? Its snippets move out of the folder.",
"createFolderDescription": "Group related snippets together.",
"editFolderDescription": "Change this folder's name, color or icon.",
"folderColorLabel": "Color",
"loadFailed": "Failed to load snippets",
"importSkipExisting": "Import (skip existing)",
"importOverwrite": "Import (overwrite existing)",
"importInvalidFile": "That file is not a valid snippets export",
"importSuccess": "Imported {{imported}}, updated {{updated}}, skipped {{skipped}}",
"exportSnippets": "Export snippets",
"exportSuccess": "Snippets exported",
"exportFailed": "Failed to export snippets",
"expandAll": "Expand all folders",
"collapseAll": "Collapse all folders",
"docsLink": "Documentation",
"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."
}
+14
View File
@@ -79,6 +79,20 @@
"labelKey": "settings.foldersCollapsed.label",
"descriptionKey": "settings.foldersCollapsed.description",
"default": true
},
{
"key": "showCommands",
"type": "boolean",
"labelKey": "settings.showCommands.label",
"descriptionKey": "settings.showCommands.description",
"default": true
},
{
"key": "confirmExecution",
"type": "boolean",
"labelKey": "settings.confirmExecution.label",
"descriptionKey": "settings.confirmExecution.description",
"default": false
}
]
},
@@ -0,0 +1,187 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ArrowLeft, FileText } from "lucide-react";
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
import type { Snippet, SnippetFolder } from "./types";
export interface SnippetFormValues {
name: string;
content: string;
description: string;
folder: string;
isNote: boolean;
}
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
export function SnippetEditor({
snippet,
defaultFolder,
folders,
onBack,
onSave,
}: {
/** The snippet being edited, or null to create one. */
snippet: Snippet | null;
defaultFolder?: string;
folders: SnippetFolder[];
onBack: () => void;
onSave: (values: SnippetFormValues) => Promise<boolean>;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<SnippetFormValues>(() => ({
name: snippet?.name ?? "",
content: snippet?.content ?? "",
description: snippet?.description ?? "",
folder: snippet?.folder ?? defaultFolder ?? "",
isNote: snippet?.isNote ?? false,
}));
const [saving, setSaving] = useState(false);
const set = <K extends keyof SnippetFormValues>(
key: K,
value: SnippetFormValues[K],
) => setValues((prev) => ({ ...prev, [key]: value }));
const canSave = !!values.name.trim() && !!values.content.trim() && !saving;
// A snippet can point at a folder that has no metadata row yet.
const folderNames = Array.from(
new Set([
...folders.map((f) => f.name),
...(values.folder ? [values.folder] : []),
]),
).sort((a, b) => a.localeCompare(b));
async function handleSave() {
if (!canSave) return;
setSaving(true);
try {
await onSave(values);
} finally {
setSaving(false);
}
}
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 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t("backToSnippets")}</span>
{snippet && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={snippet.name}
>
{snippet.name}
</span>
)}
</button>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<SectionCard
title={t(snippet ? "editSnippetTitle" : "createSnippetTitle")}
icon={<FileText className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass}>
{t("nameLabel")} <span className="text-accent-brand">*</span>
</label>
<Input
autoFocus
value={values.name}
placeholder={t("namePlaceholder")}
onChange={(e) => set("name", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass}>{t("descriptionLabel")}</label>
<Input
value={values.description}
placeholder={t("descriptionPlaceholder")}
onChange={(e) => set("description", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass}>{t("folderLabel")}</label>
<Select2
value={values.folder}
onChange={(e) => set("folder", e.target.value)}
>
<option value="">{t("noFolder")}</option>
{folderNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass}>{t("typeLabel")}</label>
<div className="flex items-stretch border border-border">
{[false, true].map((isNote) => (
<button
key={String(isNote)}
type="button"
onClick={() => set("isNote", isNote)}
className={`flex-1 h-8 text-xs transition-colors ${isNote ? "border-l border-border" : ""} ${
values.isNote === isNote
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
{t(isNote ? "typeNote" : "typeCommand")}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground">
{t(values.isNote ? "typeNoteHint" : "typeCommandHint")}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass}>
{t(values.isNote ? "noteLabel" : "commandLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<textarea
value={values.content}
placeholder={t(
values.isNote ? "notePlaceholder" : "commandPlaceholder",
)}
onChange={(e) => set("content", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void handleSave();
}
}}
className="w-full min-h-36 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-1 focus:ring-ring font-mono"
/>
<span className="text-[10px] text-muted-foreground/70 font-mono">
{t("variablesHint")}
</span>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onBack} disabled={saving}>
{t("cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
disabled={!canSave}
onClick={() => void handleSave()}
>
{t(snippet ? "saveSnippetButton" : "createSnippetButton")}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
FOLDER_COLORS,
Input,
} from "@termix/plugin-sdk/ui";
import { FolderIcon } from "./folder-icons";
import { FOLDER_ICONS, type FolderIconId, type SnippetFolder } from "./types";
export interface FolderFormValues {
name: string;
color: string;
icon: FolderIconId;
}
export function SnippetFolderDialog({
folder,
onClose,
onSave,
}: {
/** The folder being edited, or null to create one. */
folder: SnippetFolder | null;
onClose: () => void;
onSave: (values: FolderFormValues) => Promise<void>;
}) {
const { t } = useTranslation();
const [name, setName] = useState(folder?.name ?? "");
const [color, setColor] = useState<string>(folder?.color ?? FOLDER_COLORS[0]);
const [icon, setIcon] = useState<FolderIconId>(
(folder?.icon as FolderIconId) ?? "folder",
);
const [saving, setSaving] = useState(false);
const isEdit = folder !== null;
async function handleSave() {
if (!name.trim()) return;
setSaving(true);
try {
await onSave({ name: name.trim(), color, icon });
} finally {
setSaving(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t(isEdit ? "editFolderTitle" : "createFolderTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t(isEdit ? "editFolderDescription" : "createFolderDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("folderNameLabel")}
</label>
<Input
autoFocus
value={name}
placeholder={t("folderNamePlaceholder")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSave();
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("folderColorLabel")}
</label>
<div className="grid grid-cols-8 gap-1.5">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
title={c}
onClick={() => setColor(c)}
className={`h-7 transition-all ${color === c ? "ring-2 ring-offset-2 ring-offset-background ring-foreground/50" : "opacity-75 hover:opacity-100"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("folderIconLabel")}
</label>
<div className="grid grid-cols-5 gap-1.5">
{FOLDER_ICONS.map((id) => (
<button
key={id}
type="button"
title={id}
onClick={() => setIcon(id)}
className={`flex items-center justify-center h-9 border transition-colors ${
icon === id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
}`}
>
<FolderIcon icon={id} className="size-4" />
</button>
))}
</div>
</div>
<div className="flex items-center gap-2 px-2.5 py-2 border border-border bg-muted/20">
<FolderIcon icon={icon} className="size-4" style={{ color }} />
<span className="text-[13px] font-bold tracking-tight truncate">
{name.trim() || t("folderNamePlaceholder")}
</span>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving || !name.trim()}
onClick={() => void handleSave()}
>
{t(isEdit ? "saveFolderButton" : "createFolderButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,77 @@
import {
useTranslation,
type SettingsState,
} from "@termix/plugin-sdk/frontend";
import { ArrowLeft, Rows3, SquareStack } from "lucide-react";
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
import { readSnippetSettings, type SnippetDisplaySettings } from "./settings";
export function SnippetSettings({
settings,
onBack,
}: {
settings: SettingsState;
onBack: () => void;
}) {
const { t } = useTranslation();
const current = readSnippetSettings(settings.values);
const save = (patch: Partial<SnippetDisplaySettings>) =>
void settings.save({ ...settings.values, ...patch });
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 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t("backToSnippets")}</span>
<span className="ml-auto font-semibold text-foreground">
{t("settingsTitle")}
</span>
</button>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<SectionCard
title={t("settingsDisplayTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("settings.foldersCollapsed.label")}
description={t("settings.foldersCollapsed.description")}
>
<FakeSwitch
checked={current.foldersCollapsed}
onChange={(v) => save({ foldersCollapsed: v })}
/>
</SettingRow>
<SettingRow
label={t("settings.showCommands.label")}
description={t("settings.showCommands.description")}
>
<FakeSwitch
checked={current.showCommands}
onChange={(v) => save({ showCommands: v })}
/>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settingsBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("settings.confirmExecution.label")}
description={t("settings.confirmExecution.description")}
>
<FakeSwitch
checked={current.confirmExecution}
onChange={(v) => save({ confirmExecution: v })}
/>
</SettingRow>
</SectionCard>
</div>
</div>
);
}
@@ -0,0 +1,334 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
ChevronRight,
ClipboardPaste,
Copy,
FolderInput,
MoreHorizontal,
Pencil,
Play,
Plus,
StickyNote,
Terminal,
Trash2,
Users,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import { FolderIcon } from "./folder-icons";
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 function SnippetRow({
snippet,
stripeIndex,
showCommand,
folderNames,
canEdit,
canDelete,
onRun,
onCopy,
onEdit,
onMove,
onDelete,
}: {
snippet: Snippet;
stripeIndex: number;
showCommand: boolean;
folderNames: string[];
canEdit: boolean;
canDelete: boolean;
onRun: () => void;
onCopy: () => void;
onEdit: () => void;
onMove: (folder: string | null) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const owned = !snippet.isShared;
const editable = canEdit && owned;
const deletable = canDelete && owned;
const TypeIcon = snippet.isNote ? StickyNote : Terminal;
const RunIcon = snippet.isNote ? ClipboardPaste : Play;
const runLabel = t(snippet.isNote ? "pasteToTerminal" : "run");
return (
<div
onDoubleClick={onRun}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
title={t("doubleClickToRun")}
className={`group relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 ${
menuOpen ? "bg-muted/50" : stripeIndex % 2 === 1 ? "bg-muted/15" : ""
}`}
>
<div
className={`w-[3px] shrink-0 ${snippet.isNote ? "bg-muted-foreground/30" : "bg-accent-brand/60"}`}
/>
<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" />
<span className="text-[13px] font-semibold truncate text-foreground leading-none tracking-tight">
{snippet.name}
</span>
{snippet.isNote && (
<span className="text-[9px] px-1 py-px border border-border bg-muted/40 text-muted-foreground shrink-0 leading-none uppercase tracking-wider">
{t("typeNote")}
</span>
)}
{snippet.isShared && (
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
<Users className="size-2.5" />
{t("sharedBadge")}
</span>
)}
</div>
{snippet.description && (
<span className="text-[11px] text-muted-foreground/80 truncate leading-tight">
{snippet.description}
</span>
)}
{showCommand && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-tight font-mono">
{snippet.content.split("\n")[0]}
</span>
)}
</div>
<div
className={`absolute right-1.5 top-1.5 items-center border border-border bg-background shadow-sm ${
menuOpen ? "flex" : "hidden group-hover:flex pointer-coarse:flex"
}`}
>
<button
title={runLabel}
onClick={(e) => {
e.stopPropagation();
onRun();
}}
className={`${trayButtonClass} text-accent-brand/80 hover:text-accent-brand`}
>
<RunIcon className="size-3.5" />
</button>
<button
title={t("copyToClipboard")}
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
className={trayButtonClass}
>
<Copy className="size-3.5" />
</button>
{editable && (
<button
title={t("editSnippetTitle")}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className={trayButtonClass}
>
<Pencil className="size-3.5" />
</button>
)}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
title={t("moreOptions")}
onClick={(e) => e.stopPropagation()}
className={trayButtonClass}
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs min-w-44">
<DropdownMenuItem onClick={onRun}>
<RunIcon className="size-3.5 mr-2" />
{runLabel}
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopy}>
<Copy className="size-3.5 mr-2" />
{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
disabled={!snippet.folder}
onClick={() => onMove(null)}
>
{t("noFolder")}
</DropdownMenuItem>
{folderNames.map((name) => (
<DropdownMenuItem
key={name}
disabled={snippet.folder === name}
onClick={() => onMove(name)}
>
<span className="truncate">{name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{deletable && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="size-3.5 mr-2" />
{t("deleteSnippet")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export function SnippetFolderRow({
name,
folder,
count,
open,
stripeIndex,
canCreate,
canEdit,
canDelete,
onToggle,
onAddSnippet,
onEdit,
onDelete,
children,
}: {
name: string;
/** Null for a folder that only exists as a name on its snippets. */
folder: SnippetFolder | null;
count: number;
open: boolean;
stripeIndex: number;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
onToggle: () => void;
onAddSnippet: () => void;
onEdit: () => void;
onDelete: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
const hasActions = canCreate || canEdit || canDelete;
return (
<div className="border-b border-border/40">
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
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]" : ""}`}
>
<ChevronRight
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${open ? "rotate-90" : ""}`}
/>
<FolderIcon
icon={folder?.icon}
className={`size-4 shrink-0 ${folder?.color ? "" : open ? "text-accent-brand" : "text-muted-foreground/70"}`}
style={folder?.color ? { color: folder.color } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-bold text-foreground tracking-tight">
{name}
</span>
<span className="text-[10px] tabular-nums shrink-0 px-1.5 py-[1px] bg-muted/70 text-muted-foreground/70">
{count}
</span>
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={t("folderActions")}
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center size-5 shrink-0 text-muted-foreground/60 hover:text-foreground hover:bg-muted opacity-0 group-hover/folder:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 data-[state=open]:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs min-w-40">
{canCreate && (
<DropdownMenuItem onClick={onAddSnippet}>
<Plus className="size-3.5 mr-2" />
{t("addSnippetHere")}
</DropdownMenuItem>
)}
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="size-3.5 mr-2" />
{t("editFolderTitle")}
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="size-3.5 mr-2" />
{t("deleteFolder")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{open && (
<div className="border-l border-border/50 ml-[27px] border-t border-t-border/40 [&>*:last-child]:border-b-0">
{count === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">
{t("noSnippetsInFolder")}
</div>
) : (
children
)}
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,41 @@
import type { CSSProperties } from "react";
import {
Box,
Cloud,
Copy,
Cpu,
Database,
Folder,
Globe,
Network,
Server,
Settings,
type LucideIcon,
} from "lucide-react";
import type { FolderIconId } from "./types";
const ICONS: Record<FolderIconId, LucideIcon> = {
folder: Folder,
server: Server,
cloud: Cloud,
database: Database,
box: Box,
network: Network,
copy: Copy,
settings: Settings,
cpu: Cpu,
globe: Globe,
};
export function FolderIcon({
icon,
className,
style,
}: {
icon: string | null | undefined;
className?: string;
style?: CSSProperties;
}) {
const Icon = ICONS[(icon ?? "folder") as FolderIconId] ?? Folder;
return <Icon className={className} style={style} />;
}
+15
View File
@@ -0,0 +1,15 @@
export interface SnippetDisplaySettings {
foldersCollapsed: boolean;
showCommands: boolean;
confirmExecution: boolean;
}
export function readSnippetSettings(
values: Record<string, unknown>,
): SnippetDisplaySettings {
return {
foldersCollapsed: values.foldersCollapsed !== false,
showCommands: values.showCommands !== false,
confirmExecution: values.confirmExecution === true,
};
}
@@ -22,7 +22,7 @@ export interface RunTarget {
* rather than touching a terminal ref directly, since core's tab state is
* not part of the plugin surface.
*/
export function useSnippetRunner() {
export function useSnippetRunner(confirmExecution = false) {
const { t } = useTranslation();
const [runningSnippet, setRunningSnippet] = useState<{
snippet: Snippet;
@@ -35,9 +35,7 @@ export function useSnippetRunner() {
const handleConfirmRun = useCallback(
(snippet: Snippet, execute: () => void) => {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (!shouldConfirm) {
if (!confirmExecution) {
execute();
return;
}
@@ -49,7 +47,7 @@ export function useSnippetRunner() {
duration: 6000,
});
},
[t],
[confirmExecution, t],
);
async function sendResolvedToTarget(
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readSnippetSettings } from "../../src/frontend/settings";
describe("readSnippetSettings", () => {
it("falls back to the manifest defaults", () => {
expect(readSnippetSettings({})).toEqual({
foldersCollapsed: true,
showCommands: true,
confirmExecution: false,
});
});
it("reads saved values", () => {
expect(
readSnippetSettings({
foldersCollapsed: false,
showCommands: false,
confirmExecution: true,
}),
).toEqual({
foldersCollapsed: false,
showCommands: false,
confirmExecution: true,
});
});
});
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const PERMISSIONS = [
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
];
function snippet(overrides: Record<string, unknown>) {
return {
id: 1,
userId: "u1",
name: "List files",
content: "ls -la",
description: null,
folder: null,
order: 0,
hostFilter: null,
isNote: false,
createdAt: "",
updatedAt: "",
...overrides,
};
}
function fakeApi(snippets: unknown[], folders: unknown[] = []) {
const api = {
get: vi.fn(async (path: string) => ({
data: path === "/folders" ? folders : snippets,
})),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
};
return api as typeof api & PluginApiClient;
}
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
async function renderPanel(api: PluginApiClient) {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api,
permissions: PERMISSIONS,
});
rendered.renderPanel("snippets", { active: true });
}
describe("snippets panel", () => {
it("lists root snippets with their command", async () => {
await renderPanel(fakeApi([snippet({})]));
expect(await screen.findByText("List files")).toBeTruthy();
expect(screen.getByText("ls -la")).toBeTruthy();
});
it("shows empty folders and opens them on click", async () => {
const api = fakeApi(
[snippet({ id: 2, name: "Restart nginx", folder: "Web" })],
[
{ id: 1, name: "Web", color: null, icon: "server" },
{ id: 2, name: "Empty", color: null, icon: null },
],
);
await renderPanel(api);
fireEvent.click(await screen.findByText("Web"));
expect(await screen.findByText("Restart nginx")).toBeTruthy();
expect(screen.getByText("Empty")).toBeTruthy();
});
it("filters by content and opens matching folders", async () => {
const api = fakeApi([
snippet({ id: 1, name: "One", content: "uptime" }),
snippet({ id: 2, name: "Two", content: "df -h", folder: "Disk" }),
]);
await renderPanel(api);
await screen.findByText("One");
fireEvent.change(screen.getByPlaceholderText(/search snippets/i), {
target: { value: "df" },
});
expect(await screen.findByText("Two")).toBeTruthy();
expect(screen.queryByText("One")).toBeNull();
});
it("creates a snippet in the inline editor", async () => {
const api = fakeApi([]);
await renderPanel(api);
await screen.findByText(locales.emptyTitle);
fireEvent.click(screen.getByTitle(locales.newSnippet));
fireEvent.change(screen.getByPlaceholderText(locales.namePlaceholder), {
target: { value: "Uptime" },
});
fireEvent.change(screen.getByPlaceholderText(locales.commandPlaceholder), {
target: { value: "uptime" },
});
fireEvent.click(
screen.getByRole("button", { name: locales.createSnippetButton }),
);
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith("/", {
name: "Uptime",
content: "uptime",
description: null,
folder: null,
isNote: false,
}),
);
expect(await screen.findByPlaceholderText(/search snippets/i)).toBeTruthy();
});
it("opens the settings page and goes back", async () => {
await renderPanel(fakeApi([]));
await screen.findByText(locales.emptyTitle);
fireEvent.click(screen.getByTitle(locales.settingsTitle));
expect(
await screen.findByText(locales.settings.foldersCollapsed.label),
).toBeTruthy();
fireEvent.click(screen.getByText(locales.backToSnippets));
expect(await screen.findByText(locales.emptyTitle)).toBeTruthy();
});
});