fix(files): review round 1, the page — honest states, the keyboard, the tray's words

- A library past the list's cap is searched and sorted on the server; the count says "the newest".
- Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope).
- A file dropped where the page takes none never reaches the browser (which would unload the app).
- The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line;
  a text shows its first 5000 lines and says where the rest is.
- Confirms name the file (no more "your downloads"); the asking menu rows end in "…".
- The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly
  while only paused work waits, and forgets a finished file that was deleted.
- An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top;
  the link box never replaces what was typed.
- Honest states: folders that failed to load, a file no longer there, the storage figure; the rail
  says "nothing downloaded" only when there is nothing.
- Focus after deleting the open folder and when the format form opens; the format name's label.
- ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer.
- Copy fixes, stale comments and the speculative hub key; a contract test for the list's
  parameters; e2e: the Files page and the paste intake.
This commit is contained in:
2026-10-13 00:14:09 +02:00
parent 815083bcc4
commit 8b2260c9a8
41 changed files with 920 additions and 218 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ export interface DownloadJob {
// Whose it is — only the admin Storage tab's listing of every user's downloads carries them.
user_id?: number;
user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the listings carry it (the
// Files library's and the queue's) — a job a mutation returns does not.
position_seconds?: number;
}
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { FILE_SORTS, FILES_LIST_CAP, listQuery } from "./files";
// The Files list's request, held to the route that answers it (backend routes/files.py `list_files`).
// FastAPI ignores a query parameter it does not know, so a name renamed on one side only is no error
// anywhere: a folder's view lists every file, "Shared with me" lists the reader's own files, a search
// matches everything. The route's parameter names are read from its source, as text — the backend
// cannot be imported here.
const routes = readFileSync(resolve(__dirname, "../../../../backend/app/routes/files.py"), "utf-8");
function routeParams(): Map<string, string> {
const sig = routes.match(/^def list_files\(([\s\S]*?)\) -> /m);
expect(sig, "routes/files.py no longer defines list_files").toBeTruthy();
const params = new Map<string, string>();
for (const m of sig![1]!.matchAll(/^\s*(\w+): ([^\n]*?),?$/gm)) params.set(m[1]!, m[2]!);
return params;
}
const sent = (p: Parameters<typeof listQuery>[0]) => [
...new URLSearchParams(listQuery(p).replace(/^\?/, "")).keys(),
];
describe("the Files list's request", () => {
it("names only parameters the route reads", () => {
const known = routeParams();
const names = new Set([
...sent({ folderId: 3, q: "x", sort: "name", order: "asc" }),
...sent({ shared: true }),
]);
expect([...names].sort()).toEqual(["folder_id", "order", "q", "scope", "sort"]);
for (const n of names) expect(known.has(n), `list_files has no parameter "${n}"`).toBe(true);
});
it("asks for the shared list and the sorts the route knows by its own words", () => {
const known = routeParams();
expect(listQuery({ shared: true })).toBe("?scope=shared");
expect(known.get("scope")).toContain('"shared"');
for (const s of FILE_SORTS) expect(known.get("sort")).toContain(`"${s}"`);
});
it("knows the cap the route lists up to", () => {
// A list as long as the cap may be missing older files; the page then searches on the server.
const cap = routes.match(/^MAX_LIST = (\d+)$/m);
expect(cap, "routes/files.py no longer defines MAX_LIST").toBeTruthy();
expect(FILES_LIST_CAP).toBe(Number(cap![1]));
});
});
+8 -1
View File
@@ -19,7 +19,13 @@ export interface FileFolder {
created_at: string | null;
}
// The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by
// files.contract.test.ts): a list this long may be missing older files, so the page asks the
// server to search and sort it rather than doing either on the rows it got.
export const FILES_LIST_CAP = 500;
export type FileSort = "added" | "name" | "size";
export const FILE_SORTS: readonly FileSort[] = ["added", "name", "size"];
export type FileOrder = "asc" | "desc";
export interface FileListParams {
@@ -69,7 +75,8 @@ export interface StorageUsage {
upload_max_bytes: number;
}
function listQuery(p: FileListParams): string {
/** The list's query string — exported for the contract test that holds its names to the route's. */
export function listQuery(p: FileListParams): string {
const q = new URLSearchParams();
if (p.shared) q.set("scope", "shared");
else if (p.folderId != null) q.set("folder_id", String(p.folderId));