folder and file fixes (#6461)

This commit is contained in:
Anthony Stirling
2026-05-28 15:57:35 +01:00
committed by GitHub
parent 4fa67afc3d
commit c80a5db5f5
12 changed files with 314 additions and 158 deletions
@@ -289,7 +289,7 @@ export function FileDetailsPanel({
<Tooltip
label={t(
"filesPage.shareDisabledHint",
"File sharing isn't enabled on this server. Ask your admin to set storage.sharing.enabled=true to turn on share links and per-user access.",
"File sharing isn't enabled on this server. Ask your admin to enable it.",
)}
disabled={sharingEnabled}
withinPortal
@@ -30,6 +30,7 @@ import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge";
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
export type FilesPageViewMode = "grid" | "list";
@@ -80,14 +81,7 @@ interface FileGridProps {
sortMode?: FilesPageSortMode;
onChangeSortMode?: (mode: FilesPageSortMode) => void;
/** Drives the empty-state copy. */
currentTab?:
| "all"
| "local"
| "cloud"
| "recent"
| "shared"
| "sharedByMe"
| "imSharing";
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
/** Cloud reachability; switches the cloud empty-state copy. */
serverReachable?: boolean;
/** Empty-state CTA handlers; if absent the matching button hides. */
@@ -185,14 +179,7 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
interface EmptyStateProps {
/** Drives copy + iconography. */
tab?:
| "all"
| "local"
| "cloud"
| "recent"
| "shared"
| "sharedByMe"
| "imSharing";
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
/** Switches the cloud empty-state copy. */
serverReachable?: boolean;
/** CTA handlers; absent => button hidden. */
@@ -252,18 +239,10 @@ function EmptyState({
case "sharedByMe":
return {
titleKey: "filesPage.empty.sharedByMe.title",
titleFallback: "No share links yet",
titleFallback: "You haven't shared any files yet",
hintKey: "filesPage.empty.sharedByMe.hint",
hintFallback:
"Create a share link on any of your files to surface it here.",
};
case "imSharing":
return {
titleKey: "filesPage.empty.imSharing.title",
titleFallback: "Not sharing with anyone yet",
hintKey: "filesPage.empty.imSharing.hint",
hintFallback:
"Invite a teammate to one of your files to see it listed here.",
"Create a share link or invite a teammate from any of your files to see it here.",
};
case "all":
default:
@@ -278,10 +257,7 @@ function EmptyState({
})();
// Recent/Shared tabs are read-only filters; Local is cloud-only for folders.
const readOnlyTab =
tab === "recent" ||
tab === "shared" ||
tab === "sharedByMe" ||
tab === "imSharing";
tab === "recent" || tab === "shared" || tab === "sharedByMe";
const showUpload = Boolean(onUpload) && !readOnlyTab;
const showCreateFolder =
Boolean(onCreateFolder) && !readOnlyTab && tab !== "local";
@@ -645,6 +621,11 @@ function FileCard({
const extension = file.name.split(".").pop()?.toUpperCase() ?? "";
const isPdf = extension === "PDF";
const resolvedThumbnail = useLazyThumbnail(
file.id,
file.size,
file.thumbnailUrl,
);
const kebabRef = useRef<HTMLButtonElement>(null);
const handleContextMenu = useCallback(
@@ -712,9 +693,9 @@ function FileCard({
</div>
)}
<div className="files-page-card-thumb">
{file.thumbnailUrl ? (
{resolvedThumbnail ? (
// draggable={false} so card's onDragStart fires, not native image drag.
<img src={file.thumbnailUrl} alt="" draggable={false} />
<img src={resolvedThumbnail} alt="" draggable={false} />
) : (
<div className="files-page-card-thumb-fallback">
{isPdf ? (
@@ -1189,6 +1170,11 @@ function FileRow({
[file.lastModified],
);
const ext = (file.name.split(".").pop() ?? "").toUpperCase();
const resolvedThumbnail = useLazyThumbnail(
file.id,
file.size,
file.thumbnailUrl,
);
return (
<div
role="row"
@@ -1253,9 +1239,9 @@ function FileRow({
minWidth: 0,
}}
>
{file.thumbnailUrl ? (
{resolvedThumbnail ? (
<img
src={file.thumbnailUrl}
src={resolvedThumbnail}
alt=""
// draggable={false} so row's onDragStart fires, not native image drag.
draggable={false}
@@ -21,6 +21,7 @@ import { useMediaQuery } from "@mantine/hooks";
import SearchIcon from "@mui/icons-material/Search";
import CloseIcon from "@mui/icons-material/Close";
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";
@@ -58,6 +59,9 @@ import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
@@ -98,6 +102,11 @@ export default function FileManagerView() {
[activeWorkspaceFileIds],
);
const { addFiles } = useFileHandler();
const { config: appConfig } = useAppConfig();
const isMobile = useIsMobile();
const isMobileUploadAvailable =
Boolean(appConfig?.enableMobileScanner) && !isMobile;
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { actions: navActions } = useNavigationActions();
const { requestNavigation } = useNavigationGuard();
const { setActiveFileId } = useViewer();
@@ -162,9 +171,7 @@ export default function FileManagerView() {
useEffect(() => {
if (
!sharingEnabled &&
(currentTab === "shared" ||
currentTab === "sharedByMe" ||
currentTab === "imSharing")
(currentTab === "shared" || currentTab === "sharedByMe")
) {
setCurrentTab("all");
}
@@ -211,8 +218,7 @@ export default function FileManagerView() {
currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe" ||
currentTab === "imSharing"
currentTab === "sharedByMe"
) {
return [];
}
@@ -260,28 +266,37 @@ export default function FileManagerView() {
case "shared":
return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false);
case "sharedByMe":
// Files I own that have at least one outgoing public share link.
// Files I own that I've shared in any way - either with a public link
// or with a specific user. (Previously split across two visually
// identical tabs; merged here so the same idea lives in one place.)
return allFiles.filter(
(f) =>
f.remoteOwnedByCurrentUser !== false &&
f.remoteHasShareLinks === true,
);
case "imSharing":
// Files I own that I've shared directly with specific users.
return allFiles.filter(
(f) =>
f.remoteOwnedByCurrentUser !== false &&
f.remoteHasUserShares === true,
(f.remoteHasShareLinks === true || f.remoteHasUserShares === true),
);
case "all":
default:
// Search widens to the subtree.
// Files with a dangling folderId (folder deleted, or stale local IDB
// row) fall back to root so they aren't permanently invisible.
return allFiles.filter((f) => {
if (search) return subtreeFolderIds.has(f.folderId ?? null);
return (f.folderId ?? null) === (currentFolderId ?? null);
const rawFolder = f.folderId ?? null;
const effectiveFolder =
rawFolder !== null && !foldersById.has(rawFolder)
? null
: rawFolder;
if (search) return subtreeFolderIds.has(effectiveFolder);
return effectiveFolder === (currentFolderId ?? null);
});
}
}, [allFiles, currentFolderId, currentTab, search, subtreeFolderIds]);
}, [
allFiles,
currentFolderId,
currentTab,
search,
subtreeFolderIds,
foldersById,
]);
const availableTypes = useMemo(() => {
const set = new Set<string>();
@@ -786,8 +801,7 @@ export default function FileManagerView() {
if (
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe" ||
currentTab === "imSharing"
currentTab === "sharedByMe"
) {
return t(
"filesPage.newFolderTabUnavailable",
@@ -811,8 +825,7 @@ export default function FileManagerView() {
{(currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe" ||
currentTab === "imSharing") && (
currentTab === "sharedByMe") && (
<div
style={{
fontSize: "0.95rem",
@@ -827,9 +840,7 @@ export default function FileManagerView() {
? t("filesPage.tabName.recent", "Recent")
: currentTab === "shared"
? t("filesPage.tabName.shared", "Shared with me")
: currentTab === "sharedByMe"
? t("filesPage.tabName.sharedByMe", "Shared by me")
: t("filesPage.tabName.imSharing", "Sharing")}
: t("filesPage.tabName.sharedByMe", "Shared by me")}
</div>
)}
{(() => {
@@ -922,6 +933,28 @@ export default function FileManagerView() {
>
{t("filesPage.upload", "Upload")}
</Button>
{isMobileUploadAvailable && (
<Tooltip
label={t(
"filesPage.uploadFromMobile",
"Upload from Mobile",
)}
withinPortal
>
<ActionIcon
size="lg"
variant="default"
radius="md"
onClick={() => setMobileUploadModalOpen(true)}
aria-label={t(
"filesPage.uploadFromMobile",
"Upload from Mobile",
)}
>
<QrCode2Icon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
<input
ref={fileInputRef}
type="file"
@@ -987,10 +1020,6 @@ export default function FileManagerView() {
id: "sharedByMe" as const,
label: t("filesPage.tabs.sharedByMe", "Shared by me"),
},
{
id: "imSharing" as const,
label: t("filesPage.tabs.imSharing", "Sharing"),
},
]
: []),
] as const;
@@ -1571,6 +1600,16 @@ export default function FileManagerView() {
files={saveToServerTarget ?? []}
onUploaded={refresh}
/>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={(files) => {
if (files.length > 0) {
void addFiles(files);
}
}}
/>
</div>
);
}
@@ -1,62 +1,14 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useFileManagement } from "@app/contexts/FileContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
import "@app/components/shared/FileSidebarFileItem.css";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */
function useLazyThumbnail(
fileId: FileId,
size: number,
thumbnailUrl?: string,
): string | undefined {
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
const indexedDB = useIndexedDB();
const { updateStirlingFileStub } = useFileManagement();
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
useEffect(() => {
if (thumbnailUrl) setThumb(thumbnailUrl);
}, [thumbnailUrl]);
useEffect(() => {
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
return;
attempted.current = true;
let cancelled = false;
(async () => {
try {
const file = await indexedDB.loadFile(fileId);
if (!file || cancelled) return;
const thumbnail = await generateThumbnailForFile(file);
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
} catch {
// non-critical
}
})();
return () => {
cancelled = true;
};
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
return thumb;
}
export function getFileExtension(name: string): string {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
@@ -49,8 +49,7 @@ export type FilesPageTab =
| "cloud"
| "recent"
| "shared"
| "sharedByMe"
| "imSharing";
| "sharedByMe";
export interface FolderNameDialogState {
mode: "new" | "rename" | null;
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from "react";
import type { FileId } from "@app/types/file";
import { useFileManagement } from "@app/contexts/FileContext";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
/**
* 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
* cached bytes silently stay placeholder-only.
*/
export function useLazyThumbnail(
fileId: FileId,
size: number,
thumbnailUrl?: string,
): string | undefined {
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
const indexedDB = useIndexedDB();
const { updateStirlingFileStub } = useFileManagement();
useEffect(() => {
if (thumbnailUrl) setThumb(thumbnailUrl);
}, [thumbnailUrl]);
useEffect(() => {
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
return;
attempted.current = true;
let cancelled = false;
(async () => {
try {
const file = await indexedDB.loadFile(fileId);
if (!file || cancelled) return;
const thumbnail = await generateThumbnailForFile(file);
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
} catch {
// non-critical
}
})();
return () => {
cancelled = true;
};
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
return thumb;
}
+39 -14
View File
@@ -39,6 +39,29 @@ import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
import "@app/pages/HomePage.css";
const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed";
function readPersistedSidebarCollapsed(): boolean {
try {
return (
window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true"
);
} catch {
return false;
}
}
function writePersistedSidebarCollapsed(collapsed: boolean): void {
try {
window.localStorage.setItem(
SIDEBAR_COLLAPSED_STORAGE_KEY,
String(collapsed),
);
} catch {
// private mode / quota: silently no-op
}
}
type MobileView = "tools" | "workbench";
export default function HomePage() {
@@ -66,9 +89,12 @@ export default function HomePage() {
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const location = useLocation();
// Collapse the sidebar when mounting directly on /files.
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(() =>
location.pathname.startsWith("/files"),
// Persisted user preference for the FileSidebar collapsed state. Auto-
// collapse on /files is layered on top in the transition effect below and
// doesn't write to storage, so deep-linking to /files won't overwrite what
// the user actually chose last time.
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(
readPersistedSidebarCollapsed,
);
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
@@ -101,22 +127,17 @@ export default function HomePage() {
activeFiles.length,
]);
// Auto-collapse the FileSidebar on /files; snapshot prior state for restore.
const previousSidebarCollapsedRef = useRef<boolean | null>(null);
// Auto-collapse the FileSidebar while on /files; restore the user's persisted
// preference on leave. Auto-collapse doesn't write to storage so deep-linking
// to /files won't overwrite what the user actually chose.
const prevWorkbenchRef = useRef(navigationState.workbench);
useEffect(() => {
const prev = prevWorkbenchRef.current;
const curr = navigationState.workbench;
if (curr === "myFiles" && prev !== "myFiles") {
previousSidebarCollapsedRef.current = fileSidebarCollapsed;
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
} else if (
curr !== "myFiles" &&
prev === "myFiles" &&
previousSidebarCollapsedRef.current !== null
) {
setFileSidebarCollapsed(previousSidebarCollapsedRef.current);
previousSidebarCollapsedRef.current = null;
} else if (curr !== "myFiles" && prev === "myFiles") {
setFileSidebarCollapsed(readPersistedSidebarCollapsed());
}
prevWorkbenchRef.current = curr;
// fileSidebarCollapsed read as snapshot on transition only.
@@ -479,7 +500,11 @@ export default function HomePage() {
navigate("/");
return;
}
setFileSidebarCollapsed((c) => !c);
setFileSidebarCollapsed((c) => {
const next = !c;
writePersistedSidebarCollapsed(next);
return next;
});
}}
onOpenSettings={() => setConfigModalOpen(true)}
/>
@@ -193,7 +193,10 @@ export async function reconcileServerFiles(
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
? updatedAtMs
: stub.remoteStorageUpdatedAt,
folderId: safeParseFolderId(serverFile.folderId) ?? stub.folderId,
// Server is authoritative for cloud-stored files. Don't fall back to
// stub.folderId on null - that would resurrect a stale folder pointer
// after the server SET_NULL'd it (e.g. owner deleted the folder).
folderId: safeParseFolderId(serverFile.folderId),
};
});
@@ -758,17 +758,17 @@ test.describe("Files page", () => {
page.locator(".files-page-card:not(.is-folder)"),
).toHaveCount(4, { timeout: 5_000 });
// "Shared by me" -> only link-shared.pdf
// "Shared by me" -> link-shared.pdf AND user-shared.pdf
// (The previously-separate "Shared by me" / "I'm sharing" tabs are now
// merged into a single Shared-by-me view that shows both link shares
// and direct user shares.)
await page.locator("#filesPage-tab-sharedByMe").click();
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
await expect(sharedByMeCards).toHaveCount(1, { timeout: 3_000 });
await expect(sharedByMeCards.first()).toContainText("link-shared.pdf");
// "I'm sharing" -> only user-shared.pdf
await page.locator("#filesPage-tab-imSharing").click();
const imSharingCards = page.locator(".files-page-card:not(.is-folder)");
await expect(imSharingCards).toHaveCount(1, { timeout: 3_000 });
await expect(imSharingCards.first()).toContainText("user-shared.pdf");
await expect(sharedByMeCards).toHaveCount(2, { timeout: 3_000 });
await expect(sharedByMeCards).toContainText([
"link-shared.pdf",
"user-shared.pdf",
]);
// "Shared with me" -> only from-someone-else.pdf
await page.locator("#filesPage-tab-shared").click();