/** * 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 { type HotkeyScheme, type KeyBinding, keyEventToString, matchesKeyBinding, parseKeyCombo, } 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 activeSystemBindings = useMemo(() => { return keyBindings.flatMap((binding) => { const entries: { binding: string; isMac: boolean }[] = []; const macBinding = binding.mac; const pcBinding = binding.pc; if (hotkeyScheme === 'mac') { if (macBinding && macBinding !== 'Disabled') { entries.push({ binding: macBinding, isMac: true }); } return entries; } if (hotkeyScheme === 'pc') { if (pcBinding && pcBinding !== 'Disabled') { entries.push({ binding: pcBinding, isMac: false }); } return entries; } if (macBinding && macBinding !== 'Disabled') { entries.push({ binding: macBinding, isMac: true }); } if (pcBinding && pcBinding !== 'Disabled') { entries.push({ binding: pcBinding, isMac: false }); } return entries; }); }, [hotkeyScheme, keyBindings]); const buildKeyEventFromString = useCallback((keyString: string) => { const parsed = parseKeyCombo(keyString); if (!parsed) return null; const modifiers = new Set(parsed.modifiers); const key = parsed.key; const normalizedKey = (() => { switch (key) { case 'Space': return ' '; case '↑': return 'ArrowUp'; case '↓': return 'ArrowDown'; case '←': return 'ArrowLeft'; case '→': return 'ArrowRight'; case 'Esc': return 'Escape'; case '⌫': return 'Backspace'; case 'Del': return 'Delete'; case '↵': return 'Enter'; case '⇥': return 'Tab'; default: return key.length === 1 ? key.toLowerCase() : key; } })(); return new KeyboardEvent('keydown', { key: normalizedKey, metaKey: modifiers.has('⌘') || modifiers.has('Win'), ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'), altKey: modifiers.has('⌥') || modifiers.has('Alt'), shiftKey: modifiers.has('Shift'), }); }, []); const normalizeKeyString = useCallback((value: string) => ( value.toLowerCase().replace(/\s+/g, '') ), []); const validateShortkey = useCallback((key: string): string | null => { if (!key) return null; const syntheticEvent = buildKeyEventFromString(key); if (syntheticEvent) { const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => ( matchesKeyBinding(syntheticEvent, binding, bindingIsMac) )); if (conflictsSystem) { return t('snippets.shortkey.error.systemConflict'); } } 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; }, [ activeSystemBindings, buildKeyEventFromString, existingShortkeys, isMac, 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;