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.
This commit is contained in:
Reece
2026-09-02 17:21:25 +01:00
parent e4cb26be43
commit a349afe9c1
8 changed files with 407 additions and 174 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"
@@ -109,6 +109,5 @@ export const Empty: Story = {
loading: false,
currentTab: "all",
onEmptyUpload: () => {},
onEmptyCreateFolder: () => {},
},
};
@@ -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 (
<div className="files-page-empty">
@@ -501,37 +499,7 @@ function EmptyState({
{t("filesPage.empty.uploadCta", "Upload files")}
</Button>
)}
{showCreateFolder &&
(newFolderDisabledReason ? (
<Tooltip
label={newFolderDisabledReason}
withinPortal
multiline
w={260}
>
{/* Wrap so tooltip hovers while button is disabled. */}
<span style={{ display: "inline-flex" }}>
<Button
size="md"
variant="secondary"
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
style={{ pointerEvents: "auto" }}
>
{t("filesPage.empty.newFolderCta", "Create folder")}
</Button>
</span>
</Tooltip>
) : (
<Button
size="md"
variant="secondary"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={onCreateFolder}
>
{t("filesPage.empty.newFolderCta", "Create folder")}
</Button>
))}
{showCreateFolder && newFolderControl}
</div>
)}
</div>
@@ -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]);
@@ -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;
}
if (materialized.length > 0) {
await fileActions.addStirlingFileStubs(materialized, {
selectFiles: false,
});
// Branch on requested stubs so already-active files still activate.
if (materialized.length === 1) {
setActiveFileId(materialized[0].id);
}
// 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() {
<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" }}
<NewFolderButton
label={t("filesPage.newFolder", "New folder")}
disabledReason={newFolderDisabledReason}
serverDisabledReason={serverFolderDisabledReason}
currentFolderId={folders.currentFolderId}
canAddLocalFolder={canPickDirectory}
onAddLocalFolder={() => void addLocalFolder()}
onOpenDialog={openNewFolderDialog}
/>
}
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>
)}
<Button
size="sm"
leftSection={<UploadFileIcon fontSize="small" />}
@@ -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={
<NewFolderButton
label={t("filesPage.newFolder", "New folder")}
size="md"
disabledReason={newFolderDisabledReason}
serverDisabledReason={serverFolderDisabledReason}
currentFolderId={folders.currentFolderId}
canAddLocalFolder={canPickDirectory}
onAddLocalFolder={() => void addLocalFolder()}
onOpenDialog={openNewFolderDialog}
/>
}
/>
{isDraggingExternal && (
@@ -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>
);
}
@@ -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 =
if (rootFontSizePx === 0) {
rootFontSizePx =
parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return isGrid ? 13 * rem + rem : 3 * rem;
}
return isGrid ? 14 * rootFontSizePx : 3 * rootFontSizePx;
}
@@ -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();
});
@@ -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 });