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": {
|
||||
"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."
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user