mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -3364,6 +3364,7 @@
|
||||
"type": "النوع",
|
||||
"folder": "مجلد",
|
||||
"file": "ملف",
|
||||
"symlink": "رابط رمزي",
|
||||
"parent_directory": "المجلد الأصل",
|
||||
"breadcrumb_root": "الرئيسية",
|
||||
"other_accounts": "حسابات أخرى",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3364,6 +3364,7 @@
|
||||
"type": "Type",
|
||||
"folder": "Folder",
|
||||
"file": "File",
|
||||
"symlink": "Symbolic link",
|
||||
"parent_directory": "Parent directory",
|
||||
"breadcrumb_root": "Home",
|
||||
"other_accounts": "Other accounts",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3364,6 +3364,7 @@
|
||||
"type": "نوع",
|
||||
"folder": "پوشه",
|
||||
"file": "فایل",
|
||||
"symlink": "پیوند نمادین",
|
||||
"parent_directory": "پوشه والد",
|
||||
"breadcrumb_root": "خانه",
|
||||
"other_accounts": "سایر حسابها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3242,6 +3242,7 @@
|
||||
"type": "הקלד",
|
||||
"folder": "תיקיה",
|
||||
"file": "קובץ",
|
||||
"symlink": "קישור סמלי",
|
||||
"parent_directory": "ספריית הורים",
|
||||
"breadcrumb_root": "בית",
|
||||
"drop_files_here": "שחרר כאן קבצים או תיקיות כדי להעלות",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3319,6 +3319,7 @@
|
||||
"type": "種類",
|
||||
"folder": "フォルダー",
|
||||
"file": "ファイル",
|
||||
"symlink": "シンボリックリンク",
|
||||
"parent_directory": "親ディレクトリ",
|
||||
"breadcrumb_root": "ホーム",
|
||||
"drop_files_here": "ここにファイルまたはフォルダをドロップしてアップロード",
|
||||
|
||||
@@ -3319,6 +3319,7 @@
|
||||
"type": "유형",
|
||||
"folder": "폴더",
|
||||
"file": "파일",
|
||||
"symlink": "심볼릭 링크",
|
||||
"parent_directory": "상위 폴더",
|
||||
"breadcrumb_root": "홈",
|
||||
"drop_files_here": "여기에 파일이나 폴더를 끌어다 놓으세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3364,6 +3364,7 @@
|
||||
"type": "Төрөл",
|
||||
"folder": "Хавтас",
|
||||
"file": "Файл",
|
||||
"symlink": "Симбол холбоос",
|
||||
"parent_directory": "Эцэг эх лавлах",
|
||||
"breadcrumb_root": "Гэр",
|
||||
"other_accounts": "Бусад дансууд",
|
||||
|
||||
@@ -3364,6 +3364,7 @@
|
||||
"type": "Type",
|
||||
"folder": "Mappe",
|
||||
"file": "Fil",
|
||||
"symlink": "Symbolsk lenke",
|
||||
"parent_directory": "Overordnet mappe",
|
||||
"breadcrumb_root": "Hjem",
|
||||
"other_accounts": "Andre kontoer",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ć",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3319,6 +3319,7 @@
|
||||
"type": "Тип",
|
||||
"folder": "Папка",
|
||||
"file": "Файл",
|
||||
"symlink": "Символическая ссылка",
|
||||
"parent_directory": "Родительский каталог",
|
||||
"breadcrumb_root": "Главная",
|
||||
"drop_files_here": "Перетащите файлы или папки сюда для загрузки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3319,6 +3319,7 @@
|
||||
"type": "Тип",
|
||||
"folder": "Папка",
|
||||
"file": "Файл",
|
||||
"symlink": "Символічне посилання",
|
||||
"parent_directory": "Батьківський каталог",
|
||||
"breadcrumb_root": "додому",
|
||||
"drop_files_here": "Перетягніть файли або папки сюди, щоб завантажити",
|
||||
|
||||
@@ -3364,6 +3364,7 @@
|
||||
"type": "類型",
|
||||
"folder": "資料夾",
|
||||
"file": "檔案",
|
||||
"symlink": "符號連結",
|
||||
"parent_directory": "上層目錄",
|
||||
"breadcrumb_root": "首頁",
|
||||
"other_accounts": "其他帳號",
|
||||
|
||||
@@ -3319,6 +3319,7 @@
|
||||
"type": "类型",
|
||||
"folder": "文件夹",
|
||||
"file": "文件",
|
||||
"symlink": "符号链接",
|
||||
"parent_directory": "父目录",
|
||||
"breadcrumb_root": "根目录",
|
||||
"drop_files_here": "将文件或文件夹拖放到此处进行上传",
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user