mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(snippets): redesign panel to match the host list
This commit is contained in:
1 parent
278fc7e32a
commit
75c3542ba0
12 files changed
+1520
-613
No files matched your search
@@ -32,6 +32,14 @@
|
|||||||
"foldersCollapsed": {
|
"foldersCollapsed": {
|
||||||
"label": "Collapse folders by default",
|
"label": "Collapse folders by default",
|
||||||
"description": "Start each snippet folder collapsed when you open the Snippets panel."
|
"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",
|
"cancel": "Cancel",
|
||||||
@@ -45,7 +53,6 @@
|
|||||||
"run": "Run",
|
"run": "Run",
|
||||||
"pasteToTerminal": "Paste to terminal",
|
"pasteToTerminal": "Paste to terminal",
|
||||||
"copySuccess": "Copied to clipboard",
|
"copySuccess": "Copied to clipboard",
|
||||||
"shareTitle": "Shared",
|
|
||||||
"importExport": "Import / Export",
|
"importExport": "Import / Export",
|
||||||
"importFailed": "Failed to import snippets",
|
"importFailed": "Failed to import snippets",
|
||||||
"nameLabel": "Name",
|
"nameLabel": "Name",
|
||||||
@@ -61,12 +68,10 @@
|
|||||||
"noteLabel": "Note",
|
"noteLabel": "Note",
|
||||||
"notePlaceholder": "Text to paste into the terminal",
|
"notePlaceholder": "Text to paste into the terminal",
|
||||||
"createSnippetTitle": "Create snippet",
|
"createSnippetTitle": "Create snippet",
|
||||||
"createSnippetDescription": "Save a command or note you can run or paste into a terminal.",
|
|
||||||
"createSnippetButton": "Create snippet",
|
"createSnippetButton": "Create snippet",
|
||||||
"createSuccess": "Snippet created",
|
"createSuccess": "Snippet created",
|
||||||
"createFailed": "Failed to create snippet",
|
"createFailed": "Failed to create snippet",
|
||||||
"editSnippetTitle": "Edit snippet",
|
"editSnippetTitle": "Edit snippet",
|
||||||
"editSnippetDescription": "Change this snippet's name, content or folder.",
|
|
||||||
"saveSnippetButton": "Save snippet",
|
"saveSnippetButton": "Save snippet",
|
||||||
"updateSuccess": "Snippet updated",
|
"updateSuccess": "Snippet updated",
|
||||||
"updateFailed": "Failed to update snippet",
|
"updateFailed": "Failed to update snippet",
|
||||||
@@ -88,7 +93,6 @@
|
|||||||
"confirmRunButton": "Run",
|
"confirmRunButton": "Run",
|
||||||
"runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)",
|
"runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)",
|
||||||
"pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)",
|
"pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)",
|
||||||
"foldersCollapsedLabel": "Collapse folders by default",
|
|
||||||
"admin": {
|
"admin": {
|
||||||
"tabTitle": "Snippets",
|
"tabTitle": "Snippets",
|
||||||
"count": "{{count}} snippets",
|
"count": "{{count}} snippets",
|
||||||
@@ -105,5 +109,45 @@
|
|||||||
"saveFailed": "Failed to save snippet",
|
"saveFailed": "Failed to save snippet",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"save": "Save"
|
"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."
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,20 @@
|
|||||||
"labelKey": "settings.foldersCollapsed.label",
|
"labelKey": "settings.foldersCollapsed.label",
|
||||||
"descriptionKey": "settings.foldersCollapsed.description",
|
"descriptionKey": "settings.foldersCollapsed.description",
|
||||||
"default": true
|
"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} />;
|
||||||
|
}
|
||||||
@@ -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
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function useSnippetRunner() {
|
export function useSnippetRunner(confirmExecution = false) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [runningSnippet, setRunningSnippet] = useState<{
|
const [runningSnippet, setRunningSnippet] = useState<{
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
@@ -35,9 +35,7 @@ export function useSnippetRunner() {
|
|||||||
|
|
||||||
const handleConfirmRun = useCallback(
|
const handleConfirmRun = useCallback(
|
||||||
(snippet: Snippet, execute: () => void) => {
|
(snippet: Snippet, execute: () => void) => {
|
||||||
const shouldConfirm =
|
if (!confirmExecution) {
|
||||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
|
||||||
if (!shouldConfirm) {
|
|
||||||
execute();
|
execute();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +47,7 @@ export function useSnippetRunner() {
|
|||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t],
|
[confirmExecution, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function sendResolvedToTarget(
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user