Files
Netcatty/components/ScriptsSidePanel.tsx
T
Netcatty-Bot 5dcba28621 feat(scripts): add expand/collapse all and delete-selected toolbar actions (#2828)
* fix(#2826): automated Cursor CLI fix

* fix(scripts): confirm bulk delete and clear host bindings

Scripts side-panel mass-delete skipped confirmation and, when
onSnippetsChange was wired, filtered snippets without clearing host
login/connect script bindings. Confirm first, always dispatch the shared
delete event, and apply deletes via deleteSelectedSnippetsFromVault.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(scripts): harden delete handler against stale vault state

Read snippets/hosts from refs in the shared delete listener so rapid
confirms cannot resurrect already-removed items, and clear pending bulk
delete when the scripts panel hides so the confirm dialog does not
resurface after a mid-prompt hide.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(scripts): keep delete refs current within the same tick

Update snippetsRef/hostsRef immediately after applying a delete so a
same-tick second event cannot rebuild vault state from the pre-delete
snapshot and resurrect removed snippets or host bindings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2828

* fix(scripts): hoist bulk-delete confirm outside toolbar popover

Portalled Dialog focus dismissed the compact Scripts popover and cleared
pendingDeleteIds before confirm. Own the confirm in TerminalToolbar and
defer via onBulkDeleteRequest when nested in that popover.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: serialize snippet saves with bulk-delete vault lock

updateSnippets wrote STORAGE_KEY_SNIPPETS without withVaultImportLock, so a
concurrent edit could land between bulk-delete's snapshot read and journal
commit and be discarded. Queue ordinary snippet writes on the shared lock and
include them in waitForPendingVaultWrites.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: rebase queued snippet saves onto latest vault snapshot

Locking updateSnippets alone still wrote a pre-delete full array, so a
popup bulk-delete could be resurrected (or a storage-event version bump
could cancel the local edit). Rebase under the vault lock and supersede
only newer local writers via snippetsWriteOwnerRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

* fix: address Codex review on PR #2828

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-10 10:20:55 +08:00

1427 lines
51 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 {
CheckSquare,
ChevronDown,
ChevronRight,
Edit2,
Expand,
FolderPlus,
Layers,
Minimize2,
Package,
Play,
Plus,
Search,
Trash2,
Zap,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { isScriptSnippet } from '../domain/snippetScript.ts';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
import type { ScriptRun } from '../types/global/netcatty-bridge-script.d.ts';
import { ScriptRunList } from './scripts/ScriptRunList';
import { ScriptRecordingHelpDialog } from './scripts/ScriptRecordingHelpDialog';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from './ui/context-menu';
import { Dropdown, DropdownContent, DropdownTrigger } from './ui/dropdown';
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipContent';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
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;
onRunScript?: (snippet: Snippet) => void;
onRunScriptOnWorkspace?: (snippet: Snippet, mode: 'sequential' | 'parallel') => void;
onSnippetsChange?: (snippets: Snippet[]) => void;
onPackagesChange?: (packages: string[]) => void;
isVisible?: boolean;
runs?: ScriptRun[];
onStopRun?: (runId: string) => void;
onPauseRun?: (runId: string) => void;
onResumeRun?: (runId: string) => void;
onStartRecording?: () => void;
focusedSessionId?: string;
/**
* When set, bulk-delete confirmation is owned by the parent. Required when
* this panel is nested in a Popover — the portalled confirm would otherwise
* steal focus, dismiss the popover, and unmount the prompt.
*/
onBulkDeleteRequest?: (ids: string[]) => void;
}
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);
/** Collect every package path (including implied ancestors) shown in the tree. */
export function collectScriptsSidePanelPackagePaths(
packages: string[],
snippets: Snippet[],
): string[] {
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);
});
return Array.from(normalizedPackages);
}
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
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,
onRunScript,
onRunScriptOnWorkspace,
onSnippetsChange,
onPackagesChange,
isVisible = true,
runs = [],
onStopRun,
onPauseRun,
onResumeRun,
onStartRecording,
focusedSessionId,
onBulkDeleteRequest,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(null);
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const packageDialogRef = useRef<HTMLDivElement>(null);
const packageNameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ packagePath?: string }>).detail;
setSubView('library');
setSearch('');
if (detail?.packagePath) {
setExpandedPaths((prev) => {
const next = new Set(prev);
let path = detail.packagePath || '';
while (path) {
next.add(path);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
next.add('');
return next;
});
}
};
window.addEventListener('netcatty:scripts:saved', handler);
return () => window.removeEventListener('netcatty:scripts:saved', handler);
}, []);
// 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>();
return new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
}, [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;
});
}, []);
const expandAllGroups = useCallback(() => {
setExpandedPaths(new Set(normalizedPackages));
}, [normalizedPackages]);
const collapseAllGroups = useCallback(() => {
setExpandedPaths(new Set());
}, []);
const clearSnippetSelection = useCallback(() => {
setSelectedSnippetIds(new Set());
setIsMultiSelectMode(false);
}, []);
useEffect(() => {
setSelectedSnippetIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(snippets.map((snippet) => snippet.id));
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (alive.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [snippets]);
useEffect(() => {
if (!isVisible) setPendingDeleteIds(null);
}, [isVisible]);
// Parent-owned confirm (compact toolbar popover) dispatches the shared delete
// event; clear local multi-select when that bulk delete lands.
useEffect(() => {
if (!onBulkDeleteRequest) return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ ids?: string[] }>).detail;
if (!detail?.ids?.length) return;
clearSnippetSelection();
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [clearSnippetSelection, onBulkDeleteRequest]);
const toggleSnippetSelection = useCallback((id: string) => {
setSelectedSnippetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const deleteSelectedSnippets = useCallback(() => {
const ids = snippets
.filter((snippet) => selectedSnippetIds.has(snippet.id))
.map((snippet) => snippet.id);
if (ids.length === 0) return;
if (onBulkDeleteRequest) {
onBulkDeleteRequest(ids);
return;
}
setPendingDeleteIds(ids);
}, [onBulkDeleteRequest, selectedSnippetIds, snippets]);
const existingPendingDeleteIds = pendingDeleteIds
? pendingDeleteIds.filter((id) => snippets.some((snippet) => snippet.id === id))
: [];
const confirmDeleteSelectedSnippets = useCallback(() => {
const ids = (pendingDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
);
setPendingDeleteIds(null);
if (ids.length === 0) {
clearSnippetSelection();
return;
}
// Always route through the shared event so AppSideEffects can clear host
// login/connect bindings (onSnippetsChange alone would leave them stale).
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
);
clearSnippetSelection();
}, [clearSnippetSelection, pendingDeleteIds, snippets]);
// 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) => {
if (isMultiSelectMode) {
toggleSnippetSelection(snippet.id);
return;
}
if (isScriptSnippet(snippet)) {
onRunScript?.(snippet);
setSubView('running');
return;
}
onSnippetClick(snippet);
},
[isMultiSelectMode, onRunScript, onSnippetClick, toggleSnippetSelection],
);
const hasSearch = search.trim().length > 0;
const canExpandCollapse = normalizedPackages.size > 0 && !hasSearch;
const sessionRuns = useMemo(() => {
if (!focusedSessionId) return runs;
return runs.filter((run) => run.sessionId === focusedSessionId);
}, [focusedSessionId, runs]);
const recordingState = useSyncExternalStore(
subscribeScriptRecording,
getScriptRecordingSnapshot,
getScriptRecordingSnapshot,
);
const isRecordingFocusedSession = Boolean(
focusedSessionId && recordingState.sessionId === focusedSessionId,
);
const canStartRecording = Boolean(onStartRecording && focusedSessionId);
const recordingDisabledReason = !onStartRecording
? 'unavailable'
: !focusedSessionId
? 'noSession'
: null;
const recordingButtonLabel = isRecordingFocusedSession
? t('scripts.recording.active')
: t('scripts.recording.start');
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(() => {
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
}, []);
const handleAddScript = useCallback(() => {
window.dispatchEvent(new CustomEvent('netcatty:scripts:add'));
}, []);
const openPackageDialog = useCallback(() => {
setNewPackageName('');
setPackageError('');
setIsPackageDialogOpen(true);
}, []);
// Keep Tab focus inside the package dialog while it is open.
useEffect(() => {
if (!isPackageDialogOpen) return;
const focusTimer = window.setTimeout(() => packageNameInputRef.current?.focus(), 30);
const listFocusable = (): HTMLElement[] => {
const root = packageDialogRef.current;
if (!root) return [];
const nodes = root.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
return Array.from(nodes).filter((el) => {
if (el.classList.contains('sr-only')) return false;
if (el.getAttribute('aria-hidden') === 'true') return false;
return el.tabIndex >= 0 || el.tagName === 'INPUT' || el.tagName === 'BUTTON';
});
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = listFocusable();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (!active || active === first || !packageDialogRef.current?.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (!active || active === last || !packageDialogRef.current?.contains(active)) {
e.preventDefault();
first.focus();
}
};
const onFocusIn = (e: FocusEvent) => {
const target = e.target as Node | null;
if (target && packageDialogRef.current?.contains(target)) return;
(listFocusable()[0] ?? packageNameInputRef.current)?.focus();
};
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('focusin', onFocusIn);
return () => {
window.clearTimeout(focusTimer);
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('focusin', onFocusIn);
};
}, [isPackageDialogOpen]);
const handleCreatePackage = useCallback(() => {
if (!onPackagesChange) {
setPackageError(t('snippets.renameDialog.error.empty'));
return;
}
const name = newPackageName.trim();
if (!name) {
setPackageError(t('snippets.renameDialog.error.empty'));
return;
}
// Match SnippetsManager.createPackage path rules so tree rows stay stable.
if (!/^\/?([\w\p{L}\p{N}-]+(\/[\w\p{L}\p{N}-]+)*)\/?$/u.test(name)) {
setPackageError(t('snippets.renameDialog.error.invalidChars'));
return;
}
let full = name.endsWith('/') ? name.slice(0, -1) : name;
if (full !== '/' && full.endsWith('/')) full = full.slice(0, -1);
const existingPackage = packages.find((p) => p.toLowerCase() === full.toLowerCase());
if (existingPackage) {
setPackageError(t('snippets.renameDialog.error.duplicate'));
return;
}
onPackagesChange([...packages, full]);
setExpandedPaths((prev) => {
const next = new Set(prev);
let path = full.startsWith('/') ? full.slice(1) : full;
// Expand both absolute and relative forms used by the tree walker.
next.add(full);
while (path) {
next.add(path);
if (full.startsWith('/')) next.add(`/${path}`);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
return next;
});
setIsPackageDialogOpen(false);
setNewPackageName('');
setPackageError('');
}, [newPackageName, onPackagesChange, packages, t]);
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="relative h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
>
{/* Sub view tabs */}
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'px-2 border-b border-border/50 flex items-center gap-1',
)}>
<button
type="button"
className={cn(
'flex-1 h-6 rounded-md text-[11px]',
subView === 'library' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('library')}
>
{t('scripts.sidePanel.library')}
</button>
<button
type="button"
className={cn(
'flex-1 h-6 rounded-md text-[11px]',
subView === 'running' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('running')}
>
{t('scripts.sidePanel.running')}
</button>
</div>
{subView === 'running' ? (
<div className="flex-1 min-h-0 overflow-auto">
<ScriptRunList
runs={sessionRuns}
onStop={onStopRun ?? (() => {})}
onPause={onPauseRun ?? (() => {})}
onResume={onResumeRun ?? (() => {})}
/>
</div>
) : (
<>
{/* Search + tree actions + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1">
<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"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.expandAll')}
onClick={expandAllGroups}
>
<Expand size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.expandAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.collapseAll')}
onClick={collapseAllGroups}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.collapseAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
isMultiSelectMode && 'bg-muted/70 text-foreground',
)}
aria-label={t('snippets.action.selectSnippets')}
aria-pressed={isMultiSelectMode}
onClick={() => {
if (isMultiSelectMode) {
clearSnippetSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.action.selectSnippets')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
selectedSnippetIds.size > 0 && 'text-destructive hover:text-destructive',
)}
disabled={selectedSnippetIds.size === 0}
aria-label={t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
onClick={deleteSelectedSnippets}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
</TooltipContent>
</Tooltip>
{/* Split add control: primary = new snippet; menu = package / automation script */}
<div className="shrink-0 flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleAddSnippet}
aria-label={t('snippets.action.newSnippet')}
className="h-7 w-7 flex items-center justify-center rounded-l-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>
<Dropdown open={addMenuOpen} onOpenChange={setAddMenuOpen}>
<DropdownTrigger asChild>
<button
type="button"
aria-label={t('common.more')}
aria-haspopup="menu"
aria-expanded={addMenuOpen}
className="h-7 w-5 flex items-center justify-center rounded-r-md border-l border-border/40 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<ChevronDown size={12} />
</button>
</DropdownTrigger>
<DropdownContent className="w-48 p-1" align="end">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
onClick={() => {
setAddMenuOpen(false);
handleAddSnippet();
}}
>
<Zap size={12} className="text-muted-foreground" />
{t('snippets.action.newSnippet')}
</button>
{onPackagesChange ? (
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
onClick={() => {
setAddMenuOpen(false);
openPackageDialog();
}}
>
<FolderPlus size={12} className="text-muted-foreground" />
{t('snippets.action.newPackage')}
</button>
) : null}
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-md hover:bg-muted transition-colors"
onClick={() => {
setAddMenuOpen(false);
handleAddScript();
}}
>
<Play size={12} className="text-primary" />
{t('snippets.action.newScript')}
</button>
</DropdownContent>
</Dropdown>
</div>
</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') {
const isScript = isScriptSnippet(item.snippet);
return (
<SnippetRow
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
selected={selectedSnippetIds.has(item.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={false}
sortableTarget={false}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onClick={() => handleSnippetClick(item.snippet)}
onEdit={() => handleEditSnippet(item.snippet)}
onDelete={() => handleDeleteSnippet(item.snippet.id)}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(item.snippet, 'parallel')
: undefined}
onRunSequential={isScript && onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(item.snippet, 'sequential')
: undefined}
runParallelLabel={isScript
? t('scripts.actions.runParallel')
: t('scripts.actions.runOnAllTabs')}
runSequentialLabel={t('scripts.actions.runSequential')}
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)}
/>
);
}
{
const isScript = isScriptSnippet(item.row.snippet);
return (
<SnippetRow
snippet={item.row.snippet}
depth={item.row.depth}
selected={selectedSnippetIds.has(item.row.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={Boolean(onSnippetsChange) && !isMultiSelectMode}
sortableTarget={true}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onClick={() => handleSnippetClick(item.row.snippet)}
onEdit={() => handleEditSnippet(item.row.snippet)}
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(item.row.snippet, 'parallel')
: undefined}
onRunSequential={isScript && onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(item.row.snippet, 'sequential')
: undefined}
runParallelLabel={isScript
? t('scripts.actions.runParallel')
: t('scripts.actions.runOnAllTabs')}
runSequentialLabel={t('scripts.actions.runSequential')}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
}
}}
/>
)}
</div>
<div className="shrink-0 px-2 py-2 border-t border-border/50 flex items-center gap-2">
<button
type="button"
onClick={handleAddScript}
className="flex-1 h-8 rounded-md text-xs bg-secondary/60 hover:bg-secondary"
>
{t('scripts.sidePanel.newScript')}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onStartRecording}
disabled={!canStartRecording}
className={cn(
'flex-1 h-8 rounded-md text-xs disabled:opacity-50',
isRecordingFocusedSession
? 'bg-red-500/15 text-red-500 hover:bg-red-500/25'
: 'bg-secondary/60 hover:bg-secondary',
)}
>
{recordingButtonLabel}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{recordingDisabledReason === 'unavailable'
? t('scripts.recording.unavailableHint')
: recordingDisabledReason === 'noSession'
? t('scripts.recording.noSession')
: isRecordingFocusedSession
? t('scripts.recording.activeHint')
: t('scripts.recording.startHint')}
</TooltipContent>
</Tooltip>
<ScriptRecordingHelpDialog />
</div>
{isRecordingFocusedSession ? (
<p className="shrink-0 px-2 pb-2 text-[10px] text-muted-foreground leading-relaxed">
{t('scripts.recording.activeHint')}
</p>
) : null}
</>
)}
{isPackageDialogOpen ? (
<div
ref={packageDialogRef}
className="absolute inset-0 z-40 flex items-center justify-center bg-background/80 p-3"
role="dialog"
aria-modal="true"
data-state="open"
aria-label={t('snippets.packageDialog.title')}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setIsPackageDialogOpen(false);
}
}}
onClick={() => setIsPackageDialogOpen(false)}
>
<button
type="button"
data-dialog-close="true"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onClick={() => setIsPackageDialogOpen(false)}
>
{t('common.close')}
</button>
<div
className="w-full max-w-[280px] rounded-lg border border-border/60 bg-background p-3 space-y-3 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div>
<p className="text-sm font-semibold">{t('snippets.packageDialog.title')}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{t('snippets.packageDialog.parent', { parent: t('snippets.packageDialog.root') })}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('field.name')}</Label>
<Input
ref={packageNameInputRef}
value={newPackageName}
onChange={(e) => {
setNewPackageName(e.target.value);
setPackageError('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreatePackage();
}
}}
placeholder={t('snippets.packageDialog.placeholder')}
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
{packageError ? (
<p className="text-[10px] text-destructive">{packageError}</p>
) : null}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsPackageDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button type="button" size="sm" onClick={handleCreatePackage}>
{t('common.create')}
</Button>
</div>
</div>
</div>
) : null}
{!onBulkDeleteRequest ? (
<VaultDeleteConfirmDialog
open={Boolean(pendingDeleteIds)}
title={t('snippets.selection.deleteConfirmTitle', {
count: existingPendingDeleteIds.length,
})}
description={t('snippets.selection.deleteConfirmDesc')}
onOpenChange={(open) => {
if (!open) setPendingDeleteIds(null);
}}
onConfirm={confirmDeleteSelectedSnippets}
/>
) : null}
</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;
selected?: boolean;
multiSelect?: boolean;
draggable: boolean;
sortableTarget: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
onDrop: (event: React.DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
onRunParallel?: () => void;
onRunSequential?: () => void;
runParallelLabel?: string;
runSequentialLabel?: string;
editLabel: string;
deleteLabel: string;
}
const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
selected = false,
multiSelect = false,
draggable,
sortableTarget,
onDragOver,
onDrop,
onDragEnd,
onClick,
onEdit,
onDelete,
onRunParallel,
onRunSequential,
runParallelLabel,
runSequentialLabel,
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}
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden',
selected && 'bg-primary/10 hover:bg-primary/15',
)}
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 />
{multiSelect ? (
<CheckSquare
size={12}
className={cn(
'shrink-0',
selected ? 'text-primary' : 'text-muted-foreground/70',
)}
/>
) : isScriptSnippet(snippet) ? (
<Play size={12} className="shrink-0 text-primary" />
) : (
<Zap 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">
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
</TooltipContent>
</Tooltip>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onRunParallel ? (
<ContextMenuItem onClick={onRunParallel}>
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
</ContextMenuItem>
) : null}
{onRunSequential ? (
<ContextMenuItem onClick={onRunSequential}>
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
</ContextMenuItem>
) : null}
<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';