Loading panel/src/pages/ServerFiles.test.tsx +63 −0 Changes for panel/src/pages/ServerFiles.test.tsx: 63 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -227,3 +227,66 @@ describe("ServerFiles from the keyboard", () => { expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "server.properties"]]); }); }); describe("ServerFiles folder answers arriving out of order", () => { type Listing = { path: string; truncated: boolean; entries: { name: string; size: number; is_dir: boolean; mod_time: string }[] }; function deferred() { let resolve!: (v: Listing) => void; let reject!: (e: unknown) => void; const promise = new Promise<Listing>((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } const entry = (name: string, is_dir = false) => ({ name, size: 8, is_dir, mod_time: "2026-09-01T00:00:00Z" }); const listing = (path: string, ...names: string[]): Listing => ({ path, truncated: false, entries: names.map((n) => entry(n)) }); let world: ReturnType<typeof deferred>; let plugins: ReturnType<typeof deferred>; beforeEach(() => { world = deferred(); plugins = deferred(); mocks.listServerFiles.mockImplementation((_name: string, path: string) => { if (path === "world") return world.promise; if (path === "plugins") return plugins.promise; return Promise.resolve({ path, truncated: false, entries: [entry("world", true), entry("plugins", true)] }); }); }); const folder = (name: string) => screen.findByRole("button", { name: i18next.t("files:open_folder", { name }) }); const refresh = () => screen.getByRole("button", { name: i18next.t("files:refresh") }) as HTMLButtonElement; it.each([ ["answers", (d: ReturnType<typeof deferred>) => d.resolve(listing("world", "level.dat"))], ["fails", (d: ReturnType<typeof deferred>) => d.reject(new Error("world listing broke"))], ])("keeps the folder clicked last when the one left behind %s late", async (_label, settle) => { renderFiles(); await userEvent.click(await folder("world")); await userEvent.click(await folder("plugins")); await act(async () => plugins.resolve(listing("plugins", "config.yml"))); expect(await screen.findByText("config.yml")).toBeTruthy(); await act(async () => settle(world)); expect(screen.getByText("config.yml")).toBeTruthy(); expect(screen.queryByText("level.dat")).toBeNull(); expect(screen.getByRole("button", { name: "plugins" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "world" })).toBeNull(); expect(refresh().disabled).toBe(false); }); it("keeps loading while the folder clicked last is still on its way", async () => { renderFiles(); await userEvent.click(await folder("world")); await userEvent.click(await folder("plugins")); await act(async () => world.resolve(listing("world", "level.dat"))); expect(screen.queryByText("level.dat")).toBeNull(); expect(refresh().disabled).toBe(true); await act(async () => plugins.resolve(listing("plugins", "config.yml"))); expect(await screen.findByText("config.yml")).toBeTruthy(); expect(refresh().disabled).toBe(false); }); }); 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 import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AlertTriangle, Loading Loading @@ -108,20 +108,27 @@ export function ServerFiles() { const [listLoading, setListLoading] = useState(false); const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null); // Every load takes a ticket and only the newest one lands. The rows and the // breadcrumbs stay clickable while a folder loads, so a slow answer for a // folder already left behind would otherwise replace the one clicked after it. const loadSeq = useRef(0); const load = useCallback( async (p: string) => { const ticket = ++loadSeq.current; setListLoading(true); setListErr(null); try { const r = await api.listServerFiles(name, p); if (ticket !== loadSeq.current) return; setEntries(r.entries ?? []); setTruncated(r.truncated === true); setDir(p); } catch (e) { if (ticket !== loadSeq.current) return; setEntries(null); setListErr(e); } finally { setListLoading(false); if (ticket === loadSeq.current) setListLoading(false); } }, [name], Loading Loading
panel/src/pages/ServerFiles.test.tsx +63 −0 Changes for panel/src/pages/ServerFiles.test.tsx: 63 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -227,3 +227,66 @@ describe("ServerFiles from the keyboard", () => { expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "server.properties"]]); }); }); describe("ServerFiles folder answers arriving out of order", () => { type Listing = { path: string; truncated: boolean; entries: { name: string; size: number; is_dir: boolean; mod_time: string }[] }; function deferred() { let resolve!: (v: Listing) => void; let reject!: (e: unknown) => void; const promise = new Promise<Listing>((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } const entry = (name: string, is_dir = false) => ({ name, size: 8, is_dir, mod_time: "2026-09-01T00:00:00Z" }); const listing = (path: string, ...names: string[]): Listing => ({ path, truncated: false, entries: names.map((n) => entry(n)) }); let world: ReturnType<typeof deferred>; let plugins: ReturnType<typeof deferred>; beforeEach(() => { world = deferred(); plugins = deferred(); mocks.listServerFiles.mockImplementation((_name: string, path: string) => { if (path === "world") return world.promise; if (path === "plugins") return plugins.promise; return Promise.resolve({ path, truncated: false, entries: [entry("world", true), entry("plugins", true)] }); }); }); const folder = (name: string) => screen.findByRole("button", { name: i18next.t("files:open_folder", { name }) }); const refresh = () => screen.getByRole("button", { name: i18next.t("files:refresh") }) as HTMLButtonElement; it.each([ ["answers", (d: ReturnType<typeof deferred>) => d.resolve(listing("world", "level.dat"))], ["fails", (d: ReturnType<typeof deferred>) => d.reject(new Error("world listing broke"))], ])("keeps the folder clicked last when the one left behind %s late", async (_label, settle) => { renderFiles(); await userEvent.click(await folder("world")); await userEvent.click(await folder("plugins")); await act(async () => plugins.resolve(listing("plugins", "config.yml"))); expect(await screen.findByText("config.yml")).toBeTruthy(); await act(async () => settle(world)); expect(screen.getByText("config.yml")).toBeTruthy(); expect(screen.queryByText("level.dat")).toBeNull(); expect(screen.getByRole("button", { name: "plugins" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "world" })).toBeNull(); expect(refresh().disabled).toBe(false); }); it("keeps loading while the folder clicked last is still on its way", async () => { renderFiles(); await userEvent.click(await folder("world")); await userEvent.click(await folder("plugins")); await act(async () => world.resolve(listing("world", "level.dat"))); expect(screen.queryByText("level.dat")).toBeNull(); expect(refresh().disabled).toBe(true); await act(async () => plugins.resolve(listing("plugins", "config.yml"))); expect(await screen.findByText("config.yml")).toBeTruthy(); expect(refresh().disabled).toBe(false); }); });
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 import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AlertTriangle, Loading Loading @@ -108,20 +108,27 @@ export function ServerFiles() { const [listLoading, setListLoading] = useState(false); const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null); // Every load takes a ticket and only the newest one lands. The rows and the // breadcrumbs stay clickable while a folder loads, so a slow answer for a // folder already left behind would otherwise replace the one clicked after it. const loadSeq = useRef(0); const load = useCallback( async (p: string) => { const ticket = ++loadSeq.current; setListLoading(true); setListErr(null); try { const r = await api.listServerFiles(name, p); if (ticket !== loadSeq.current) return; setEntries(r.entries ?? []); setTruncated(r.truncated === true); setDir(p); } catch (e) { if (ticket !== loadSeq.current) return; setEntries(null); setListErr(e); } finally { setListLoading(false); if (ticket === loadSeq.current) setListLoading(false); } }, [name], Loading