diff --git a/panel/src/pages/ServerFiles.test.tsx b/panel/src/pages/ServerFiles.test.tsx index a40a8a8..c0a5bf0 100644 --- a/panel/src/pages/ServerFiles.test.tsx +++ b/panel/src/pages/ServerFiles.test.tsx @@ -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((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; + let plugins: ReturnType; + 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) => d.resolve(listing("world", "level.dat"))], + ["fails", (d: ReturnType) => 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); + }); +}); diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx index e6c6083..b1d22f5 100644 --- a/panel/src/pages/ServerFiles.tsx +++ b/panel/src/pages/ServerFiles.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AlertTriangle, @@ -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],