mirror of https://github.com/jetkvm/kvm.git
291 lines
11 KiB
TypeScript
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;
|