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. */}
-
- }
- disabled
- style={{ pointerEvents: "auto" }}
- >
- {t("filesPage.empty.newFolderCta", "Create folder")}
-
-
-
- ) : (
- }
- onClick={onCreateFolder}
- >
- {t("filesPage.empty.newFolderCta", "Create folder")}
-
- ))}
+ {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 ? (
-
-
- }
- disabled
- style={{ pointerEvents: "auto" }}
- >
- {t("filesPage.newFolder", "New folder")}
-
-
-
- ) : 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.
- }
- onClick={() =>
- folders.currentFolderId !== null
- ? openNewFolderDialog()
- : openNewFolderDialog(null, "server")
- }
- >
- {t("filesPage.newFolder", "New folder")}
-
- ) : (
- // Desktop root: two peer destinations, so the button is the menu.
-
- )}
+ void addLocalFolder()}
+ onOpenDialog={openNewFolderDialog}
+ />
}
@@ -1934,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={
+ void addLocalFolder()}
+ onOpenDialog={openNewFolderDialog}
+ />
}
/>
{isDraggingExternal && (
diff --git a/frontend/editor/src/core/components/filesPage/NewFolderButton.tsx b/frontend/editor/src/core/components/filesPage/NewFolderButton.tsx
new file mode 100644
index 0000000000..79de32b013
--- /dev/null
+++ b/frontend/editor/src/core/components/filesPage/NewFolderButton.tsx
@@ -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 (
+
+ {/* Wrapped so the tooltip still opens while the button is disabled. */}
+
+ }
+ disabled
+ style={{ pointerEvents: "auto" }}
+ >
+ {label}
+
+
+
+ );
+ }
+
+ // 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 (
+ }
+ onClick={() =>
+ currentFolderId !== null
+ ? onOpenDialog()
+ : onOpenDialog(null, "server")
+ }
+ >
+ {label}
+
+ );
+ }
+
+ return (
+
+ );
+}
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 });