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.
This commit is contained in:
Linus Rath
2026-09-25 18:35:47 +02:00
parent a7c2e531c7
commit 92de8e99bd
39 changed files with 275 additions and 52 deletions
+56 -29
View File
@@ -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 <File className={`${cls} text-muted-foreground`} />;
}
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 (
<Link className={`${className} text-muted-foreground`} aria-label={label}>
<title>{label}</title>
</Link>
);
}
function getFileIcon(resource: FileResource, symlinkLabel: string) {
if (resource.isDirectory) {
return <Folder className="w-5 h-5 text-blue-500" />;
}
if (resource.isSymlink) return <SymlinkIcon className="w-5 h-5" label={symlinkLabel} />;
return getFileIconByName(resource.name, "sm");
}
function getGridIcon(resource: FileResource) {
function getGridIcon(resource: FileResource, symlinkLabel: string) {
if (resource.isDirectory) {
return <Folder className="w-10 h-10 text-blue-500" />;
}
if (resource.isSymlink) return <SymlinkIcon className="w-10 h-10" label={symlinkLabel} />;
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))}
>
<Download className="w-4 h-4 me-1" />
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
{t("download")} ({[...selectedResources].filter(hasContent).length})
</Button>
<Button
variant="ghost"
@@ -1512,9 +1531,9 @@ export function FileBrowser({
data-checked={selectedResources.has(resource.name)}
onClick={(e) => e.stopPropagation()}
/>
{showThumbnails && isImageFile(resource.name)
{showThumbnails && !resource.isSymlink && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
: getGridIcon(resource)}
: getGridIcon(resource, symlinkLabel)}
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
@@ -1662,15 +1681,15 @@ export function FileBrowser({
className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0"
onClick={(e) => e.stopPropagation()}
/>
{showThumbnails && isImageFile(resource.name)
{showThumbnails && !resource.isSymlink && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
: getFileIcon(resource)}
: getFileIcon(resource, symlinkLabel)}
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
{resource.isDirectory || resource.isSymlink ? "-" : formatFileSize(resource.contentLength)}
</td>
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
{formatDate(resource.lastModified)}
@@ -1702,7 +1721,7 @@ export function FileBrowser({
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
{hasContent(contextMenu.name) && isPreviewable(contextMenu.name) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
@@ -1718,7 +1737,7 @@ export function FileBrowser({
{t("preview")}
</button>
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeEditable?.(contextMenu.name) && onEditFile && (
{hasContent(contextMenu.name) && isOfficeEditable?.(contextMenu.name) && onEditFile && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
@@ -1730,7 +1749,7 @@ export function FileBrowser({
{t("office_edit")}
</button>
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
{hasContent(contextMenu.name) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
@@ -1752,16 +1771,18 @@ export function FileBrowser({
<Scissors className="w-4 h-4" />
{t("cut")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onCopy([contextMenu.name]);
setContextMenu(null);
}}
>
<Copy className="w-4 h-4" />
{t("copy")}
</button>
{!resources.find(r => r.name === contextMenu.name)?.isSymlink && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onCopy([contextMenu.name]);
setContextMenu(null);
}}
>
<Copy className="w-4 h-4" />
{t("copy")}
</button>
)}
{clipboard && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
@@ -1774,7 +1795,7 @@ export function FileBrowser({
{t("paste")}
</button>
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
{hasContent(contextMenu.name) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
@@ -1971,15 +1992,21 @@ export function FileBrowser({
<div className="flex flex-col items-center gap-3 mb-4">
{detailResource.isDirectory
? <Folder className="w-12 h-12 text-blue-500" />
: getFileIconByName(detailResource.name, "lg")}
: detailResource.isSymlink
? <SymlinkIcon className="w-12 h-12" label={symlinkLabel} />
: getFileIconByName(detailResource.name, "lg")}
<p className="text-sm font-medium text-center break-all">{detailResource.name}</p>
</div>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-muted-foreground text-xs">{t("type")}</dt>
<dd>{detailResource.isDirectory ? t("folder") : (detailResource.contentType || t("file"))}</dd>
<dd>
{detailResource.isDirectory
? t("folder")
: detailResource.isSymlink ? symlinkLabel : (detailResource.contentType || t("file"))}
</dd>
</div>
{!detailResource.isDirectory && (
{!detailResource.isDirectory && !detailResource.isSymlink && (
<div>
<dt className="text-muted-foreground text-xs">{t("size")}</dt>
<dd className="tabular-nums">{formatFileSize(detailResource.contentLength)}</dd>
+1 -1
View File
@@ -279,7 +279,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
const resource = resources.find((r) => r.name === pending);
if (!resource) return;
pendingPreviewRef.current = null;
if (isFilePreviewable(pending)) {
if (!resource.isSymlink && isFilePreviewable(pending)) {
setPreviewFile(pending);
} else {
toast.error(tDeepLink('file_not_found'));
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { fileNodeKind } from '../jmap/filenode-kind';
describe('fileNodeKind', () => {
it('takes nodeType as Stalwart 0.16.6+ returns it', () => {
expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file');
expect(fileNodeKind({ nodeType: 'directory', blobId: null })).toBe('directory');
});
// 1.0 symlinks: `blobId`, `size` and `type` are null, like a folder's.
it('tells a symlink from a folder on Stalwart 1.0', () => {
expect(fileNodeKind({ nodeType: 'symlink', blobId: null })).toBe('symlink');
});
it('falls back to the blob rule without nodeType (#379)', () => {
expect(fileNodeKind({ blobId: null })).toBe('directory');
expect(fileNodeKind({ blobId: 'b1' })).toBe('file');
// An unknown value from some other server doesn't count.
expect(fileNodeKind({ nodeType: 'fifo' as never, blobId: 'b1' })).toBe('file');
});
});
@@ -155,6 +155,17 @@ describe('JMAPClient FileNode listing past maxObjectsInGet (#1069)', () => {
const nodes = await client.listAllFileNodesAcrossAccounts();
expect(sortedIds(nodes)).toEqual(['f0', 'f1', 'f2', 'f3']);
});
it('asks for nodeType, which tells Stalwart 1.0 symlinks from folders', async () => {
const all = [folder('d1'), file('f0'), file('f1'), file('f2')];
const client = createLimitedClient({ maxObjectsInGet: 2 });
const sent = fakeFilesServer(all, { maxObjectsInGet: 2 });
await client.listAllFileNodes();
const gets = sent.flat().filter(([method]) => method === 'FileNode/get');
expect(gets.length).toBeGreaterThan(1);
for (const [, args] of gets) expect(args.properties).toContain('nodeType');
});
});
describe('JMAPClient FileNode name decoding (#869)', () => {
@@ -97,6 +97,32 @@ describe('FileNode writes', () => {
expect(calls.some(([m]) => m === 'Blob/copy')).toBe(false);
});
it('leaves symlinks out of a folder copy instead of making them empty folders (Stalwart 1.0)', async () => {
const { client, calls } = makeClient({
me: [
{ id: 'd1', name: 'Docs', parentId: null, nodeType: 'directory', blobId: null, type: null, size: null },
{ id: 'l1', name: 'latest', parentId: 'd1', nodeType: 'symlink', blobId: null, type: null, size: null },
{ id: 'f1', name: 'a.txt', parentId: 'd1', nodeType: 'file', blobId: 'b1', type: 'text/plain', size: 3 },
],
});
await client.copyFileNode('d1', 'Docs (copy)', null);
// Nor is nodeType ever sent: 1.0 treats it as immutable.
const creates = sets(calls).map(a => (a.create as { n0: Record<string, unknown> }).n0);
expect(creates).toEqual([
{ name: 'Docs (copy)' },
{ name: 'a.txt', parentId: 'new1', type: 'text/plain', blobId: 'b1' },
]);
});
it('refuses to copy a symlink itself', async () => {
const { client, calls } = makeClient({
me: [{ id: 'l1', name: 'latest', parentId: null, nodeType: 'symlink', blobId: null, type: null, size: null }],
});
await expect(client.copyFileNode('l1', 'latest (copy)', null)).rejects.toThrow('Symbolic links');
expect(sets(calls)).toEqual([]);
});
it('copies WebDAV-created names inside a folder the way they are shown (#869)', async () => {
const { client, calls } = makeClient({
me: [
@@ -356,4 +356,16 @@ describe('files provider', () => {
expect(hits.map((h) => h.id)).toEqual(['x']);
expect(hits[0]).toMatchObject({ folderPath: '/zebra archive', isFolder: true, jmapAccountId: 'owner' });
});
it('tells folders by nodeType and leaves Stalwart 1.0 symlinks out of the hits', async () => {
const listing = [
{ id: 'd', parentId: null, name: 'zebra folder', nodeType: 'directory', type: null, blobId: null, size: null, created: '', modified: '' },
{ id: 'l', parentId: null, name: 'zebra link', nodeType: 'symlink', type: null, blobId: null, size: null, created: '', modified: '' },
];
invalidateFileSearchCache();
const acc = account('login-l', { listAllFileNodesAcrossAccounts: vi.fn(async () => listing) });
const { hits } = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
expect(hits.map((h) => h.id)).toEqual(['d']);
expect(hits[0]).toMatchObject({ isFolder: true });
});
});
+6 -6
View File
@@ -1,6 +1,7 @@
import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser';
import type { FileHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
import { useFileStore } from '@/stores/file-store';
/**
@@ -66,10 +67,6 @@ export function peekFileListing(localAccountId: string): FileNode[] | null {
return cached?.nodes ?? null;
}
export function isFolderNode(node: Pick<FileNode, 'blobId'>): boolean {
return node.blobId == null;
}
/** Inverse of the Files store's `resolvePathToId`: the folder path a node lives in. */
export function pathOfNode(nodes: FileNode[], node: FileNode): string {
const byId = new Map(nodes.map((n) => [n.id, n]));
@@ -99,7 +96,10 @@ function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAcco
for (const node of nodes) {
// The owner's folders above a shared node only feed the path below.
if (node.discoverableOnly) continue;
if (!matchesTerms(parsed.terms, [node.name, isFolderNode(node) ? 'folder' : node.type])) continue;
const kind = fileNodeKind(node);
// A symlink has nothing a hit could open.
if (kind === 'symlink') continue;
if (!matchesTerms(parsed.terms, [node.name, kind === 'directory' ? 'folder' : node.type])) continue;
const folderPath = pathOfNode(nodes, node);
hits.push({
kind: 'files',
@@ -114,7 +114,7 @@ function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAcco
source,
node,
folderPath,
isFolder: isFolderNode(node),
isFolder: kind === 'directory',
});
if (hits.length >= limit) break;
}
+10 -2
View File
@@ -14,6 +14,7 @@ import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { fileNodeKind } from "./filenode-kind";
import { contactFromWire, contactToWire } from "./contact-wire";
import { SchedulingDeniedError } from "./scheduling-error";
import { jmapError, jmapErrorType, jmapSetError, type JmapErrorInfo } from "./jmap-error";
@@ -7874,6 +7875,8 @@ export class JMAPClient implements IJMAPClient {
private static FILE_NODE_PROPERTIES = [
"id", "parentId", "name", "type", "blobId", "size", "created", "modified",
// Tells 1.0's symlinks from folders (see fileNodeKind).
"nodeType",
// Stalwart omits shareWith/myRights from FileNode/get unless requested
// explicitly, so the share dialog and indicators can't see existing
// shares without naming them here (same as CALENDAR_PROPERTIES).
@@ -8393,7 +8396,10 @@ export class JMAPClient implements IJMAPClient {
const tree = await this.fetchAllFileNodes(source.accountId);
const original = tree.find(n => n.id === source.id);
if (!original) throw new Error('File node not found');
const isFolderNode = (n: FileNode) => n.blobId == null;
// Symlinks (Stalwart 1.0) are left out: there is no content to copy, and
// the blobId rule used to recreate them as empty folders.
if (fileNodeKind(original) === 'symlink') throw new Error('Symbolic links cannot be copied');
const isFolderNode = (n: FileNode) => fileNodeKind(n) === 'directory';
// The whole subtree is laid out from one listing before the first create.
const childrenOf = new Map<string, FileNode[]>();
@@ -8410,7 +8416,9 @@ export class JMAPClient implements IJMAPClient {
const below = isFolderNode(node) ? childrenOf.get(node.id) ?? [] : [];
return {
node: withDecodedName(node),
children: below.filter(child => !seen.has(child.id)).map(collect),
children: below
.filter(child => !seen.has(child.id) && fileNodeKind(child) !== 'symlink')
.map(collect),
};
};
const plan = collect(original);
+20
View File
@@ -0,0 +1,20 @@
import type { FileNode, FileNodeType } from './types';
/**
* What a FileNode is: a file, a folder or a symlink.
*
* `nodeType` says so on Stalwart 0.16.6 and later. Without it, a node is a
* folder exactly when it has no content blob (#379). Stalwart 1.0 adds
* symlinks, which have no blob either but can't hold anything, so the blob
* rule alone would show them as empty folders.
*/
export function fileNodeKind(node: Pick<FileNode, 'nodeType' | 'blobId'>): FileNodeType {
switch (node.nodeType) {
case 'file':
case 'directory':
case 'symlink':
return node.nodeType;
default:
return node.blobId == null ? 'directory' : 'file';
}
}
+5
View File
@@ -997,11 +997,16 @@ export interface AccountStates {
// JMAP FileNode types (draft-ietf-jmap-filenode / Stalwart implementation)
export type FileNodeType = 'file' | 'directory' | 'symlink';
export interface FileNode {
id: string;
parentId: string | null;
name: string;
type: string; // "d" for directory, MIME type for files
// Returned by Stalwart 0.16.6+; 1.0 adds symlinks. Read it through
// fileNodeKind(), and never send it in FileNode/set.
nodeType?: FileNodeType;
blobId: string | null;
size: number;
created: string;
+1
View File
@@ -3364,6 +3364,7 @@
"type": "النوع",
"folder": "مجلد",
"file": "ملف",
"symlink": "رابط رمزي",
"parent_directory": "المجلد الأصل",
"breadcrumb_root": "الرئيسية",
"other_accounts": "حسابات أخرى",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Tipus",
"folder": "Carpeta",
"file": "Fitxer",
"symlink": "Enllaç simbòlic",
"parent_directory": "Directori superior",
"breadcrumb_root": "Inici",
"other_accounts": "Altres comptes",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Typ",
"folder": "Složka",
"file": "Soubor",
"symlink": "Symbolický odkaz",
"parent_directory": "Nadřazený adresář",
"breadcrumb_root": "Domů",
"drop_files_here": "Přetáhněte sem soubory nebo složky pro jejich nahrání",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Type",
"folder": "Mappe",
"file": "Fil",
"symlink": "Symbolsk link",
"parent_directory": "Overordnet mappe",
"breadcrumb_root": "Hjem",
"drop_files_here": "Træk filer eller mapper hertil for at uploade",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Typ",
"folder": "Ordner",
"file": "Datei",
"symlink": "Symbolischer Link",
"parent_directory": "Übergeordnetes Verzeichnis",
"breadcrumb_root": "Startseite",
"drop_files_here": "Dateien oder Ordner hier ablegen zum Hochladen",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Type",
"folder": "Folder",
"file": "File",
"symlink": "Symbolic link",
"parent_directory": "Parent directory",
"breadcrumb_root": "Home",
"other_accounts": "Other accounts",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Tipo",
"folder": "Carpeta",
"file": "Archivo",
"symlink": "Enlace simbólico",
"parent_directory": "Directorio superior",
"breadcrumb_root": "Inicio",
"drop_files_here": "Suelte archivos o carpetas aquí para subirlos",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "نوع",
"folder": "پوشه",
"file": "فایل",
"symlink": "پیوند نمادین",
"parent_directory": "پوشه والد",
"breadcrumb_root": "خانه",
"other_accounts": "سایر حساب‌ها",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Type",
"folder": "Dossier",
"file": "Fichier",
"symlink": "Lien symbolique",
"parent_directory": "Répertoire parent",
"breadcrumb_root": "Accueil",
"drop_files_here": "Déposez des fichiers ou dossiers ici pour les téléverser",
+1
View File
@@ -3242,6 +3242,7 @@
"type": "הקלד",
"folder": "תיקיה",
"file": "קובץ",
"symlink": "קישור סמלי",
"parent_directory": "ספריית הורים",
"breadcrumb_root": "בית",
"drop_files_here": "שחרר כאן קבצים או תיקיות כדי להעלות",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Típus",
"folder": "Mappa",
"file": "Fájl",
"symlink": "Szimbolikus hivatkozás",
"parent_directory": "Szülő könyvtár",
"breadcrumb_root": "Kezdőlap",
"other_accounts": "További fiókok",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Tipo",
"folder": "Cartella",
"file": "File",
"symlink": "Collegamento simbolico",
"parent_directory": "Directory superiore",
"breadcrumb_root": "Home",
"drop_files_here": "Trascina file o cartelle qui per caricarli",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "種類",
"folder": "フォルダー",
"file": "ファイル",
"symlink": "シンボリックリンク",
"parent_directory": "親ディレクトリ",
"breadcrumb_root": "ホーム",
"drop_files_here": "ここにファイルまたはフォルダをドロップしてアップロード",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "유형",
"folder": "폴더",
"file": "파일",
"symlink": "심볼릭 링크",
"parent_directory": "상위 폴더",
"breadcrumb_root": "홈",
"drop_files_here": "여기에 파일이나 폴더를 끌어다 놓으세요",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Veids",
"folder": "Mape",
"file": "Fails",
"symlink": "Simboliskā saite",
"parent_directory": "Augstāka līmeņa mape",
"breadcrumb_root": "Sākums",
"drop_files_here": "Velciet failus vai mapes šeit, lai augšupielādētu",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Төрөл",
"folder": "Хавтас",
"file": "Файл",
"symlink": "Симбол холбоос",
"parent_directory": "Эцэг эх лавлах",
"breadcrumb_root": "Гэр",
"other_accounts": "Бусад дансууд",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Type",
"folder": "Mappe",
"file": "Fil",
"symlink": "Symbolsk lenke",
"parent_directory": "Overordnet mappe",
"breadcrumb_root": "Hjem",
"other_accounts": "Andre kontoer",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Type",
"folder": "Map",
"file": "Bestand",
"symlink": "Symbolische koppeling",
"parent_directory": "Bovenliggende map",
"breadcrumb_root": "Start",
"drop_files_here": "Sleep bestanden of mappen hierheen om te uploaden",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Typ",
"folder": "Folder",
"file": "Plik",
"symlink": "Dowiązanie symboliczne",
"parent_directory": "Katalog nadrzędny",
"breadcrumb_root": "Strona główna",
"drop_files_here": "Upuść pliki lub foldery tutaj, aby je przesłać",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Tipo",
"folder": "Pasta",
"file": "Arquivo",
"symlink": "Link simbólico",
"parent_directory": "Diretório superior",
"breadcrumb_root": "Início",
"drop_files_here": "Largue arquivos ou pastas aqui para carregar",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Tastați",
"folder": "Dosar",
"file": "Fișier",
"symlink": "Legătură simbolică",
"parent_directory": "Directorul părinte",
"breadcrumb_root": "Acasă",
"other_accounts": "Alte conturi",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Тип",
"folder": "Папка",
"file": "Файл",
"symlink": "Символическая ссылка",
"parent_directory": "Родительский каталог",
"breadcrumb_root": "Главная",
"drop_files_here": "Перетащите файлы или папки сюда для загрузки",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Typ",
"folder": "Priečinok",
"file": "Súbor",
"symlink": "Symbolický odkaz",
"parent_directory": "Nadradený adresár",
"breadcrumb_root": "Domov",
"other_accounts": "Ostatné účty",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "Tür",
"folder": "Klasör",
"file": "Dosya",
"symlink": "Sembolik bağlantı",
"parent_directory": "Üst dizin",
"breadcrumb_root": "Ana Sayfa",
"drop_files_here": "Yüklemek için dosya veya klasörleri buraya bırakın",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "Тип",
"folder": "Папка",
"file": "Файл",
"symlink": "Символічне посилання",
"parent_directory": "Батьківський каталог",
"breadcrumb_root": "додому",
"drop_files_here": "Перетягніть файли або папки сюди, щоб завантажити",
+1
View File
@@ -3364,6 +3364,7 @@
"type": "類型",
"folder": "資料夾",
"file": "檔案",
"symlink": "符號連結",
"parent_directory": "上層目錄",
"breadcrumb_root": "首頁",
"other_accounts": "其他帳號",
+1
View File
@@ -3319,6 +3319,7 @@
"type": "类型",
"folder": "文件夹",
"file": "文件",
"symlink": "符号链接",
"parent_directory": "父目录",
"breadcrumb_root": "根目录",
"drop_files_here": "将文件或文件夹拖放到此处进行上传",
+50
View File
@@ -478,6 +478,56 @@ describe('file-store "Shared with me"', () => {
});
});
describe('file-store symlinks (Stalwart 1.0)', () => {
// A 1.0 symlink: no blob, size or type, like a folder, but nodeType says so.
const link = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, nodeType: 'symlink', type: null as unknown as string, blobId: null, size: null as unknown as number,
created: '2026-09-01T08:00:00Z', modified: '2026-09-01T08:00:00Z',
});
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
selectedResources: new Set(),
clipboard: null,
lastAction: null,
});
});
it('lists a symlink as its own kind of row, not as a folder', async () => {
const client = makeMockClient([dir('docs', 'Docs', null), link('l', 'latest', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources.find(r => r.name === 'latest'))
.toMatchObject({ isDirectory: false, isSymlink: true, contentType: '' });
expect(useFileStore.getState().resources.find(r => r.name === 'Docs'))
.toMatchObject({ isDirectory: true, isSymlink: false });
// A path through it doesn't resolve to a folder.
await useFileStore.getState().navigateByPath('/latest');
expect(useFileStore.getState().currentPath).toBe('/');
});
it('keeps symlinks out of copies and duplicates', async () => {
const client = makeMockClient([file('f', 'a.txt', null), link('l', 'latest', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
useFileStore.getState().copyResources(['a.txt', 'latest']);
expect(useFileStore.getState().clipboard).toMatchObject({ mode: 'copy', ids: ['f'], names: ['a.txt'] });
useFileStore.getState().copyResources(['latest']);
expect(useFileStore.getState().clipboard).toMatchObject({ ids: ['f'] });
await useFileStore.getState().duplicateResource('latest');
expect(client._nodes()).toHaveLength(2);
});
});
describe('resourceServerRef', () => {
it('splits a shared drive\'s namespaced id into the owner account and the node id', () => {
expect(resourceServerRef({ id: 'owner1:n5', isShared: true, ownerAccountId: 'owner1' }))
+30 -14
View File
@@ -1,12 +1,16 @@
import { create } from 'zustand';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode, FileNodeRights } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
export interface FileResource {
id: string;
name: string;
serverName: string;
isDirectory: boolean;
// A symbolic link (Stalwart 1.0). Shown, renamed, moved and deleted like
// any node, but it has no content and holds nothing, so it can't be opened.
isSymlink?: boolean;
contentType: string;
contentLength: number;
lastModified: string;
@@ -145,12 +149,11 @@ function isDirectoryType(type: string | undefined): boolean {
return DIRECTORY_TYPES.has(type) || type.includes('directory');
}
// A FileNode is a folder iff it has no content blob. This is the authoritative
// signal in the JMAP FileNode spec and in Stalwart (a node is a container when
// its `file`/`blobId` is null); a `type` of "d" is not — older builds created
// "folders" as blob-backed files, which can't hold children (#379).
function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
return node.blobId == null;
// Whether a FileNode is a folder, by `nodeType` or else by having no content
// blob (see fileNodeKind). A `type` of "d" doesn't make one: older builds
// created "folders" as blob-backed files, which can't hold children (#379).
function isFolder(node: Pick<FileNode, 'nodeType' | 'blobId'>): boolean {
return fileNodeKind(node) === 'directory';
}
// Direct children of a given parent in the FileNode hierarchy.
@@ -181,13 +184,14 @@ export function resourceServerRef(
}
function nodeToResource(node: FileNode): FileResource {
const isDir = isFolder(node);
const kind = fileNodeKind(node);
return {
id: node.id,
name: node.name,
serverName: node.name,
isDirectory: isDir,
contentType: isDir ? '' : node.type,
isDirectory: kind === 'directory',
isSymlink: kind === 'symlink',
contentType: kind === 'file' ? node.type : '',
contentLength: node.size,
lastModified: node.modified || node.created,
blobId: node.blobId,
@@ -371,7 +375,7 @@ export const useFileStore = create<FileState>((set, get) => ({
// Safety net: a real folder has no content blob. If the server (or a stale
// build of createFileDirectory) hands back a blob-backed node, it is NOT a
// folder - abort before we delete anything irreversible (see below).
if (created.blobId != null) {
if (!isFolder(created)) {
creationBroken = true;
throw new Error('createFileDirectory returned a non-folder (has a blobId)');
}
@@ -856,7 +860,7 @@ export const useFileStore = create<FileState>((set, get) => ({
if (!client) return;
const resource = resources.find(r => r.name === name);
if (!resource) return;
if (!resource || resource.isSymlink) return;
const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0
@@ -919,9 +923,21 @@ export const useFileStore = create<FileState>((set, get) => ({
copyResources: (names: string[]) => {
const { currentPath, currentParentId, resources } = get();
const ids = names.map(n => resources.find(r => r.name === n)?.id).filter(Boolean) as string[];
const serverNames = names.map(n => resources.find(r => r.name === n)?.serverName).filter(Boolean) as string[];
set({ clipboard: { mode: 'copy', ids, names, serverNames, sourceParentId: currentParentId, sourcePath: currentPath } });
// A symlink has nothing to copy (see copyFileNode), so it stays behind.
const picked = names
.map(n => resources.find(r => r.name === n))
.filter((r): r is FileResource => !!r && !r.isSymlink);
if (picked.length === 0) return;
set({
clipboard: {
mode: 'copy',
ids: picked.map(r => r.id),
names: picked.map(r => r.name),
serverNames: picked.map(r => r.serverName),
sourceParentId: currentParentId,
sourcePath: currentPath,
},
});
},
pasteResources: async () => {