mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
832 lines
26 KiB
TypeScript
832 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
useHosts,
|
|
usePermission,
|
|
usePluginApi,
|
|
useSettings,
|
|
useTranslation,
|
|
type PanelProps,
|
|
} from "@termix/plugin-sdk/frontend";
|
|
import { toast } from "sonner";
|
|
import {
|
|
ChevronsDownUp,
|
|
ChevronsUpDown,
|
|
Download,
|
|
ExternalLink,
|
|
FolderPlus,
|
|
MoreHorizontal,
|
|
Plus,
|
|
Search,
|
|
SlidersHorizontal,
|
|
Upload,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
copyToClipboard,
|
|
useConfirmation,
|
|
} from "@termix/plugin-sdk/ui";
|
|
import { createSnippetsApi } from "./snippets-api";
|
|
import { useSnippetRunner, type TargetHost } from "./use-snippet-runner";
|
|
import {
|
|
TargetTerminals,
|
|
toRunTargets,
|
|
useOpenTerminals,
|
|
} from "./TargetTerminals";
|
|
import { SnippetShareView, type ShareTarget } from "./SnippetShareView";
|
|
import { SnippetEditor, type SnippetFormValues } from "./SnippetEditor";
|
|
import { SnippetSettings } from "./SnippetSettings";
|
|
import { readSnippetSettings } from "./settings";
|
|
import {
|
|
SnippetFolderDialog,
|
|
type FolderFormValues,
|
|
} from "./SnippetFolderDialog";
|
|
import { SnippetFolderRow, SnippetRow, type DropPosition } from "./SnippetTree";
|
|
import {
|
|
errorMessage,
|
|
parseHostFilter,
|
|
type Snippet,
|
|
type SnippetFolder,
|
|
} from "./types";
|
|
|
|
const DOCS_URL = "https://docs.termix.site/features/terminal/snippets";
|
|
|
|
type View =
|
|
| { kind: "list" }
|
|
| { kind: "edit"; snippet: Snippet | null; folder?: string }
|
|
| { kind: "settings" }
|
|
| { kind: "share"; target: ShareTarget };
|
|
|
|
interface FolderGroup {
|
|
name: string;
|
|
folder: SnippetFolder | null;
|
|
snippets: Snippet[];
|
|
}
|
|
|
|
function matchesQuery(snippet: Snippet, query: string): boolean {
|
|
return (
|
|
snippet.name.toLowerCase().includes(query) ||
|
|
(snippet.description ?? "").toLowerCase().includes(query) ||
|
|
snippet.content.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
export function SnippetsPanel({ active: _active }: PanelProps) {
|
|
const { t } = useTranslation();
|
|
const api = usePluginApi();
|
|
const client = useMemo(() => createSnippetsApi(api), [api]);
|
|
const canView = usePermission("view");
|
|
const canCreate = usePermission("create");
|
|
const canEdit = usePermission("edit");
|
|
const canDelete = usePermission("delete");
|
|
const canShare = usePermission("share");
|
|
const settings = useSettings("user");
|
|
const display = readSnippetSettings(settings.values);
|
|
const {
|
|
runSnippet,
|
|
runOnActive,
|
|
runOnHosts,
|
|
dialog: runnerDialog,
|
|
} = useSnippetRunner(display.confirmExecution);
|
|
const openTerminals = useOpenTerminals();
|
|
const [selectedTerminals, setSelectedTerminals] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
useEffect(() => {
|
|
setSelectedTerminals((prev) => {
|
|
const open = new Set(openTerminals.map((terminal) => terminal.id));
|
|
const next = new Set([...prev].filter((id) => open.has(id)));
|
|
return next.size === prev.size ? prev : next;
|
|
});
|
|
}, [openTerminals]);
|
|
const { hosts } = useHosts();
|
|
const { confirmWithToast } = useConfirmation();
|
|
|
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
|
const [folders, setFolders] = useState<SnippetFolder[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [view, setView] = useState<View>({ kind: "list" });
|
|
// Folders the user opened or closed by hand; the rest follow the setting.
|
|
const [folderOverrides, setFolderOverrides] = useState<Map<string, boolean>>(
|
|
new Map(),
|
|
);
|
|
const [folderDialog, setFolderDialog] = useState<{
|
|
folder: SnippetFolder | null;
|
|
/** The name being edited, set even when the folder has no metadata row. */
|
|
editName: string | null;
|
|
} | null>(null);
|
|
const [dragged, setDragged] = useState<Snippet | null>(null);
|
|
const [dropTarget, setDropTarget] = useState<{
|
|
id: number;
|
|
position: DropPosition;
|
|
} | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const importOverwriteRef = useRef(false);
|
|
|
|
const hostsById = useMemo(
|
|
() => new Map(hosts.map((host) => [Number(host.id), host])),
|
|
[hosts],
|
|
);
|
|
|
|
function targetHostsOf(snippet: Snippet): TargetHost[] {
|
|
if (snippet.isNote) return [];
|
|
return parseHostFilter(snippet.hostFilter).flatMap((id) => {
|
|
const host = hostsById.get(id);
|
|
return host
|
|
? [
|
|
{
|
|
id,
|
|
name: host.name,
|
|
ip: host.ip,
|
|
username: host.username,
|
|
port: host.port,
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
}
|
|
|
|
const load = useCallback(async () => {
|
|
if (!canView) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
const [snippetList, folderList] = await Promise.all([
|
|
client.list(),
|
|
client.listFolders(),
|
|
]);
|
|
setSnippets(Array.isArray(snippetList) ? snippetList : []);
|
|
setFolders(Array.isArray(folderList) ? folderList : []);
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("loadFailed")));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canView, client, t]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const query = search.trim().toLowerCase();
|
|
|
|
const { groups, rootSnippets } = useMemo(() => {
|
|
const byFolder = new Map<string, FolderGroup>();
|
|
for (const folder of folders) {
|
|
byFolder.set(folder.name, {
|
|
name: folder.name,
|
|
folder,
|
|
snippets: [],
|
|
});
|
|
}
|
|
const root: Snippet[] = [];
|
|
for (const snippet of snippets) {
|
|
if (query && !matchesQuery(snippet, query)) continue;
|
|
if (!snippet.folder) {
|
|
root.push(snippet);
|
|
continue;
|
|
}
|
|
const group = byFolder.get(snippet.folder) ?? {
|
|
name: snippet.folder,
|
|
folder: null,
|
|
snippets: [],
|
|
};
|
|
group.snippets.push(snippet);
|
|
byFolder.set(snippet.folder, group);
|
|
}
|
|
const sorted = Array.from(byFolder.values())
|
|
.filter((group) => !query || group.snippets.length > 0)
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
return { groups: sorted, rootSnippets: root };
|
|
}, [folders, snippets, query]);
|
|
|
|
const folderNames = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set([
|
|
...folders.map((f) => f.name),
|
|
...snippets.flatMap((s) => (s.folder ? [s.folder] : [])),
|
|
]),
|
|
).sort((a, b) => a.localeCompare(b)),
|
|
[folders, snippets],
|
|
);
|
|
|
|
function isOpen(name: string): boolean {
|
|
if (query) return true;
|
|
return folderOverrides.get(name) ?? !display.foldersCollapsed;
|
|
}
|
|
|
|
function toggleFolder(name: string) {
|
|
setFolderOverrides((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(name, !isOpen(name));
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function setAllFolders(open: boolean) {
|
|
setFolderOverrides(new Map(folderNames.map((name) => [name, open])));
|
|
}
|
|
|
|
async function handleSaveSnippet(values: SnippetFormValues) {
|
|
if (view.kind !== "edit") return false;
|
|
const body = {
|
|
name: values.name.trim(),
|
|
content: values.content.trim(),
|
|
description: values.description.trim() || null,
|
|
folder: values.folder || null,
|
|
isNote: values.isNote,
|
|
hostFilter:
|
|
!values.isNote && values.hostIds.length > 0 ? values.hostIds : null,
|
|
};
|
|
const editing = view.snippet;
|
|
try {
|
|
if (editing) {
|
|
await client.update(editing.id, body);
|
|
toast.success(t("updateSuccess"));
|
|
} else {
|
|
await client.create(body);
|
|
toast.success(t("createSuccess"));
|
|
}
|
|
if (body.folder) {
|
|
setFolderOverrides((prev) => new Map(prev).set(body.folder!, true));
|
|
}
|
|
setView({ kind: "list" });
|
|
await load();
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(
|
|
errorMessage(err, t(editing ? "updateFailed" : "createFailed")),
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function handleRun(snippet: Snippet) {
|
|
const targets = targetHostsOf(snippet);
|
|
const terminalTargets = toRunTargets(openTerminals, selectedTerminals);
|
|
if (targets.length > 0 && !snippet.isNote) runOnHosts(snippet, targets);
|
|
else if (terminalTargets.length > 0) runSnippet(snippet, terminalTargets);
|
|
else runOnActive(snippet, null);
|
|
}
|
|
|
|
function endDrag() {
|
|
setDragged(null);
|
|
setDropTarget(null);
|
|
}
|
|
|
|
// Drops the dragged snippet next to a row, taking that row's folder.
|
|
async function handleDropOnRow(target: Snippet) {
|
|
const source = dragged;
|
|
const position = dropTarget?.position ?? "below";
|
|
endDrag();
|
|
if (!source || source.id === target.id) return;
|
|
|
|
const folder = target.folder ?? null;
|
|
const group = snippets.filter(
|
|
(s) => (s.folder ?? null) === folder && s.id !== source.id,
|
|
);
|
|
const index = group.findIndex((s) => s.id === target.id);
|
|
group.splice(position === "above" ? index : index + 1, 0, {
|
|
...source,
|
|
folder,
|
|
});
|
|
await saveOrder(group, folder);
|
|
}
|
|
|
|
async function handleDropOnFolder(folder: string) {
|
|
const source = dragged;
|
|
endDrag();
|
|
if (!source || source.folder === folder) return;
|
|
const group = [
|
|
...snippets.filter((s) => s.folder === folder && s.id !== source.id),
|
|
{ ...source, folder },
|
|
];
|
|
setFolderOverrides((prev) => new Map(prev).set(folder, true));
|
|
await saveOrder(group, folder);
|
|
}
|
|
|
|
async function saveOrder(group: Snippet[], folder: string | null) {
|
|
const ordered = group.map((s, order) => ({ ...s, order }));
|
|
const byId = new Map(ordered.map((s) => [s.id, s]));
|
|
setSnippets((prev) => {
|
|
const rest = prev.filter((s) => !byId.has(s.id));
|
|
return [...rest, ...ordered];
|
|
});
|
|
try {
|
|
await client.reorder(
|
|
ordered.map((s) => ({
|
|
id: s.id,
|
|
order: s.order,
|
|
folder: folder ?? "",
|
|
})),
|
|
);
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("reorderFailed")));
|
|
await load();
|
|
}
|
|
}
|
|
|
|
function handleDeleteSnippet(snippet: Snippet) {
|
|
void confirmWithToast(
|
|
t("deleteSnippetConfirm", { name: snippet.name }),
|
|
async () => {
|
|
try {
|
|
await client.remove(snippet.id);
|
|
toast.success(t("deleteSuccess"));
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("deleteFailed")));
|
|
}
|
|
},
|
|
t("delete"),
|
|
t("cancel"),
|
|
);
|
|
}
|
|
|
|
async function handleMoveSnippet(snippet: Snippet, folder: string | null) {
|
|
try {
|
|
await client.update(snippet.id, { folder });
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("updateFailed")));
|
|
}
|
|
}
|
|
|
|
async function handleCopy(snippet: Snippet) {
|
|
const ok = await copyToClipboard(snippet.content);
|
|
if (ok) toast.success(t("copySuccess"));
|
|
else toast.error(t("copyFailed"));
|
|
}
|
|
|
|
async function handleSaveFolder(values: FolderFormValues) {
|
|
const editName = folderDialog?.editName ?? null;
|
|
try {
|
|
if (editName === null) {
|
|
await client.createFolder(values);
|
|
toast.success(t("folderCreateSuccess"));
|
|
} else {
|
|
// A folder named only on its snippets gets a metadata row first.
|
|
if (!folderDialog?.folder) {
|
|
await client.createFolder({ name: editName });
|
|
}
|
|
if (values.name !== editName) {
|
|
await client.renameFolder(editName, values.name);
|
|
setFolderOverrides((prev) => {
|
|
const next = new Map(prev);
|
|
const wasOpen = next.get(editName);
|
|
next.delete(editName);
|
|
if (wasOpen !== undefined) next.set(values.name, wasOpen);
|
|
return next;
|
|
});
|
|
}
|
|
await client.updateFolderMetadata(values.name, {
|
|
color: values.color,
|
|
icon: values.icon,
|
|
});
|
|
toast.success(t("folderEditSuccess"));
|
|
}
|
|
setFolderDialog(null);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(
|
|
errorMessage(
|
|
err,
|
|
t(editName === null ? "folderCreateFailed" : "folderEditFailed"),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleDeleteFolder(name: string) {
|
|
void confirmWithToast(
|
|
t("deleteFolderConfirm", { name }),
|
|
async () => {
|
|
try {
|
|
await client.deleteFolder(name);
|
|
toast.success(t("folderDeleteSuccess"));
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("folderDeleteFailed")));
|
|
}
|
|
},
|
|
t("delete"),
|
|
t("cancel"),
|
|
);
|
|
}
|
|
|
|
async function handleExport() {
|
|
try {
|
|
const data = await client.export();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
type: "application/json",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = "termix-snippets.json";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
toast.success(t("exportSuccess"));
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("exportFailed")));
|
|
}
|
|
}
|
|
|
|
async function handleImport(file: File) {
|
|
let parsed: { snippets?: unknown; folders?: unknown };
|
|
try {
|
|
parsed = JSON.parse(await file.text());
|
|
} catch {
|
|
toast.error(t("importInvalidFile"));
|
|
return;
|
|
}
|
|
if (
|
|
!parsed ||
|
|
typeof parsed !== "object" ||
|
|
(!Array.isArray(parsed.snippets) && !Array.isArray(parsed.folders))
|
|
) {
|
|
toast.error(t("importInvalidFile"));
|
|
return;
|
|
}
|
|
try {
|
|
const result = (await client.bulkImport({
|
|
snippets: Array.isArray(parsed.snippets) ? parsed.snippets : [],
|
|
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
overwrite: importOverwriteRef.current,
|
|
})) as {
|
|
snippetsImported?: number;
|
|
snippetsUpdated?: number;
|
|
snippetsSkipped?: number;
|
|
};
|
|
toast.success(
|
|
t("importSuccess", {
|
|
imported: result.snippetsImported ?? 0,
|
|
updated: result.snippetsUpdated ?? 0,
|
|
skipped: result.snippetsSkipped ?? 0,
|
|
}),
|
|
);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, t("importFailed")));
|
|
}
|
|
}
|
|
|
|
function startImport(overwrite: boolean) {
|
|
importOverwriteRef.current = overwrite;
|
|
fileInputRef.current?.click();
|
|
}
|
|
|
|
if (!canView) {
|
|
return null;
|
|
}
|
|
|
|
if (view.kind === "edit") {
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<SnippetEditor
|
|
snippet={view.snippet}
|
|
defaultFolder={view.folder}
|
|
folders={folders}
|
|
onBack={() => setView({ kind: "list" })}
|
|
onSave={handleSaveSnippet}
|
|
/>
|
|
{runnerDialog}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (view.kind === "share") {
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<SnippetShareView
|
|
target={view.target}
|
|
client={client}
|
|
onBack={() => setView({ kind: "list" })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (view.kind === "settings") {
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<SnippetSettings
|
|
settings={settings}
|
|
onBack={() => setView({ kind: "list" })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let stripe = 0;
|
|
const renderSnippet = (snippet: Snippet) => (
|
|
<SnippetRow
|
|
key={snippet.id}
|
|
snippet={snippet}
|
|
stripeIndex={stripe++}
|
|
showCommand={display.showCommands}
|
|
folderNames={folderNames}
|
|
targetHostNames={targetHostsOf(snippet).map(
|
|
(host) => host.name || host.ip || String(host.id),
|
|
)}
|
|
canEdit={canEdit}
|
|
canDelete={canDelete}
|
|
canShare={canShare}
|
|
draggable={canEdit && !snippet.isShared && !query}
|
|
dragActive={dragged !== null}
|
|
isDragging={dragged?.id === snippet.id}
|
|
dropIndicator={
|
|
dropTarget?.id === snippet.id && dragged?.id !== snippet.id
|
|
? dropTarget.position
|
|
: null
|
|
}
|
|
onRun={() => handleRun(snippet)}
|
|
onCopy={() => void handleCopy(snippet)}
|
|
onEdit={() => setView({ kind: "edit", snippet })}
|
|
onShare={() =>
|
|
setView({ kind: "share", target: { kind: "snippet", snippet } })
|
|
}
|
|
onMove={(folder) => void handleMoveSnippet(snippet, folder)}
|
|
onDelete={() => handleDeleteSnippet(snippet)}
|
|
onDragStart={() => setDragged(snippet)}
|
|
onDragEnd={endDrag}
|
|
onDragOverRow={(position) =>
|
|
setDropTarget((prev) =>
|
|
prev?.id === snippet.id && prev.position === position
|
|
? prev
|
|
: { id: snippet.id, position },
|
|
)
|
|
}
|
|
onDropRow={() => void handleDropOnRow(snippet)}
|
|
/>
|
|
);
|
|
|
|
const isEmpty = groups.length === 0 && rootSnippets.length === 0;
|
|
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
|
|
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
|
|
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("searchPlaceholder")}
|
|
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
|
/>
|
|
{search && (
|
|
<button
|
|
onClick={() => setSearch("")}
|
|
title={t("clearSearch")}
|
|
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<TargetTerminals
|
|
terminals={openTerminals}
|
|
selected={selectedTerminals}
|
|
onChange={setSelectedTerminals}
|
|
/>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json,application/json"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = "";
|
|
if (file) void handleImport(file);
|
|
}}
|
|
/>
|
|
|
|
<div className="flex items-center gap-1.5 overflow-x-auto overflow-y-hidden toolbar-scrollbar">
|
|
<div className="flex items-center border border-border shrink-0">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 text-muted-foreground hover:text-foreground"
|
|
title={t("importExport")}
|
|
>
|
|
<Upload className="size-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="text-xs">
|
|
{canCreate && (
|
|
<>
|
|
<DropdownMenuItem onClick={() => startImport(false)}>
|
|
<Upload className="size-3.5 mr-2" />
|
|
{t("importSkipExisting")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => startImport(true)}>
|
|
<Upload className="size-3.5 mr-2" />
|
|
{t("importOverwrite")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem
|
|
onClick={() => void handleExport()}
|
|
disabled={snippets.length === 0}
|
|
>
|
|
<Download className="size-3.5 mr-2" />
|
|
{t("exportSnippets")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<div className="w-px self-stretch bg-border" />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 text-muted-foreground hover:text-foreground"
|
|
title={t("moreActions")}
|
|
>
|
|
<MoreHorizontal className="size-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="text-xs min-w-44">
|
|
{canCreate && (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
setFolderDialog({ folder: null, editName: null })
|
|
}
|
|
>
|
|
<FolderPlus className="size-3.5 mr-2" />
|
|
{t("newFolder")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem onClick={() => setAllFolders(true)}>
|
|
<ChevronsUpDown className="size-3.5 mr-2" />
|
|
{t("expandAll")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setAllFolders(false)}>
|
|
<ChevronsDownUp className="size-3.5 mr-2" />
|
|
{t("collapseAll")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<div className="w-px self-stretch bg-border" />
|
|
<a
|
|
href={DOCS_URL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={t("docsLink")}
|
|
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
</a>
|
|
</div>
|
|
<div className="flex items-center border border-border shrink-0">
|
|
<button
|
|
onClick={() => setView({ kind: "settings" })}
|
|
title={t("settingsTitle")}
|
|
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
|
|
>
|
|
<SlidersHorizontal className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
{canCreate && (
|
|
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
|
|
<button
|
|
onClick={() => setView({ kind: "edit", snippet: null })}
|
|
title={t("newSnippet")}
|
|
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
|
|
>
|
|
<Plus className="size-3 shrink-0" />
|
|
<span className="hidden min-[280px]:inline">
|
|
{t("newSnippet")}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="p-4 text-xs text-muted-foreground">
|
|
{t("loading")}
|
|
</div>
|
|
) : isEmpty ? (
|
|
<div className="flex flex-col items-center gap-1 px-4 py-8 text-center">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t(query ? "noSearchResults" : "emptyTitle")}
|
|
</span>
|
|
{!query && (
|
|
<span className="text-[11px] text-muted-foreground/60">
|
|
{t("emptyDescription")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{groups.map((group, index) => {
|
|
const open = isOpen(group.name);
|
|
return (
|
|
<SnippetFolderRow
|
|
key={group.name}
|
|
name={group.name}
|
|
folder={group.folder}
|
|
count={group.snippets.length}
|
|
open={open}
|
|
stripeIndex={index}
|
|
canCreate={canCreate}
|
|
canEdit={canEdit}
|
|
canDelete={canDelete}
|
|
canShare={canShare}
|
|
acceptsDrop={
|
|
dragged !== null && dragged.folder !== group.name
|
|
}
|
|
onToggle={() => toggleFolder(group.name)}
|
|
onAddSnippet={() =>
|
|
setView({
|
|
kind: "edit",
|
|
snippet: null,
|
|
folder: group.name,
|
|
})
|
|
}
|
|
onEdit={() =>
|
|
setFolderDialog({
|
|
folder: group.folder,
|
|
editName: group.name,
|
|
})
|
|
}
|
|
onShare={() =>
|
|
setView({
|
|
kind: "share",
|
|
target: { kind: "folder", name: group.name },
|
|
})
|
|
}
|
|
onDelete={() => handleDeleteFolder(group.name)}
|
|
onDropSnippet={() => void handleDropOnFolder(group.name)}
|
|
>
|
|
{open && group.snippets.map(renderSnippet)}
|
|
</SnippetFolderRow>
|
|
);
|
|
})}
|
|
{rootSnippets.map(renderSnippet)}
|
|
{dragged?.folder && (
|
|
<div
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const source = dragged;
|
|
endDrag();
|
|
void saveOrder(
|
|
[
|
|
...snippets.filter((s) => !s.folder),
|
|
{ ...source, folder: null },
|
|
],
|
|
null,
|
|
);
|
|
}}
|
|
className="m-2 px-3 py-3 text-center text-[11px] text-muted-foreground border border-dashed border-border"
|
|
>
|
|
{t("dropToRemoveFromFolder")}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{folderDialog && (
|
|
<SnippetFolderDialog
|
|
folder={
|
|
folderDialog.folder ??
|
|
(folderDialog.editName
|
|
? ({
|
|
name: folderDialog.editName,
|
|
color: null,
|
|
icon: null,
|
|
} as SnippetFolder)
|
|
: null)
|
|
}
|
|
onClose={() => setFolderDialog(null)}
|
|
onSave={handleSaveFolder}
|
|
/>
|
|
)}
|
|
{runnerDialog}
|
|
</div>
|
|
);
|
|
}
|