Unverified Commit 42431210 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(files): 文件行的名字做成按钮,键盘和读屏都能打开文件和文件夹

parent 75dd639c
Loading
Loading
Loading
Loading
+2 −0
Changes for panel/src/i18n/resources/en-US/files.json: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@
  "root": "World root",
  "up": "Up",
  "refresh": "Refresh",
  "open_folder": "Open folder {{name}}",
  "open_file": "Open file {{name}}",
  "col_name": "Name",
  "col_size": "Size",
  "col_modified": "Modified",
+2 −0
Changes for panel/src/i18n/resources/zh-CN/files.json: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@
  "root": "世界根目录",
  "up": "上级目录",
  "refresh": "刷新",
  "open_folder": "打开文件夹 {{name}}",
  "open_file": "打开文件 {{name}}",
  "col_name": "名称",
  "col_size": "大小",
  "col_modified": "修改时间",
+55 −8
Changes for panel/src/pages/ServerFiles.test.tsx: 55 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -7,7 +7,13 @@ import i18next from "i18next";
import { ServerFiles } from "./ServerFiles";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";

const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn(), stop: vi.fn() }));
const mocks = vi.hoisted(() => ({
  writeServerFile: vi.fn(),
  status: vi.fn(),
  stop: vi.fn(),
  listServerFiles: vi.fn(),
  readServerFile: vi.fn(),
}));

vi.mock("@/lib/api", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/api")>();
@@ -17,13 +23,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
      ...actual.api,
      status: mocks.status,
      stop: mocks.stop,
      listServerFiles: () =>
        Promise.resolve({
          path: "",
          truncated: false,
          entries: [{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }],
        }),
      readServerFile: () => Promise.resolve({ path: "server.properties", content: btoa("motd=hi\n"), sha256: "abc" }),
      listServerFiles: mocks.listServerFiles,
      readServerFile: mocks.readServerFile,
      writeServerFile: mocks.writeServerFile,
    },
  };
@@ -53,6 +54,21 @@ const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredSt

beforeEach(() => {
  mocks.writeServerFile.mockReset();
  mocks.listServerFiles.mockReset();
  mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
    Promise.resolve({
      path,
      truncated: false,
      entries: [
        { name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" },
        { name: "world", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" },
      ],
    }),
  );
  mocks.readServerFile.mockReset();
  mocks.readServerFile.mockImplementation((_name: string, path: string) =>
    Promise.resolve({ path, content: btoa("motd=hi\n"), sha256: "abc" }),
  );
  mocks.stop.mockReset();
  mocks.status.mockReset();
  mocks.status.mockResolvedValue(stopped);
@@ -180,3 +196,34 @@ describe("ServerFiles on a running server", () => {
    expect(mocks.stop).toHaveBeenCalledWith("lobby");
  });
});

describe("ServerFiles from the keyboard", () => {
  it("opens a folder and then a file without a mouse", async () => {
    renderFiles();
    const folder = await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) });
    folder.focus();
    expect(document.activeElement).toBe(folder);

    await userEvent.keyboard(" ");
    expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");

    const file = await screen.findByRole("button", { name: i18next.t("files:open_file", { name: "server.properties" }) });
    file.focus();
    expect(document.activeElement).toBe(file);
    await userEvent.keyboard("{Enter}");

    expect(await screen.findByRole("dialog")).toBeTruthy();
    // One press reads the file once: the button's click and the row's are one handler.
    expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "world/server.properties"]]);
  });

  it("still opens from a click anywhere on the row", async () => {
    renderFiles();
    const size = await screen.findByText("8 B");

    await userEvent.click(size);

    expect(await screen.findByRole("dialog")).toBeTruthy();
    expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "server.properties"]]);
  });
});
+9 −2
Changes for panel/src/pages/ServerFiles.tsx: 9 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -411,7 +411,14 @@ export function ServerFiles() {
                            className="cursor-pointer transition-colors hover:bg-muted/30"
                          >
                            <td className="px-4 py-3">
                              <span className="flex items-center gap-2 min-w-0">
                              {/* The name is a button so the keyboard and a screen
                                  reader reach every row; its click bubbles to the
                                  row, which opens it from anywhere on the row. */}
                              <button
                                type="button"
                                aria-label={t(e.is_dir ? "open_folder" : "open_file", { name: e.name })}
                                className="flex min-w-0 max-w-full items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                              >
                                {e.is_dir ? (
                                  <Folder className="h-4 w-4 shrink-0 text-primary" />
                                ) : (
@@ -421,7 +428,7 @@ export function ServerFiles() {
                                {opening === joinPath(dir, e.name) && (
                                  <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
                                )}
                              </span>
                              </button>
                            </td>
                            <td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
                              {e.is_dir ? "—" : formatBytes(e.size)}