/** * QuickAddSnippetDialog — lightweight "new / edit snippet" modal mounted at the * App root and triggered by the `netcatty:snippets:add` / `:edit` window events. * * Opens as a centered Dialog so it does not compete with the scripts side panel. * Fields: label, command, package, shortkey, multi-line mode. * Advanced fields (target hosts, tags) can still be set later in the full * Snippets manager. */ import { Keyboard, Package, RotateCcw } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import type { Snippet } from '../domain/models'; import { findActiveSystemShortcutConflict } from '../domain/activeKeyBindings'; import { type HotkeyScheme, type KeyBinding, keyEventToString, keyStringToKeyboardEvent, matchesKeyBinding, } from '../domain/models'; import { isScriptSnippet } from '../domain/snippetScript.ts'; import { cn, isMacPlatform } from '../lib/utils'; import { Button } from './ui/button'; import { Combobox } from './ui/combobox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from './ui/dialog'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { SnippetScriptEditor } from './snippets/SnippetScriptEditor'; export interface QuickAddSnippetDialogProps { snippets: Snippet[]; packages: string[]; onCreateSnippet: (snippet: Snippet) => void; onUpdateSnippet?: (snippet: Snippet) => void; onCreatePackage?: (packagePath: string) => void; /** Optional — used to validate shortkey conflicts with system bindings. */ hotkeyScheme?: HotkeyScheme; keyBindings?: KeyBinding[]; } export function getQuickAddSnippetInitialCommand(event: Event): string { const detail = (event as CustomEvent<{ command?: unknown }>).detail; return typeof detail?.command === 'string' ? detail.command : ''; } export const QuickAddSnippetDialog: React.FC = ({ snippets, packages, onCreateSnippet, onUpdateSnippet, onCreatePackage, hotkeyScheme = 'disabled', keyBindings = [], }) => { const { t } = useI18n(); const [open, setOpen] = useState(false); const [label, setLabel] = useState(''); const [command, setCommand] = useState(''); const [packagePath, setPackagePath] = useState(''); const [noAutoRun, setNoAutoRun] = useState(false); const [multiLineRunMode, setMultiLineRunMode] = useState(undefined); const [shortkey, setShortkey] = useState(undefined); const [isRecordingShortkey, setIsRecordingShortkey] = useState(false); const [shortkeyError, setShortkeyError] = useState(null); const [editing, setEditing] = useState(null); const labelInputRef = useRef(null); // Listen for the global "add snippet" request dispatched by the // terminal-side ScriptsSidePanel + button. We reset form state on // every open so stale input from a previous cancel does not leak. useEffect(() => { const handler = (event: Event) => { setEditing(null); setLabel(''); setCommand(getQuickAddSnippetInitialCommand(event)); setPackagePath(''); setNoAutoRun(false); setMultiLineRunMode(undefined); setShortkey(undefined); setShortkeyError(null); setIsRecordingShortkey(false); setOpen(true); }; window.addEventListener('netcatty:snippets:add', handler); return () => window.removeEventListener('netcatty:snippets:add', handler); }, []); // Sibling event for editing an existing snippet from the ScriptsSidePanel // context menu. Prefills the form and flips the dialog into update mode. useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent<{ snippet?: Snippet }>).detail; const snippet = detail?.snippet; if (!snippet || isScriptSnippet(snippet)) return; setEditing(snippet); setLabel(snippet.label ?? ''); setCommand(snippet.command ?? ''); setPackagePath(snippet.package ?? ''); setNoAutoRun(snippet.noAutoRun ?? false); setMultiLineRunMode(snippet.multiLineRunMode); setShortkey(snippet.shortkey); setShortkeyError(null); setIsRecordingShortkey(false); setOpen(true); }; window.addEventListener('netcatty:snippets:edit', handler); return () => window.removeEventListener('netcatty:snippets:edit', handler); }, []); // Focus the label field when the modal opens. useEffect(() => { if (!open) return; const focusTimer = window.setTimeout(() => labelInputRef.current?.focus(), 50); return () => window.clearTimeout(focusTimer); }, [open]); const isMac = useMemo(() => ( hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform()) ), [hotkeyScheme]); const existingShortkeys = useMemo(() => ( snippets.filter((s) => Boolean(s.shortkey) && s.id !== editing?.id) ), [snippets, editing?.id]); 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, ]); // Shortkey recording capture. Escape cancels recording only (does not close the modal). useEffect(() => { if (!isRecordingShortkey) return; const handleKeyDown = (e: KeyboardEvent) => { 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); setShortkey(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]); // Derive combobox options from the union of existing packages (from // props) and any package path referenced by an existing snippet, so // the user can reuse anything they see in the main snippets view. const packageOptions = useMemo(() => { const set = new Set(); for (const p of packages) { if (p) set.add(p); } for (const s of snippets) { if (s.package) set.add(s.package); } return Array.from(set).sort().map((value) => ({ value, label: value })); }, [packages, snippets]); const canSave = label.trim().length > 0 && command.trim().length > 0; const handleClose = useCallback(() => { setOpen(false); setIsRecordingShortkey(false); setShortkeyError(null); }, []); const handleOpenChange = useCallback((nextOpen: boolean) => { if (!nextOpen) { // Escape while recording is gated by onEscapeKeyDown; X/backdrop always close. handleClose(); return; } setOpen(true); }, [handleClose]); const handleSave = useCallback(() => { if (!canSave) return; const trimmedPackage = packagePath.trim(); // If the user typed a brand new package name, surface it to the parent // so it can be added to the user's package list alongside the snippet. if (trimmedPackage && !packages.includes(trimmedPackage)) { onCreatePackage?.(trimmedPackage); } if (editing && onUpdateSnippet) { // Preserve tags/targets/etc. that this lightweight panel does not expose. onUpdateSnippet({ ...editing, label: label.trim(), command, package: trimmedPackage || '', noAutoRun: noAutoRun || undefined, multiLineRunMode, shortkey: shortkey || undefined, }); } else { onCreateSnippet({ id: crypto.randomUUID(), label: label.trim(), command, // preserve whitespace in multi-line commands tags: [], package: trimmedPackage || '', targets: [], noAutoRun: noAutoRun || undefined, multiLineRunMode, shortkey: shortkey || undefined, }); } handleClose(); }, [ canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun, multiLineRunMode, shortkey, handleClose, ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; // Cmd/Ctrl+Enter from anywhere in the modal saves the snippet. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) { e.preventDefault(); handleSave(); } }, [canSave, handleSave], ); const handleSubmitShortcut = useCallback(() => { if (canSave) handleSave(); }, [canSave, handleSave]); return ( { if (isRecordingShortkey) { e.preventDefault(); setIsRecordingShortkey(false); setShortkeyError(null); } }} > {t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')} {t('snippets.empty.desc')}
setLabel(e.target.value)} placeholder={t('snippets.field.descriptionPlaceholder')} className="h-9" spellCheck={false} />
{shortkey ? ( {t('snippets.shortkey.clear')} ) : null}
{shortkeyError ? (

{shortkeyError}

) : null}

{t('snippets.shortkey.hint')}

); }; export default QuickAddSnippetDialog;