Files
NetMesh/components/SnippetsManager.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2170 lines
81 KiB
TypeScript

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<Uint8Array> = [];
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<typeof useI18n>['t'];
type SnippetImportDialogProps = {
open: boolean;
pendingImport: PendingSnippetImport | null;
t: SnippetsT;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onOpenChange: (open: boolean) => void;
onFileSelected: (event: React.ChangeEvent<HTMLInputElement>) => void;
onChooseFile: () => void;
onDownloadExamples: () => void;
onConfirmSkip: () => void;
onConfirmOverwrite: () => void;
};
type SnippetImportDialogContentProps = Omit<SnippetImportDialogProps, "open" | "onOpenChange"> & {
onCancel: () => void;
};
export const SnippetImportDialogContent: React.FC<SnippetImportDialogContentProps> = ({
pendingImport,
t,
fileInputRef,
onFileSelected,
onChooseFile,
onDownloadExamples,
onConfirmSkip,
onConfirmOverwrite,
onCancel,
}) => (
<>
<DialogHeader>
<DialogTitle>{t('snippets.import.modal.title')}</DialogTitle>
<DialogDescription>{t('snippets.import.modal.desc')}</DialogDescription>
</DialogHeader>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".json,application/json"
multiple
onChange={onFileSelected}
/>
<div className="space-y-3">
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs font-medium text-muted-foreground">
{t('snippets.import.modal.exampleTitle')}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onDownloadExamples}>
<Download size={12} className="mr-1" /> {t('snippets.import.modal.downloadExamples')}
</Button>
</div>
<pre className="max-h-48 overflow-auto rounded-md bg-background/80 p-3 text-xs leading-relaxed text-muted-foreground">
<code>{SNIPPET_IMPORT_EXAMPLE_JSON}</code>
</pre>
</div>
<div className="rounded-lg border border-border/60 p-3">
{pendingImport ? (
<div className="space-y-1 text-sm">
<div className="font-medium">{pendingImport.fileName}</div>
<div className="text-xs text-muted-foreground">
{t('snippets.import.modal.parsedSummary', {
files: pendingImport.fileCount,
total: pendingImport.payload.snippets.length,
packages: pendingImport.payload.snippetPackages.length,
conflicts: pendingImport.conflicts,
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t('snippets.import.modal.noFile')}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button variant="secondary" onClick={onChooseFile}>
<Upload size={14} className="mr-1" /> {t('snippets.import.modal.chooseFile')}
</Button>
{pendingImport?.conflicts ? (
<>
<Button variant="secondary" onClick={onConfirmSkip}>
{t('snippets.import.conflict.skip')}
</Button>
<Button onClick={onConfirmOverwrite}>
{t('snippets.import.conflict.overwrite')}
</Button>
</>
) : (
<Button disabled={!pendingImport} onClick={onConfirmSkip}>
{t('snippets.import.modal.confirm')}
</Button>
)}
</DialogFooter>
</>
);
export const SnippetImportDialog: React.FC<SnippetImportDialogProps> = ({
open,
pendingImport,
t,
fileInputRef,
onOpenChange,
onFileSelected,
onChooseFile,
onDownloadExamples,
onConfirmSkip,
onConfirmOverwrite,
}) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<SnippetImportDialogContent
pendingImport={pendingImport}
t={t}
fileInputRef={fileInputRef}
onFileSelected={onFileSelected}
onChooseFile={onChooseFile}
onDownloadExamples={onDownloadExamples}
onConfirmSkip={onConfirmSkip}
onConfirmOverwrite={onConfirmOverwrite}
onCancel={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
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<SnippetsManagerProps> = ({
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<RightPanelMode>('none');
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>({
label: '',
command: '',
package: '',
targets: [],
});
const [targetSelection, setTargetSelection] = useState<string[]>([]);
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [newPackageName, setNewPackageName] = useState('');
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(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<SortMode>('manual');
const listRef = useRef<HTMLDivElement | null>(null);
const snippetImportInputRef = useRef<HTMLInputElement | null>(null);
const lastPreviewReorderRef = useRef<string | null>(null);
const draggingSnippetIdRef = useRef<string | null>(null);
const draggingPackagePathRef = useRef<string | null>(null);
const [draggingSnippetId, setDraggingSnippetId] = useState<string | null>(null);
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(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<PendingSnippetImport | null>(null);
useEffect(() => {
const handlePackagePathChange = (event: Event) => {
const change = (event as CustomEvent<SnippetPackagePathChange>).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<HTMLDivElement>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
const [shortkeyError, setShortkeyError] = useState<string | null>(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<string, number>();
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<string>(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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<string>();
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<HTMLDivElement>) => {
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 = () => (
<SnippetsRightPanel
rightPanelMode={rightPanelMode}
hosts={hosts}
customGroups={customGroups}
targetSelection={targetSelection}
setTargetSelection={setTargetSelection}
targetGroupSelection={targetGroupSelection}
setTargetGroupSelection={setTargetGroupSelection}
handleTargetSelect={handleTargetSelect}
handleTargetSelectionChange={handleTargetSelectionChange}
handleTargetPickerBack={handleTargetPickerBack}
availableKeys={availableKeys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
t={t}
handleClosePanel={handleClosePanel}
editingSnippet={editingSnippet}
onDelete={requestDeleteSnippet}
handleSave={handleSave}
handleSaveAndRun={handleSaveAndRun}
setEditingSnippet={setEditingSnippet}
packageOptions={packageOptions}
selectedPackage={selectedPackage}
packages={packages}
onPackagesChange={onPackagesChange}
shortkeyError={shortkeyError}
setShortkeyError={setShortkeyError}
isRecordingShortkey={isRecordingShortkey}
setIsRecordingShortkey={setIsRecordingShortkey}
openTargetPicker={openTargetPicker}
targetHosts={targetHosts}
shellHistory={shellHistory}
handleHistoryScroll={handleHistoryScroll}
historyScrollRef={historyScrollRef}
visibleHistory={visibleHistory}
saveHistoryAsSnippet={saveHistoryAsSnippet}
handleCopy={handleCopy}
copiedId={copiedId}
hasMoreHistory={hasMoreHistory}
isLoadingMore={isLoadingMore}
loadMoreHistory={loadMoreHistory}
onRunSnippet={onRunSnippet}
/>
);
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-1 min-h-0 min-w-0 relative">
<div className="flex flex-1 flex-col min-h-0 min-w-0 overflow-hidden">
<VaultPageHeader contentClassName="min-w-0 gap-2 overflow-hidden">
<VaultHeaderSearch
placeholder={t('snippets.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className={cn(
'shrink-0',
hasSnippetsSidePanel ? 'w-[140px] min-w-[120px]' : 'w-64',
)}
/>
<div className={snippetsHeaderActionsWrapClass}>
<div className={cn(snippetsHeaderActionsClass, hasSnippetsSidePanel && 'pr-1')}>
<Button onClick={() => handleEdit()} size="sm" className="h-10 px-3 shrink-0">
<Plus size={14} className="mr-2" /> {t('snippets.action.newSnippet')}
</Button>
<Button onClick={() => handleEdit(undefined, true)} size="sm" variant="secondary" className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}>
<Play size={14} className="mr-2" /> {t('snippets.action.newScript')}
</Button>
<Button
onClick={() => {
setNewPackageName('');
setIsPackageDialogOpen(true);
}}
size="sm"
variant="secondary"
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
>
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
</Button>
<Button
variant="secondary"
size="sm"
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
onClick={() => {
setPendingImport(null);
setIsSnippetImportDialogOpen(true);
}}
>
<Upload size={14} className="mr-1" /> {t('snippets.action.import')}
</Button>
<Button
variant="secondary"
size="sm"
className={cn(
vaultHeaderSecondaryButtonClass,
'shrink-0',
rightPanelMode === 'history' && "bg-foreground/10 hover:bg-foreground/15",
)}
onClick={() => setRightPanelMode(rightPanelMode === 'history' ? 'none' : 'history')}
>
<Clock size={14} /> {t('snippets.history.title')}
</Button>
</div>
{hasSnippetsSidePanel ? (
<div className={snippetsHeaderActionsFadeClass} aria-hidden />
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<Dropdown>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
{viewMode === 'grid' ? <LayoutGrid size={16} /> : <ListIcon size={16} />}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={14} /> {t('snippets.view.grid')}
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode('list')}
>
<ListIcon size={14} /> {t('snippets.view.list')}
</Button>
</DropdownContent>
</Dropdown>
<SortDropdown
value={sortMode}
onChange={setSortMode}
className={vaultHeaderIconButtonClass}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className={vaultHeaderIconButtonClass}
aria-label={t('snippets.action.selectSnippets')}
onClick={() => {
if (isMultiSelectMode) {
clearSnippetSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('snippets.action.selectSnippets')}</TooltipContent>
</Tooltip>
</div>
</VaultPageHeader>
{isMultiSelectMode && (
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
<span className="flex h-7 items-center text-xs leading-4 text-muted-foreground">
{t('snippets.selection.selected', { count: selectedSnippetIds.size })}
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={selectVisibleSnippets}
>
{t('snippets.selection.selectVisible')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearSnippetSelection}
>
{t('snippets.selection.deselectAll')}
</Button>
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedSnippetIds.size === 0}
onClick={exportSelectedSnippets}
>
<Download size={12} className="mr-1" />
{t('snippets.selection.exportSelected', { count: selectedSnippetIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedSnippetIds.size === 0}
onClick={requestDeleteSelectedSnippets}
>
<Trash2 size={12} className="mr-1" />
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={clearSnippetSelection}
>
<X size={12} />
</Button>
</div>
)}
<div className="flex items-center gap-2 text-sm font-semibold px-4 py-2">
<button className="text-primary hover:underline" onClick={() => setSelectedPackage(null)}>{t('snippets.breadcrumb.allPackages')}</button>
{breadcrumb.map((b) => (
<span key={b.path} className="flex items-center gap-2">
<span className="text-muted-foreground">{t('snippets.breadcrumb.separator')}</span>
<button className="text-primary hover:underline" onClick={() => setSelectedPackage(b.path)}>{b.name}</button>
</span>
))}
</div>
{!snippets.length && displayedPackages.length === 0 && (
<div className="flex-1 flex items-center justify-center px-4">
<div className="flex flex-col items-center justify-center text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<FileCode size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{t('snippets.empty.title')}</h3>
<p className="text-sm text-center max-w-sm">{t('snippets.empty.desc')}</p>
</div>
</div>
)}
<div
ref={listRef}
className="flex-1 space-y-3 overflow-y-auto px-4 pb-4"
onDragOverCapture={handleReorderDragOver}
onDropCapture={handleReorderDrop}
onDragEndCapture={resetSnippetDragState}
>
{displayedPackages.length > 0 && !search.trim() && (
<>
<div className="flex items-center justify-between">
<h3 className={vaultSectionTitleClass}>{t('snippets.section.packages')}</h3>
</div>
<div className={cn(
viewMode === 'grid'
? cn(
"grid gap-3",
hasSnippetsSidePanel
? "grid-cols-1"
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
)
: "flex flex-col gap-0"
)}
style={splitViewGridStyle}
>
{displayedPackages.map((pkg) => (
<ContextMenu key={pkg.path}>
<ContextMenuTrigger asChild>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
)}
data-pkg-path={pkg.path}
data-vault-grid-item={`snippet-package:${pkg.path}`}
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
data-vault-reorder-dragging={draggingPackagePath === pkg.path ? 'true' : undefined}
draggable
{...primaryOnlyDragHandlers(true)}
onDragStart={(e) => {
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)}
>
<div className="flex items-center gap-3 h-full min-w-0">
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Package size={18} />}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
<ContextMenuItem
onClick={() => exportPackageSnippets(pkg.path)}
disabled={getSnippetsInPackage(pkg.path).length === 0}
>
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.package')}
</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => requestDeletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
</>
)}
{displayedSnippets.length > 0 && (
<div className="space-y-2">
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
<div className={cn(
viewMode === 'grid'
? cn(
"grid gap-3",
hasSnippetsSidePanel
? "grid-cols-1"
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
)
: "flex flex-col gap-0"
)}
style={splitViewGridStyle}
>
{displayedSnippets.map((snippet) => {
const isSelected = selectedSnippetIds.has(snippet.id);
return (
<ContextMenu key={snippet.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
)}
data-snippet-id={isSearchActive ? undefined : snippet.id}
data-vault-grid-item={`snippet:${snippet.id}`}
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
draggable={!isSearchActive && !isMultiSelectMode}
{...primaryOnlyDragHandlers(!isSearchActive && !isMultiSelectMode)}
onDragStart={(e) => {
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);
}
}}
>
<div className="flex items-center gap-3 h-full min-w-0">
{isMultiSelectMode && (
<div
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
toggleSnippetSelection(snippet.id);
}}
>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<VaultEntityIcon
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
icon={isScriptSnippet(snippet) ? (
<Play size={18} />
) : (
<Zap size={18} />
)}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<SnippetCommandTooltipContent
command={snippet.command}
fallback={t('snippets.commandFallback')}
/>
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
{snippet.shortkey}
</div>
)}
{viewMode === 'list' && !isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
>
<Edit2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
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}
>
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleEdit(snippet)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
</ContextMenuItem>
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</div>
)}
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<div className="h-14 w-14 rounded-2xl bg-secondary/80 flex items-center justify-center mb-3">
<Search size={24} className="opacity-60" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1">
{t('snippets.search.noResults.title')}
</h3>
<p className="text-xs text-center max-w-sm">
{t('snippets.search.noResults.desc', { query: search.trim() })}
</p>
</div>
)}
</div>
</div>
<SnippetsPackageDialogs
isPackageDialogOpen={isPackageDialogOpen}
t={t}
selectedPackage={selectedPackage}
newPackageName={newPackageName}
setNewPackageName={setNewPackageName}
createPackage={createPackage}
setIsPackageDialogOpen={setIsPackageDialogOpen}
isRenameDialogOpen={isRenameDialogOpen}
renamingPackagePath={renamingPackagePath}
renamePackageName={renamePackageName}
setRenamePackageName={setRenamePackageName}
setRenameError={setRenameError}
renamePackage={renamePackage}
renameError={renameError}
setIsRenameDialogOpen={setIsRenameDialogOpen}
/>
<SnippetImportDialog
open={isSnippetImportDialogOpen}
pendingImport={pendingImport}
t={t}
fileInputRef={snippetImportInputRef}
onOpenChange={(open) => {
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');
}}
/>
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={deleteTarget?.type === 'selection'
? t('snippets.selection.deleteConfirmTitle', { count: existingDeleteSelectionIds.length })
: t('vault.deleteConfirm.title', { name: deleteTarget?.name ?? '' })}
description={
deleteTarget?.type === 'package'
? t('vault.deleteConfirm.packageDesc')
: deleteTarget?.type === 'selection'
? t('snippets.selection.deleteConfirmDesc')
: t('vault.deleteConfirm.desc')
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
{renderRightPanel()}
</div>
</TooltipProvider>
);
};
export default SnippetsManager;