diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 1ea0ddd0a..924b41df5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -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> getAppConfig() { + public ResponseEntity> getAppConfig(HttpServletRequest request) { Map 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( diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java index 3a1f95d0a..6b5a9b9da 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java @@ -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")); + } } diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index db6e0499d..097fb9a5e 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx index e7086f2fc..d57869d5d 100644 --- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -289,7 +289,7 @@ export function FileDetailsPanel({ 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(null); const handleContextMenu = useCallback( @@ -712,9 +693,9 @@ function FileCard({ )}
- {file.thumbnailUrl ? ( + {resolvedThumbnail ? ( // draggable={false} so card's onDragStart fires, not native image drag. - + ) : (
{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 (
- {file.thumbnailUrl ? ( + {resolvedThumbnail ? ( { 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(); @@ -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") && (
)} {(() => { @@ -922,6 +933,28 @@ export default function FileManagerView() { > {t("filesPage.upload", "Upload")} + {isMobileUploadAvailable && ( + + setMobileUploadModalOpen(true)} + aria-label={t( + "filesPage.uploadFromMobile", + "Upload from Mobile", + )} + > + + + + )} + + setMobileUploadModalOpen(false)} + onFilesReceived={(files) => { + if (files.length > 0) { + void addFiles(files); + } + }} + />
); } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index b0138ef2b..5cd2eb262 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -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(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() : ""; diff --git a/frontend/editor/src/core/contexts/FilesPageContext.tsx b/frontend/editor/src/core/contexts/FilesPageContext.tsx index cccf50108..a4c0b5660 100644 --- a/frontend/editor/src/core/contexts/FilesPageContext.tsx +++ b/frontend/editor/src/core/contexts/FilesPageContext.tsx @@ -49,8 +49,7 @@ export type FilesPageTab = | "cloud" | "recent" | "shared" - | "sharedByMe" - | "imSharing"; + | "sharedByMe"; export interface FolderNameDialogState { mode: "new" | "rename" | null; diff --git a/frontend/editor/src/core/hooks/useLazyThumbnail.ts b/frontend/editor/src/core/hooks/useLazyThumbnail.ts new file mode 100644 index 000000000..414bfef8b --- /dev/null +++ b/frontend/editor/src/core/hooks/useLazyThumbnail.ts @@ -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(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; +} diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 98de09bb1..43a31b41f 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -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(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)} /> diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index f025ee184..ffd182868 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -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), }; }); diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 2d36a0227..8b71c839e 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -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();