feat(rcon): retain terminal transcript
This commit is contained in:
@@ -102,6 +102,26 @@ describe("RconConsole", () => {
|
||||
expect(input.value).toBe("draft command");
|
||||
});
|
||||
|
||||
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
let listResponses = 0;
|
||||
for (const command of ["list", "say hello", "list"]) {
|
||||
fireEvent.change(input, { target: { value: command } });
|
||||
fireEvent.submit(input.form!);
|
||||
if (command === "list") listResponses += 1;
|
||||
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||
}
|
||||
|
||||
const transcript = screen.getByLabelText("Terminal transcript");
|
||||
const text = transcript.textContent ?? "";
|
||||
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns focus to the command prompt after changing servers", async () => {
|
||||
render(<RconConsole servers={[server, creative]} />);
|
||||
const select = screen.getByLabelText("Server");
|
||||
|
||||
@@ -14,6 +14,15 @@ import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
const MAX_COMMAND_HISTORY = 50;
|
||||
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||
|
||||
type TranscriptExchange = {
|
||||
id: number;
|
||||
serverName: string;
|
||||
command: string;
|
||||
status: "pending" | "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
@@ -40,11 +49,13 @@ export function RconConsole({
|
||||
const [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const nextExchangeIdRef = useRef(0);
|
||||
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
const responseServer = servers.find((server) => server.id === state.serverId);
|
||||
const output = terminalOutput({ notice, pending, responseServer, selected, state });
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -54,6 +65,20 @@ export function RconConsole({
|
||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||
}, [pending, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const exchangeId = pendingExchangeIdRef.current;
|
||||
if (exchangeId === null || state.status === "idle") return;
|
||||
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||
? { ...exchange, status: resultStatus, message: state.message }
|
||||
: exchange));
|
||||
pendingExchangeIdRef.current = null;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
function navigateHistory(direction: "older" | "newer") {
|
||||
if (!history.length) return;
|
||||
if (direction === "older") {
|
||||
@@ -76,7 +101,17 @@ export function RconConsole({
|
||||
|
||||
function rememberSubmittedCommand() {
|
||||
const submitted = command.trim();
|
||||
if (!submitted) return;
|
||||
if (!submitted || !selected) return;
|
||||
const exchangeId = ++nextExchangeIdRef.current;
|
||||
pendingExchangeIdRef.current = exchangeId;
|
||||
const exchange: TranscriptExchange = {
|
||||
id: exchangeId,
|
||||
serverName: selected.name,
|
||||
command: submitted,
|
||||
status: "pending",
|
||||
message: "Command in progress…",
|
||||
};
|
||||
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||
setHistoryIndex(null);
|
||||
draftRef.current = "";
|
||||
@@ -140,9 +175,27 @@ export function RconConsole({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" role={output.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${output.status === "error" ? "text-accent" : "text-muted"}`}>{output.label}</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
|
||||
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||
{notice && (
|
||||
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||
<p className="mt-2">{notice.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||
<div className="space-y-6">
|
||||
{transcript.map((exchange) => (
|
||||
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||
<p className="break-words">
|
||||
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||
<span className="text-accent">$</span> {exchange.command}
|
||||
</p>
|
||||
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
||||
@@ -177,29 +230,10 @@ export function RconConsole({
|
||||
);
|
||||
}
|
||||
|
||||
function terminalOutput({
|
||||
notice,
|
||||
pending,
|
||||
responseServer,
|
||||
selected,
|
||||
state,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
pending: boolean;
|
||||
responseServer?: RconServerOption;
|
||||
selected?: RconServerOption;
|
||||
state: RconCommandState;
|
||||
}) {
|
||||
if (pending) return { status: "success" as const, label: "Executing", message: "Command in progress…" };
|
||||
if (state.status !== "idle") return {
|
||||
status: state.status,
|
||||
label: `${state.status === "error" ? "Error" : "Response"}${responseServer ? ` — ${responseServer.name}` : ""}`,
|
||||
message: state.message,
|
||||
};
|
||||
if (notice) return { ...notice, label: notice.status === "error" ? "Connection error" : "Connection update" };
|
||||
if (!selected) return { status: "success" as const, label: "Ready", message: "No connections configured. Use Add to create a server connection." };
|
||||
if (!selected.enabled) return { status: "error" as const, label: `Disabled — ${selected.name}`, message: "Enable this connection before testing commands." };
|
||||
return { status: "success" as const, label: `Ready — ${selected.name}`, message: "Awaiting command" };
|
||||
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
|
||||
Reference in New Issue
Block a user