Compare commits

...
Author SHA1 Message Date
Reece 997b6c03de refactor(files): tidy the windowing hook and its story
The row window returned through a useMemo whose dependency is rebuilt every render,
so the cache never hit; the values behind it are a couple of multiplications.

VirtualFileRows is only its own return type, so it stops being exported, and the
empty-state story stands up its own New folder control now that the page owns it.
2026-09-02 20:14:50 +01:00
Reece 4a57b6ad56 Merge remote-tracking branch 'origin/main' into files-grid-perf
# Conflicts:
#	frontend/editor/src/core/components/filesPage/FileGrid.tsx
#	frontend/editor/src/core/components/filesPage/FileManagerView.tsx
#	frontend/editor/src/desktop/services/localFolderContents.ts
2026-09-02 19:55:58 +01:00
Reece a349afe9c1 fix(files): folder history, one New folder control, and open-once
Walking into a folder wrote its path with replace, so the whole journey shared one
history entry: Back did not step up a folder, it left the library and landed on
whatever came before it. Each folder is its own entry now, and the two effects that
keep path and selection in step carry a marker so neither overwrites the entry the
other just arrived at. A path naming a folder that has not loaded yet waits for the
folder map to fill instead of falling back to the root.

New folder is one control in both places it appears. The empty state offered a
single click that guessed a destination and blocked itself where it could not; it
now shows the header's menu, under the header's label.

Opening a file already in the workspace skips the fetch-and-add and just goes to
it, and a folder answers to the source filter the way its files do.
2026-09-02 17:21:25 +01:00
Reece e4cb26be43 refactor(files): Local in the tree sets the source filter
It was a pseudo-tab with a view of its own: its own predicate for which files
count, its own empty state, and a carve-out anywhere folders are involved -
folder visibility, the New folder button, the heading. All of it to say "files
with no server copy", which the source filter already says.

Clicking it now sets that filter and nothing else, so it narrows whichever view
you are in instead of taking you somewhere. The tab value goes with the
machinery, and the strings only its empty state read.
2026-09-01 22:54:13 +01:00
Reece 6fc1a39970 perf(files): render a window of a long folder, and drop the 500-file cap
A mounted directory listed at most 500 files. The cap was there because the grid
rendered a card for every entry, so a big Downloads folder was slow whether or not
the user scrolled that far - it traded away the rest of the folder to stay usable.

The grid and the list now render only the rows in view plus a spacer at each end,
so DOM size tracks the viewport instead of the folder. Spacers rather than
absolute positioning, so the grid keeps its own auto-fill layout and the list its
row flow; the column count is read off the computed style, leaving the CSS the one
place that decides it. With no measurable scrolling ancestor - a short list, the
first paint, a test environment with no geometry - every item renders, as before.

The cap goes with it: listDirectory returns what the directory holds.

What this does not change is the IPC cost of listing. Each entry is a stat over
the bridge, batched, so a 10k-file directory still pays for 10k stats before the
first card appears.
2026-09-01 19:05:41 +01:00
Reece 5be8c72172 Merge branch 'folder-kinds' into files-grid-perf
Both sides restructured FileGrid: folder kinds added disk-listed cards and
kind-aware folder menus, this branch made every item memoized behind one stable
actions dispatcher and took the folder context back out of the items.

The dispatcher stays, and the new behaviour moves onto it. Folder menus keep
their kind gating, deriving editsDisabled from the serverReachable prop rather
than subscribing to the folder context - a subscription inside a memoized item
undoes what the memo buys. Opening a disk-listed file becomes
actions.openDiskFile, so DiskFileCard and DiskFileRow take the dispatcher instead
of a closure rebuilt every render, and are memoized like every other item.
2026-09-01 17:51:43 +01:00
Reece da77a7c099 docs(folders): fix the eight clunky ones
Two deleted outright: an upload branch and a folder lookup whose comments said
what the condition below them said.

The rest kept their fact and lost the rest of the sentence. DiskFileCard gets
back the constraint that makes it unusual - no stub, so no selection or move.
The "Local" tab keeps only the both-halves rule, not the predicate beside it.
The disk-subfolder state says it is never persisted rather than restating its
type. FolderRecord.kind pointed at folderKind twice over; now the accessor holds
the rule and the field points at it.
2026-09-01 16:45:29 +01:00
Reece 67e4f4b301 docs(folders): delete the comments that say what the line below says
Nine that carried nothing: four sat above a throw whose message was the comment,
the rest restated the name or type they documented.

Two were wrong rather than redundant. One counted two systems of record where
three stores are loaded. The other explained local-file membership above the
branch that reports server files being left behind.
2026-09-01 16:30:01 +01:00
Reece 8afc769f05 test(files): count card re-renders so the memoization cannot rot
The restructure's whole claim is that selecting a file redraws the cards whose
selection changed rather than the folder. Nothing enforced it: one inline object
or closure at a call site undoes every bit of it, with no visible symptom until a
folder is large enough to feel it.

Counts the badge row each card renders exactly once, selects one of four, and
expects one card's worth of redraw. With React.memo stripped from FileCard the
same test reports four.
2026-09-01 16:14:55 +01:00
Reece 218a8400ca perf(files): big file lists render only what changed and only what shows
Three compounding costs made a full folder feel sticky:

- Every card and row re-rendered on ANY page state change, because item
  components weren't memoized and got fresh closures each render. Items
  now take a single stable actions dispatcher (latest-ref backed, so
  behavior stays current while identity stays fixed) and are React.memo —
  a selection click re-renders the two cards whose selection changed, not
  all 500. Selection-aware behavior (drag payloads, multi-move) moved
  into the dispatcher so items no longer hold the selection Set, whose
  identity changes on every click.
- Each lazily generated thumbnail updated the shared stub immediately,
  re-rendering every file-list consumer once per thumbnail — hundreds of
  times as a folder fills in. Updates now flush in windows; the card
  itself paints instantly from local state.
- Offscreen cards still paid layout and paint. content-visibility lets
  the browser skip them; the intrinsic size keeps the scrollbar honest.
2026-09-01 16:14:54 +01:00
Reece bfb48c88de docs(folders): put back the halves that carried the reason
Cutting each block to its first sentence sometimes kept the what and dropped the
why, which leaves a comment saying what the signature already says. Those are
deleted where the name covers them, and where the second sentence was the point
it is back: the OS error codes behind isAlreadyExists, why a virtual folder
cannot hang off a server one, the effect that snaps folder selection back to root.
2026-09-01 16:07:32 +01:00
Reece b6139d1cd0 docs(folders): one line each
Every multi-line aside cut to its first sentence. What went was the second and
third sentences qualifying it.
2026-09-01 16:00:26 +01:00
Reece 056de6d9ee docs(folders): cut the comments back
Same facts, a third of the words. Mostly three- and four-line asides saying one
thing, and rhetorical framing around explanations that stand up on their own.
2026-09-01 15:53:09 +01:00
Reece 90b6762869 docs(folders): trim the comments this branch adds
The same three lines explaining which folder kinds can go offline sat above both
the grid card and the list row; one copy carries the reasoning and the other
points at it.

The rest is the module docs on the new stores, saying the same things with less
around them.
2026-09-01 15:35:40 +01:00
Reece c7fc306605 style(folders): oxfmt after banner removal 2026-09-01 14:50:30 +01:00
Reece 539b933ce8 style(folders): drop two decorative section banners
main's comment gate blocks banner comments on added lines (CMT002):
decoration carries nothing a reader could not get from the code below it.
2026-09-01 14:35:34 +01:00
Reece Browne c0de945f32 Merge branch 'main' into folder-kinds 2026-09-01 14:29:32 +01:00
Reece da5edb2d3a feat(folders): folder kinds — server folders everywhere, disk mounts on desktop
Folders now carry a kind, and each kind has its own system of record:

- "server": the backend owns them, as before. The only kind the web
  offers — the root New-folder button goes straight to the server dialog
  and greys out with the reason (sign in / storage off / unreachable)
  when the server can't take one.
- "local": a directory on the machine, mounted read-through on desktop
  via the native picker ("Add local folder"). The directory is the source
  of truth: the listing is taken fresh from disk (stats batched — a
  directory's open time is IPC latency, so the calls overlap), opening a
  file loads its bytes into the workbench, and moving, dropping, or
  uploading files into the mount writes them to the directory itself —
  the app copy is retired only after the bytes verifiably land, taking
  superseded versions with it. Names are reduced to a safe basename
  before writing; collisions take the OS's " (n)" suffix convention.
  Mount records dedupe through a lexical directory key (case-folded for
  Windows-style paths, separators unified) and refuse nested or
  containing directories — one directory, one row.
- "virtual": browser-owned IndexedDB folders. Dormant by decision:
  nothing creates one at the root any more, but existing rows still
  render, take subfolders, and hold files.

One kind per subtree, always — each kind has its own store and a mixed
chain would mean an ancestry no single store can vouch for.

Placement is part of creation: a file uploaded while standing in a
folder is born with that folderId, set atomically with the stub — for a
server folder the save-to-server is the sync step, and a failed sync
leaves the file visibly in its folder rather than stranded. moveFilesTo
falls back to storage for ids newer than its render-time snapshot, so
just-born files never silently drop out of a move.

Platform gating goes through build seams (@app): the directory picker,
the disk listing/read/write, and the server-folder blocker — desktop's
blocker speaks in connection modes ("Sign in to Stirling Cloud or
connect a self-hosted server"), seeded from the service's cache so first
paint answers correctly. The one-click New-folder surfaces (sidebar
rail, empty-state CTA) share one flow: the native picker on desktop, a
server folder on the web, disabled with the reason when neither applies.
2026-09-01 13:09:21 +01:00
14 changed files with 1124 additions and 524 deletions
@@ -4409,7 +4409,6 @@ everywhereHint = "Deletes the file from this device and the cloud."
[filesPage.empty]
hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organize your files."
newFolderCta = "Create folder"
title = "This folder is empty"
uploadCta = "Upload files"
@@ -4419,10 +4418,6 @@ offlineHint = "Reconnect to load your cloud library."
offlineTitle = "No cached cloud files"
title = "No cloud files yet"
[filesPage.empty.local]
hint = "Files saved without uploading stay here. Drop a file to add one."
title = "No local-only files"
[filesPage.empty.noResults]
hint = "No files in this folder match your filter. Try a different term or clear the filter."
title = "No matching files"
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from "vitest";
import { render as baseRender } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
/**
* The grid's items are memoized so a selection click re-renders the cards whose
* selection changed rather than the whole folder. That only holds while every prop
* they take stays stable - one inline object or closure at a call site silently
* undoes it, with no visible symptom until a folder is large. These count renders
* so that regression fails here instead of in someone's 500-file folder.
*/
// @app/ui wraps Mantine, so the provider has to be in the tree.
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Every card renders this exactly once, so its calls are a per-card render count.
const badgeRenders: { n: number } = { n: 0 };
vi.mock("@app/components/shared/PolicyBadges", () => ({
PolicyBadges: () => {
badgeRenders.n += 1;
return null;
},
}));
const buildStub = (id: string, name: string): StirlingFileStub =>
({
id: id as FileId,
name,
type: "application/pdf",
size: 1_000,
lastModified: 0,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
// Set so useLazyThumbnail short-circuits instead of reading IndexedDB.
thumbnailUrl: "data:image/svg+xml,%3Csvg/%3E",
}) as StirlingFileStub;
describe("FileGrid item memoization", () => {
it("re-renders only the cards whose selection changed", async () => {
const { FileGrid } = await import("@app/components/filesPage/FileGrid");
const { FileContextProvider } = await import("@app/contexts/FileContext");
const files = ["a", "b", "c", "d"].map((id) => buildStub(id, `${id}.pdf`));
const entries = files.map((file) => ({ kind: "file" as const, file }));
const props = {
entries,
viewMode: "grid" as const,
onSelectFile: () => {},
onOpenFolder: () => {},
onOpenFile: () => {},
onMoveFiles: () => {},
onMoveFolder: () => {},
onRenameFolder: () => {},
onDeleteFolder: () => {},
onChangeFolderAppearance: () => {},
onRemoveFiles: () => {},
onPromptMoveFiles: () => {},
};
const view = render(
<FileContextProvider>
<FileGrid {...props} selectedFileIds={new Set<FileId>()} />
</FileContextProvider>,
);
const cards = () =>
view.container.querySelectorAll(".files-page-card:not(.is-folder)");
expect(cards()).toHaveLength(4);
const initialRenders = badgeRenders.n;
expect(initialRenders).toBeGreaterThanOrEqual(4);
// Selecting one file changes isSelected for exactly one card. The rest take
// identical props, so memo should skip them.
view.rerender(
<FileContextProvider>
<FileGrid
{...props}
selectedFileIds={new Set<FileId>(["a" as FileId])}
/>
</FileContextProvider>,
);
expect(cards()).toHaveLength(4);
expect(
view.container.querySelectorAll(".files-page-card.is-selected"),
).toHaveLength(1);
// The point of the exercise: one card changed, so the re-render count moves by
// one card's worth and not four. Unmemoized items redraw the whole folder here.
const rerendered = badgeRenders.n - initialRenders;
const perCard = initialRenders / 4;
expect(rerendered).toBe(perCard);
});
});
@@ -5,6 +5,7 @@ import {
type FilesPageEntry,
} from "@app/components/filesPage/FileGrid";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NewFolderButton } from "@app/components/filesPage/NewFolderButton";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
@@ -109,6 +110,17 @@ export const Empty: Story = {
loading: false,
currentTab: "all",
onEmptyUpload: () => {},
onEmptyCreateFolder: () => {},
// The page owns this control, so the story stands one up to keep both CTAs on
// screen here.
emptyNewFolderControl: (
<NewFolderButton
label="New folder"
size="md"
currentFolderId={null}
canAddLocalFolder={false}
onAddLocalFolder={() => {}}
onOpenDialog={() => {}}
/>
),
},
};
File diff suppressed because it is too large Load Diff
@@ -10,10 +10,8 @@ import { useLocation, useNavigate } from "react-router-dom";
import {
Drawer,
Group,
Menu,
MultiSelect,
Select,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
@@ -25,7 +23,6 @@ import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import QrCode2Icon from "@mui/icons-material/QrCode2";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import GridViewIcon from "@mui/icons-material/GridView";
import ViewListIcon from "@mui/icons-material/ViewList";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -34,14 +31,12 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import CloudIcon from "@mui/icons-material/Cloud";
import RefreshIcon from "@mui/icons-material/Refresh";
import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu";
import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount";
import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu";
import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu";
import { NewFolderButton } from "@app/components/filesPage/NewFolderButton";
import { stripBasePath } from "@app/constants/app";
import { useAuth } from "@app/auth/UseSession";
@@ -225,21 +220,44 @@ export default function FileManagerView() {
const foldersById = folders.foldersById;
const currentFolderId = folders.currentFolderId;
// Sync the URL into FolderContext.
// Which folder the path last selected. The two effects below keep the path and the
// selection in step, and each uses this to tell its own write from the other's.
const pathSelectedRef = useRef<string | null>(null);
// Path -> selection. Covers arrival, a deep link, and back/forward.
useEffect(() => {
const match = location.pathname.match(/^\/files\/([^/]+)/);
const param = match?.[1] ?? null;
if (param === null) {
pathSelectedRef.current = null;
setCurrentFolderId(ROOT_FOLDER_ID);
} else if (foldersById.has(param as FolderId)) {
return;
}
if (foldersById.has(param as FolderId)) {
pathSelectedRef.current = param;
setCurrentFolderId(param as FolderId);
} else if (isDiskFolderId(param) && resolveDiskFolder(param as FolderId)) {
return;
}
if (isDiskFolderId(param) && resolveDiskFolder(param as FolderId)) {
// A mount subdirectory deep link: rebuilt from the id, mapped next render.
pathSelectedRef.current = param;
setCurrentFolderId(param as FolderId);
} else {
return;
}
// Not known yet is not the same as not real: folders load asynchronously, and a
// mount's subdirectories arrive with the listing that finds them. Wait for the map
// to fill - this re-runs as it does - and only fall back once it cannot.
if (!folders.loading) {
pathSelectedRef.current = null;
setCurrentFolderId(ROOT_FOLDER_ID);
}
}, [location.pathname, foldersById, setCurrentFolderId, resolveDiskFolder]);
}, [
location.pathname,
foldersById,
setCurrentFolderId,
resolveDiskFolder,
folders.loading,
]);
// Bounce off any share-related tab when sharing isn't enabled.
useEffect(() => {
@@ -251,14 +269,19 @@ export default function FileManagerView() {
}
}, [sharingEnabled, currentTab, setCurrentTab]);
// Push folder selection into the URL while still on /files.
// Selection -> path, for a folder opened here. Pushed, not replaced: each folder is
// its own history entry, so Back walks up the tree rather than out of the library.
useEffect(() => {
const stripped = stripBasePath(window.location.pathname);
if (!stripped.startsWith("/files")) return;
const target =
currentFolderId === null ? "/files" : `/files/${currentFolderId}`;
const selected = currentFolderId === null ? null : String(currentFolderId);
// The path already says this, being what selected the folder. Writing it again
// overwrites the entry a back or forward just landed on.
if (pathSelectedRef.current === selected) return;
const target = selected === null ? "/files" : `/files/${selected}`;
if (stripped !== target) {
navigate(target, { replace: true });
pathSelectedRef.current = selected;
navigate(target);
}
}, [currentFolderId, navigate]);
@@ -290,7 +313,6 @@ export default function FileManagerView() {
const visibleFolders = useMemo(() => {
// Folders only appear in cloud-rooted tabs.
if (
currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe"
@@ -301,6 +323,12 @@ export default function FileManagerView() {
const matched = folders.folders.filter((f) => {
// The Cloud tab is the server's view: browser folders and mounts aren't on it.
if (currentTab === "cloud" && folderKind(f) !== "server") return false;
// A folder answers to the source filter the way its files would: a server
// folder is cloud, a browser folder and a mount are both local.
if (originFilter !== "all") {
const folderOrigin = folderKind(f) === "server" ? "cloud" : "local";
if (folderOrigin !== originFilter) return false;
}
if (search) {
// Subtree-wide name match; exclude the current folder itself.
return (
@@ -315,18 +343,19 @@ export default function FileManagerView() {
return matched.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
}, [folders.folders, currentFolderId, search, currentTab, subtreeFolderIds]);
}, [
folders.folders,
currentFolderId,
search,
currentTab,
subtreeFolderIds,
originFilter,
]);
// Files in current folder, pre-filter. Drives the type-filter dropdown.
const filesInCurrentFolder = useMemo(() => {
// Tab overrides folder navigation for Local/Recent/Shared.
switch (currentTab) {
case "local":
// Both halves: a local file inside a browser folder belongs to that folder,
// not here as well.
return allFiles.filter(
(f) => f.remoteStorageId == null && (f.folderId ?? null) === null,
);
case "cloud":
// Cloud bucket; search widens to subtree, else direct-folder match.
return allFiles.filter((f) => {
@@ -753,26 +782,40 @@ export default function FileManagerView() {
const proceed = async () => {
clearFilesPageReturnRoute();
// Already in the workspace: nothing to fetch or add, so just go to it. Sending
// it through materialize and add again has no reason to succeed - the bytes
// are already spoken for.
const alreadyOpen = stubs.filter((stub) =>
activeWorkspaceFileIdSet.has(stub.id as string),
);
const toOpen = stubs.filter(
(stub) => !activeWorkspaceFileIdSet.has(stub.id as string),
);
// Server-only stubs have no bytes in IDB; download + ingest first.
const materialized = await materializeServerStubs(stubs, {
const materialized = await materializeServerStubs(toOpen, {
addFiles: fileActions.addFilesWithOptions,
updateStub: fileActions.updateStirlingFileStub,
});
if (materialized.length !== stubs.length) {
if (materialized.length !== toOpen.length) {
// At least one server download failed; refresh so the grid
// reflects any successful ingests and the user can retry.
await refresh();
return;
}
await fileActions.addStirlingFileStubs(materialized, {
selectFiles: false,
});
// Branch on requested stubs so already-active files still activate.
if (materialized.length === 1) {
setActiveFileId(materialized[0].id);
if (materialized.length > 0) {
await fileActions.addStirlingFileStubs(materialized, {
selectFiles: false,
});
}
// Every file the user asked for, whether it arrived now or was already there.
const opened = [...alreadyOpen, ...materialized];
if (opened.length === 1) {
setActiveFileId(opened[0].id);
navActions.setWorkbench("viewer");
} else if (materialized.length > 1) {
} else if (opened.length > 1) {
navActions.setWorkbench("fileEditor");
}
navigate(EDITOR_BASENAME);
@@ -790,6 +833,8 @@ export default function FileManagerView() {
navigate,
requestNavigation,
clearFilesPageReturnRoute,
activeWorkspaceFileIdSet,
refresh,
],
);
@@ -1094,14 +1139,12 @@ export default function FileManagerView() {
// disabled item's caption.
const serverFolderDisabledReason = useServerFolderBlock() ?? undefined;
const { addLocalFolder, createFolderHere, createFolderHereBlockedReason } =
useNewFolderFlow();
const { addLocalFolder } = useNewFolderFlow();
// null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => {
// Only All/Cloud render folders, so creating one elsewhere would look inert.
if (
currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe"
@@ -1143,8 +1186,7 @@ export default function FileManagerView() {
<header className="files-page-header">
{/* Breadcrumb only for folder-rooted tabs. */}
{(currentTab === "all" || currentTab === "cloud") && <Breadcrumbs />}
{(currentTab === "local" ||
currentTab === "recent" ||
{(currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe") && (
<div
@@ -1155,13 +1197,11 @@ export default function FileManagerView() {
color: "var(--c-text)",
}}
>
{currentTab === "local"
? t("filesPage.tabName.local", "Local")
: currentTab === "recent"
? t("filesPage.tabName.recent", "Recent")
: currentTab === "shared"
? t("filesPage.tabName.shared", "Shared with me")
: t("filesPage.tabName.sharedByMe", "Shared by me")}
{currentTab === "recent"
? t("filesPage.tabName.recent", "Recent")
: currentTab === "shared"
? t("filesPage.tabName.shared", "Shared with me")
: t("filesPage.tabName.sharedByMe", "Shared by me")}
</div>
)}
{(() => {
@@ -1223,89 +1263,15 @@ export default function FileManagerView() {
<RefreshIcon />
</ActionIcon>
</Tooltip>
{newFolderDisabledReason ? (
<Tooltip
label={newFolderDisabledReason}
withinPortal
multiline
w={220}
>
<span style={{ display: "inline-flex" }}>
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
style={{ pointerEvents: "auto" }}
>
{t("filesPage.newFolder", "New folder")}
</Button>
</span>
</Tooltip>
) : folders.currentFolderId !== null || !canPickDirectory ? (
// Nothing to choose: a subfolder inherits its parent's kind, and on
// the web everything lives on the server. Straight to the dialog.
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() =>
folders.currentFolderId !== null
? openNewFolderDialog()
: openNewFolderDialog(null, "server")
}
>
{t("filesPage.newFolder", "New folder")}
</Button>
) : (
// Desktop root: two peer destinations, so the button is the menu.
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
rightSection={<ArrowDropDownIcon fontSize="small" />}
>
{t("filesPage.newFolder", "New folder")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<DriveFolderUploadIcon
fontSize="small"
style={{ marginRight: "0.3rem" }}
/>
}
onClick={() => void addLocalFolder()}
>
{t(
"filesPage.newFolderMenu.addExisting",
"Add local folder",
)}
</Menu.Item>
<Menu.Item
className="files-page-new-folder-option"
leftSection={<CloudIcon fontSize="small" />}
disabled={Boolean(serverFolderDisabledReason)}
onClick={() => openNewFolderDialog(null, "server")}
>
{t(
"filesPage.newFolderMenu.server",
"New folder on the server",
)}
<Text size="xs" c="dimmed">
{serverFolderDisabledReason ??
t(
"filesPage.newFolderMenu.serverHint",
"Synced to your account, available wherever you sign in.",
)}
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
<NewFolderButton
label={t("filesPage.newFolder", "New folder")}
disabledReason={newFolderDisabledReason}
serverDisabledReason={serverFolderDisabledReason}
currentFolderId={folders.currentFolderId}
canAddLocalFolder={canPickDirectory}
onAddLocalFolder={() => void addLocalFolder()}
onOpenDialog={openNewFolderDialog}
/>
<Button
size="sm"
leftSection={<UploadFileIcon fontSize="small" />}
@@ -1902,6 +1868,7 @@ export default function FileManagerView() {
currentTab={currentTab}
searchActive={search.trim().length > 0}
serverReachable={folders.serverReachable}
onActionError={folders.setError}
selectedFileIds={selectedFileIds}
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
viewMode={viewMode}
@@ -1944,11 +1911,17 @@ export default function FileManagerView() {
// (disabled tooltips, native file picker, dialog) is
// identical regardless of where the user clicks from.
onEmptyUpload={() => fileInputRef.current?.click()}
onEmptyCreateFolder={createFolderHere}
// A single-click shortcut, so it also blocks where it has nothing safe
// to do, unlike the header button whose menu still offers the choices.
newFolderDisabledReason={
newFolderDisabledReason ?? createFolderHereBlockedReason
emptyNewFolderControl={
<NewFolderButton
label={t("filesPage.newFolder", "New folder")}
size="md"
disabledReason={newFolderDisabledReason}
serverDisabledReason={serverFolderDisabledReason}
currentFolderId={folders.currentFolderId}
canAddLocalFolder={canPickDirectory}
onAddLocalFolder={() => void addLocalFolder()}
onOpenDialog={openNewFolderDialog}
/>
}
/>
{isDraggingExternal && (
@@ -319,6 +319,14 @@
gap: 1rem;
}
/* Stands in for the rows outside the rendered window, so the scrollbar reflects the
whole folder. Spans every column: in the grid a spacer sharing a row with cards
would be laid out beside them instead of above. */
.files-page-virtual-pad {
grid-column: 1 / -1;
pointer-events: none;
}
.files-page-list {
display: flex;
flex-direction: column;
@@ -333,6 +341,9 @@
grid-template-columns: 2.25rem minmax(0, 3fr) 1fr 1fr 1fr 2.5rem;
gap: 0.5rem;
align-items: center;
/* Same offscreen skip as .files-page-card. */
content-visibility: auto;
contain-intrinsic-size: auto 3rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--c-border-subtle);
cursor: pointer;
@@ -385,6 +396,11 @@
position: relative;
display: flex;
flex-direction: column;
/* Offscreen cards skip layout and paint — a 500-entry folder only pays
for the rows in view. The intrinsic size stands in for unrendered
cards so the scrollbar doesn't jump (auto: measured size once seen). */
content-visibility: auto;
contain-intrinsic-size: auto 13rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: 0.85rem;
@@ -72,7 +72,13 @@ export function FolderTreeSidebar({
}: FolderTreeSidebarProps) {
const { t } = useTranslation();
const { tree, currentFolderId, setCurrentFolderId } = useFolders();
const { currentTab, setCurrentTab, moveFolderTo } = useFilesPage();
const {
currentTab,
setCurrentTab,
moveFolderTo,
originFilter,
setOriginFilter,
} = useFilesPage();
return (
<div
@@ -99,8 +105,8 @@ export function FolderTreeSidebar({
}
/>
<LocalRow
isActive={currentTab === "local"}
onSelect={() => setCurrentTab("local")}
isActive={originFilter === "local"}
onSelect={() => setOriginFilter("local")}
/>
{tree.map((node) => (
<TreeNodeRow
@@ -198,10 +204,9 @@ interface LocalRowProps {
}
/**
* Pinned pseudo-folder row that selects the Local tab. Local files don't
* belong to a folder (folders are a cloud concept) so this row is not a
* drop target and has no count badge - the Local view scopes by predicate
* (`remoteStorageId == null`), not by folderId.
* Sets the source filter to local, and nothing else: it narrows whatever view you
* are in rather than being a place of its own. Not a drop target and no count
* badge - a local file has no folder to be counted under.
*/
function LocalRow({ isActive, onSelect }: LocalRowProps) {
const { t } = useTranslation();
@@ -0,0 +1,126 @@
import type { ReactNode } from "react";
import { Menu, Text, Tooltip } from "@mantine/core";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import CloudIcon from "@mui/icons-material/Cloud";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import type { FolderId, FolderKind } from "@app/types/folder";
export interface NewFolderButtonProps {
label: string;
size?: "sm" | "md";
/** Set when a folder cannot be created here at all; also the tooltip. */
disabledReason?: string | null;
/** Set when only the server destination is unavailable; also its tooltip. */
serverDisabledReason?: string | null;
/** A subfolder inherits its parent's kind, so inside one there is no choice. */
currentFolderId: FolderId | null;
/** Whether this build can put a directory on screen to be mounted. */
canAddLocalFolder: boolean;
onAddLocalFolder: () => void;
onOpenDialog: (parentId?: FolderId | null, kind?: FolderKind) => void;
}
/**
* New folder, in the three shapes the destinations allow: blocked with a reason, a
* plain button where only one destination exists, and a menu where two do. Shared by
* the header and the empty state, so one label cannot offer two different things.
*/
export function NewFolderButton({
label,
size = "sm",
disabledReason,
serverDisabledReason,
currentFolderId,
canAddLocalFolder,
onAddLocalFolder,
onOpenDialog,
}: NewFolderButtonProps): ReactNode {
const { t } = useTranslation();
if (disabledReason) {
return (
<Tooltip label={disabledReason} withinPortal multiline w={260}>
{/* Wrapped so the tooltip still opens while the button is disabled. */}
<span style={{ display: "inline-flex" }}>
<Button
variant="secondary"
size={size}
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
style={{ pointerEvents: "auto" }}
>
{label}
</Button>
</span>
</Tooltip>
);
}
// Inside a folder the kind is inherited, and on the web the server is the only
// place a folder can go.
if (currentFolderId !== null || !canAddLocalFolder) {
return (
<Button
variant="secondary"
size={size}
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() =>
currentFolderId !== null
? onOpenDialog()
: onOpenDialog(null, "server")
}
>
{label}
</Button>
);
}
return (
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="secondary"
size={size}
leftSection={<CreateNewFolderIcon fontSize="small" />}
rightSection={<ArrowDropDownIcon fontSize="small" />}
>
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<DriveFolderUploadIcon
fontSize="small"
style={{ marginRight: "0.3rem" }}
/>
}
onClick={onAddLocalFolder}
>
{t("filesPage.newFolderMenu.addExisting", "Add local folder")}
</Menu.Item>
<Menu.Item
className="files-page-new-folder-option"
leftSection={<CloudIcon fontSize="small" />}
disabled={Boolean(serverDisabledReason)}
onClick={() => onOpenDialog(null, "server")}
>
{t("filesPage.newFolderMenu.server", "New folder on the server")}
{/* The reason is the caption: a disabled item with no explanation
reads as broken rather than unavailable. */}
<Text size="xs" c="dimmed">
{serverDisabledReason ??
t(
"filesPage.newFolderMenu.serverHint",
"Synced to your account, available wherever you sign in.",
)}
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
/** Rows above and below the viewport kept mounted, so a fast scroll stays filled. */
const OVERSCAN = 3;
/**
* How many columns the grid is actually laying out. Read off the computed style
* rather than recomputed from a breakpoint, so `auto-fill` stays the one place the
* column count is decided.
*/
function useColumnCount(el: HTMLElement | null): number {
const [columns, setColumns] = useState(1);
useEffect(() => {
if (!el) return;
const read = () => {
const template = getComputedStyle(el).gridTemplateColumns;
const n =
template === "none" ? 1 : template.split(" ").filter(Boolean).length;
setColumns(Math.max(1, n));
};
read();
const ro = new ResizeObserver(read);
ro.observe(el);
return () => ro.disconnect();
}, [el]);
return columns;
}
/** The scrolling ancestor the virtualiser measures against. */
function useScrollParent(el: HTMLElement | null): HTMLElement | null {
const [parent, setParent] = useState<HTMLElement | null>(null);
useEffect(() => {
setParent(el?.closest<HTMLElement>(".files-page-content") ?? null);
}, [el]);
return parent;
}
interface VirtualFileRows {
/** The slice to render, or every index when virtualisation is standing down. */
range: { start: number; end: number };
/** Height to leave above and below the slice, keeping the scrollbar honest. */
padTop: number;
padBottom: number;
columns: number;
/** Ref for the element the rows live in. */
setContainer: (el: HTMLDivElement | null) => void;
}
/**
* Renders a window of a long file list instead of all of it, as a slice plus a
* spacer at each end. Spacers rather than absolute positioning so the grid keeps
* its own `auto-fill` layout and the list its own row flow.
*
* Stands down - every item rendered, no spacers - until there is a scrolling
* ancestor with a measured height. That covers a short list, the first paint
* before layout, and any environment without real geometry.
*/
export function useVirtualFileRows(
itemCount: number,
rowHeightEstimate: number,
isGrid: boolean,
): VirtualFileRows {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const scrollParent = useScrollParent(container);
const measuredColumns = useColumnCount(isGrid ? container : null);
const columns = isGrid ? measuredColumns : 1;
const rowCount = Math.ceil(itemCount / columns);
const getScrollElement = useCallback(() => scrollParent, [scrollParent]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement,
estimateSize: () => rowHeightEstimate,
overscan: OVERSCAN,
});
const rows = virtualizer.getVirtualItems();
const active = Boolean(scrollParent) && rows.length > 0;
if (!active) {
return {
range: { start: 0, end: itemCount },
padTop: 0,
padBottom: 0,
columns,
setContainer,
};
}
const first = rows[0];
const last = rows[rows.length - 1];
return {
range: {
start: first.index * columns,
end: Math.min((last.index + 1) * columns, itemCount),
},
padTop: first.start,
padBottom: Math.max(0, virtualizer.getTotalSize() - last.end),
columns,
setContainer,
};
}
// Read once. The root font size is a layout read, and this is called on every render
// of a list whose whole point is not doing needless work. A root restyled mid-session
// keeps the first answer, which only shifts an estimate.
let rootFontSizePx = 0;
/** Card and row heights including their gap, matching contain-intrinsic-size. */
export function rowHeightPx(isGrid: boolean): number {
if (rootFontSizePx === 0) {
rootFontSizePx =
parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
}
return isGrid ? 14 * rootFontSizePx : 3 * rootFontSizePx;
}
@@ -55,13 +55,7 @@ export type FilesPageOriginFilter =
| "shared-with-me";
/** all|local|cloud|recent|shared filter presets. */
export type FilesPageTab =
| "all"
| "local"
| "cloud"
| "recent"
| "shared"
| "sharedByMe";
export type FilesPageTab = "all" | "cloud" | "recent" | "shared" | "sharedByMe";
export interface FolderNameDialogState {
mode: "new" | "rename" | null;
@@ -32,6 +32,32 @@ function drainLazyThumbQueue(): void {
});
}
// Stub updates go through the file context, and each one re-renders every
// consumer of the file list. A big folder filling in generates hundreds of
// thumbnails over minutes; flushing them in windows turns that into a handful
// of re-renders (React batches same-tick updates into one). The card itself
// paints immediately from its local state — only the shared stub waits.
const STUB_THUMB_FLUSH_MS = 500;
const pendingStubThumbs = new Map<
FileId,
{ thumbnail: string; apply: (id: FileId, thumbnail: string) => void }
>();
let stubThumbFlushTimer: ReturnType<typeof setTimeout> | null = null;
function queueStubThumbUpdate(
fileId: FileId,
thumbnail: string,
apply: (id: FileId, thumbnail: string) => void,
): void {
pendingStubThumbs.set(fileId, { thumbnail, apply });
stubThumbFlushTimer ??= setTimeout(() => {
stubThumbFlushTimer = null;
const batch = Array.from(pendingStubThumbs);
pendingStubThumbs.clear();
for (const [id, entry] of batch) entry.apply(id, entry.thumbnail);
}, STUB_THUMB_FLUSH_MS);
}
/**
* Show the stub's thumbnail if present; otherwise pull bytes from IndexedDB,
* generate one, persist it, and update the stub. Server-only files with no
@@ -68,7 +94,9 @@ export function useLazyThumbnail(
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
queueStubThumbUpdate(fileId, thumbnail, (id, url) =>
updateStirlingFileStub(id, { thumbnailUrl: url }),
);
} catch {
// non-critical
}
@@ -485,6 +485,10 @@ export async function materializeServerStubs(
const primary = ingested[ingested.length - 1]!;
const newId = primary.fileId as FileId;
const remoteUpdates = {
// The ingest above made a new local file, which starts in no folder. Without
// carrying membership across, materialising a file to open it moves it to the
// library root - the copy is the file as far as the library is concerned.
folderId: stub.folderId ?? null,
remoteStorageId: stub.remoteStorageId,
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
remoteOwnerUsername: stub.remoteOwnerUsername,
@@ -8,12 +8,24 @@ interface SeedFile {
id: string;
name: string;
remoteStorageId: number | null;
folderId?: string;
versionNumber?: number;
toolHistory?: Array<{ toolId: string; timestamp: number }>;
}
/** Seed IDB + register the cloud entries with the server stub. */
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
interface SeedFolder {
id: string;
name: string;
}
async function seedFiles(
page: Page,
files: SeedFile[],
// Browser-owned folders, seeded in the same open: a server folder needs an
// authenticated sync the stubbed app never runs.
virtualFolders: SeedFolder[] = [],
): Promise<void> {
// Build the server-side view from the cloud entries so reconcileServerFiles
// sees them as still-existing on the server (otherwise they get detached).
const serverFiles = files
@@ -36,7 +48,7 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
route.fulfill({ json: serverFiles }),
);
await page.addInitScript(
({ records, dbVersion }) => {
({ records, vFolders, dbVersion }) => {
const open = window.indexedDB.open("stirling-pdf-files", dbVersion);
open.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
@@ -56,15 +68,36 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
});
fStore.createIndex("name", "name", { unique: false });
}
if (!db.objectStoreNames.contains("virtual_folders")) {
const vStore = db.createObjectStore("virtual_folders", {
keyPath: "id",
});
vStore.createIndex("parentFolderId", "parentFolderId", {
unique: false,
});
}
if (!db.objectStoreNames.contains("local_folders")) {
db.createObjectStore("local_folders", { keyPath: "id" });
}
};
open.onsuccess = () => {
const db = open.result;
// Yield the connection if the app ever needs to upgrade, and drop it
// once the writes commit, so the seed never blocks the app's open.
db.onversionchange = () => db.close();
const tx = db.transaction("files", "readwrite");
const tx = db.transaction(["files", "virtual_folders"], "readwrite");
const store = tx.objectStore("files");
const now = Date.now();
for (const folder of vFolders) {
tx.objectStore("virtual_folders").put({
id: folder.id,
kind: "virtual",
name: folder.name,
parentFolderId: null,
createdAt: now,
updatedAt: now,
});
}
for (const f of records) {
store.put({
id: f.id,
@@ -83,7 +116,7 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
originalFileId: f.id,
parentFileId: null,
toolHistory: f.toolHistory ?? [],
folderId: null,
folderId: f.folderId ?? null,
remoteStorageId: f.remoteStorageId,
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
@@ -97,7 +130,11 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
tx.oncomplete = () => db.close();
};
},
{ records: files, dbVersion: DATABASE_CONFIGS.FILES.version },
{
records: files,
vFolders: virtualFolders,
dbVersion: DATABASE_CONFIGS.FILES.version,
},
);
}
@@ -415,6 +452,48 @@ test.describe("Files page", () => {
});
});
test.describe("Opening a file already in the workspace", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "dupe-test", name: "dupe-test.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
/**
* Opening a file that is already open has nothing to fetch and nothing to add:
* sending it through materialize-and-add again has no reason to succeed twice.
*/
test("opens it once, and opening it again neither duplicates nor throws", async ({
page,
}) => {
await gotoFilesPage(page);
const card = () =>
page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "dupe-test.pdf" });
await card().dblclick();
await expect(page).not.toHaveURL(/\/files/, { timeout: 5_000 });
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(1, {
timeout: 10_000,
});
// Back to the library and open the same file again.
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(card()).toBeVisible({ timeout: 10_000 });
await card().dblclick();
await expect(page).not.toHaveURL(/\/files/, { timeout: 5_000 });
// Still one: the workspace holds the file once, and the app is still up.
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(1, {
timeout: 10_000,
});
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
});
});
test.describe("Drag-and-drop wiring", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
@@ -499,7 +578,7 @@ test.describe("Files page", () => {
test.describe("Empty-state CTAs", () => {
test.use({ autoGoto: false });
test("renders Upload + Create folder CTAs when grid is empty", async ({
test("renders Upload + New folder CTAs when grid is empty", async ({
page,
}) => {
await stubStorageApis(page);
@@ -519,15 +598,15 @@ test.describe("Files page", () => {
await expect(
page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Create folder/i }),
.getByRole("button", { name: /New folder/i }),
).toBeVisible();
});
test("Create folder CTA disabled when storage isn't reachable", async ({
test("New folder CTA is disabled when storage isn't reachable", async ({
page,
}) => {
// Storage disabled - the New folder action is gated and the CTA
// should mirror that gating with a disabled state.
// The CTA is the header's control, so it reports the same blocked reason
// rather than offering a click that cannot land.
await stubStorageApis(page, { storageEnabled: false });
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
@@ -535,7 +614,7 @@ test.describe("Files page", () => {
});
const createCta = page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Create folder/i });
.getByRole("button", { name: /New folder/i });
await expect(createCta).toBeVisible();
await expect(createCta).toBeDisabled();
});
@@ -856,4 +935,120 @@ test.describe("Files page", () => {
expect(after).toBeGreaterThanOrEqual(before + 24);
});
});
test.describe("Folder navigation", () => {
const FOLDER_ID = "11111111-2222-4333-8444-555555555555";
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(
page,
[
{ id: "nav-outside", name: "nav-outside.pdf", remoteStorageId: null },
{
id: "nav-inside",
name: "nav-inside.pdf",
remoteStorageId: null,
folderId: FOLDER_ID,
},
],
[{ id: FOLDER_ID, name: "Invoices" }],
);
});
test.use({ autoGoto: false });
const intoFolder = async (page: Page) => {
const tree = page.getByRole("tree", { name: /Folders/i });
await expect(tree).toBeVisible({ timeout: 10_000 });
await tree.getByRole("treeitem", { name: /Invoices/i }).click();
await expect(page).toHaveURL(new RegExp(`/files/${FOLDER_ID}`), {
timeout: 5_000,
});
};
/**
* A breadcrumb is a plain jump to an ancestor. Everything the selection change
* drives - the listing, the folder filters, the path write - has to survive it.
*/
test("clicking a breadcrumb returns to the root without throwing", async ({
page,
}) => {
await page.goto("/files", { waitUntil: "domcontentloaded" });
await intoFolder(page);
const crumbs = page.getByRole("navigation", { name: /Folder path/i });
await expect(crumbs).toBeVisible({ timeout: 5_000 });
await crumbs.getByRole("button", { name: /All files/i }).click();
await expect(page).toHaveURL(/\/files\/?$/, { timeout: 5_000 });
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
// Still a working library, not a husk.
await expect(page.getByRole("tree", { name: /Folders/i })).toBeVisible();
});
/** Each folder is its own history entry, so Back walks up the tree rather than
* out of the library, and Forward returns to the folder. */
test("back leaves the folder rather than the library, and forward returns", async ({
page,
}) => {
await page.goto("/files", { waitUntil: "domcontentloaded" });
await intoFolder(page);
const deep = page.url();
await page.goBack();
await expect(page).toHaveURL(/\/files\/?$/, { timeout: 5_000 });
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
await page.goForward();
await expect(page).toHaveURL(deep, { timeout: 5_000 });
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
});
});
test.describe("Long lists", () => {
test.use({ autoGoto: false });
/**
* A folder can hold thousands of files, so the grid renders a window of them plus
* a spacer at each end rather than the whole list. Needs a real browser: without
* layout the window stands down and everything renders, which is the intended
* fallback but proves nothing about the windowing.
*/
test("renders a window of a long list, not all of it", async ({ page }) => {
const COUNT = 400;
await stubStorageApis(page);
await seedFiles(
page,
Array.from({ length: COUNT }, (_, i) => ({
id: `bulk-${i}`,
name: `bulk-${String(i).padStart(4, "0")}.pdf`,
remoteStorageId: null,
})),
);
await gotoFilesPage(page);
const cards = page.locator(
".files-page-card:not(.files-page-skeleton-card)",
);
const rendered = await cards.count();
expect(rendered).toBeGreaterThan(0);
expect(rendered).toBeLessThan(COUNT / 2);
// The spacers stand in for the rest, so the scroll height still reflects the
// whole folder rather than only what is mounted.
const scroller = page.locator(".files-page-content");
const metrics = await scroller.evaluate((el) => ({
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
}));
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight * 3);
// Scrolling to the end swaps the window rather than growing it.
const firstBefore = await cards.first().textContent();
await scroller.evaluate((el) => el.scrollTo({ top: el.scrollHeight }));
await expect
.poll(async () => cards.first().textContent(), { timeout: 5_000 })
.not.toBe(firstBefore);
expect(await cards.count()).toBeLessThan(COUNT / 2);
});
});
});
@@ -36,10 +36,6 @@ async function isWithinMount(path: string): Promise<boolean> {
);
}
/** Caps the listing; past the cap the freshest files win, which is what is wanted
* in a Downloads-like directory. */
const LIST_CAP = 500;
/**
* Every stat is a webview-to-Rust round trip, so listing cost is IPC latency, not disk
* speed.
@@ -100,7 +96,7 @@ export async function listDirectory(
}
}
files.sort((a, b) => b.lastModified - a.lastModified);
return { files: files.slice(0, LIST_CAP), directories };
return { files, directories };
}
export async function makeDiskDirectory(