Skip to content

Commit 153da23

Browse files
authored
Faster folders, working breadcrumbs and bug fixes (#7675)
## What this changes - **Big folders don't lag.** The file list only draws the rows you can actually see, so a folder with thousands of files feels the same as one with ten. Selecting a file redraws that file, not the whole folder. - **The 500-file limit is gone.** A mounted disk folder used to list only its 500 newest files and quietly hide the rest. You now see everything in it. - **Back and the breadcrumbs work.** Walking into folders now leaves proper history, so Back steps up one folder. Before, every folder overwrote the same history entry, so Back dumped you out of the library entirely. - **Deep links to a folder work.** Opening a link to a folder that hadn't finished loading used to bounce you to the top of the library. It now waits for the folder to appear. - **"New folder" is one button.** The version on an empty folder was a different button, with a different name, that guessed where to put the folder and greyed itself out when it couldn't. It's now the same menu as the one in the top right. - **"Local" in the left bar is just a filter.** Clicking it filters the list by source. It used to switch you into a separate view with its own rules for what counted as a file, its own empty screen, and its own exceptions everywhere folders were involved. - **Folders obey the source filter.** Picking Cloud or Local now filters folders too, not just files, so mounted folders stop appearing under every setting. - **Opening a file that's already open** takes you to it, instead of trying to add it a second time. - **Opening a cloud file keeps it in its folder** instead of dropping it at the top of the library.
1 parent 987756a commit 153da23

19 files changed

Lines changed: 1609 additions & 632 deletions

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4289,6 +4289,7 @@ back = "Back"
42894289
backToFolder = "Back to {{folder}}"
42904290
backToMyFiles = "Back to File library"
42914291
breadcrumbs = "Folder path"
4292+
breadcrumbsOverflow = "Show parent folders"
42924293
bulkActions = "Actions"
42934294
cancel = "Cancel"
42944295
classification = "Classification"
@@ -4410,7 +4411,6 @@ everywhereHint = "Deletes the file from this device and the cloud."
44104411

44114412
[filesPage.empty]
44124413
hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organize your files."
4413-
newFolderCta = "Create folder"
44144414
title = "This folder is empty"
44154415
uploadCta = "Upload files"
44164416

@@ -4420,10 +4420,6 @@ offlineHint = "Reconnect to load your cloud library."
44204420
offlineTitle = "No cached cloud files"
44214421
title = "No cloud files yet"
44224422

4423-
[filesPage.empty.local]
4424-
hint = "Files saved without uploading stay here. Drop a file to add one."
4425-
title = "No local-only files"
4426-
44274423
[filesPage.empty.noResults]
44284424
hint = "No files in this folder match your filter. Try a different term or clear the filter."
44294425
title = "No matching files"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render as baseRender } from "@testing-library/react";
3+
import { MantineProvider } from "@mantine/core";
4+
import type { FileId } from "@app/types/file";
5+
import type { StirlingFileStub } from "@app/types/fileContext";
6+
7+
/**
8+
* The grid's items are memoized so a selection click re-renders the cards whose
9+
* selection changed rather than the whole folder. That only holds while every prop
10+
* they take stays stable - one inline object or closure at a call site silently
11+
* undoes it, with no visible symptom until a folder is large. These count renders
12+
* so that regression fails here instead of in someone's 500-file folder.
13+
*/
14+
15+
// @app/ui wraps Mantine, so the provider has to be in the tree.
16+
const render = (ui: Parameters<typeof baseRender>[0]) =>
17+
baseRender(ui, { wrapper: MantineProvider });
18+
// Every card renders this exactly once, so its calls are a per-card render count.
19+
const badgeRenders: { n: number } = { n: 0 };
20+
vi.mock("@app/components/shared/PolicyBadges", () => ({
21+
PolicyBadges: () => {
22+
badgeRenders.n += 1;
23+
return null;
24+
},
25+
}));
26+
const buildStub = (id: string, name: string): StirlingFileStub =>
27+
({
28+
id: id as FileId,
29+
name,
30+
type: "application/pdf",
31+
size: 1_000,
32+
lastModified: 0,
33+
isLeaf: true,
34+
originalFileId: id,
35+
versionNumber: 1,
36+
// Set so useLazyThumbnail short-circuits instead of reading IndexedDB.
37+
thumbnailUrl: "data:image/svg+xml,%3Csvg/%3E",
38+
}) as StirlingFileStub;
39+
40+
describe("FileGrid item memoization", () => {
41+
it("re-renders only the cards whose selection changed", async () => {
42+
const { FileGrid } = await import("@app/components/filesPage/FileGrid");
43+
const { FileContextProvider } = await import("@app/contexts/FileContext");
44+
45+
const files = ["a", "b", "c", "d"].map((id) => buildStub(id, `${id}.pdf`));
46+
const entries = files.map((file) => ({ kind: "file" as const, file }));
47+
48+
const props = {
49+
entries,
50+
viewMode: "grid" as const,
51+
onSelectFile: () => {},
52+
onOpenFolder: () => {},
53+
onOpenFile: () => {},
54+
onMoveFiles: () => {},
55+
onMoveFolder: () => {},
56+
onRenameFolder: () => {},
57+
onDeleteFolder: () => {},
58+
onChangeFolderAppearance: () => {},
59+
onRemoveFiles: () => {},
60+
onPromptMoveFiles: () => {},
61+
};
62+
63+
const view = render(
64+
<FileContextProvider>
65+
<FileGrid {...props} selectedFileIds={new Set<FileId>()} />
66+
</FileContextProvider>,
67+
);
68+
const cards = () =>
69+
view.container.querySelectorAll(".files-page-card:not(.is-folder)");
70+
expect(cards()).toHaveLength(4);
71+
const initialRenders = badgeRenders.n;
72+
expect(initialRenders).toBeGreaterThanOrEqual(4);
73+
74+
// Selecting one file changes isSelected for exactly one card. The rest take
75+
// identical props, so memo should skip them.
76+
view.rerender(
77+
<FileContextProvider>
78+
<FileGrid
79+
{...props}
80+
selectedFileIds={new Set<FileId>(["a" as FileId])}
81+
/>
82+
</FileContextProvider>,
83+
);
84+
expect(cards()).toHaveLength(4);
85+
expect(
86+
view.container.querySelectorAll(".files-page-card.is-selected"),
87+
).toHaveLength(1);
88+
89+
// The point of the exercise: one card changed, so the re-render count moves by
90+
// one card's worth and not four. Unmemoized items redraw the whole folder here.
91+
const rerendered = badgeRenders.n - initialRenders;
92+
const perCard = initialRenders / 4;
93+
expect(rerendered).toBe(perCard);
94+
// The real provider tree and four thumbnail-bearing cards cost seconds to mount,
95+
// which the default budget cannot absorb alongside the rest of the suite.
96+
}, 20_000);
97+
});

frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type FilesPageEntry,
66
} from "@app/components/filesPage/FileGrid";
77
import { FileContextProvider } from "@app/contexts/FileContext";
8+
import { NewFolderButton } from "@app/components/filesPage/NewFolderButton";
89
import type { StirlingFileStub } from "@app/types/fileContext";
910
import type { FileId } from "@app/types/file";
1011

@@ -109,6 +110,17 @@ export const Empty: Story = {
109110
loading: false,
110111
currentTab: "all",
111112
onEmptyUpload: () => {},
112-
onEmptyCreateFolder: () => {},
113+
// The page owns this control, so the story stands one up to keep both CTAs on
114+
// screen here.
115+
emptyNewFolderControl: (
116+
<NewFolderButton
117+
label="New folder"
118+
size="md"
119+
currentFolderId={null}
120+
canAddLocalFolder={false}
121+
onAddLocalFolder={() => {}}
122+
onOpenDialog={() => {}}
123+
/>
124+
),
113125
},
114126
};

0 commit comments

Comments
 (0)