/** * 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 { cn } from '../lib/utils'; import { Snippet } from '../types'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from './ui/context-menu'; import { Input } from './ui/input'; import { ScrollArea } from './ui/scroll-area'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; interface ScriptsSidePanelProps { snippets: Snippet[]; packages: string[]; onSnippetClick: (snippet: Snippet) => 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 ScriptsSidePanelInner: React.FC = ({ snippets, packages, onSnippetClick, isVisible = true, }) => { const { t } = useI18n(); const [search, setSearch] = useState(''); const [expandedPaths, setExpandedPaths] = useState>(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(() => { const set = new Set(); 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]); // 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>(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(() => { 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); newlySeen.forEach((p) => next.add(p)); return next; }); }, [normalizedPackages]); 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(() => { const q = search.trim().toLowerCase(); if (!q) return null; return snippets.filter( (s) => s.label.toLowerCase().includes(q) || s.command.toLowerCase().includes(q), ); }, [snippets, search]); const rows = useMemo(() => { if (searchMatches !== null) return []; const out: TreeRow[] = []; const paths: string[] = []; normalizedPackages.forEach((p) => paths.push(p)); const childPackagesOf = (parent: string | null): string[] => { const prefix = parent === null ? '' : parent + '/'; return paths .filter((p) => { if (parent === null) { // Root-level: no "/" inside the body const body = p.startsWith('/') ? p.slice(1) : p; return !body.includes('/'); } if (!p.startsWith(prefix)) return false; const rest = p.slice(prefix.length); return rest.length > 0 && !rest.includes('/'); }) .sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b))); }; const snippetsIn = (pkg: string | null): Snippet[] => snippets .filter((s) => (s.package || '') === (pkg ?? '')) .sort((a, b) => a.label.localeCompare(b.label)); const countDescendants = (pkg: string): number => snippets.filter((s) => { const sp = s.package || ''; return sp === pkg || sp.startsWith(pkg + '/'); }).length; 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); out.push({ type: 'package', id: pkg, path: pkg, name: pkgDisplayName(pkg), depth, count: countDescendants(pkg), hasChildren, isExpanded, }); if (!isExpanded) return; children.forEach((c) => walk(c, depth + 1)); localSnippets.forEach((s) => out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }), ); }; // Orphan / uncategorized snippets first (package === '') snippetsIn(null).forEach((s) => out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }), ); childPackagesOf(null).forEach((root) => walk(root, 0)); return out; }, [normalizedPackages, snippets, expandedPaths, searchMatches]); const handleSnippetClick = useCallback( (snippet: Snippet) => { onSnippetClick(snippet); }, [onSnippetClick], ); 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 (
{/* Search + Add */}
setSearch(e.target.value)} placeholder={t('snippets.searchPlaceholder')} className="h-7 pl-7 text-xs bg-muted/30 border-none" />
{t('snippets.action.newSnippet')}
{/* Content */}
{!hasAnyContent && (
{t('terminal.toolbar.noSnippets')}
)} {/* Search flat list */} {searchMatches !== null && searchMatches.length > 0 && searchMatches.map((s) => ( handleSnippetClick(s)} onEdit={() => handleEditSnippet(s)} onDelete={() => handleDeleteSnippet(s.id)} editLabel={t('action.edit')} deleteLabel={t('action.delete')} /> ))} {/* Tree */} {searchMatches === null && rows.map((row) => row.type === 'package' ? ( togglePackage(row.path)} /> ) : ( handleSnippetClick(row.snippet)} onEdit={() => handleEditSnippet(row.snippet)} onDelete={() => handleDeleteSnippet(row.snippet.id)} editLabel={t('action.edit')} deleteLabel={t('action.delete')} /> ), )} {hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
{t('common.noResultsFound')}
)}
); }; interface PackageRowProps { row: Extract; countLabel: string; onToggle: () => void; } const PackageRow: React.FC = ({ row, countLabel, onToggle }) => ( ); interface SnippetRowProps { snippet: Snippet; depth: number; subtitle?: string; onClick: () => void; onEdit: () => void; onDelete: () => void; editLabel: string; deleteLabel: string; } const SnippetRow: React.FC = ({ snippet, depth, subtitle, onClick, onEdit, onDelete, editLabel, deleteLabel, }) => (
{snippet.label}
              {snippet.command}
            
{editLabel} {deleteLabel}
); export const ScriptsSidePanel = memo(ScriptsSidePanelInner); ScriptsSidePanel.displayName = 'ScriptsSidePanel';