kvm/ui/src/components/CommandInput.tsx

291 lines
11 KiB
TypeScript

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<string[]>([]);
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<number>(-1); // -1 = fresh line
const [anchorPrefix, setAnchorPrefix] = useState<string | null>(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 };
}
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,
}: {
open: boolean;
results: Hit[];
sel: number;
setSel: (i: number) => void;
onPick: (val: string) => void;
onClose: () => void;
}) {
const listRef = React.useRef<HTMLDivElement>(null);
// keep selected item in view when sel changes
useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.querySelector<HTMLDivElement>(`[data-idx="${sel}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [sel, results]);
if (!open) return null;
return (
<Portal>
<div
className="absolute bottom-12 left-0 right-0 ml-17 mr-8 mb-5 rounded-md border border-slate-600 bg-slate-900/95 p-2 shadow-lg"
role="listbox"
aria-activedescendant={`rev-opt-${sel}`}
>
<div ref={listRef} className="max-h-48 overflow-auto">
{results.length === 0 ? (
<div className="px-2 py-1 text-sm text-slate-400">No matches</div>
) : results.map((r, i) => (
<div
id={`rev-opt-${i}`}
data-idx={i}
key={`${r.index}-${i}`}
role="option"
aria-selected={i === sel}
className={clsx(
"px-2 py-1 font-mono text-sm cursor-pointer",
i === sel ? "bg-slate-700 text-white rounded" : "text-slate-200",
)}
onMouseEnter={() => setSel(i)}
onClick={() => onPick(r.value)}
>
{r.value}
</div>
))}
</div>
<div className="mt-1 flex justify-between text-s text-slate-400">
<span>/ select Enter accept Esc close</span>
<button className="underline" onClick={onClose}>Close</button>
</div>
</div>
</Portal>
);
}
// ---------- 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 } = useCommandHistory();
const results = useMemo(() => search(revQuery), [revQuery, search]);
useEffect(() => { setSel(0); }, [results]);
const cmdInputRef = React.useRef<HTMLInputElement>(null);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<div className={clsx("relative", className)}>
<div className="flex items-center gap-2" style={{visibility: revOpen ? "hidden" : "unset"} }>
<span className="text-xs text-slate-400 select-none">CMD</span>
<InputField
ref={cmdInputRef}
size="MD"
disabled={disabled}
value={cmd}
onChange={(e) => { setCmd(e.target.value); resetTraversal(); }}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="font-mono"
/>
</div>
{/* Reverse search controls */}
{revOpen && (
<div className="mt-[-40px]">
<div className="flex items-center gap-2 bg-[#0f172a]">
<span className="text-s text-slate-400 select-none">Search</span>
<InputField
size="MD"
autoFocus
value={revQuery}
onChange={(e) => 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"
/>
</div>
<ReverseSearch
open={revOpen}
results={results}
sel={sel}
setSel={setSel}
onPick={(v) => { setCmd(v); setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus()); }}
onClose={() => {setRevOpen(false); requestAnimationFrame(() => cmdInputRef.current?.focus());}}
/>
</div>
)}
</div>
);
};
export default CommandInput;