import React, { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; import InputField from "@/components/InputField"; // your existing input component import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; interface Hit { value: string; index: number } // ---------- history hook ---------- function useCommandHistory(max = 300) { const { send } = useJsonRpc(); const [items, setItems] = useState([]); const deleteHistory = useCallback(() => { console.log("Deleting serial command history"); send("deleteSerialCommandHistory", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( `Failed to delete serial command history: ${resp.error.data || "Unknown error"}`, ); } else { setItems([]); notifications.success("Serial command history deleted"); } }); }, [send]); useEffect(() => { send("getSerialCommandHistory", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( `Failed to get command history: ${resp.error.data || "Unknown error"}`, ); } else if ("result" in resp) { setItems(resp.result as string[]); } }); }, [send]); const [pointer, setPointer] = useState(-1); // -1 = fresh line const [anchorPrefix, setAnchorPrefix] = useState(null); useEffect(() => { if (items.length > 1) { send("setSerialCommandHistory", { commandHistory: items }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to update command history: ${resp.error.data || "Unknown error"}`); return; } }); } }, [items, send]); const push = useCallback((cmd: string) => { if (!cmd.trim()) return; setItems((prev) => { const next = prev[prev.length - 1] === cmd ? prev : [...prev, cmd]; return next.slice(-max); }); setPointer(-1); setAnchorPrefix(null); }, [max]); const resetTraversal = useCallback(() => { setPointer(-1); setAnchorPrefix(null); }, []); const up = useCallback((draft: string) => { const pref = anchorPrefix ?? draft; if (anchorPrefix == null) setAnchorPrefix(pref); let i = pointer < 0 ? items.length - 1 : pointer - 1; for (; i >= 0; i--) { if (items[i].startsWith(pref)) { setPointer(i); return items[i]; } } return draft; }, [items, pointer, anchorPrefix]); const down = useCallback((draft: string) => { const pref = anchorPrefix ?? draft; if (anchorPrefix == null) setAnchorPrefix(pref); let i = pointer < 0 ? 0 : pointer + 1; for (; i < items.length; i++) { if (items[i].startsWith(pref)) { setPointer(i); return items[i]; } } setPointer(-1); return draft; }, [items, pointer, anchorPrefix]); const search = useCallback((query: string): Hit[] => { if (!query) return []; const q = query.toLowerCase(); return [...items] .map((value, index) => ({ value, index })) .filter((x) => x.value.toLowerCase().includes(q)) .reverse(); // newest first }, [items]); return { push, up, down, resetTraversal, search, deleteHistory }; } function Portal({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) return null; return createPortal(children, document.body); } // ---------- reverse search popup ---------- function ReverseSearch({ open, results, sel, setSel, onPick, onClose, onDeleteHistory }: { open: boolean; results: Hit[]; sel: number; setSel: (i: number) => void; onPick: (val: string) => void; onClose: () => void; onDeleteHistory: () => void; }) { const listRef = React.useRef(null); // keep selected item in view when sel changes useEffect(() => { if (!listRef.current) return; const el = listRef.current.querySelector(`[data-idx="${sel}"]`); el?.scrollIntoView({ block: "nearest" }); }, [sel, results]); if (!open) return null; return (
{results.length === 0 ? (
No matches
) : results.map((r, i) => (
setSel(i)} onClick={() => onPick(r.value)} > {r.value}
))}
↑/↓ select • Enter accept • Esc close
); } // ---------- main component ---------- interface CommandInputProps { onSend: (line: string) => void; // called on Enter storageKey?: string; // localStorage key for history placeholder?: string; // input placeholder className?: string; // container className disabled?: boolean; // disable input (optional) } export function CommandInput({ onSend, placeholder = "Type serial command… (Enter to send • ↑/↓ history • Ctrl+R search)", className, disabled, }: CommandInputProps) { const [cmd, setCmd] = useState(""); const [revOpen, setRevOpen] = useState(false); const [revQuery, setRevQuery] = useState(""); const [sel, setSel] = useState(0); const { push, up, down, resetTraversal, search, deleteHistory } = useCommandHistory(); const results = useMemo(() => search(revQuery), [revQuery, search]); useEffect(() => { setSel(0); }, [results]); const cmdInputRef = React.useRef(null); const handleKeyDown = (e: React.KeyboardEvent) => { const isMeta = e.ctrlKey || e.metaKey; if (e.key === "Enter" && !e.shiftKey && !isMeta) { e.preventDefault(); if (!cmd) return; onSend(cmd); push(cmd); setCmd(""); resetTraversal(); setRevOpen(false); return; } if (e.key === "ArrowUp") { e.preventDefault(); setCmd((prev) => up(prev)); return; } if (e.key === "ArrowDown") { e.preventDefault(); setCmd((prev) => down(prev)); return; } if (isMeta && e.key.toLowerCase() === "r") { e.preventDefault(); setRevOpen(true); setRevQuery(cmd); setSel(0); return; } if (e.key === "Escape" && revOpen) { e.preventDefault(); setRevOpen(false); return; } }; return (
CMD { setCmd(e.target.value); resetTraversal(); }} onKeyDown={handleKeyDown} placeholder={placeholder} className="font-mono" />
{/* Reverse search controls */} {revOpen && (
Search setRevQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setSel((i) => (i + 1) % Math.max(1, results.length)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSel((i) => (i - 1 + results.length) % Math.max(1, results.length)); } else if (e.key === "Enter") { e.preventDefault(); const pick = results[sel]?.value ?? results[0]?.value; if (pick) { setCmd(pick); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); } } else if (e.key === "Escape") { e.preventDefault(); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); } }} placeholder="Type to filter history…" className="font-mono" />
{ setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }} onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}} onDeleteHistory={deleteHistory} />
)}
); }; export default CommandInput;