Files
Netcatty/components/ScriptsSidePanel.tsx
2026-06-11 16:05:17 +08:00

770 lines
27 KiB
TypeScript

/**
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
*
* Shows snippets organized by package hierarchy as a single tree view.
* Packages expand / collapse via a chevron; clicking a snippet executes it
* in the focused terminal session. Typing in the search box flattens to a
* list of matching snippets regardless of package nesting.
*/
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from './ui/context-menu';
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
import { Input } from './ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const SCRIPT_ROW_HEIGHT = 34;
const isRootPackagePath = (path: string): boolean => {
const body = path.startsWith('/') ? path.slice(1) : path;
return body.length > 0 && !body.includes('/');
};
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (snippet: Snippet) => void;
onSnippetsChange?: (snippets: Snippet[]) => void;
onPackagesChange?: (packages: string[]) => void;
isVisible?: boolean;
}
type TreeRow =
| {
type: 'package';
id: string;
path: string;
name: string;
depth: number;
count: number;
hasChildren: boolean;
isExpanded: boolean;
}
| {
type: 'snippet';
id: string;
depth: number;
snippet: Snippet;
packagePath: string;
};
const pkgDisplayName = (path: string) => {
const clean = path.startsWith('/') ? path.slice(1) : path;
const last = clean.split('/').filter(Boolean).pop() ?? clean;
// Preserve the leading slash on absolute root packages so they stay
// distinguishable from relative ones (matches the previous breadcrumb UI).
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
};
const packageDisplayIndex = (packages: string[], path: string): number => {
const exactIndex = packages.indexOf(path);
if (exactIndex >= 0) return exactIndex;
const childIndex = packages.findIndex((pkg) => pkg.startsWith(`${path}/`));
return childIndex >= 0 ? childIndex : Number.MAX_SAFE_INTEGER;
};
let activeScriptsDropIndicator: HTMLElement | null = null;
const clearScriptsDropIndicator = () => {
activeScriptsDropIndicator?.removeAttribute('data-vault-drop-position');
activeScriptsDropIndicator = null;
};
const markScriptsDropIndicator = (target: HTMLElement, position: 'before' | 'after') => {
if (target.dataset.vaultDropPosition === position) return;
clearScriptsDropIndicator();
target.dataset.vaultDropPosition = position;
activeScriptsDropIndicator = target;
};
const markScriptsInsideIndicator = (target: HTMLElement) => {
if (target.dataset.vaultDropPosition === 'inside') return;
clearScriptsDropIndicator();
target.dataset.vaultDropPosition = 'inside';
activeScriptsDropIndicator = target;
};
const getVerticalDropIntent = (
element: HTMLElement,
clientY: number,
): 'before' | 'inside' | 'after' => {
const rect = element.getBoundingClientRect();
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
if (clientY <= rect.top + edgeSize) return 'before';
if (clientY >= rect.bottom - edgeSize) return 'after';
return 'inside';
};
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
const normalizedPackages = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i += 1) {
const sub = parts.slice(0, i).join('/');
normalizedPackages.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
snippets.forEach((snippet) => {
if (snippet.package) addWithAncestors(snippet.package);
});
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
const bumpCount = (path: string) => {
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
};
for (const snippet of snippets) {
const pkg = snippet.package || '';
const bucket = snippetsByPackage.get(pkg);
if (bucket) bucket.push(snippet);
else snippetsByPackage.set(pkg, [snippet]);
if (pkg === '') {
bumpCount('');
continue;
}
let path = pkg;
while (true) {
bumpCount(path);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
}
const packagePaths = Array.from(normalizedPackages);
const childPackagesOf = (parent: string | null): string[] => {
const prefix = parent === null ? '' : `${parent}/`;
return packagePaths
.filter((path) => {
if (parent === null) {
const body = path.startsWith('/') ? path.slice(1) : path;
return !body.includes('/');
}
if (!path.startsWith(prefix)) return false;
const rest = path.slice(prefix.length);
return rest.length > 0 && !rest.includes('/');
})
.sort((a, b) => {
const orderDiff = packageDisplayIndex(packages, a) - packageDisplayIndex(packages, b);
if (orderDiff !== 0) return orderDiff;
return pkgDisplayName(a).localeCompare(pkgDisplayName(b));
});
};
const snippetsIn = (pkg: string | null): Snippet[] =>
sortByVaultOrder(snippetsByPackage.get(pkg ?? '') ?? []);
const rows: TreeRow[] = [];
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
const localSnippets = snippetsIn(pkg);
const hasChildren = children.length > 0 || localSnippets.length > 0;
const isExpanded = expandedPaths.has(pkg);
rows.push({
type: 'package',
id: pkg,
path: pkg,
name: pkgDisplayName(pkg),
depth,
count: descendantCountByPackage.get(pkg) ?? 0,
hasChildren,
isExpanded,
});
if (!isExpanded) return;
children.forEach((child) => walk(child, depth + 1));
localSnippets.forEach((snippet) =>
rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }),
);
};
snippetsIn(null).forEach((snippet) =>
rows.push({ type: 'snippet', id: snippet.id, depth: 0, snippet, packagePath: '' }),
);
childPackagesOf(null).forEach((root) => walk(root, 0));
return rows;
}
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
onSnippetClick,
onSnippetsChange,
onPackagesChange,
isVisible = true,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
// Normalize the package list + derive ancestor packages implied by each path
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
if (!isVisible) return new Set<string>();
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
const sub = parts.slice(0, i).join('/');
set.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
// A snippet may reference a package path that's not in `packages` yet.
snippets.forEach((s) => {
if (s.package) addWithAncestors(s.package);
});
return set;
}, [packages, snippets, isVisible]);
// Track every package we've ever observed so we can tell "new" from
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
// that reduced prev.size (because the user collapsed a row) would
// incorrectly trip a bulk re-expand.
const seenPackagesRef = useRef<Set<string>>(new Set());
// Default: auto-expand packages the first time they appear, so the user sees
// everything without drilling in. After that, respect the user's collapse
// choices across unrelated refreshes.
useEffect(() => {
if (!isVisible) return;
const seen = seenPackagesRef.current;
const newlySeen: string[] = [];
normalizedPackages.forEach((p) => {
if (!seen.has(p)) {
seen.add(p);
newlySeen.push(p);
}
});
if (newlySeen.length === 0) return;
setExpandedPaths((prev) => {
const next = new Set(prev);
// Only auto-expand root packages on first sight — expanding the full
// tree upfront was freezing the panel on large snippet libraries.
newlySeen.filter(isRootPackagePath).forEach((p) => next.add(p));
return next;
});
}, [normalizedPackages, isVisible]);
const togglePackage = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
if (!isVisible) return null;
const q = search.trim().toLowerCase();
if (!q) return null;
return sortByVaultOrder(snippets.filter(
(s) =>
s.label.toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q),
));
}, [snippets, search, isVisible]);
const rows = useMemo<TreeRow[]>(() => {
if (!isVisible) return [];
if (searchMatches !== null) return [];
return buildScriptsSidePanelRows({ snippets, packages, expandedPaths });
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
| { key: string; kind: 'package'; row: Extract<TreeRow, { type: 'package' }>; countLabel: string }
| { key: string; kind: 'snippet'; row: Extract<TreeRow, { type: 'snippet' }> };
const listItems = useMemo((): ScriptsListItem[] => {
if (!isVisible) return [];
if (searchMatches !== null) {
return searchMatches.map((snippet) => ({
key: `search:${snippet.id}`,
kind: 'search',
snippet,
}));
}
return rows.flatMap((row): ScriptsListItem[] => {
if (row.type === 'package') {
return [{
key: `pkg:${row.id}`,
kind: 'package',
row,
countLabel: t('snippets.package.count', { count: row.count }),
}];
}
return [{
key: `snip:${row.id}`,
kind: 'snippet',
row,
}];
});
}, [rows, searchMatches, t, isVisible]);
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
onSnippetClick(snippet);
},
[onSnippetClick],
);
const moveSnippetToPackage = useCallback((snippetId: string, packagePath: string | null) => {
if (!onSnippetsChange) return;
const targetPackage = packagePath || '';
const snippet = snippets.find((item) => item.id === snippetId);
if (!snippet || (snippet.package || '') === targetPackage) return;
onSnippetsChange(snippets.map((item) =>
item.id === snippetId ? { ...item, package: targetPackage } : item,
));
}, [onSnippetsChange, snippets]);
const movePackageToPackage = useCallback((source: string, target: string | null) => {
if (!onPackagesChange || !onSnippetsChange) return;
const name = source.split('/').pop() || '';
const isAbsolute = source.startsWith('/');
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
if (newPath === source || newPath.startsWith(`${source}/`) || packages.includes(newPath)) return;
const updatedPackages = packages.map((path) => {
if (path === source) return newPath;
if (path.startsWith(`${source}/`)) return newPath + path.substring(source.length);
return path;
});
const updatedSnippets = snippets.map((snippet) => {
const packagePath = snippet.package || '';
if (packagePath === source) return { ...snippet, package: newPath };
if (packagePath.startsWith(`${source}/`)) {
return { ...snippet, package: newPath + packagePath.substring(source.length) };
}
return snippet;
});
onPackagesChange(Array.from(new Set(updatedPackages)));
onSnippetsChange(updatedSnippets);
}, [onPackagesChange, onSnippetsChange, packages, snippets]);
const reorderSnippetToTarget = useCallback((
sourceSnippetId: string,
targetSnippetId: string,
position: 'before' | 'after',
) => {
if (!onSnippetsChange || sourceSnippetId === targetSnippetId) return;
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
if (!targetSnippet) return;
const movedSnippets = snippets.map((snippet) =>
snippet.id === sourceSnippetId
? { ...snippet, package: targetSnippet.package || '' }
: snippet,
);
onSnippetsChange(reorderVaultItems(movedSnippets, sourceSnippetId, targetSnippetId, position));
}, [onSnippetsChange, snippets]);
const reorderPackageToTarget = useCallback((
sourcePackage: string,
targetPackage: string,
position: 'before' | 'after',
) => {
if (!onPackagesChange || sourcePackage === targetPackage) return;
const parentOf = (path: string) => {
const parts = path.split('/').filter(Boolean);
const prefix = path.startsWith('/') ? '/' : '';
return prefix + parts.slice(0, -1).join('/');
};
if (parentOf(sourcePackage) !== parentOf(targetPackage)) return;
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, position));
}, [onPackagesChange, packages]);
const handleRowDragOver = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!onSnippetsChange && !onPackagesChange) return;
const row = event.currentTarget;
const targetSnippetId = row.getAttribute('data-snippet-id');
const targetPackage = row.getAttribute('data-pkg-path');
const isDraggingSnippet = hasDragType(event.dataTransfer, 'snippet-id');
const isDraggingPackage = hasDragType(event.dataTransfer, 'pkg-path');
if (targetSnippetId && isDraggingSnippet) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
const rect = row.getBoundingClientRect();
markScriptsDropIndicator(row, event.clientY < rect.top + rect.height / 2 ? 'before' : 'after');
return;
}
if (targetPackage && isDraggingSnippet) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
markScriptsInsideIndicator(row);
return;
}
if (targetPackage && isDraggingPackage) {
const sourcePackage = event.dataTransfer.getData('pkg-path');
if (
sourcePackage &&
(sourcePackage === targetPackage || targetPackage.startsWith(`${sourcePackage}/`))
) {
event.dataTransfer.dropEffect = 'none';
clearScriptsDropIndicator();
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
const intent = getVerticalDropIntent(row, event.clientY);
if (intent === 'inside') {
markScriptsInsideIndicator(row);
return;
}
markScriptsDropIndicator(row, intent);
return;
}
event.dataTransfer.dropEffect = 'none';
clearScriptsDropIndicator();
}, [onPackagesChange, onSnippetsChange]);
const handleRowDrop = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!onSnippetsChange && !onPackagesChange) return;
const row = event.currentTarget;
clearScriptsDropIndicator();
const targetSnippetId = row.getAttribute('data-snippet-id');
const targetPackage = row.getAttribute('data-pkg-path');
const sourceSnippetId = event.dataTransfer.getData('snippet-id');
const sourcePackage = event.dataTransfer.getData('pkg-path');
if (sourceSnippetId && targetSnippetId) {
event.preventDefault();
event.stopPropagation();
const rect = row.getBoundingClientRect();
reorderSnippetToTarget(
sourceSnippetId,
targetSnippetId,
event.clientY < rect.top + rect.height / 2 ? 'before' : 'after',
);
return;
}
if (sourceSnippetId && targetPackage) {
event.preventDefault();
event.stopPropagation();
moveSnippetToPackage(sourceSnippetId, targetPackage);
return;
}
if (sourcePackage && targetPackage) {
event.preventDefault();
event.stopPropagation();
const intent = getVerticalDropIntent(row, event.clientY);
if (intent === 'inside') movePackageToPackage(sourcePackage, targetPackage);
else reorderPackageToTarget(sourcePackage, targetPackage, intent);
}
}, [
movePackageToPackage,
moveSnippetToPackage,
onPackagesChange,
onSnippetsChange,
reorderPackageToTarget,
reorderSnippetToTarget,
]);
const handleAddSnippet = useCallback(() => {
// Let the App shell listen and navigate to the Snippets section with
// the "add" panel pre-opened, so the user does not have to leave the
// terminal to jump back and click "New Snippet".
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
}, []);
const handleEditSnippet = useCallback((snippet: Snippet) => {
window.dispatchEvent(
new CustomEvent('netcatty:snippets:edit', { detail: { snippet } }),
);
}, []);
const handleDeleteSnippet = useCallback((id: string) => {
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { id } }),
);
}, []);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<TooltipProvider delayDuration={300}>
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('snippets.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleAddSnippet}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
</Tooltip>
</div>
{/* Content */}
<div className="flex-1 min-h-0">
{!hasAnyContent ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
) : hasAnyContent && searchMatches !== null && searchMatches.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
) : (
<FixedSizeVirtualList
className="h-full"
contentClassName="py-1"
items={listItems}
itemHeight={SCRIPT_ROW_HEIGHT}
getItemKey={(item) => item.key}
renderItem={(item) => {
if (item.kind === 'search') {
return (
<SnippetRow
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
draggable={false}
sortableTarget={false}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onClick={() => handleSnippetClick(item.snippet)}
onEdit={() => handleEditSnippet(item.snippet)}
onDelete={() => handleDeleteSnippet(item.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
}
if (item.kind === 'package') {
return (
<PackageRow
row={item.row}
countLabel={item.countLabel}
draggable={Boolean(onPackagesChange || onSnippetsChange)}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onToggle={() => togglePackage(item.row.path)}
/>
);
}
return (
<SnippetRow
snippet={item.row.snippet}
depth={item.row.depth}
draggable={Boolean(onSnippetsChange)}
sortableTarget={true}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onClick={() => handleSnippetClick(item.row.snippet)}
onEdit={() => handleEditSnippet(item.row.snippet)}
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
}}
/>
)}
</div>
</div>
</TooltipProvider>
);
};
interface PackageRowProps {
row: Extract<TreeRow, { type: 'package' }>;
countLabel: string;
draggable: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
onDrop: (event: React.DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
onToggle: () => void;
}
const PackageRow = memo<PackageRowProps>(({ row, countLabel, draggable, onDragOver, onDrop, onDragEnd, onToggle }) => (
<button
type="button"
onClick={onToggle}
className="vault-drop-indicator-row w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
style={{ paddingLeft: 8 + row.depth * 14 }}
data-pkg-path={row.path}
draggable={draggable}
onDragStart={(event) => {
if (!draggable) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('pkg-path', row.path);
}}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-muted-foreground transition-transform',
row.isExpanded && 'rotate-90',
!row.hasChildren && 'opacity-0',
)}
/>
<Package size={12} className="shrink-0 text-primary/80" />
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
</button>
));
PackageRow.displayName = 'PackageRow';
interface SnippetRowProps {
snippet: Snippet;
depth: number;
subtitle?: string;
draggable: boolean;
sortableTarget: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
onDrop: (event: React.DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
editLabel: string;
deleteLabel: string;
}
const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
draggable,
sortableTarget,
onDragOver,
onDrop,
onDragEnd,
onClick,
onEdit,
onDelete,
editLabel,
deleteLabel,
}) => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="vault-drop-indicator-row"
data-snippet-id={sortableTarget ? snippet.id : undefined}
draggable={draggable}
onDragStart={(event) => {
if (!draggable) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('snippet-id', snippet.id);
}}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
style={{ paddingLeft: 8 + depth * 14 }}
>
{/* Hidden chevron column mirrors PackageRow's layout so the
snippet icon lines up exactly with the package icon above. */}
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
<FileCode size={12} className="shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate text-xs font-medium">{snippet.label}</span>
{subtitle && (
<span className="shrink-0 max-w-[40%] truncate text-[10px] text-muted-foreground">
{subtitle}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" align="start" className="max-w-[480px]">
<div className="font-medium text-xs mb-1 break-all">{snippet.label}</div>
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all leading-snug opacity-90">
{snippet.command}
</pre>
</TooltipContent>
</Tooltip>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
));
SnippetRow.displayName = 'SnippetRow';
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';