From a349afe9c16d8ba826349deca3948fccbb9ea674 Mon Sep 17 00:00:00 2001 From: Reece Date: Wed, 2 Sep 2026 17:21:25 +0100 Subject: [PATCH] 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. --- .../public/locales/en-US/translation.toml | 1 - .../components/filesPage/FileGrid.stories.tsx | 1 - .../core/components/filesPage/FileGrid.tsx | 54 +---- .../components/filesPage/FileManagerView.tsx | 213 ++++++++---------- .../components/filesPage/NewFolderButton.tsx | 126 +++++++++++ .../filesPage/useVirtualFileRows.ts | 13 +- .../src/core/services/fileSyncService.ts | 4 + .../src/core/tests/stubbed/files-page.spec.ts | 169 +++++++++++++- 8 files changed, 407 insertions(+), 174 deletions(-) create mode 100644 frontend/editor/src/core/components/filesPage/NewFolderButton.tsx 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 });