feat(rcon): add command history navigation
CI / validate (push) Successful in 6m26s
Release / release (push) Successful in 8m14s

This commit is contained in:
dmg
2026-08-08 10:07:05 -04:00
parent 19486150c3
commit d45ea4db68
5 changed files with 117 additions and 9 deletions
+55 -2
View File
@@ -1,10 +1,21 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
status: "success" as const,
message: `Executed ${String(formData.get("command") ?? "")}`,
serverId: String(formData.get("serverId") ?? ""),
})),
}));
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
createRconServer: vi.fn(),
deleteRconServer: vi.fn(),
executeRconCommand: vi.fn(),
executeRconCommand: actionMocks.execute,
setRconServerEnabled: vi.fn(),
testSavedRconServer: vi.fn(),
updateRconServer: vi.fn(),
@@ -12,6 +23,8 @@ vi.mock("@/app/admin/(console)/rcon/actions", () => ({
import { RconConsole } from "./rcon-console";
afterEach(() => cleanup());
const server = {
id: "11111111-1111-4111-8111-111111111111",
name: "Season 4",
@@ -20,6 +33,13 @@ const server = {
enabled: true,
};
const creative = {
...server,
id: "22222222-2222-4222-8222-222222222222",
name: "Creative",
host: "creative.example.com",
};
describe("RconConsole", () => {
it("renders one wide terminal workspace with connection controls and modal forms", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
@@ -56,4 +76,37 @@ describe("RconConsole", () => {
expect(markup).not.toContain("Edit");
expect(markup).not.toContain("Delete");
});
it("navigates page-memory command history and restores the unsent draft", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
expect(document.activeElement).toBe(input);
expect(input.value).toBe("");
fireEvent.change(input, { target: { value: "say hello" } });
fireEvent.submit(input.form!);
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
fireEvent.change(input, { target: { value: "draft command" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("say hello");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("say hello");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("draft command");
});
it("returns focus to the command prompt after changing servers", async () => {
render(<RconConsole servers={[server, creative]} />);
const select = screen.getByLabelText("Server");
select.focus();
fireEvent.change(select, { target: { value: creative.id } });
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
});
});
+54 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useActionState, useState } from "react";
import { useActionState, useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
@@ -13,6 +13,7 @@ import {
import { AdminModalForm } from "@/components/admin-modal-form";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
export type RconServerOption = {
id: string;
@@ -36,10 +37,52 @@ export function RconConsole({
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const draftRef = useRef("");
const inputRef = useRef<HTMLInputElement>(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();
}, [selectedId]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
function navigateHistory(direction: "older" | "newer") {
if (!history.length) return;
if (direction === "older") {
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
if (historyIndex === null) draftRef.current = command;
setHistoryIndex(nextIndex);
setCommand(history[nextIndex]!);
return;
}
if (historyIndex === null) return;
if (historyIndex < history.length - 1) {
const nextIndex = historyIndex + 1;
setHistoryIndex(nextIndex);
setCommand(history[nextIndex]!);
} else {
setHistoryIndex(null);
setCommand(draftRef.current);
}
}
function rememberSubmittedCommand() {
const submitted = command.trim();
if (!submitted) return;
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
setHistoryIndex(null);
draftRef.current = "";
setCommand("");
}
return (
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
@@ -102,7 +145,7 @@ export function RconConsole({
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
</div>
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3">
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
@@ -115,9 +158,18 @@ export function RconConsole({
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
}
}}
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
ref={inputRef}
required
spellCheck={false}
value={command}
/>
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
</form>