refactor(editor): one top bar in the file library, not two

The library kept a header of its own, so with the workbench bar now showing it
had two stacked bars - and two search boxes, since both carried a SuperSearch.

Its Refresh, New folder, Upload and Upload-from-Mobile controls register into the
workbench bar through the same hook the viewer and page editor use, and the header
goes. What stays in the view is the breadcrumb and tab name: that says where you
are rather than doing anything, and the bar has nowhere to put it.

The handlers passed to the bar are memoised. Registration re-runs whenever they
change identity, so an arrow defined in the call loops until React gives up.
This commit is contained in:
Reece
2026-09-01 13:19:48 +01:00
parent e91c4cf580
commit 56a90b543a
3 changed files with 169 additions and 229 deletions
@@ -22,8 +22,6 @@ import { useMediaQuery } from "@mantine/hooks";
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";
@@ -32,11 +30,11 @@ 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 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 { useFileLibraryWorkbenchBarButtons } from "@app/components/filesPage/useFileLibraryWorkbenchBarButtons";
import { stripBasePath } from "@app/constants/app";
import { useAuth } from "@app/auth/UseSession";
@@ -63,8 +61,6 @@ import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
@@ -92,7 +88,6 @@ export default function FileManagerView() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const searchScopes = useEditorSearchScopes();
// Hide Shared tab when storageSharingEnabled is false.
const { sharingEnabled } = useSharingEnabled();
@@ -914,6 +909,32 @@ export default function FileManagerView() {
);
// null = New folder actionable; string = disabled tooltip reason.
// Lifted out of the header that used to render it: the workbench bar owns these
// controls now, and a handler defined inside JSX cannot be handed to it.
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
// pullFromServer bumps the folder revision, which the FolderProvider's effect
// reacts to by re-running refresh() - no need to await folders.refresh() here.
const result = await folders.pullFromServer();
if (!result.ok && result.reason !== "endpoint-missing") {
folders.setError(
result.reason === "network"
? t("filesPage.syncError.network", "Could not reach the server.")
: result.reason === "server"
? t(
"filesPage.syncError.server",
"Server error during folder sync.",
)
: t("filesPage.syncError.client", "Folder sync failed."),
);
}
await refresh();
} finally {
setRefreshing(false);
}
}, [folders, refresh, t]);
const newFolderDisabledReason: string | null = useMemo(() => {
// Guests can't use cloud folders at all - say so before any tab/storage
// hint, since switching tabs wouldn't help them.
@@ -945,23 +966,35 @@ export default function FileManagerView() {
return null;
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
// Stable identities: the bar re-registers whenever these change, and a fresh arrow
// per render turns that into an endless register -> render -> register loop.
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
const openMobileUpload = useCallback(
() => setMobileUploadModalOpen(true),
[],
);
useFileLibraryWorkbenchBarButtons({
onRefresh: handleRefresh,
refreshing,
refreshDisabledReason: signInRequiredReason,
onNewFolder: openNewFolderDialog,
newFolderDisabledReason,
onUpload: openFilePicker,
onUploadFromMobile: isMobileUploadAvailable ? openMobileUpload : undefined,
});
return (
<div className="files-page" ref={dropZoneRef}>
<header className="files-page-header">
{/* Breadcrumb only for folder-rooted tabs. */}
{/* Folder context, not a bar: the controls that used to sit beside this are
registered into the workbench bar the other views use. */}
<div className="files-page-location">
{(currentTab === "all" || currentTab === "cloud") && <Breadcrumbs />}
{(currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe") && (
<div
style={{
fontSize: "0.95rem",
fontWeight: 600,
padding: "0.25rem 0.5rem",
color: "var(--c-text)",
}}
>
<span className="files-page-location-name">
{currentTab === "local"
? t("filesPage.tabName.local", "Local")
: currentTab === "recent"
@@ -969,132 +1002,16 @@ export default function FileManagerView() {
: currentTab === "shared"
? t("filesPage.tabName.shared", "Shared with me")
: t("filesPage.tabName.sharedByMe", "Shared by me")}
</div>
</span>
)}
{(() => {
// Both the inline desktop buttons and the mobile kebab menu need
// these handlers - extract once so we don't drift two copies.
const handleRefresh = async () => {
setRefreshing(true);
try {
// pullFromServer bumps the folder revision, which the
// FolderProvider's effect reacts to by re-running refresh() -
// no need to await folders.refresh() manually.
const result = await folders.pullFromServer();
if (!result.ok && result.reason !== "endpoint-missing") {
folders.setError(
result.reason === "network"
? t(
"filesPage.syncError.network",
"Could not reach the server.",
)
: result.reason === "server"
? t(
"filesPage.syncError.server",
"Server error during folder sync.",
)
: t("filesPage.syncError.client", "Folder sync failed."),
);
}
await refresh();
} finally {
setRefreshing(false);
}
};
return (
<>
<div className="files-page-header-search">
<SuperSearch scopes={searchScopes} />
</div>
<div className="files-page-header-actions">
<Tooltip
label={
signInRequiredReason ??
t("filesPage.refresh", "Refresh from server")
}
withinPortal
>
<ActionIcon
variant="secondary"
size="sm"
loading={refreshing}
disabled={refreshing || Boolean(signInRequiredReason)}
aria-busy={refreshing}
onClick={handleRefresh}
aria-label={t("filesPage.refresh", "Refresh from server")}
>
<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>
) : (
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => openNewFolderDialog()}
>
{t("filesPage.newFolder", "New folder")}
</Button>
)}
<Button
size="sm"
leftSection={<UploadFileIcon fontSize="small" />}
onClick={() => fileInputRef.current?.click()}
>
{t("filesPage.upload", "Upload")}
</Button>
{isMobileUploadAvailable && (
<Tooltip
label={t(
"filesPage.uploadFromMobile",
"Upload from Mobile",
)}
withinPortal
>
<ActionIcon
size="sm"
variant="secondary"
onClick={() => setMobileUploadModalOpen(true)}
aria-label={t(
"filesPage.uploadFromMobile",
"Upload from Mobile",
)}
>
<QrCode2Icon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={onFileInputChange}
/>
</div>
</>
);
})()}
</header>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={onFileInputChange}
/>
{folders.error && (
<div
@@ -8,57 +8,21 @@
overflow: hidden;
}
.files-page-header {
/* 3-column grid keeps the search bar position-stable regardless of
* breadcrumb length. Left column flexes for the breadcrumb (truncating
* via overflow:hidden), middle column holds the search, right column
* holds the action buttons right-aligned. */
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(min(14rem, 100%), 40rem) minmax(
0,
1fr
);
align-items: center;
gap: 1rem;
min-height: 48px;
padding: 0 0.75rem;
border-bottom: 1px solid var(--c-border-subtle);
background: var(--c-bg-raised);
flex-shrink: 0;
}
.files-page-header-actions {
justify-self: end;
/* Where you are in the library. Not a bar - the controls that used to share this
row are registered into the workbench bar, and the search lives there too. */
.files-page-location {
display: flex;
align-items: center;
gap: 0.5rem;
}
.files-page-header-search {
display: flex;
align-items: center;
justify-content: center;
min-height: 2rem;
padding: 0.25rem 0.75rem 0;
min-width: 0;
}
.files-page-header-search .super-search {
flex: 0 1 24rem;
width: min(100%, 24rem);
max-width: 24rem;
}
.files-page-header-search .super-search input {
background-color: transparent;
padding-top: 4px;
padding-bottom: 4px;
font-size: 12.5px;
}
[data-mantine-color-scheme="dark"]
.files-page-header-search
.super-search
input {
background-color: transparent;
.files-page-location-name {
font-size: 0.95rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
color: var(--c-text);
}
.files-page-breadcrumbs {
@@ -1317,12 +1281,6 @@
filter selects, and pins the view toggle so it never clips off the
right edge. The header stays in desktop layout here (it has fewer
items and the side panels disappearing actually GIVES it room). */
.files-page-header [data-desktop-hide="true"] {
/* The mobile-only overflow menu trigger. Hidden by default; shown
inside the mobile breakpoint below. Live as inline-flex so it
sits next to the Upload button without breaking the action row. */
display: none;
}
@media (max-width: 1024px) {
.files-page-toolbar {
/* nowrap so "7 items" + "Select all" sit on the same row as the
@@ -1395,31 +1353,6 @@
search takes whatever is left. Ends at the app's mobile breakpoint so
it matches the layout HomePage is already rendering. */
@media (max-width: 1024px) {
.files-page-header {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.4rem;
overflow-x: hidden;
}
.files-page-header-search {
flex: 1 1 auto;
min-width: 0;
justify-content: flex-start;
}
/* Undo the fixed 24rem basis so the pill tracks the row's spare width. */
.files-page-header-search .super-search {
flex: 1 1 auto;
width: 100%;
max-width: none;
}
.files-page-header-actions {
flex: 0 0 auto;
margin-left: auto;
gap: 0.25rem;
flex-wrap: nowrap;
}
.files-page-breadcrumbs {
flex: 0 1 auto;
font-size: 0.85rem;
@@ -1427,17 +1360,6 @@
overflow-x: auto;
min-width: 0;
}
/* Icon-only actions: the labels are what pushed Upload past the edge. */
.files-page-header-actions .mantine-Button-root {
padding-left: 0.55rem;
padding-right: 0.55rem;
}
.files-page-header-actions .mantine-Button-section[data-position="left"] {
margin-right: 0;
}
.files-page-header-actions .mantine-Button-label {
display: none;
}
}
@media (max-width: 640px) {
@@ -0,0 +1,101 @@
import { useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import QrCode2Icon from "@mui/icons-material/QrCode2";
import RefreshIcon from "@mui/icons-material/Refresh";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import {
useWorkbenchBarButtons,
type WorkbenchBarButtonWithAction,
} from "@app/hooks/useWorkbenchBarButtons";
export interface FileLibraryWorkbenchBarButtonsOptions {
onRefresh: () => void;
refreshing: boolean;
/** Set when refreshing needs a session the user does not have; also the tooltip. */
refreshDisabledReason: ReactNode;
onNewFolder: () => void;
/** Set when the server ships no folder storage; also the tooltip. */
newFolderDisabledReason: ReactNode;
onUpload: () => void;
onUploadFromMobile?: () => void;
}
/**
* The library's own actions, in the bar every other view uses. It had a header of
* its own carrying these plus a second search box, which read as two stacked bars.
*/
export function useFileLibraryWorkbenchBarButtons({
onRefresh,
refreshing,
refreshDisabledReason,
onNewFolder,
newFolderDisabledReason,
onUpload,
onUploadFromMobile,
}: FileLibraryWorkbenchBarButtonsOptions): void {
const { t } = useTranslation();
const buttons = useMemo<WorkbenchBarButtonWithAction[]>(
() => [
{
id: "fileLibraryRefresh",
icon: <RefreshIcon />,
tooltip:
refreshDisabledReason ??
t("filesPage.refresh", "Refresh from server"),
ariaLabel: t("filesPage.refresh", "Refresh from server"),
section: "top",
order: 10,
disabled: refreshing || Boolean(refreshDisabledReason),
onClick: onRefresh,
},
{
id: "fileLibraryNewFolder",
icon: <CreateNewFolderIcon />,
tooltip:
newFolderDisabledReason ?? t("filesPage.newFolder", "New folder"),
ariaLabel: t("filesPage.newFolder", "New folder"),
section: "top",
order: 20,
disabled: Boolean(newFolderDisabledReason),
onClick: onNewFolder,
},
{
id: "fileLibraryUpload",
icon: <UploadFileIcon />,
tooltip: t("filesPage.upload", "Upload"),
ariaLabel: t("filesPage.upload", "Upload"),
section: "top",
order: 30,
onClick: onUpload,
},
// Only where a phone can pair with this session.
...(onUploadFromMobile
? [
{
id: "fileLibraryUploadFromMobile",
icon: <QrCode2Icon />,
tooltip: t("filesPage.uploadFromMobile", "Upload from Mobile"),
ariaLabel: t("filesPage.uploadFromMobile", "Upload from Mobile"),
section: "top" as const,
order: 40,
onClick: onUploadFromMobile,
},
]
: []),
],
[
t,
onRefresh,
refreshing,
refreshDisabledReason,
onNewFolder,
newFolderDisabledReason,
onUpload,
onUploadFromMobile,
],
);
useWorkbenchBarButtons(buttons);
}