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
@@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
@@ -91,6 +93,44 @@ public class ConfigController {
return null;
}
/**
* Resolve the frontend URL the client should advertise to phones / share-link recipients.
* Priority: explicit system.frontendUrl, then the Host the user is already using to reach this
* server (works for Docker, reverse proxies, and bare-metal LANs), then a detected site-local
* IPv4, then empty.
*/
// visible for testing
String resolveFrontendUrl(HttpServletRequest request, AppConfig appConfig) {
String configured = applicationProperties.getSystem().getFrontendUrl();
if (configured != null && !configured.isBlank()) {
return configured;
}
if (request != null) {
String host = request.getServerName();
if (host != null && !host.isBlank() && !isLoopbackHost(host)) {
String scheme = request.getScheme();
int port = request.getServerPort();
boolean defaultPort =
("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
}
}
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
}
return "";
}
private static boolean isLoopbackHost(String host) {
return "localhost".equalsIgnoreCase(host)
|| "127.0.0.1".equals(host)
|| "::1".equals(host)
|| "0:0:0:0:0:0:0:1".equals(host);
}
/** Check if running Enterprise edition dynamically. */
private Boolean isRunningEE() {
// Use LicenseService for fresh license status if available
@@ -107,7 +147,7 @@ public class ConfigController {
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
Map<String, Object> configData = new HashMap<>();
try {
@@ -124,17 +164,7 @@ public class ConfigController {
configData.put("serverPort", appConfig.getServerPort());
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
if ((frontendUrl == null || frontendUrl.isBlank())
&& Boolean.parseBoolean(
System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme =
appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
frontendUrl = scheme + "://" + localIp + ":" + appConfig.getServerPort();
}
}
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
// Add mobile scanner settings
configData.put(
@@ -15,10 +15,14 @@ import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.System;
import stirling.software.common.service.LicenseServiceInterface;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -173,4 +177,71 @@ class ConfigControllerTest {
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(endpointConfiguration).getAllEndpoints();
}
@Test
void resolveFrontendUrl_prefersExplicitConfiguredValue() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn("https://pdf.example.com");
// Request would say something else, but configured wins.
HttpServletRequest req = mock(HttpServletRequest.class);
AppConfig appConfig = mock(AppConfig.class);
assertEquals(
"https://pdf.example.com", configController.resolveFrontendUrl(req, appConfig));
}
@Test
void resolveFrontendUrl_usesRequestHostWhenNotConfigured() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn(null);
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("192.168.1.100");
when(req.getScheme()).thenReturn("http");
when(req.getServerPort()).thenReturn(8080);
assertEquals(
"http://192.168.1.100:8080",
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
}
@Test
void resolveFrontendUrl_elidesDefaultHttpsPort() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn("");
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("pdf.example.com");
when(req.getScheme()).thenReturn("https");
when(req.getServerPort()).thenReturn(443);
assertEquals(
"https://pdf.example.com",
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
}
@Test
void resolveFrontendUrl_fallsThroughOnLoopbackHost() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn(null);
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("localhost");
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080");
when(appConfig.getServerPort()).thenReturn("8080");
// Detected IP (if any) wins over loopback request host. We can't assert the
// exact value (depends on the host running the test) but we can assert it
// never returns "localhost".
String result = configController.resolveFrontendUrl(req, appConfig);
assertNotNull(result);
assertFalse(result.contains("localhost"));
}
}
@@ -3854,7 +3854,7 @@ selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
selectFile = "Select file {{name}}"
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."
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
shareManage = "Manage sharing"
showDetails = "Show details"
summary = "{{count}} items"
@@ -3863,6 +3863,7 @@ syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be
tree = "Folders"
upload = "Upload"
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
uploadFromMobile = "Upload from Mobile"
versionActions = "Version actions"
versionCollapse = "Collapse middle versions"
versionOrigin = "Original upload"
@@ -3885,16 +3886,14 @@ empty.cloud.hint = "Upload a file to start, or create a folder to organise."
empty.cloud.offlineHint = "Reconnect to load your cloud library."
empty.cloud.offlineTitle = "No cached cloud files"
empty.cloud.title = "No cloud files yet"
empty.imSharing.hint = "Invite a teammate to one of your files to see it listed here."
empty.imSharing.title = "Not sharing with anyone yet"
empty.local.hint = "Files saved without uploading stay here. Drop a file to add one."
empty.local.title = "No local-only files"
empty.recent.hint = "Files you open or edit will appear here."
empty.recent.title = "Nothing modified yet"
empty.shared.hint = "When someone shares a file via link, it appears here."
empty.shared.title = "Nothing shared with you"
empty.sharedByMe.hint = "Create a share link on any of your files to surface it here."
empty.sharedByMe.title = "No share links yet"
empty.sharedByMe.hint = "Create a share link or invite a teammate from any of your files to see it here."
empty.sharedByMe.title = "You haven't shared any files yet"
error.actionFailed = "Could not {{action}}."
error.actionFailedDetail = "Could not {{action}}: {{message}}"
error.deleteFolderFailed = "Could not delete folder."
@@ -3951,7 +3950,6 @@ sort.sizeDesc = "Largest first"
syncError.client = "Folder sync failed."
syncError.network = "Could not reach the server."
syncError.server = "Server error during folder sync."
tabName.imSharing = "I'm sharing"
tabName.local = "Local"
tabName.recent = "Recent"
tabName.shared = "Shared with me"
@@ -3959,7 +3957,6 @@ tabName.sharedByMe = "Shared by me"
tabs.all = "All"
tabs.ariaLabel = "File views"
tabs.cloud = "Cloud"
tabs.imSharing = "I'm sharing"
tabs.local = "Local"
tabs.recent = "Recent"
tabs.shared = "Shared with me"
@@ -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();