fix: reach directly shared files and keep others' files on a failed copy

Fixes from the review of the Files work (1cf9944b..a9a25b94).

- A file shared on its own, or one below a folder its owner took back,
  could not be reached. Once Stalwart 1.0's placeholder folders were
  left out it became a root of "Shared with me", which listed folders
  only, and search opened it on the drive root. 0.16 lists such files
  as roots too. Each owner's files shared on their own are now listed
  on one page under "Shared with me" ("Files from <name>"), where they
  open, preview and download like any other file and nothing can be
  added; two of the same name get a number. Search opens a shared hit
  where it is listed, and links into a shared drive name the node by id
  (`/files?shared=<owner>:<id>`), so a shared folder or page also
  survives a reload. A Files page that is already open takes the link
  from search directly, since Next keeps it mounted when only the query
  changes.
- A copy that fails part-way destroys only the nodes it made, the
  deepest first and without `onDestroyRemoveChildren`, one call per
  level because 0.16 refuses a folder destroyed in the same call as
  its children. It used to destroy the new folder with everything
  below it, so whatever someone put there during a long copy was
  deleted for good. A folder holding such nodes now stays, and the
  error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
  rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
  without Modify, so every save of such a document failed with a 500.
  1.0, told apart by its `maxFileNodeDepth`, still asks for
  `mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
  keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
  item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
  the 1.0 pre-releases a failed update also drops the cached copy, and
  the next restart or reload of the Applications downloads it again,
  not only a later `UpdateApps`. The static README says a restart
  applies the CORS setting on the current 1.0 pre-releases, where
  `ReloadSettings` fails with the stock spam-filter rules.
This commit is contained in:
Linus Rath
2026-09-25 22:16:19 +02:00
parent 5a553a315b
commit d9ffe8a76e
49 changed files with 1010 additions and 126 deletions
+44 -16
View File
@@ -10,8 +10,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore, resourceServerRef, type UploadReport } from "@/stores/file-store";
import { fileErrorKind } from "@/lib/file-errors";
import { useFileStore, isSharedLocation, resourceServerRef, type UploadReport } from "@/stores/file-store";
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store";
import { cn, formatFileSize } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -34,7 +34,7 @@ import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle, Loader2 } from "@/components/icons";
import { isFilePreviewable } from "@/lib/file-preview";
import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
import { appPath, buildFilesPath, buildSharedFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
@@ -108,6 +108,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
undoLastAction,
lastAction,
shareResource,
openSharedNode,
} = useFileStore();
const isMobile = useIsMobile();
@@ -223,23 +224,40 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
return link;
}, []);
// Pro shell only: this surface stays mounted for the whole session, so links
// arriving after mount are delivered live instead of being parked forever.
// The bootstrap has already run by then - walk the drive directly and let
// the preview effect below claim `pendingPreviewRef` once the listing lands.
// Embedded-only: during a cold-load redirect the standard instance renders
// briefly and must not steal the link parked for the Pro one.
// A node of a drive shared with the user has no path under the user's own
// root, so its link names it by id and the store opens where it is listed.
// False when it is gone or no longer shared.
const openSharedLink = useCallback(async (id: string): Promise<boolean> => {
if (await openSharedNode(id, (name) => t("shared_files_from", { name }))) return true;
toast.error(tDeepLink(pendingPreviewRef.current !== null ? "file_not_found" : "folder_not_found"));
// Not a file of the same name in whatever folder is open instead.
pendingPreviewRef.current = null;
return false;
}, [openSharedNode, t, tDeepLink]);
const openSharedLinkRef = useRef(openSharedLink);
openSharedLinkRef.current = openSharedLink;
// Links arriving after mount are delivered live. The Pro shell keeps this
// surface mounted for the whole session, and so does Next for the standard
// page when a link changes only the query (`?shared=`), while the route is
// read on mount only. The bootstrap has already run by then - walk the
// drive directly and let the preview effect below claim `pendingPreviewRef`
// once the listing lands. While the Pro interface is on, the standard
// instance renders only during a cold-load redirect and must not steal the
// link parked for the Pro one.
const proInterfaceActive = useProInterfaceActive();
const navigateByPathRef = useRef(navigateByPath);
navigateByPathRef.current = navigateByPath;
useEffect(() => {
if (!isEmbedded) return;
if (!isEmbedded && proInterfaceActive) return;
return subscribePendingDeepLink('files', (segments, search) => {
const link = parseFilesPath(segments, new URLSearchParams(search ?? window.location.search));
if (!link) return;
if (link.preview) pendingPreviewRef.current = link.preview;
void navigateByPathRef.current(link.path);
if (link.shared) void openSharedLinkRef.current(link.shared);
else void navigateByPathRef.current(link.path);
});
}, [isEmbedded]);
}, [isEmbedded, proInterfaceActive]);
// Check support and load the first listing after the client is initialized.
// One effect, run once: `checkSupport` publishes `supportsFiles` before it
@@ -267,7 +285,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// listing the root first would strand the user there and rewrite the
// URL they arrived on.
const link = takeFilesLink();
if (link && link.path !== '/') await navigateByPath(link.path);
if (link?.shared && await openSharedLinkRef.current(link.shared)) return;
if (link && !link.shared && link.path !== '/') await navigateByPath(link.path);
else if (firstCheck) await navigate(null);
})();
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate, navigateByPath, takeFilesLink]);
@@ -310,7 +329,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// What the server said went wrong, as the second line under an action's
// own error message; undefined when it said nothing specific.
const failureReason = useCallback((err: unknown): string | undefined => {
const serverReason = useCallback((err: unknown): string | undefined => {
switch (fileErrorKind(err)) {
case "overQuota": return t("error_over_quota");
case "tooDeep": return t("error_too_deep");
@@ -320,6 +339,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
default: return undefined;
}
}, [t]);
// A copy that stopped part-way and could not all be taken back says so too.
const failureReason = useCallback((err: unknown): string | undefined => {
const reason = serverReason(err);
if (!isPartialCopy(err)) return reason;
return reason ? `${reason} ${t("error_partial_copy")}` : t("error_partial_copy");
}, [serverReason, t]);
// An upload goes on past a file the server refuses; the refused ones are
// reported once it is done.
@@ -536,9 +561,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// The permalink for the folder on screen. In the Pro shell only the focused
// tab writes the address bar; the standard instance that renders while Pro
// takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
const isFocusedProTab = useIsFocusedProTab();
const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile));
// A shared drive's folders and pages are linked by id (see openSharedLink).
const currentParentId = useFileStore((s) => s.currentParentId);
const filesLinkPath = appPath(isSharedLocation(currentParentId)
? buildSharedFilesPath(currentParentId, previewFile)
: buildFilesPath(currentPath, previewFile));
useDeepLinkUrl(
isEmbedded
? (isFocusedProTab ? filesLinkPath : null)
+44 -4
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import {
File,
Folder,
FolderOpen,
ChevronRight,
@@ -11,7 +12,7 @@ import {
Share2,
} from "@/components/icons";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
import { useFileStore, sharedFilesLocation, type FileResource } from "@/stores/file-store";
interface FolderNode {
id: string;
@@ -132,6 +133,21 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
onNavigate(path, id);
}, [onNavigate]);
const sharedFolders = sharedRoots.filter(r => r.isDirectory);
// Files shared on their own sit in no shared folder; each owner's are
// listed on one page.
const fileOwners = new Map<string, string>();
for (const r of sharedRoots) {
if (!r.isDirectory && r.ownerAccountId) fileOwners.set(r.ownerAccountId, r.ownerName || r.ownerAccountId);
}
const sharedFilePages = [...fileOwners]
.sort(([, a], [, b]) => a.localeCompare(b))
.map(([accountId, ownerName]) => ({
id: sharedFilesLocation(accountId),
name: t("shared_files_from", { name: ownerName }),
ownerName,
}));
return (
<div
className={cn(
@@ -186,14 +202,14 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
))
)}
{/* Shared with me: folders another principal has shared with the user */}
{sharedRoots.filter(r => r.isDirectory).length > 0 && (
{/* Shared with me: what another principal has shared with the user */}
{(sharedFolders.length > 0 || sharedFilePages.length > 0) && (
<div className="mt-2 pt-2 border-t border-border/60">
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
<span className="truncate">{t("shared_with_me")}</span>
</div>
{sharedRoots.filter(r => r.isDirectory).map(r => {
{sharedFolders.map(r => {
const path = `/${r.name}`;
const isSelected = currentPath === path;
return (
@@ -217,6 +233,30 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
</div>
);
})}
{sharedFilePages.map(page => {
const path = `/${page.name}`;
const isSelected = currentPath === path;
return (
<div
key={page.id}
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
)}
>
<button
onClick={() => handleFolderClick(path, page.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
title={t("shared_by", { name: page.ownerName })}
>
<File className="w-4 h-4 flex-shrink-0 me-2 text-primary" />
<span className="truncate">{page.name}</span>
</button>
</div>
);
})}
</div>
)}
</div>