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

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:05:11 +08:00
1 parent 75dd639cca
commit 42431210c3
4 files changed
+68 -10

No files matched your search

@@ -4,6 +4,8 @@
"root": "World root", "root": "World root",
"up": "Up", "up": "Up",
"refresh": "Refresh", "refresh": "Refresh",
"open_folder": "Open folder {{name}}",
"open_file": "Open file {{name}}",
"col_name": "Name", "col_name": "Name",
"col_size": "Size", "col_size": "Size",
"col_modified": "Modified", "col_modified": "Modified",
@@ -4,6 +4,8 @@
"root": "世界根目录", "root": "世界根目录",
"up": "上级目录", "up": "上级目录",
"refresh": "刷新", "refresh": "刷新",
"open_folder": "打开文件夹 {{name}}",
"open_file": "打开文件 {{name}}",
"col_name": "名称", "col_name": "名称",
"col_size": "大小", "col_size": "大小",
"col_modified": "修改时间", "col_modified": "修改时间",
+55 -8
View File
@@ -7,7 +7,13 @@ import i18next from "i18next";
import { ServerFiles } from "./ServerFiles"; import { ServerFiles } from "./ServerFiles";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks"; 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) => { vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>(); const actual = await importOriginal<typeof import("@/lib/api")>();
@@ -17,13 +23,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
...actual.api, ...actual.api,
status: mocks.status, status: mocks.status,
stop: mocks.stop, stop: mocks.stop,
listServerFiles: () => listServerFiles: mocks.listServerFiles,
Promise.resolve({ readServerFile: mocks.readServerFile,
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" }),
writeServerFile: mocks.writeServerFile, writeServerFile: mocks.writeServerFile,
}, },
}; };
@@ -53,6 +54,21 @@ const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredSt
beforeEach(() => { beforeEach(() => {
mocks.writeServerFile.mockReset(); 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.stop.mockReset();
mocks.status.mockReset(); mocks.status.mockReset();
mocks.status.mockResolvedValue(stopped); mocks.status.mockResolvedValue(stopped);
@@ -180,3 +196,34 @@ describe("ServerFiles on a running server", () => {
expect(mocks.stop).toHaveBeenCalledWith("lobby"); 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
View File
@@ -411,7 +411,14 @@ export function ServerFiles() {
className="cursor-pointer transition-colors hover:bg-muted/30" className="cursor-pointer transition-colors hover:bg-muted/30"
> >
<td className="px-4 py-3"> <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 ? ( {e.is_dir ? (
<Folder className="h-4 w-4 shrink-0 text-primary" /> <Folder className="h-4 w-4 shrink-0 text-primary" />
) : ( ) : (
@@ -421,7 +428,7 @@ export function ServerFiles() {
{opening === joinPath(dir, e.name) && ( {opening === joinPath(dir, e.name) && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" /> <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)} )}
</span> </button>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground"> <td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
{e.is_dir ? "—" : formatBytes(e.size)} {e.is_dir ? "—" : formatBytes(e.size)}