import { CheckSquare, ChevronDown, Clock, Copy, Download, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Package, Play, Plus, Search, Square, Trash2, Upload, X, Zap } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import { useStoredViewMode } from '../application/state/useStoredViewMode'; import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys'; import { cn, isMacPlatform } from '../lib/utils'; import { Host, ProxyProfile, ShellHistoryEntry, Snippet, SSHKey } from '../types'; import { getShellHistorySnapshot, subscribeShellHistory, } from '../application/state/shellHistoryStore'; import { findActiveSystemShortcutConflict } from '../domain/activeKeyBindings'; import { HotkeyScheme, KeyBinding, keyEventToString, keyStringToKeyboardEvent, ManagedSource, matchesKeyBinding } from '../domain/models'; import { buildSnippetExportPayload, combineSnippetImportPayloads, mergeSnippetImportPayload, parseSnippetImportPayload, type SnippetExportPayload, type SnippetImportConflictAction, } from '../domain/snippetTransfer'; import { getRunnableHostsForSnippet, resolveSnippetTargetGroupsForSave, snippetHasRunTargets, } from '../domain/snippetTargets.ts'; import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts'; import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts'; import { applySnippetPackagePathChange, deleteSnippetPackage, SNIPPET_PACKAGE_PATH_CHANGE_EVENT, renameSnippetPackage, type SnippetPackagePathChange, } from '../domain/snippetPackage.ts'; import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts'; import { DEFAULT_SCRIPT_TEMPLATE, isScriptSnippet } from '../domain/snippetScript.ts'; import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder'; import { Button } from './ui/button'; import { ComboboxOption } from './ui/combobox'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; import { Dropdown, DropdownContent, DropdownTrigger } from './ui/dropdown'; import { SortDropdown, SortMode } from './ui/sort-dropdown'; import { toast } from './ui/toast'; import { isNonPrimaryPointer, primaryOnlyDragHandlers } from './ui/primaryOnlyDrag'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipContent'; import { SnippetsRightPanel } from './SnippetsRightPanel'; import { SnippetsPackageDialogs } from './SnippetsPackageDialogs'; import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass, vaultSectionTitleClass, } from './vault/VaultPageHeader'; import { VaultEntityIcon, vaultAutomationScriptIconClass, vaultPrimaryIconClass, vaultSnippetIconClass, } from './vault/VaultEntityIcon'; import { isAppLockOverlayActive } from '../infrastructure/appLockOverlayDom'; import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog'; import { clearVaultDropIndicator as clearSnippetDropIndicator, getVaultDropIntent as getPackageDropIntent, getVaultDropPosition as getDropPosition, hasVaultDragType as hasDragType, markVaultDropIndicator as markSnippetDropIndicator, markVaultInsideDropIndicator as markSnippetInsideIndicator, useVaultGridLayoutAnimation, } from './vault/vaultReorderDrag'; interface SnippetsManagerProps { snippets: Snippet[]; packages: string[]; hosts: Host[]; customGroups?: string[]; /** @deprecated Prefer shellHistoryStore; optional override for tests only. */ shellHistory?: ShellHistoryEntry[]; hotkeyScheme: HotkeyScheme; keyBindings: KeyBinding[]; onSave: (snippet: Snippet) => void; onBulkSave: (snippets: Snippet[]) => void; onDelete: (id: string) => void; onPackagesChange: (packages: string[]) => void; onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void; availableKeys?: SSHKey[]; proxyProfiles?: ProxyProfile[]; managedSources?: ManagedSource[]; onSaveHost?: (host: Host) => void; onUpdateHosts?: (hosts: Host[]) => void; onCreateGroup?: (groupPath: string) => void; openSnippetId?: string | null; openSnippetRequestId?: number | null; onOpenSnippetIdHandled?: () => void; } type RightPanelMode = 'none' | 'edit-snippet' | 'history' | 'select-targets'; const HISTORY_PAGE_SIZE = 30; type PendingSnippetImport = { fileName: string; fileCount: number; payload: SnippetExportPayload; conflicts: number; }; export const SNIPPET_IMPORT_EXAMPLE_JSON = `{ "kind": "netcatty.snippets", "version": 1, "snippets": [ { "label": "Check Disk Space", "command": "df -h", "package": "ops" } ] }`; export type SnippetImportSampleFile = { name: string; content: string; }; const stringifySample = (value: unknown) => JSON.stringify(value, null, 2); export const SNIPPET_IMPORT_SAMPLE_FILES: SnippetImportSampleFile[] = [ { name: "01-standard-netcatty-object.json", content: stringifySample({ kind: "netcatty.snippets", version: 1, exportedAt: "2026-06-23T00:00:00.000Z", snippetPackages: ["ops", "ops/linux"], snippets: [ { label: "Show Kernel", command: "uname -a", package: "ops/linux", tags: ["linux", "system"], }, { label: "Memory Usage", command: "free -h", package: "ops/linux", tags: ["linux"], }, ], }), }, { name: "02-plain-snippet-array.json", content: stringifySample([ { label: "List Listening Ports", command: "ss -lntp", package: "network", tags: ["network"], }, { label: "Current Directory", command: "pwd", }, ]), }, { name: "03-more-snippets-for-multi-select.json", content: stringifySample({ kind: "netcatty.snippets", version: 1, exportedAt: "2026-06-23T00:00:00.000Z", snippetPackages: ["containers", "logs"], snippets: [ { label: "Docker Containers", command: "docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'", package: "containers", tags: ["docker"], }, { label: "Journal Errors", command: "journalctl -p err -n 50 --no-pager", package: "logs", tags: ["logs"], }, ], }), }, { name: "04-duplicate-command-conflict.json", content: stringifySample({ kind: "netcatty.snippets", version: 1, exportedAt: "2026-06-23T00:00:00.000Z", snippetPackages: ["conflicts"], snippets: [ { label: "Duplicate Check Disk Space", command: "df -h", package: "conflicts", tags: ["duplicate"], }, ], }), }, { name: "05-host-bindings-ignored.json", content: stringifySample({ kind: "netcatty.snippets", version: 1, exportedAt: "2026-06-23T00:00:00.000Z", snippetPackages: ["security"], snippets: [ { label: "Who Is Logged In", command: "who", package: "security", tags: ["audit"], targets: ["host-that-should-not-import"], }, ], }), }, ]; const crcTable = Array.from({ length: 256 }, (_, index) => { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } return value >>> 0; }); const crc32 = (bytes: Uint8Array): number => { let crc = 0xffffffff; bytes.forEach((byte) => { crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); }); return (crc ^ 0xffffffff) >>> 0; }; const pushUint16 = (parts: number[], value: number) => { parts.push(value & 0xff, (value >>> 8) & 0xff); }; const pushUint32 = (parts: number[], value: number) => { parts.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff); }; export const buildSnippetImportSamplesZip = (files: SnippetImportSampleFile[] = SNIPPET_IMPORT_SAMPLE_FILES): Blob => { const encoder = new TextEncoder(); const chunks: Array = []; const centralDirectory: Uint8Array[] = []; let offset = 0; files.forEach((file) => { const nameBytes = encoder.encode(file.name); const contentBytes = encoder.encode(`${file.content}\n`); const checksum = crc32(contentBytes); const localHeader: number[] = []; pushUint32(localHeader, 0x04034b50); pushUint16(localHeader, 20); pushUint16(localHeader, 0); pushUint16(localHeader, 0); pushUint16(localHeader, 0); pushUint16(localHeader, 0); pushUint32(localHeader, checksum); pushUint32(localHeader, contentBytes.length); pushUint32(localHeader, contentBytes.length); pushUint16(localHeader, nameBytes.length); pushUint16(localHeader, 0); const localChunk = new Uint8Array([...localHeader, ...nameBytes, ...contentBytes]); chunks.push(localChunk); const centralHeader: number[] = []; pushUint32(centralHeader, 0x02014b50); pushUint16(centralHeader, 20); pushUint16(centralHeader, 20); pushUint16(centralHeader, 0); pushUint16(centralHeader, 0); pushUint16(centralHeader, 0); pushUint16(centralHeader, 0); pushUint32(centralHeader, checksum); pushUint32(centralHeader, contentBytes.length); pushUint32(centralHeader, contentBytes.length); pushUint16(centralHeader, nameBytes.length); pushUint16(centralHeader, 0); pushUint16(centralHeader, 0); pushUint16(centralHeader, 0); pushUint16(centralHeader, 0); pushUint32(centralHeader, 0); pushUint32(centralHeader, offset); centralDirectory.push(new Uint8Array([...centralHeader, ...nameBytes])); offset += localChunk.length; }); const centralDirectorySize = centralDirectory.reduce((total, chunk) => total + chunk.length, 0); const endRecord: number[] = []; pushUint32(endRecord, 0x06054b50); pushUint16(endRecord, 0); pushUint16(endRecord, 0); pushUint16(endRecord, files.length); pushUint16(endRecord, files.length); pushUint32(endRecord, centralDirectorySize); pushUint32(endRecord, offset); pushUint16(endRecord, 0); return new Blob([...chunks, ...centralDirectory, new Uint8Array(endRecord)], { type: "application/zip", }); }; type SnippetsT = ReturnType['t']; type SnippetImportDialogProps = { open: boolean; pendingImport: PendingSnippetImport | null; t: SnippetsT; fileInputRef: React.RefObject; onOpenChange: (open: boolean) => void; onFileSelected: (event: React.ChangeEvent) => void; onChooseFile: () => void; onDownloadExamples: () => void; onConfirmSkip: () => void; onConfirmOverwrite: () => void; }; type SnippetImportDialogContentProps = Omit & { onCancel: () => void; }; export const SnippetImportDialogContent: React.FC = ({ pendingImport, t, fileInputRef, onFileSelected, onChooseFile, onDownloadExamples, onConfirmSkip, onConfirmOverwrite, onCancel, }) => ( <> {t('snippets.import.modal.title')} {t('snippets.import.modal.desc')}
{t('snippets.import.modal.exampleTitle')}
          {SNIPPET_IMPORT_EXAMPLE_JSON}
        
{pendingImport ? (
{pendingImport.fileName}
{t('snippets.import.modal.parsedSummary', { files: pendingImport.fileCount, total: pendingImport.payload.snippets.length, packages: pendingImport.payload.snippetPackages.length, conflicts: pendingImport.conflicts, })}
) : (
{t('snippets.import.modal.noFile')}
)}
{pendingImport?.conflicts ? ( <> ) : ( )} ); export const SnippetImportDialog: React.FC = ({ open, pendingImport, t, fileInputRef, onOpenChange, onFileSelected, onChooseFile, onDownloadExamples, onConfirmSkip, onConfirmOverwrite, }) => ( onOpenChange(false)} /> ); const sanitizeTransferFileNamePart = (value: string): string => { const normalized = value .trim() .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); return normalized.slice(0, 80) || 'snippets'; }; const SnippetsManager: React.FC = ({ snippets, packages, hosts, customGroups = [], shellHistory: shellHistoryProp, hotkeyScheme, keyBindings, onSave, onBulkSave, onDelete, onPackagesChange, onRunSnippet, availableKeys = [], proxyProfiles = [], managedSources = [], onSaveHost, onUpdateHosts, onCreateGroup, openSnippetId = null, openSnippetRequestId = null, onOpenSnippetIdHandled, }) => { const { t } = useI18n(); const shellHistoryFromStore = useSyncExternalStore( subscribeShellHistory, getShellHistorySnapshot, getShellHistorySnapshot, ); const shellHistory = shellHistoryProp ?? (shellHistoryFromStore as ShellHistoryEntry[]); const [rightPanelMode, setRightPanelMode] = useState('none'); const [editingSnippet, setEditingSnippet] = useState>({ label: '', command: '', package: '', targets: [], }); const [targetSelection, setTargetSelection] = useState([]); const [targetGroupSelection, setTargetGroupSelection] = useState([]); const [copiedId, setCopiedId] = useState(null); const [selectedPackage, setSelectedPackage] = useState(null); const [newPackageName, setNewPackageName] = useState(''); const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [renamingPackagePath, setRenamingPackagePath] = useState(null); const [renamePackageName, setRenamePackageName] = useState(''); const [renameError, setRenameError] = useState(''); const [search, setSearch] = useState(''); const [viewMode, setViewMode] = useStoredViewMode( STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE, 'grid', ); const [sortMode, setSortMode] = useState('manual'); const listRef = useRef(null); const snippetImportInputRef = useRef(null); const lastPreviewReorderRef = useRef(null); const draggingSnippetIdRef = useRef(null); const draggingPackagePathRef = useRef(null); const [draggingSnippetId, setDraggingSnippetId] = useState(null); const [draggingPackagePath, setDraggingPackagePath] = useState(null); const [isMultiSelectMode, setIsMultiSelectMode] = useState(false); const [selectedSnippetIds, setSelectedSnippetIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState< | { type: 'snippet' | 'package'; id: string; name: string } | { type: 'selection'; ids: string[] } | null >(null); const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false); const [pendingImport, setPendingImport] = useState(null); useEffect(() => { const handlePackagePathChange = (event: Event) => { const change = (event as CustomEvent).detail; if (!change?.from || (change.to !== null && !change.to)) return; setSelectedPackage((current) => { if (!current) return current; const next = applySnippetPackagePathChange(current, change); return next || null; }); setEditingSnippet((current) => { if (!current.package) return current; const next = applySnippetPackagePathChange(current.package, change); return next === current.package ? current : { ...current, package: next }; }); if (isRenameDialogOpen && renamingPackagePath) { const nextPath = applySnippetPackagePathChange(renamingPackagePath, change); if (nextPath !== renamingPackagePath) { setIsRenameDialogOpen(false); setRenamingPackagePath(null); setRenamePackageName(''); setRenameError(''); } } if (deleteTarget?.type === 'package') { const nextPath = applySnippetPackagePathChange(deleteTarget.id, change); if (nextPath !== deleteTarget.id) setDeleteTarget(null); } }; window.addEventListener(SNIPPET_PACKAGE_PATH_CHANGE_EVENT, handlePackagePathChange); return () => window.removeEventListener(SNIPPET_PACKAGE_PATH_CHANGE_EVENT, handlePackagePathChange); }, [deleteTarget, isRenameDialogOpen, renamingPackagePath]); const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef); const hasSnippetsSidePanel = rightPanelMode !== 'none'; const splitGridColsRef = useRef(2); const splitViewGridStyle = hasSnippetsSidePanel && viewMode === 'grid' ? { gridTemplateColumns: 'var(--snippets-grid-cols, repeat(2, minmax(0, 1fr)))' } : undefined; useEffect(() => { const el = listRef.current; if (!el || typeof ResizeObserver === 'undefined') return; const GAP = 12; const MIN_CARD = 220; const PADDING_X = 32; const recompute = () => { const usable = el.clientWidth - PADDING_X; if (usable <= 0) return; const next = Math.max(1, Math.floor((usable + GAP) / (MIN_CARD + GAP))); if (next === splitGridColsRef.current) return; splitGridColsRef.current = next; el.style.setProperty('--snippets-grid-cols', `repeat(${next}, minmax(0, 1fr))`); }; recompute(); const observer = new ResizeObserver(recompute); observer.observe(el); return () => observer.disconnect(); }, [hasSnippetsSidePanel, viewMode]); const snippetsHeaderActionsClass = 'flex min-w-0 flex-1 items-center gap-2 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'; const snippetsHeaderActionsWrapClass = 'relative min-w-0 flex-1'; const snippetsHeaderActionsFadeClass = 'pointer-events-none absolute inset-y-0 right-0 z-10 w-10 bg-gradient-to-l from-background from-35% to-transparent'; const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE); const historyScrollRef = useRef(null); const [isLoadingMore, setIsLoadingMore] = useState(false); const [isRecordingShortkey, setIsRecordingShortkey] = useState(false); const [shortkeyError, setShortkeyError] = useState(null); const existingShortkeys = useMemo(() => ( snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id) ), [snippets, editingSnippet.id]); const isMac = useMemo(() => ( hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform()) ), [hotkeyScheme]); const normalizeKeyString = useCallback((value: string) => ( value.toLowerCase().replace(/\s+/g, '') ), []); const validateShortkey = useCallback((key: string): string | null => { if (!key) return null; const systemConflict = findActiveSystemShortcutConflict(key, hotkeyScheme, keyBindings); if (systemConflict) { const nameKey = `settings.shortcuts.binding.${systemConflict.id}`; const name = t(nameKey) !== nameKey ? t(nameKey) : systemConflict.label; return t('snippets.shortkey.error.systemConflict', { name }); } const syntheticEvent = keyStringToKeyboardEvent(key); if (syntheticEvent) { for (const snippet of existingShortkeys) { if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) { return t('snippets.shortkey.error.snippetConflict', { name: snippet.label }); } } } else { const normalizedKey = normalizeKeyString(key); const conflictingSnippet = existingShortkeys.find(snippet => ( snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey )); if (conflictingSnippet) { return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label }); } } return null; }, [ existingShortkeys, hotkeyScheme, isMac, keyBindings, normalizeKeyString, t, ]); useEffect(() => { if (!isRecordingShortkey) return; const handleKeyDown = (e: KeyboardEvent) => { // App lock overlay installs later; skip while locked so password keys // never mutate shortkeys (Codex P2). if (isAppLockOverlayActive()) return; e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { setIsRecordingShortkey(false); setShortkeyError(null); return; } if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return; const keyString = keyEventToString(e, isMac); const error = validateShortkey(keyString); if (error) { setShortkeyError(error); return; } setShortkeyError(null); setEditingSnippet(prev => ({ ...prev, shortkey: keyString })); setIsRecordingShortkey(false); }; const handleClick = () => { setIsRecordingShortkey(false); setShortkeyError(null); }; const timer = setTimeout(() => { window.addEventListener('click', handleClick, true); }, 100); window.addEventListener('keydown', handleKeyDown, true); return () => { clearTimeout(timer); window.removeEventListener('keydown', handleKeyDown, true); window.removeEventListener('click', handleClick, true); }; }, [isRecordingShortkey, isMac, validateShortkey]); const handleEdit = useCallback((snippet?: Snippet, asScript = false) => { if (snippet) { setEditingSnippet(snippet); setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || [])); setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups || [])); } else { setEditingSnippet(asScript ? { label: '', command: DEFAULT_SCRIPT_TEMPLATE, package: selectedPackage || '', targets: [], kind: 'script', language: 'javascript', trigger: 'manual', } : { label: '', command: '', package: selectedPackage || '', targets: [], }); setTargetSelection([]); setTargetGroupSelection([]); } setRightPanelMode('edit-snippet'); }, [selectedPackage]); useEffect(() => { if (!openSnippetId) return; const snippet = snippets.find((item) => item.id === openSnippetId); if (!snippet) return; handleEdit(snippet); onOpenSnippetIdHandled?.(); }, [handleEdit, onOpenSnippetIdHandled, openSnippetId, openSnippetRequestId, snippets]); const buildSavedSnippet = useCallback((): Snippet | null => { if (!editingSnippet.label || !editingSnippet.command) return null; return { id: editingSnippet.id || crypto.randomUUID(), label: editingSnippet.label, command: editingSnippet.command, tags: editingSnippet.tags || [], package: editingSnippet.package || '', targets: editingSnippet.targetsAllHosts ? [] : targetSelection, targetGroups: resolveSnippetTargetGroupsForSave( editingSnippet, targetGroupSelection, ), targetsAllHosts: editingSnippet.targetsAllHosts || undefined, shortkey: editingSnippet.shortkey, noAutoRun: editingSnippet.noAutoRun, multiLineRunMode: editingSnippet.multiLineRunMode, order: editingSnippet.order, kind: editingSnippet.kind, language: editingSnippet.language, description: editingSnippet.description, trigger: editingSnippet.trigger, triggerPattern: editingSnippet.triggerPattern, }; }, [editingSnippet, targetGroupSelection, targetSelection]); const syncHostsAfterSnippetSave = useCallback(( savedSnippet: Snippet, nextSnippets: Snippet[], ) => { if (!onUpdateHosts || !savedSnippet.id) return; const original = snippets.find((item) => item.id === savedSnippet.id); const prevTargetIds = original?.targetsAllHosts ? [] : (original?.targets ?? []); let nextHosts = hosts; if (isScriptSnippet(savedSnippet) && savedSnippet.trigger === 'onConnect') { nextHosts = syncHostsForSnippetTargetChange(hosts, savedSnippet, prevTargetIds, nextSnippets); } else if (original && isScriptSnippet(original) && original.trigger === 'onConnect') { nextHosts = hosts.map((item) => removeHostConnectScript(item, savedSnippet.id!, nextSnippets)); } const changed = nextHosts.length !== hosts.length || nextHosts.some((host, index) => host !== hosts[index]); if (changed) { onUpdateHosts(nextHosts); } }, [hosts, onUpdateHosts, snippets]); const handleSave = useCallback(() => { const savedSnippet = buildSavedSnippet(); if (!savedSnippet) return; const nextSnippets = snippets.find((ex) => ex.id === savedSnippet.id) ? snippets.map((ex) => (ex.id === savedSnippet.id ? savedSnippet : ex)) : [...snippets, savedSnippet]; onSave(savedSnippet); syncHostsAfterSnippetSave(savedSnippet, nextSnippets); setRightPanelMode('none'); }, [buildSavedSnippet, onSave, snippets, syncHostsAfterSnippetSave]); const handleSaveAndRun = useCallback(() => { const savedSnippet = buildSavedSnippet(); if (!savedSnippet) return; const nextSnippets = snippets.find((ex) => ex.id === savedSnippet.id) ? snippets.map((ex) => (ex.id === savedSnippet.id ? savedSnippet : ex)) : [...snippets, savedSnippet]; onSave(savedSnippet); syncHostsAfterSnippetSave(savedSnippet, nextSnippets); const runTargets = getRunnableHostsForSnippet(savedSnippet, hosts); if (snippetHasRunTargets(savedSnippet) && runTargets.length > 0) { onRunSnippet?.(savedSnippet, runTargets); } setRightPanelMode('none'); }, [buildSavedSnippet, hosts, onRunSnippet, onSave, snippets, syncHostsAfterSnippetSave]); useEffect(() => { const handler = (event: KeyboardEvent) => { if (isAppLockOverlayActive()) return; if (rightPanelMode !== 'edit-snippet') return; if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return; event.preventDefault(); handleSave(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [handleSave, rightPanelMode]); const handleCopy = (id: string, text: string) => { navigator.clipboard.writeText(text); setCopiedId(id); setTimeout(() => setCopiedId(null), 1500); }; const handleClosePanel = () => { setRightPanelMode('none'); setEditingSnippet({ label: '', command: '', package: '', targets: [] }); setTargetSelection([]); setTargetGroupSelection([]); }; const hostById = useMemo(() => ( new Map(hosts.map((host) => [host.id, host])) ), [hosts]); const targetHosts = useMemo(() => { return targetSelection .map((id) => hostById.get(id)) .filter((h): h is Host => Boolean(h)); }, [targetSelection, hostById]); const openTargetPicker = () => { setRightPanelMode('select-targets'); }; const handleTargetSelect = (host: Host) => { if (editingSnippet.targetsAllHosts) { setEditingSnippet((prev) => ({ ...prev, targetsAllHosts: undefined })); } setTargetSelection((prev) => { const next = prev.includes(host.id) ? prev.filter((id) => id !== host.id) : [...prev, host.id]; setEditingSnippet((snippet) => ({ ...snippet, targetsAllHosts: undefined, targets: next, })); return next; }); }; const handleTargetSelectionChange = (nextSelectedHostIds: string[]) => { setTargetSelection(nextSelectedHostIds); setEditingSnippet((snippet) => ({ ...snippet, targetsAllHosts: undefined, targets: nextSelectedHostIds, })); }; const handleTargetPickerBack = () => { setRightPanelMode('edit-snippet'); }; const snippetPackageDescendantCounts = useMemo(() => { const counts = new Map(); snippets.forEach((snippet) => { const pkg = snippet.package || ''; if (!pkg) return; if (pkg.startsWith('/')) { const parts = pkg.substring(1).split('/').filter(Boolean); for (let index = 0; index < parts.length; index += 1) { const path = `/${parts.slice(0, index + 1).join('/')}`; counts.set(path, (counts.get(path) ?? 0) + 1); } return; } const parts = pkg.split('/').filter(Boolean); for (let index = 0; index < parts.length; index += 1) { const path = parts.slice(0, index + 1).join('/'); counts.set(path, (counts.get(path) ?? 0) + 1); } }); return counts; }, [snippets]); const displayedPackages = useMemo(() => { const packageIndexByPath = new Map(packages.map((pkg, index) => [pkg, index])); const getPackageDisplayOrder = (path: string) => { const exactIndex = packageIndexByPath.get(path); if (typeof exactIndex === 'number') return exactIndex; const childIndex = packages.findIndex((pkg) => pkg.startsWith(path + '/')); return childIndex >= 0 ? childIndex : Number.MAX_SAFE_INTEGER; }; const sortBySavedPackageOrder = ( items: { name: string; path: string; count: number }[], ) => { return [...items].sort((a, b) => { const orderDiff = getPackageDisplayOrder(a.path) - getPackageDisplayOrder(b.path); if (orderDiff !== 0) return orderDiff; return a.name.localeCompare(b.name); }); }; if (!selectedPackage) { const absolutePaths = packages.filter(p => p.startsWith('/')); const relativePaths = packages.filter(p => !p.startsWith('/')); const results: { name: string; path: string; count: number }[] = []; const relativeRoots = relativePaths .map((p) => p.split('/')[0]) .filter((name): name is string => Boolean(name) && name.length > 0); Array.from(new Set(relativeRoots)).forEach((name: string) => { const path: string = name; const count = snippetPackageDescendantCounts.get(path) ?? 0; results.push({ name, path, count }); }); const absoluteRoots = absolutePaths .map((p) => { const cleanPath = p.substring(1); // Remove leading slash const firstSegment = cleanPath.split('/')[0]; return firstSegment; }) .filter((name): name is string => Boolean(name) && name.length > 0); Array.from(new Set(absoluteRoots)).forEach((name: string) => { const path: string = `/${name}`; const displayName: string = `/${name}`; // Show with leading slash to distinguish const count = snippetPackageDescendantCounts.get(path) ?? 0; results.push({ name: displayName, path, count }); }); return sortBySavedPackageOrder(results); } const prefix = selectedPackage + '/'; const children = packages .filter((p) => p.startsWith(prefix)) .map((p) => p.replace(prefix, '').split('/')[0]) .filter((name): name is string => Boolean(name) && name.length > 0); return sortBySavedPackageOrder(Array.from(new Set(children)).map((name) => { const path = `${selectedPackage}/${name}`; const count = snippetPackageDescendantCounts.get(path) ?? 0; return { name, path, count }; })); }, [packages, selectedPackage, snippetPackageDescendantCounts]); const displayedSnippets = useMemo(() => { const hasSearch = search.trim().length > 0; let result = hasSearch ? snippets : snippets.filter((s) => (s.package || '') === (selectedPackage || '')); if (hasSearch) { const s = search.toLowerCase(); result = result.filter(sn => sn.label.toLowerCase().includes(s) || sn.command.toLowerCase().includes(s) ); } result = [...result].sort((a, b) => { switch (sortMode) { case 'az': return a.label.localeCompare(b.label); case 'za': return b.label.localeCompare(a.label); case 'manual': return 0; default: return 0; } }); return sortMode === 'manual' ? sortByVaultOrder(result) : result; }, [snippets, selectedPackage, search, sortMode]); const isSearchActive = search.trim().length > 0; useEffect(() => { setSelectedSnippetIds((prev) => { const existingIds = new Set(snippets.map((snippet) => snippet.id)); const next = new Set(Array.from(prev).filter((id) => existingIds.has(id))); if (next.size === prev.size) return prev; return next; }); }, [snippets]); const clearSnippetSelection = useCallback(() => { setSelectedSnippetIds(new Set()); setIsMultiSelectMode(false); }, []); 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 selectVisibleSnippets = useCallback(() => { setSelectedSnippetIds(new Set(displayedSnippets.map((snippet) => snippet.id))); }, [displayedSnippets]); const getSnippetsInPackage = useCallback( (path: string) => ( snippets.filter((snippet) => { const packagePath = snippet.package || ''; return packagePath === path || packagePath.startsWith(`${path}/`); }) ), [snippets], ); const downloadSnippetPayload = useCallback((payload: SnippetExportPayload, fileNamePart: string) => { const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `netcatty-snippets-${sanitizeTransferFileNamePart(fileNamePart)}.json`; a.click(); URL.revokeObjectURL(url); }, []); const downloadSnippetImportExamples = useCallback(() => { const blob = buildSnippetImportSamplesZip(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'netcatty-snippet-import-samples.zip'; a.click(); URL.revokeObjectURL(url); }, []); const exportSnippetList = useCallback( (items: Snippet[], fileNamePart: string) => { if (items.length === 0) { toast.warning(t('snippets.export.toast.empty')); return; } const payload = buildSnippetExportPayload({ snippets: items, snippetPackages: packages, }); downloadSnippetPayload(payload, fileNamePart); toast.success( t('snippets.export.toast.success', { count: items.length }), t('snippets.export.toast.successTitle'), ); }, [downloadSnippetPayload, packages, t], ); const exportSingleSnippet = useCallback( (snippet: Snippet) => { exportSnippetList([snippet], snippet.label); }, [exportSnippetList], ); const exportPackageSnippets = useCallback( (path: string) => { exportSnippetList(getSnippetsInPackage(path), path); }, [exportSnippetList, getSnippetsInPackage], ); const exportSelectedSnippets = useCallback(() => { const selected = snippets.filter((snippet) => selectedSnippetIds.has(snippet.id)); exportSnippetList(selected, `selected-${selected.length}`); }, [exportSnippetList, selectedSnippetIds, snippets]); const applySnippetImport = useCallback( (payload: SnippetExportPayload, conflictAction: SnippetImportConflictAction) => { const result = mergeSnippetImportPayload({ existingSnippets: snippets, existingSnippetPackages: packages, payload, conflictAction, createId: () => crypto.randomUUID(), }); onBulkSave(result.snippets); onPackagesChange(result.snippetPackages); setPendingImport(null); setIsSnippetImportDialogOpen(false); toast.success( t('snippets.import.toast.summary', { imported: result.stats.imported, overwritten: result.stats.overwritten, skipped: result.stats.skipped, }), t('snippets.import.toast.successTitle'), ); }, [onBulkSave, onPackagesChange, packages, snippets, t], ); const handleSnippetImportFileSelected = useCallback( async (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); event.target.value = ''; if (files.length === 0) return; try { const payloads = await Promise.all( files.map(async (file) => parseSnippetImportPayload(await file.text())), ); const payload = combineSnippetImportPayloads(payloads); if (payload.snippets.length === 0) { toast.warning(t('snippets.import.toast.empty')); setPendingImport(null); return; } const existingCommands = new Set(snippets.map((snippet) => snippet.command)); const conflicts = payload.snippets.filter((snippet) => existingCommands.has(snippet.command)).length; const fileName = files.length === 1 ? files[0].name : t('snippets.import.modal.multipleFiles', { count: files.length }); setPendingImport({ fileName, fileCount: files.length, payload, conflicts }); } catch { setPendingImport(null); toast.error( t('snippets.import.toast.invalidDesc'), t('snippets.import.toast.failedTitle'), ); } }, [snippets, t], ); const breadcrumb = useMemo(() => { if (!selectedPackage) return []; const isAbsolute = selectedPackage.startsWith('/'); const parts = selectedPackage.split('/').filter(Boolean); return parts.map((name, idx) => { const pathSegments = parts.slice(0, idx + 1); const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/'); return { name, path }; }); }, [selectedPackage]); const createPackage = () => { const name = newPackageName.trim(); if (!name) return; if (!/^\/?([\w\p{L}\p{N}-]+(\/[\w\p{L}\p{N}-]+)*)\/?$/u.test(name)) { return; } let full: string; if (selectedPackage) { const normalizedName = name.startsWith('/') ? name.substring(1) : name; full = `${selectedPackage}/${normalizedName}`; } else { full = name; } if (full.endsWith('/')) { full = full.slice(0, -1); } const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase()); if (existingPackage) { return; } onPackagesChange([...packages, full]); setNewPackageName(''); setIsPackageDialogOpen(false); }; const performDeletePackage = (path: string) => { const result = deleteSnippetPackage(packages, snippets, path); onPackagesChange(result.packages); onBulkSave(result.snippets); if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) { setSelectedPackage(null); } }; const requestDeletePackage = (path: string) => { setDeleteTarget({ type: 'package', id: path, name: path, }); }; const requestDeleteSnippet = (id: string) => { const snippet = snippets.find((item) => item.id === id); setDeleteTarget({ type: 'snippet', id, name: snippet?.label || t('snippets.panel.editTitle'), }); }; const requestDeleteSelectedSnippets = () => { const ids = snippets .filter((snippet) => selectedSnippetIds.has(snippet.id)) .map((snippet) => snippet.id); if (ids.length === 0) return; setDeleteTarget({ type: 'selection', ids }); }; const existingDeleteSelectionIds = deleteTarget?.type === 'selection' ? deleteTarget.ids.filter((id) => snippets.some((snippet) => snippet.id === id)) : []; const confirmDeleteTarget = () => { if (!deleteTarget) return; if (deleteTarget.type === 'package') { performDeletePackage(deleteTarget.id); } else if (deleteTarget.type === 'selection') { const deletedIds = new Set(deleteTarget.ids); const result = deleteSelectedSnippetsFromVault(snippets, hosts, deletedIds); if (result.deletedCount === 0) { clearSnippetSelection(); setDeleteTarget(null); return; } onBulkSave(result.snippets); onUpdateHosts?.(result.hosts); if (editingSnippet.id && deletedIds.has(editingSnippet.id)) { handleClosePanel(); } toast.success(t('snippets.selection.deleteSuccess', { count: result.deletedCount })); clearSnippetSelection(); } else { onDelete(deleteTarget.id); setSelectedSnippetIds((prev) => { if (!prev.has(deleteTarget.id)) return prev; const next = new Set(prev); next.delete(deleteTarget.id); return next; }); if (editingSnippet.id === deleteTarget.id) { handleClosePanel(); } } setDeleteTarget(null); }; const movePackage = (source: string, target: string | null) => { const name = source.split('/').pop() || ''; const isAbsolute = source.startsWith('/'); const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name); if (newPath === source || newPath.startsWith(source + '/')) return; if (packages.includes(newPath)) return; const updatedPackages = packages.map((p) => { if (p === source) return newPath; if (p.startsWith(source + '/')) { return newPath + p.substring(source.length); } return p; }); const updatedSnippets = snippets.map((s) => { if (!s.package) return s; if (s.package === source) return { ...s, package: newPath }; if (s.package.startsWith(source + '/')) { return { ...s, package: newPath + s.package.substring(source.length) }; } return s; }); onPackagesChange(Array.from(new Set(updatedPackages))); onBulkSave(updatedSnippets); if (selectedPackage === source) setSelectedPackage(newPath); }; const openRenameDialog = (path: string) => { const name = path.split('/').pop() || ''; setRenamingPackagePath(path); setRenamePackageName(name); setRenameError(''); setIsRenameDialogOpen(true); }; const renamePackage = () => { if (!renamingPackagePath) return; const result = renameSnippetPackage(packages, snippets, renamingPackagePath, renamePackageName); if (!result.ok) { setRenameError(t(`snippets.renameDialog.error.${result.error}`)); return; } if (result.newPath === renamingPackagePath) { setIsRenameDialogOpen(false); return; } onPackagesChange(result.packages); onBulkSave(result.snippets); if (selectedPackage === renamingPackagePath) { setSelectedPackage(result.newPath); } else if (selectedPackage?.startsWith(renamingPackagePath + '/')) { setSelectedPackage(result.newPath + selectedPackage.substring(renamingPackagePath.length)); } if (editingSnippet.package) { if (editingSnippet.package === renamingPackagePath) { setEditingSnippet(prev => ({ ...prev, package: result.newPath })); } else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) { setEditingSnippet(prev => ({ ...prev, package: result.newPath + prev.package!.substring(renamingPackagePath.length) })); } } setIsRenameDialogOpen(false); }; const moveSnippet = (id: string, pkg: string | null) => { const sn = snippets.find((s) => s.id === id); if (!sn) return; onSave({ ...sn, package: pkg || '' }); }; const parentOfPackage = useCallback((path: string) => { const parts = path.split('/').filter(Boolean); const prefix = path.startsWith('/') ? '/' : ''; return prefix + parts.slice(0, -1).join('/'); }, []); const resetSnippetDragState = useCallback(() => { clearSnippetDropIndicator(); lastPreviewReorderRef.current = null; draggingSnippetIdRef.current = null; draggingPackagePathRef.current = null; setDraggingSnippetId(null); setDraggingPackagePath(null); }, []); const handleReorderDragOver = useCallback((event: React.DragEvent) => { const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]'); if (!(target instanceof HTMLElement)) return; const isGrid = viewMode === 'grid'; const targetSnippetId = target.getAttribute('data-snippet-id'); const targetPackage = target.getAttribute('data-pkg-path'); const isDraggingSnippet = Boolean(draggingSnippetIdRef.current) || hasDragType(event.dataTransfer, 'snippet-id'); const isDraggingPackage = Boolean(draggingPackagePathRef.current) || hasDragType(event.dataTransfer, 'pkg-path'); if (targetSnippetId && isDraggingSnippet) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id'); const position = getDropPosition(target, event.clientX, event.clientY, isGrid); if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) { const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId); const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId); if (!targetSnippet || !sourceSnippet) return; const previewKey = `${sourceSnippetId}:${targetSnippetId}:${position}`; if (lastPreviewReorderRef.current === previewKey) return; prepareGridLayoutAnimation(); lastPreviewReorderRef.current = previewKey; const movedSnippets = snippets.map((snippet) => snippet.id === sourceSnippetId ? { ...snippet, package: targetSnippet.package || '' } : snippet, ); onBulkSave(reorderVaultItems(movedSnippets, sourceSnippetId, targetSnippetId, position)); setSortMode('manual'); return; } markSnippetDropIndicator(target, position, isGrid ? 'x' : 'y'); return; } if (targetPackage && isDraggingSnippet) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; markSnippetInsideIndicator(target); return; } if (targetPackage && isDraggingPackage) { const sourcePackage = draggingPackagePathRef.current || event.dataTransfer.getData('pkg-path'); if ( sourcePackage && targetPackage.startsWith(`${sourcePackage}/`) ) { event.dataTransfer.dropEffect = 'none'; clearSnippetDropIndicator(); return; } const intent = getPackageDropIntent(target, event.clientX, event.clientY, isGrid); event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (intent === 'inside') { markSnippetInsideIndicator(target); return; } if ( isGrid && sourcePackage && parentOfPackage(sourcePackage) === parentOfPackage(targetPackage) ) { const previewKey = `package:${sourcePackage}:${targetPackage}:${intent}`; if (lastPreviewReorderRef.current !== previewKey) { prepareGridLayoutAnimation(); lastPreviewReorderRef.current = previewKey; const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage])); onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, intent)); setSortMode('manual'); } return; } markSnippetDropIndicator(target, intent, isGrid ? 'x' : 'y'); return; } event.dataTransfer.dropEffect = 'none'; clearSnippetDropIndicator(); }, [ onBulkSave, onPackagesChange, packages, parentOfPackage, prepareGridLayoutAnimation, snippets, viewMode, ]); const handleReorderDrop = useCallback((event: React.DragEvent) => { const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]'); clearSnippetDropIndicator(); if (!(target instanceof HTMLElement)) return; const isGrid = viewMode === 'grid'; const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id'); const targetSnippetId = target.getAttribute('data-snippet-id'); if (sourceSnippetId && targetSnippetId) { event.preventDefault(); event.stopPropagation(); if (sourceSnippetId === targetSnippetId) { lastPreviewReorderRef.current = null; return; } const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId); const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId); if (!targetSnippet || !sourceSnippet) return; const movedSnippets = snippets.map((snippet) => snippet.id === sourceSnippetId ? { ...snippet, package: targetSnippet.package || '' } : snippet, ); const position = getDropPosition(target, event.clientX, event.clientY, isGrid); const previewKey = `${sourceSnippetId}:${targetSnippetId}:${position}`; if (!isGrid || lastPreviewReorderRef.current !== previewKey) { prepareGridLayoutAnimation(); onBulkSave(reorderVaultItems( movedSnippets, sourceSnippetId, targetSnippetId, position, )); } lastPreviewReorderRef.current = null; setSortMode('manual'); return; } const sourcePackage = draggingPackagePathRef.current || event.dataTransfer.getData('pkg-path'); const targetPackage = target.getAttribute('data-pkg-path'); if (sourcePackage && targetPackage) { event.preventDefault(); event.stopPropagation(); if (sourcePackage === targetPackage) { lastPreviewReorderRef.current = null; return; } const intent = getPackageDropIntent(target, event.clientX, event.clientY, isGrid); if (intent === 'inside') return; if (parentOfPackage(sourcePackage) !== parentOfPackage(targetPackage)) return; const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage])); const previewKey = `package:${sourcePackage}:${targetPackage}:${intent}`; if (!isGrid || lastPreviewReorderRef.current !== previewKey) { prepareGridLayoutAnimation(); onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, intent)); } lastPreviewReorderRef.current = null; setSortMode('manual'); } }, [ onBulkSave, onPackagesChange, packages, parentOfPackage, prepareGridLayoutAnimation, snippets, viewMode, ]); const packageOptions: ComboboxOption[] = useMemo(() => { const allPaths = new Set(); packages.forEach(pkg => { allPaths.add(pkg); const parts = pkg.split('/').filter(Boolean); const isAbsolute = pkg.startsWith('/'); for (let i = 1; i < parts.length; i++) { const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/'); allPaths.add(parentPath); } }); return Array.from(allPaths) .sort((a, b) => { const depthA = (a.match(/\//g) || []).length; const depthB = (b.match(/\//g) || []).length; if (depthA !== depthB) return depthA - depthB; return a.localeCompare(b); }) .map(p => ({ value: p, label: p.includes('/') ? p.split('/').pop()! : p, sublabel: p.includes('/') ? p : undefined, })); }, [packages]); const visibleHistory = useMemo(() => { return shellHistory.slice(0, historyVisibleCount); }, [shellHistory, historyVisibleCount]); const hasMoreHistory = historyVisibleCount < shellHistory.length; const loadMoreHistory = useCallback(() => { if (isLoadingMore || !hasMoreHistory) return; setIsLoadingMore(true); setTimeout(() => { setHistoryVisibleCount((prev) => Math.min(prev + HISTORY_PAGE_SIZE, shellHistory.length)); setIsLoadingMore(false); }, 200); }, [isLoadingMore, hasMoreHistory, shellHistory.length]); const handleHistoryScroll = useCallback((e: React.UIEvent) => { const target = e.target as HTMLDivElement; const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; if (scrollBottom < 100 && hasMoreHistory && !isLoadingMore) { loadMoreHistory(); } }, [hasMoreHistory, isLoadingMore, loadMoreHistory]); useEffect(() => { if (rightPanelMode === 'history') { setHistoryVisibleCount(HISTORY_PAGE_SIZE); } }, [rightPanelMode]); const saveHistoryAsSnippet = (entry: ShellHistoryEntry, label: string) => { if (!label.trim()) return; onSave({ id: crypto.randomUUID(), label: label.trim(), command: entry.command, package: selectedPackage || '', targets: [], }); }; const renderRightPanel = () => ( ); return (
setSearch(e.target.value)} className={cn( 'shrink-0', hasSnippetsSidePanel ? 'w-[140px] min-w-[120px]' : 'w-64', )} />
{hasSnippetsSidePanel ? (
) : null}
{t('snippets.action.selectSnippets')}
{isMultiSelectMode && (
{t('snippets.selection.selected', { count: selectedSnippetIds.size })}
)}
{breadcrumb.map((b) => ( {t('snippets.breadcrumb.separator')} ))}
{!snippets.length && displayedPackages.length === 0 && (

{t('snippets.empty.title')}

{t('snippets.empty.desc')}

)}
{displayedPackages.length > 0 && !search.trim() && ( <>

{t('snippets.section.packages')}

{displayedPackages.map((pkg) => (
{ if (isNonPrimaryPointer(e) || isNonPrimaryPointer(e.nativeEvent)) { e.preventDefault(); return; } e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('pkg-path', pkg.path); draggingPackagePathRef.current = pkg.path; setDraggingPackagePath(pkg.path); lastPreviewReorderRef.current = null; const sourceNode = e.currentTarget as HTMLElement; const handleNativeDragEnd = () => { sourceNode.removeEventListener('dragend', handleNativeDragEnd); resetSnippetDragState(); }; sourceNode.addEventListener('dragend', handleNativeDragEnd); }} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); const sId = draggingSnippetIdRef.current || e.dataTransfer.getData('snippet-id'); const pPath = draggingPackagePathRef.current || e.dataTransfer.getData('pkg-path'); if (sId) moveSnippet(sId, pkg.path); if ( pPath && getPackageDropIntent(e.currentTarget, e.clientX, e.clientY, viewMode === 'grid') === 'inside' ) { movePackage(pPath, pkg.path); } }} onClick={() => setSelectedPackage(pkg.path)} >
} />
{pkg.name}
{t('snippets.package.count', { count: pkg.count })}
setSelectedPackage(pkg.path)}>{t('action.open')} exportPackageSnippets(pkg.path)} disabled={getSnippetsInPackage(pkg.path).length === 0} > {t('snippets.export.package')} openRenameDialog(pkg.path)}>{t('common.rename')} requestDeletePackage(pkg.path)}>{t('action.delete')}
))}
)} {displayedSnippets.length > 0 && (

{t('snippets.section.snippets')}

{displayedSnippets.map((snippet) => { const isSelected = selectedSnippetIds.has(snippet.id); return (
{ if (isNonPrimaryPointer(e) || isNonPrimaryPointer(e.nativeEvent)) { e.preventDefault(); return; } e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('snippet-id', snippet.id); draggingSnippetIdRef.current = snippet.id; setDraggingSnippetId(snippet.id); lastPreviewReorderRef.current = null; const sourceNode = e.currentTarget as HTMLElement; const handleNativeDragEnd = () => { sourceNode.removeEventListener('dragend', handleNativeDragEnd); resetSnippetDragState(); }; sourceNode.addEventListener('dragend', handleNativeDragEnd); }} onClick={() => { if (isMultiSelectMode) { toggleSnippetSelection(snippet.id); } else { handleEdit(snippet); } }} >
{isMultiSelectMode && (
{ e.stopPropagation(); toggleSnippetSelection(snippet.id); }} > {isSelected ? ( ) : ( )}
)} ) : ( )} />
{snippet.label}
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
{snippet.shortkey && (
{snippet.shortkey}
)} {viewMode === 'list' && !isMultiSelectMode && ( )}
{ const runTargets = getRunnableHostsForSnippet(snippet, hosts); if (runTargets.length > 0) { onRunSnippet?.(snippet, runTargets); return; } toast.error(t('scripts.actions.noRunnableHosts')); }} disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0} > {t('action.run')} handleEdit(snippet)}> {t('action.edit')} handleCopy(snippet.id, snippet.command)}> {t('action.copy')} exportSingleSnippet(snippet)}> {t('snippets.export.snippet')} requestDeleteSnippet(snippet.id)}> {t('action.delete')}
); })}
)} {search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (

{t('snippets.search.noResults.title')}

{t('snippets.search.noResults.desc', { query: search.trim() })}

)}
{ setIsSnippetImportDialogOpen(open); if (!open) setPendingImport(null); }} onFileSelected={handleSnippetImportFileSelected} onChooseFile={() => snippetImportInputRef.current?.click()} onDownloadExamples={downloadSnippetImportExamples} onConfirmSkip={() => { if (pendingImport) applySnippetImport(pendingImport.payload, 'skip'); }} onConfirmOverwrite={() => { if (pendingImport) applySnippetImport(pendingImport.payload, 'overwrite'); }} /> { if (!open) setDeleteTarget(null); }} onConfirm={confirmDeleteTarget} /> {renderRightPanel()}
); }; export default SnippetsManager;