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
+27 -3
View File
@@ -12,6 +12,7 @@ import {
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../lib/api";
import { formatBytes } from "../lib/format";
import { dispatch, useKeyScope } from "../lib/keymap";
import { useLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
@@ -32,6 +33,8 @@ import { useContextMenu } from "./ui/useContextMenu";
//
// Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer.
const STAGE = "h-[min(75dvh,56rem)] w-full";
// How many lines of a text file the viewer lays out (see TextStage).
const TEXT_LINES = 5000;
/** A text file's head (one read, shared by the viewer's header and its stage). */
function useTextPreview(id: number, enabled: boolean) {
@@ -74,7 +77,17 @@ export default function FilePreview({
if (next) onFileChange(next.id);
};
useKeyScope("fileViewer");
// The viewer's own place on the layer stack (above its dialog): ← / → are its only while nothing is
// on top of it — its "…" menu, a confirm, the Share or Details dialog opened from that menu. There an
// arrow moves through the layer's own buttons; paging the viewer under it would show one file while
// the dialog acts on another.
const layer = useLayer(true, undefined, { passive: true });
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (layer.blockingAbove()) return;
// The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
const scroller =
e.target instanceof Element ? e.target.closest("[data-scrolls-sideways]") : null;
if (scroller && scroller.scrollWidth > scroller.clientWidth) return;
dispatch(e, "fileViewer", {
prevFile: (ev) => {
ev.preventDefault();
@@ -217,8 +230,11 @@ function TextStage({ file }: { file: LibraryFile }) {
</StateMessage>
);
if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />;
// A file's last line ends in a newline; that is no line of its own.
const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
// A file's last line ends in a newline; that is no line of its own. Only the first TEXT_LINES are
// laid out: every line is a row of the page, and a head of short lines (a log) runs to tens of
// thousands of them — a stall on open and on every wrap toggle. The rest is behind Download.
const all = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
const lines = all.length > TEXT_LINES ? all.slice(0, TEXT_LINES) : all;
return (
<div className={`${STAGE} flex flex-col gap-2`}>
<div className="flex items-center gap-2 text-xs text-muted">
@@ -239,8 +255,10 @@ function TextStage({ file }: { file: LibraryFile }) {
</IconButton>
</div>
<div
// Scrolls by keyboard too: a focusable region with a name.
// Scrolls by keyboard too: a focusable region with a name — sideways as well, its ← / →
// taken from the viewer's paging while a line runs past the edge.
tabIndex={0}
data-scrolls-sideways
role="region"
aria-label={jobTitle(file)}
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed"
@@ -262,6 +280,12 @@ function TextStage({ file }: { file: LibraryFile }) {
</li>
))}
</ol>
{lines.length < all.length && (
<p className="m-0 border-t border-border px-3 py-2 font-sans text-muted">
{t("files.preview.firstLines", { lines: TEXT_LINES })}{" "}
<LinkButton href={api.fileUrl(file.id)}>{t("files.preview.downloadWhole")}</LinkButton>
</p>
)}
</div>
</div>
);