From 92de8e99bd163253ae7cf7422c4d55eca240f011 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:35:47 +0200 Subject: [PATCH] fix: show Stalwart 1.0 symlinks as links instead of empty folders Bulwark counted a FileNode as a folder when it had no blobId. Stalwart 1.0 adds symlinks, which have no blob either, so they showed up as empty folders that could be opened, and folder copies recreated them as such. Listings now ask for `nodeType` (returned since 0.16.6) and a single helper, `fileNodeKind()`, decides what a node is in the Files store, the copy code and the global search, falling back to the blobId rule when `nodeType` is missing. `nodeType` is never sent in FileNode/set. A symlink is shown as an inert row with a link icon. It can be renamed, moved and deleted, but not opened, previewed, downloaded, copied or shared, and folder copies skip it. --- components/files/file-browser.tsx | 85 ++++++++++++------- components/files/files-app.tsx | 2 +- lib/__tests__/filenode-kind.test.ts | 21 +++++ lib/__tests__/jmap-filenode-client.test.ts | 11 +++ lib/__tests__/jmap-filenode-writes.test.ts | 26 ++++++ lib/global-search/__tests__/providers.test.ts | 12 +++ lib/global-search/providers/files.ts | 12 +-- lib/jmap/client.ts | 12 ++- lib/jmap/filenode-kind.ts | 20 +++++ lib/jmap/types.ts | 5 ++ locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + stores/__tests__/file-store.test.ts | 50 +++++++++++ stores/file-store.ts | 44 +++++++--- 39 files changed, 275 insertions(+), 52 deletions(-) create mode 100644 lib/__tests__/filenode-kind.test.ts create mode 100644 lib/jmap/filenode-kind.ts diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 8a47b7d32..b9cdcb867 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -11,7 +11,7 @@ import { AlertCircle, Star, Clock, FolderUp, FileArchive, FileSpreadsheet, Presentation, FileCode, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, - Menu, Users, Share2, SquarePen, + Menu, Users, Share2, SquarePen, Link, } from "@/components/icons"; import { useIsDesktop } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -239,17 +239,28 @@ function getFileIconByName(name: string, size: "sm" | "lg") { return ; } -function getFileIcon(resource: FileResource) { +// A symlink's icon, whatever its name suggests: it has no content to open. +function SymlinkIcon({ className, label }: { className: string; label: string }) { + return ( + + {label} + + ); +} + +function getFileIcon(resource: FileResource, symlinkLabel: string) { if (resource.isDirectory) { return ; } + if (resource.isSymlink) return ; return getFileIconByName(resource.name, "sm"); } -function getGridIcon(resource: FileResource) { +function getGridIcon(resource: FileResource, symlinkLabel: string) { if (resource.isDirectory) { return ; } + if (resource.isSymlink) return ; return getFileIconByName(resource.name, "lg"); } @@ -418,8 +429,15 @@ export function FileBrowser({ // it (not a shared-with-me node), and holds the mayShare right (owned nodes // report full rights; treat missing myRights as allowed). const canShare = useCallback((r: FileResource | null | undefined): boolean => - !!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)), + !!(sharingEnabled && onShare && client && r && !r.isShared && !r.isSymlink && (r.myRights?.mayShare ?? true)), [sharingEnabled, onShare, client]); + // Files have content to preview, open, download or duplicate. Folders and + // symlinks don't: a symlink row only supports rename, move and delete. + const hasContent = useCallback((name: string): boolean => { + const r = resources.find(res => res.name === name); + return !!r && !r.isDirectory && !r.isSymlink; + }, [resources]); + const symlinkLabel = t("symlink"); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); const [showNewTextFile, setShowNewTextFile] = useState(false); @@ -700,6 +718,7 @@ export function FileBrowser({ }, [marquee, onSetSelection]); const handleResourceDoubleClick = (resource: FileResource) => { + if (resource.isSymlink) return; if (resource.isDirectory) { const newPath = currentPath === '/' ? `/${resource.name}` @@ -929,7 +948,7 @@ export function FileBrowser({ ? `/${resource.name}` : `${currentPath}/${resource.name}`; onNavigate(newPath, resource.id); - } else if (resource) { + } else if (resource && !resource.isSymlink) { onDownload(resource.name); } return; @@ -1003,10 +1022,10 @@ export function FileBrowser({ variant="ghost" size="sm" className="h-8" - onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))} + onClick={() => onBatchDownload([...selectedResources].filter(hasContent))} > - {t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length}) + {t("download")} ({[...selectedResources].filter(hasContent).length}) )} - {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeEditable?.(contextMenu.name) && onEditFile && ( + {hasContent(contextMenu.name) && isOfficeEditable?.(contextMenu.name) && onEditFile && ( )} - {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( + {hasContent(contextMenu.name) && ( - + {!resources.find(r => r.name === contextMenu.name)?.isSymlink && ( + + )} {clipboard && (