diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index a8585debd3..1c77e19af4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx index d1cb9f32b6..e13bb86d9e 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx @@ -109,6 +109,5 @@ export const Empty: Story = { loading: false, currentTab: "all", onEmptyUpload: () => {}, - onEmptyCreateFolder: () => {}, }, }; diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index eb1bb5df3e..7fc393edb5 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -17,7 +17,6 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import UploadFileIcon from "@mui/icons-material/UploadFile"; -import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import SearchIcon from "@mui/icons-material/Search"; import { FileId } from "@app/types/file"; @@ -160,7 +159,10 @@ interface FileGridProps { serverReachable?: boolean; /** Empty-state CTA handlers; if absent the matching button hides. */ onEmptyUpload?: () => void; - onEmptyCreateFolder?: () => void; + /** The New-folder control for the empty state, built by the page that owns the + * destinations. Passed in rather than rebuilt here so the empty state and the + * header cannot offer different things. */ + emptyNewFolderControl?: React.ReactNode; /** Non-null disables the New folder CTA with this reason as tooltip. */ newFolderDisabledReason?: string | null; /** @@ -220,8 +222,7 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) { searchActive, serverReachable, onEmptyUpload, - onEmptyCreateFolder, - newFolderDisabledReason, + emptyNewFolderControl, } = props; const latest = useRef(props); @@ -304,8 +305,7 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) { searchActive={searchActive} serverReachable={serverReachable} onUpload={onEmptyUpload} - onCreateFolder={onEmptyCreateFolder} - newFolderDisabledReason={newFolderDisabledReason} + newFolderControl={emptyNewFolderControl} /> ); // When a filter empties the list view, keep the column headers in place and @@ -391,9 +391,8 @@ interface EmptyStateProps { serverReachable?: boolean; /** CTA handlers; absent => button hidden. */ onUpload?: () => void; - onCreateFolder?: () => void; - /** Non-null disables New folder CTA with this reason. */ - newFolderDisabledReason?: string | null; + /** Absent => no New folder CTA. */ + newFolderControl?: React.ReactNode; } function EmptyState({ @@ -401,8 +400,7 @@ function EmptyState({ searchActive = false, serverReachable = true, onUpload, - onCreateFolder, - newFolderDisabledReason, + newFolderControl, }: EmptyStateProps) { const { t } = useTranslation(); @@ -481,7 +479,7 @@ function EmptyState({ const readOnlyTab = tab === "recent" || tab === "shared" || tab === "sharedByMe"; const showUpload = Boolean(onUpload) && !readOnlyTab; - const showCreateFolder = Boolean(onCreateFolder) && !readOnlyTab; + const showCreateFolder = Boolean(newFolderControl) && !readOnlyTab; const showCtas = showUpload || showCreateFolder; return (
@@ -501,37 +499,7 @@ function EmptyState({ {t("filesPage.empty.uploadCta", "Upload files")} )} - {showCreateFolder && - (newFolderDisabledReason ? ( - - {/* Wrap so tooltip hovers while button is disabled. */} - - - - - ) : ( - - ))} + {showCreateFolder && newFolderControl}
)} diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index f7afd07dfb..bfb57d24cf 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -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(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]); @@ -300,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 ( @@ -314,7 +343,14 @@ 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(() => { @@ -746,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); @@ -783,6 +833,8 @@ export default function FileManagerView() { navigate, requestNavigation, clearFilesPageReturnRoute, + activeWorkspaceFileIdSet, + refresh, ], ); @@ -1087,8 +1139,7 @@ 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(() => { @@ -1212,89 +1263,15 @@ export default function FileManagerView() { - {newFolderDisabledReason ? ( - - - - - - ) : 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. - - ) : ( - // Desktop root: two peer destinations, so the button is the menu. - - - - - - - } - onClick={() => void addLocalFolder()} - > - {t( - "filesPage.newFolderMenu.addExisting", - "Add local folder", - )} - - } - disabled={Boolean(serverFolderDisabledReason)} - onClick={() => openNewFolderDialog(null, "server")} - > - {t( - "filesPage.newFolderMenu.server", - "New folder on the server", - )} - - {serverFolderDisabledReason ?? - t( - "filesPage.newFolderMenu.serverHint", - "Synced to your account, available wherever you sign in.", - )} - - - - - )} + void addLocalFolder()} + onOpenDialog={openNewFolderDialog} + /> + + + ); + } + + // 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 ( + + ); + } + + return ( + + + + + + + } + onClick={onAddLocalFolder} + > + {t("filesPage.newFolderMenu.addExisting", "Add local folder")} + + } + 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. */} + + {serverDisabledReason ?? + t( + "filesPage.newFolderMenu.serverHint", + "Synced to your account, available wherever you sign in.", + )} + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/useVirtualFileRows.ts b/frontend/editor/src/core/components/filesPage/useVirtualFileRows.ts index 1006db3858..7f9c43fb66 100644 --- a/frontend/editor/src/core/components/filesPage/useVirtualFileRows.ts +++ b/frontend/editor/src/core/components/filesPage/useVirtualFileRows.ts @@ -103,9 +103,16 @@ export function useVirtualFileRows( }, [active, rows, columns, itemCount, virtualizer]); } +// 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 { - const rem = - parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - return isGrid ? 13 * rem + rem : 3 * rem; + if (rootFontSizePx === 0) { + rootFontSizePx = + parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + } + return isGrid ? 14 * rootFontSizePx : 3 * rootFontSizePx; } diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index 32e8ae32f1..599f397b12 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -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, diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 250156be47..057bece3ac 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -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 { +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 { // 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 { 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 { }); 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 { 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 { 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(); }); @@ -857,6 +936,74 @@ test.describe("Files page", () => { }); }); + 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 });