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