diff --git a/panel/src/i18n/resources/en-US/files.json b/panel/src/i18n/resources/en-US/files.json index 4b80a36..bc7609b 100644 --- a/panel/src/i18n/resources/en-US/files.json +++ b/panel/src/i18n/resources/en-US/files.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/files.json b/panel/src/i18n/resources/zh-CN/files.json index 6b46075..5001da8 100644 --- a/panel/src/i18n/resources/zh-CN/files.json +++ b/panel/src/i18n/resources/zh-CN/files.json @@ -4,6 +4,8 @@ "root": "世界根目录", "up": "上级目录", "refresh": "刷新", + "open_folder": "打开文件夹 {{name}}", + "open_file": "打开文件 {{name}}", "col_name": "名称", "col_size": "大小", "col_modified": "修改时间", diff --git a/panel/src/pages/ServerFiles.test.tsx b/panel/src/pages/ServerFiles.test.tsx index 18791ac..a40a8a8 100644 --- a/panel/src/pages/ServerFiles.test.tsx +++ b/panel/src/pages/ServerFiles.test.tsx @@ -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(); @@ -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"]]); + }); +}); diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx index 8fe919c..e6c6083 100644 --- a/panel/src/pages/ServerFiles.tsx +++ b/panel/src/pages/ServerFiles.tsx @@ -411,7 +411,14 @@ export function ServerFiles() { className="cursor-pointer transition-colors hover:bg-muted/30" > - + {/* 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. */} + {e.is_dir ? "—" : formatBytes(e.size)}