Loading panel/src/i18n/resources/en-US/files.json +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 Loading @@ -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", Loading panel/src/i18n/resources/zh-CN/files.json +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 Loading @@ -4,6 +4,8 @@ "root": "世界根目录", "up": "上级目录", "refresh": "刷新", "open_folder": "打开文件夹 {{name}}", "open_file": "打开文件 {{name}}", "col_name": "名称", "col_size": "大小", "col_modified": "修改时间", Loading panel/src/pages/ServerFiles.test.tsx +55 −8 Changes for panel/src/pages/ServerFiles.test.tsx: 55 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -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")>(); Loading @@ -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, }, }; Loading Loading @@ -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); Loading Loading @@ -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"]]); }); }); panel/src/pages/ServerFiles.tsx +9 −2 Changes for panel/src/pages/ServerFiles.tsx: 9 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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" /> ) : ( Loading @@ -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)} Loading Loading
panel/src/i18n/resources/en-US/files.json +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 Loading @@ -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", Loading
panel/src/i18n/resources/zh-CN/files.json +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 Loading @@ -4,6 +4,8 @@ "root": "世界根目录", "up": "上级目录", "refresh": "刷新", "open_folder": "打开文件夹 {{name}}", "open_file": "打开文件 {{name}}", "col_name": "名称", "col_size": "大小", "col_modified": "修改时间", Loading
panel/src/pages/ServerFiles.test.tsx +55 −8 Changes for panel/src/pages/ServerFiles.test.tsx: 55 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -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")>(); Loading @@ -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, }, }; Loading Loading @@ -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); Loading Loading @@ -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"]]); }); });
panel/src/pages/ServerFiles.tsx +9 −2 Changes for panel/src/pages/ServerFiles.tsx: 9 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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" /> ) : ( Loading @@ -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)} Loading