fix(panel): 建服表单按后端规则即时校验名称和子域名,控制台命令历史的下键不再跳到最早一条,补请求体测试
This commit is contained in:
8 files changed
+405
-20
No files matched your search
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CreateServerDialog } from "./CreateServerDialog";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import type { CreateServerRequest, WhitelistImage } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
// Radix Select opens with pointer capture and scrolls the picked item into view,
|
||||
// neither of which jsdom implements.
|
||||
beforeAll(() => {
|
||||
Element.prototype.hasPointerCapture ??= () => false;
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
const PAPER = "registry.example.test/paper:1.21";
|
||||
const RETIRED = "registry.example.test/paper:1.20";
|
||||
const cfg = { apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 };
|
||||
const onCreated = vi.fn();
|
||||
|
||||
function image(image_ref: string, enabled: boolean): WhitelistImage {
|
||||
return { image_ref, enabled } as WhitelistImage;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.listImages.mockReset();
|
||||
calls.createServer.mockReset();
|
||||
onCreated.mockReset();
|
||||
calls.listImages.mockResolvedValue([image(PAPER, true), image(RETIRED, false)]);
|
||||
calls.createServer.mockResolvedValue({});
|
||||
});
|
||||
|
||||
async function openDialog() {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateServerDialog cfg={cfg} onCreated={onCreated} />);
|
||||
await user.click(screen.getByRole("button", { name: "New server" }));
|
||||
await screen.findByRole("dialog");
|
||||
return user;
|
||||
}
|
||||
|
||||
const nameBox = () => screen.getByLabelText("Name") as HTMLInputElement;
|
||||
const subdomainBox = () => screen.getByLabelText("Subdomain") as HTMLInputElement;
|
||||
const create = () => screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||
|
||||
async function pick(user: ReturnType<typeof userEvent.setup>, field: string, option: string) {
|
||||
await user.click(screen.getByRole("combobox", { name: field }));
|
||||
await user.click(await screen.findByRole("option", { name: option }));
|
||||
}
|
||||
|
||||
/** fillValid fills every required field with what the API takes. */
|
||||
async function fillValid(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(nameBox(), "survival");
|
||||
await user.type(subdomainBox(), "survival");
|
||||
await pick(user, "Image", PAPER);
|
||||
}
|
||||
|
||||
function sent(): CreateServerRequest {
|
||||
expect(calls.createServer).toHaveBeenCalledTimes(1);
|
||||
return calls.createServer.mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe("CreateServerDialog request body", () => {
|
||||
it("sends every field as chosen, lowercased and trimmed", async () => {
|
||||
const user = await openDialog();
|
||||
|
||||
await user.type(nameBox(), "Survival-2");
|
||||
await user.type(subdomainBox(), "SMP");
|
||||
await user.type(screen.getByLabelText("Display name (optional)"), " Survival World ");
|
||||
await pick(user, "Image", PAPER);
|
||||
await pick(user, "Memory", "8Gi");
|
||||
await pick(user, "Storage", "20Gi");
|
||||
await pick(user, "Autostart policy", "Public — any player join wakes it");
|
||||
await user.click(create());
|
||||
|
||||
expect(sent()).toStrictEqual({
|
||||
name: "survival-2",
|
||||
subdomain: "smp",
|
||||
displayName: "Survival World",
|
||||
image: PAPER,
|
||||
memory: "8Gi",
|
||||
storage: "20Gi",
|
||||
autostartPolicy: "public",
|
||||
});
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a blank display name out, and sends the default sizes and policy", async () => {
|
||||
const user = await openDialog();
|
||||
|
||||
await fillValid(user);
|
||||
await user.type(screen.getByLabelText("Display name (optional)"), " ");
|
||||
await user.click(create());
|
||||
|
||||
expect(sent()).toStrictEqual({
|
||||
name: "survival",
|
||||
subdomain: "survival",
|
||||
displayName: undefined,
|
||||
image: PAPER,
|
||||
memory: "4Gi",
|
||||
storage: "10Gi",
|
||||
autostartPolicy: "ownerOnly",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers only the enabled images", async () => {
|
||||
const user = await openDialog();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Image" }));
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([PAPER]);
|
||||
});
|
||||
|
||||
it("keeps the form and says why when the API refuses, then creates on the next try", async () => {
|
||||
const refusal = { status: 409, code: "conflict", message: "server survival already exists" };
|
||||
calls.createServer.mockRejectedValueOnce(refusal);
|
||||
const user = await openDialog();
|
||||
|
||||
await fillValid(user);
|
||||
await user.click(create());
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText(humanizeError(refusal))).toBeTruthy();
|
||||
expect(nameBox().value).toBe("survival");
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(create());
|
||||
expect(calls.createServer).toHaveBeenCalledTimes(2);
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateServerDialog names", () => {
|
||||
it.each([
|
||||
["ab", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
|
||||
["-survival", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
|
||||
["survival-", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
|
||||
["my_world", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
|
||||
["a".repeat(33), "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
|
||||
["lobby", "“lobby” is kept for the platform. Pick another."],
|
||||
])("refuses the name %j before sending, and says why", async (name, why) => {
|
||||
const user = await openDialog();
|
||||
await fillValid(user);
|
||||
expect(create().disabled).toBe(false);
|
||||
|
||||
await user.clear(nameBox());
|
||||
await user.type(nameBox(), name);
|
||||
|
||||
expect(create().disabled).toBe(true);
|
||||
expect(nameBox().getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByText(why)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("holds the subdomain to the same rule", async () => {
|
||||
const user = await openDialog();
|
||||
await fillValid(user);
|
||||
|
||||
await user.clear(subdomainBox());
|
||||
await user.type(subdomainBox(), "api");
|
||||
|
||||
expect(create().disabled).toBe(true);
|
||||
expect(screen.getByText("“api” is kept for the platform. Pick another.")).toBeTruthy();
|
||||
expect(screen.queryByText(/Will be reachable at/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows where a good subdomain will be reachable", async () => {
|
||||
const user = await openDialog();
|
||||
|
||||
await user.type(subdomainBox(), "smp");
|
||||
|
||||
expect(screen.getByText(/Will be reachable at smp\.example\.test/)).toBeTruthy();
|
||||
expect(subdomainBox().getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
||||
import { serverNameIssue, type ServerNameIssue } from "@/lib/naming";
|
||||
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
|
||||
@@ -35,8 +36,6 @@ import { InlineError } from "@/components/MessageLine";
|
||||
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
||||
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
|
||||
|
||||
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
||||
|
||||
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||
return [
|
||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
||||
@@ -72,10 +71,11 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
}
|
||||
|
||||
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
|
||||
const subdomainValid = SUBDOMAIN_RE.test(form.subdomain);
|
||||
const nameIssue = serverNameIssue(form.name);
|
||||
const subdomainIssue = serverNameIssue(form.subdomain);
|
||||
const canSubmit =
|
||||
!!form.name &&
|
||||
subdomainValid &&
|
||||
nameIssue === null &&
|
||||
subdomainIssue === null &&
|
||||
!!form.image &&
|
||||
!!form.memory &&
|
||||
!!form.storage &&
|
||||
@@ -102,6 +102,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
|
||||
const policies = policyOptions(t);
|
||||
|
||||
// A field's hint turns into the reason it would be refused once something is typed.
|
||||
const issueText = (issue: ServerNameIssue, value: string) =>
|
||||
issue === "reserved" ? t("create_server_name_reserved", { name: value }) : t("create_server_name_invalid");
|
||||
const hintClass = (value: string, issue: ServerNameIssue | null) =>
|
||||
value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -125,8 +131,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
id="cs-name"
|
||||
placeholder={t("create_server_name_placeholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
onChange={(e) => set("name", e.target.value.toLowerCase())}
|
||||
aria-invalid={!!form.name && nameIssue !== null}
|
||||
aria-describedby="cs-name-hint"
|
||||
/>
|
||||
<p id="cs-name-hint" className={hintClass(form.name, nameIssue)}>
|
||||
{form.name && nameIssue
|
||||
? issueText(nameIssue, form.name)
|
||||
: t("create_server_name_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -136,10 +149,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
placeholder={t("create_server_subdomain_placeholder")}
|
||||
value={form.subdomain}
|
||||
onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
|
||||
aria-invalid={!!form.subdomain && subdomainIssue !== null}
|
||||
aria-describedby="cs-sub-hint"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.subdomain && !subdomainValid
|
||||
? t("create_server_subdomain_invalid")
|
||||
<p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}>
|
||||
{form.subdomain && subdomainIssue
|
||||
? issueText(subdomainIssue, form.subdomain)
|
||||
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"console_offline_title": "Console is offline",
|
||||
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
|
||||
"my_servers_breadcrumb": "My servers",
|
||||
"command_label": "Server command",
|
||||
"command_placeholder": "Type a command… e.g. list",
|
||||
"log_connecting": "Connecting…",
|
||||
"log_live": "Live",
|
||||
@@ -132,9 +133,11 @@
|
||||
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
|
||||
"create_server_name": "Name",
|
||||
"create_server_name_placeholder": "survival",
|
||||
"create_server_name_hint": "3–32 lowercase letters, digits or hyphens.",
|
||||
"create_server_name_invalid": "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end.",
|
||||
"create_server_name_reserved": "“{{name}}” is kept for the platform. Pick another.",
|
||||
"create_server_subdomain": "Subdomain",
|
||||
"create_server_subdomain_placeholder": "survival",
|
||||
"create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.",
|
||||
"create_server_subdomain_hint": "Will be reachable at {{host}}",
|
||||
"create_server_display_name": "Display name (optional)",
|
||||
"create_server_display_name_placeholder": "Survival World",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"console_offline_title": "控制台未连接",
|
||||
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
|
||||
"my_servers_breadcrumb": "我的服务器",
|
||||
"command_label": "服务器命令",
|
||||
"command_placeholder": "输入命令…如 list",
|
||||
"log_connecting": "连接中…",
|
||||
"log_live": "实时",
|
||||
@@ -132,9 +133,11 @@
|
||||
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
|
||||
"create_server_name": "名称",
|
||||
"create_server_name_placeholder": "survival",
|
||||
"create_server_name_hint": "3–32 位小写字母、数字或连字符。",
|
||||
"create_server_name_invalid": "请用 3–32 位小写字母、数字或连字符,首尾不能是连字符。",
|
||||
"create_server_name_reserved": "“{{name}}” 是平台保留的名字,请换一个。",
|
||||
"create_server_subdomain": "子域名",
|
||||
"create_server_subdomain_placeholder": "survival",
|
||||
"create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。",
|
||||
"create_server_subdomain_hint": "访问地址:{{host}}",
|
||||
"create_server_display_name": "显示名称(选填)",
|
||||
"create_server_display_name_placeholder": "生存世界",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// The server name rule (spec §2, §22) as the API enforces it in internal/naming:
|
||||
// 3–32 lowercase letters, digits or hyphens, no hyphen at either end, and none
|
||||
// of the names the platform keeps for itself. The same rule covers subdomains.
|
||||
// The create form checks it as the person types, so a name the API would
|
||||
// answer with 400 never reaches it.
|
||||
|
||||
// RESERVED_SERVER_NAMES mirrors `reserved` in internal/naming/naming.go.
|
||||
export const RESERVED_SERVER_NAMES: readonly string[] = [
|
||||
"login",
|
||||
"lobby",
|
||||
"admin",
|
||||
"panel",
|
||||
"console",
|
||||
"api",
|
||||
"felis",
|
||||
"velocity",
|
||||
"registry",
|
||||
"internal",
|
||||
"www",
|
||||
];
|
||||
|
||||
const SERVER_NAME_RE = /^[a-z0-9-]{3,32}$/;
|
||||
|
||||
export type ServerNameIssue = "shape" | "reserved";
|
||||
|
||||
/** serverNameIssue says why the API would refuse this name, or null if it takes it. */
|
||||
export function serverNameIssue(name: string): ServerNameIssue | null {
|
||||
if (!SERVER_NAME_RE.test(name) || name.startsWith("-") || name.endsWith("-")) return "shape";
|
||||
if (RESERVED_SERVER_NAMES.includes(name)) return "reserved";
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServerConsole } from "./ServerConsole";
|
||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
|
||||
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
|
||||
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn(), sendCommand: vi.fn() }));
|
||||
const tier = vi.hoisted(() => ({
|
||||
loading: false,
|
||||
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
||||
@@ -35,6 +36,7 @@ beforeEach(() => {
|
||||
calls.myServers.mockResolvedValue([]);
|
||||
calls.listImages.mockReset();
|
||||
calls.listImages.mockResolvedValue([]);
|
||||
calls.sendCommand.mockReset();
|
||||
});
|
||||
|
||||
function renderConsole() {
|
||||
@@ -212,3 +214,103 @@ describe("ServerConsole retirement", () => {
|
||||
expect(giveUp()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerConsole command line", () => {
|
||||
const HISTORY = "felis:cmd:history:survival";
|
||||
|
||||
beforeEach(() => {
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
|
||||
calls.sendCommand.mockResolvedValue({ output: "There are 0 of a max of 20 players online.\n" });
|
||||
});
|
||||
|
||||
async function commandLine() {
|
||||
renderConsole();
|
||||
return (await screen.findByRole("textbox", { name: "Server command" })) as HTMLInputElement;
|
||||
}
|
||||
|
||||
it("sends the typed command, trimmed, and shows the reply under it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
await user.type(box, " list {Enter}");
|
||||
|
||||
expect(calls.sendCommand).toHaveBeenCalledExactlyOnceWith("survival", "list");
|
||||
expect(await screen.findByText("There are 0 of a max of 20 players online.")).toBeTruthy();
|
||||
expect(screen.getByText(/^> list/)).toBeTruthy();
|
||||
expect(box.value).toBe("");
|
||||
});
|
||||
|
||||
it("sends nothing for a blank line", async () => {
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
await user.type(box, " {Enter}");
|
||||
|
||||
expect(calls.sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says why a command failed", async () => {
|
||||
const refusal = { status: 503, code: "rcon_unavailable", message: "rcon: connection refused" };
|
||||
calls.sendCommand.mockRejectedValue(refusal);
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
await user.type(box, "list{Enter}");
|
||||
|
||||
expect(await screen.findByText(humanizeError(refusal))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sends nothing on the Enter that picks an input-method candidate", async () => {
|
||||
const box = await commandLine();
|
||||
|
||||
fireEvent.compositionStart(box);
|
||||
fireEvent.change(box, { target: { value: "你好" } });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(calls.sendCommand).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.compositionEnd(box);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(calls.sendCommand).toHaveBeenCalledExactlyOnceWith("survival", "你好"));
|
||||
});
|
||||
|
||||
it("walks the history with Up and Down, and brings back the line being typed", async () => {
|
||||
localStorage.setItem(HISTORY, JSON.stringify(["say hi", "list"]));
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
await user.type(box, "tim");
|
||||
const seen: string[] = [];
|
||||
for (const key of ["{ArrowUp}", "{ArrowUp}", "{ArrowUp}", "{ArrowDown}", "{ArrowDown}", "{ArrowDown}"]) {
|
||||
await user.keyboard(key);
|
||||
seen.push(box.value);
|
||||
}
|
||||
|
||||
expect(seen).toEqual(["list", "say hi", "say hi", "list", "tim", "tim"]);
|
||||
});
|
||||
|
||||
it("leaves a fresh line alone on Down", async () => {
|
||||
localStorage.setItem(HISTORY, JSON.stringify(["say hi", "list"]));
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
expect(box.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps each server's last 50 commands across visits, without repeats in a row", async () => {
|
||||
localStorage.setItem(HISTORY, JSON.stringify(Array.from({ length: 50 }, (_, i) => `cmd ${i}`)));
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
await user.type(box, "list{Enter}");
|
||||
await waitFor(() => expect(calls.sendCommand).toHaveBeenCalledTimes(1));
|
||||
await user.type(box, "list{Enter}");
|
||||
await waitFor(() => expect(calls.sendCommand).toHaveBeenCalledTimes(2));
|
||||
|
||||
const kept = JSON.parse(localStorage.getItem(HISTORY) ?? "[]") as string[];
|
||||
expect(kept).toHaveLength(50);
|
||||
expect(kept[0]).toBe("cmd 1");
|
||||
expect(kept.slice(-2)).toEqual(["cmd 49", "list"]);
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,8 @@ function CommandInput({ name }: { name: string }) {
|
||||
const [last, setLast] = useState<{ cmd: string; out: string } | null>(null);
|
||||
const historyRef = useRef<string[]>(loadHistory(name));
|
||||
const cursorRef = useRef(-1);
|
||||
// What was typed before stepping up into the history.
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
|
||||
@@ -172,21 +174,25 @@ function CommandInput({ name }: { name: string }) {
|
||||
return;
|
||||
}
|
||||
if (h.length === 0) return;
|
||||
// The cursor is -1 or h.length on the line being typed, else on a history entry.
|
||||
const onLine = cursorRef.current === -1 || cursorRef.current >= h.length;
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const idx = Math.max(0, cursorRef.current === -1 ? h.length - 1 : cursorRef.current - 1);
|
||||
if (onLine) draftRef.current = command;
|
||||
const idx = Math.max(0, onLine ? h.length - 1 : cursorRef.current - 1);
|
||||
cursorRef.current = idx;
|
||||
setCommand(h[idx]);
|
||||
setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
// Down walks back toward the line being typed, and brings it back as it was;
|
||||
// on that line there is nothing below.
|
||||
if (e.key === "ArrowDown" && !onLine) {
|
||||
e.preventDefault();
|
||||
const idx = Math.min(h.length, cursorRef.current + 1);
|
||||
const idx = cursorRef.current + 1;
|
||||
cursorRef.current = idx;
|
||||
setCommand(idx < h.length ? h[idx] : "");
|
||||
if (idx < h.length) {
|
||||
setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0);
|
||||
}
|
||||
const next = idx < h.length ? h[idx] : draftRef.current;
|
||||
setCommand(next);
|
||||
setTimeout(() => { inputRef.current?.setSelectionRange(next.length, next.length); }, 0);
|
||||
}
|
||||
}, [command, send]);
|
||||
|
||||
@@ -207,6 +213,7 @@ function CommandInput({ name }: { name: string }) {
|
||||
onKeyDown={onKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true; }}
|
||||
onCompositionEnd={() => { composingRef.current = false; }}
|
||||
aria-label={t("command_label")}
|
||||
placeholder={t("command_placeholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
|
||||
Reference in new issue
Block a user