fix: show and share shared folders in both folder layouts #1181

This commit is contained in:
Linus Rath
2026-10-06 22:27:56 +02:00
parent 9738231c2a
commit 992ede9500
3 changed files with 318 additions and 13 deletions
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { FileBrowser } from '../file-browser';
import { useFileStore, type FileResource } from '@/stores/file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/settings/share-collection-dialog', () => ({
ShareCollectionDialog: ({ collectionName, shareWith }: { collectionName: string; shareWith?: Record<string, unknown> | null }) => (
<div data-testid="share-dialog">{collectionName}:{Object.keys(shareWith ?? {}).join(',')}</div>
),
}));
// Shared folders must be reachable, and own folders shareable, in both
// folder layouts (#1181).
function node(id: string, name: string, parentId: string | null, extra: Partial<FileNode> = {}): FileNode {
return {
id,
name,
parentId,
type: 'd',
blobId: null,
size: 0,
created: '2026-10-01T00:00:00Z',
modified: '2026-10-01T00:00:00Z',
...extra,
};
}
function resource(n: FileNode): FileResource {
return {
id: n.id,
name: n.name,
serverName: n.name,
isDirectory: n.blobId == null,
contentType: '',
contentLength: n.size,
lastModified: n.modified ?? '',
blobId: n.blobId,
parentId: n.parentId,
shareWith: n.shareWith,
isShared: n.isShared,
} as FileResource;
}
const OWN: FileNode[] = [
node('f-docs', 'Documents', null),
node('f-notes', 'notes.txt', null, { blobId: 'b-notes', type: 'text/plain', size: 10 }),
];
const SHARED = node('e:f-team', 'Team', null, { isShared: true, accountId: 'e', accountName: 'userb@example.org' });
function setLayout(folderLayout: 'inline' | 'sidebar') {
localStorage.setItem('files-settings', JSON.stringify({ folderLayout }));
}
function renderBrowser(opts: { resources?: FileResource[]; listByParentId?: (id: string | null) => Promise<FileResource[]> } = {}) {
const client = {
listAllFileNodesAcrossAccounts: vi.fn(async () => [...OWN, SHARED]),
} as unknown as IJMAPClient;
act(() => { useFileStore.setState({ client, sharedRoots: [] }); });
const onNavigate = vi.fn();
const noop = vi.fn(async () => {});
const listByParentId = opts.listByParentId
?? vi.fn(async (parentId: string | null) => OWN.filter(n => n.parentId === parentId).map(resource));
render(
<FileBrowser
currentPath="/"
resources={opts.resources ?? OWN.map(resource)}
isLoading={false}
error={null}
selectedResources={new Set()}
uploadProgress={null}
clipboard={null}
onNavigate={onNavigate}
onCreateFolder={noop}
onUploadFiles={noop}
onUploadFolder={noop}
onCancelUpload={vi.fn()}
onDelete={noop}
onBatchDelete={noop}
onRename={noop}
onDownload={noop}
onBatchDownload={noop}
onRefresh={noop}
onSelectResource={vi.fn()}
onToggleSelect={vi.fn()}
onSelectAll={vi.fn()}
onClearSelection={vi.fn()}
onSetSelection={vi.fn()}
onCut={vi.fn()}
onCopy={vi.fn()}
onPaste={noop}
onMoveToFolder={noop}
onMoveToParent={noop}
onPreviewImage={vi.fn()}
onPreviewFile={vi.fn()}
onShowDetails={vi.fn()}
onCreateTextFile={noop}
onDuplicate={noop}
getImageUrl={async () => ''}
listPath={async () => []}
listByParentId={listByParentId}
favorites={[]}
recentFiles={[]}
onToggleFavorite={vi.fn()}
showDetails={false}
onToggleDetails={vi.fn()}
detailResource={null}
client={client}
ownAccountId="a"
sharingEnabled
onShare={noop}
/>,
);
return { onNavigate, listByParentId };
}
describe('FileBrowser shared folders (#1181)', () => {
beforeEach(() => localStorage.clear());
afterEach(() => {
act(() => { useFileStore.setState({ client: null, sharedRoots: [] }); });
});
it('lists folders shared with the user at the root in the inline layout', async () => {
setLayout('inline');
const { onNavigate } = renderBrowser();
const team = await screen.findByText('Team');
expect(screen.getAllByText('shared_with_me').length).toBeGreaterThan(0);
fireEvent.click(team);
expect(onNavigate).toHaveBeenCalledWith('/Team', 'e:f-team');
});
it('shows shared folders instead of the upload area when the own root is empty', async () => {
setLayout('inline');
renderBrowser({ resources: [] });
expect(await screen.findByText('Team')).toBeInTheDocument();
});
it('does not repeat shared folders in the list in the sidebar layout', async () => {
setLayout('sidebar');
renderBrowser();
// The tree lists it once; the file list does not.
expect(await screen.findAllByText('Team')).toHaveLength(1);
expect(screen.queryByRole('table')?.textContent ?? '').not.toContain('Team');
});
it('shares an own folder from the tree in the sidebar layout', async () => {
setLayout('sidebar');
const shared = { ...OWN[0], shareWith: { p1: { mayRead: true } } } as unknown as FileNode;
const listByParentId = vi.fn(async (parentId: string | null) =>
parentId === null ? [resource(shared), resource(OWN[1])] : []);
// Browsing another folder: the tree's folder is not in the listing.
renderBrowser({ resources: [resource(OWN[1])], listByParentId });
const folder = await screen.findByText('Documents');
fireEvent.contextMenu(folder);
fireEvent.click(await screen.findByText('share'));
// The dialog reads the folder fresh, so it shows the current shares.
expect(await screen.findByTestId('share-dialog')).toHaveTextContent('Documents:p1');
});
it('offers no share menu on folders shared with the user', async () => {
setLayout('sidebar');
renderBrowser();
const tree = (await screen.findByText('Team')).closest('div')!;
fireEvent.contextMenu(within(tree).getByText('Team'));
expect(screen.queryByText('share')).toBeNull();
});
});
+110 -9
View File
@@ -25,7 +25,7 @@ import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
import { useFileStore, type FileResource } from "@/stores/file-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
@@ -290,13 +290,38 @@ export function FileBrowser({
const [isDraggingOver, setIsDraggingOver] = useState(false);
// The share dialog is bound to a node id (not name) so its shareWith stays
// live after a share refresh re-derives the resource list.
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
// A folder shared from the tree (sidebar layout) is usually not among the
// listed resources, so the dialog keeps its own copy and re-reads it after
// each change.
const [treeShareTarget, setTreeShareTarget] = useState<FileResource | null>(null);
const shareTarget = shareTargetId
? resources.find(r => r.id === shareTargetId)
?? (treeShareTarget?.id === shareTargetId ? treeShareTarget : null)
: null;
// A node is shareable when the server supports JMAP Sharing, the viewer owns
// 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]);
const reloadTreeShareTarget = useCallback(async (target: FileResource) => {
const siblings = await listByParentId(target.parentId ?? null);
setTreeShareTarget(siblings.find(r => r.id === target.id) ?? target);
}, [listByParentId]);
const handleTreeShare = useCallback((target: FileResource) => {
setTreeShareTarget(target);
setShareTargetId(target.id);
// The tree caches its listing; fetch the current shares before editing.
void reloadTreeShareTarget(target);
}, [reloadTreeShareTarget]);
// Folders other principals shared with the user. The sidebar layout lists
// them in the folder tree; the inline layout lists them at the root (#1181).
const storeClient = useFileStore(s => s.client);
const sharedRoots = useFileStore(s => s.sharedRoots);
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
useEffect(() => {
if (storeClient) void loadSharedRoots();
}, [storeClient, loadSharedRoots]);
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);
@@ -420,6 +445,13 @@ export function FileBrowser({
return sorted;
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
const sharedFolders = useMemo(() => {
if (folderLayout !== "inline" || currentPath !== "/" || accountPickerMode) return [];
const q = searchQuery.toLowerCase();
return sharedRoots.filter(r => r.isDirectory && (!q || r.name.toLowerCase().includes(q)));
}, [folderLayout, currentPath, accountPickerMode, sharedRoots, searchQuery]);
const openSharedFolder = (resource: FileResource) => onNavigate(`/${resource.name}`, resource.id);
// Build breadcrumb segments. In Pro mode an account is mounted "between"
// Home and the account's filesystem - surfaced as a non-clickable label
// (clicking the actual account again would be a no-op; Home detaches it).
@@ -1142,6 +1174,8 @@ export function FileBrowser({
onNavigate={onNavigate}
listByParentId={listByParentId}
width={288}
canShare={canShare}
onShare={handleTreeShare}
/>
</div>
) : (
@@ -1152,6 +1186,8 @@ export function FileBrowser({
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
canShare={canShare}
onShare={handleTreeShare}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
@@ -1277,7 +1313,7 @@ export function FileBrowser({
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
</div>
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
) : resources.length === 0 && sharedFolders.length === 0 && !searchQuery && currentPath === '/' ? (
<FileUploadArea
onUpload={async (files: File[]) => {
setIsUploading(true);
@@ -1329,9 +1365,9 @@ export function FileBrowser({
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
</div>
)}
{displayResources.length === 0 && searchQuery ? (
{displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? (
<p className="px-4 py-8 text-center text-muted-foreground text-sm">{t("no_results")}</p>
) : (
) : displayResources.length === 0 ? null : (
<div
className="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2"
onContextMenu={(e) => {
@@ -1400,6 +1436,31 @@ export function FileBrowser({
))}
</div>
)}
{sharedFolders.length > 0 && (
<section aria-label={t("shared_with_me")} className={cn(displayResources.length > 0 && "mt-4")}>
<h4 className="px-1 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_with_me")}
</h4>
<div className="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2">
{sharedFolders.map((resource) => (
<button
key={resource.id}
type="button"
onClick={() => openSharedFolder(resource)}
title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : resource.name}
className="flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors hover:bg-muted/50"
>
{getGridIcon(resource)}
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1">
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
</span>
</button>
))}
</div>
</section>
)}
</div>
) : (
/* ======= LIST VIEW ======= */
@@ -1482,7 +1543,7 @@ export function FileBrowser({
<td />
</tr>
)}
{displayResources.length === 0 && searchQuery ? (
{displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground text-sm">
{t("no_results")}
@@ -1565,6 +1626,44 @@ export function FileBrowser({
</td>
</tr>
))}
{sharedFolders.length > 0 && (
<tr className="border-b border-border bg-muted/30">
<th colSpan={4} scope="rowgroup" className="px-4 py-1.5 text-start text-xs font-medium text-muted-foreground uppercase tracking-wider">
<span className="flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_with_me")}
</span>
</th>
</tr>
)}
{sharedFolders.map((resource) => (
<tr
key={resource.id}
className="border-b border-border cursor-pointer transition-colors hover:bg-muted/50"
onClick={() => openSharedFolder(resource)}
title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : undefined}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3 min-w-0">
<div className="w-4 h-4 shrink-0" />
{getFileIcon(resource)}
<button
type="button"
className="truncate text-start"
onClick={(e) => { e.stopPropagation(); openSharedFolder(resource); }}
>
{resource.name}
</button>
<ShareBadge resource={resource} t={t} />
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell">-</td>
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
{formatDate(resource.lastModified)}
</td>
<td className="px-2 py-2.5" />
</tr>
))}
</tbody>
</table>
)}
@@ -1927,9 +2026,11 @@ export function FileBrowser({
collectionName={shareTarget.name}
shareWith={shareTarget.shareWith}
ownAccountId={ownAccountId || ""}
onShare={(principalId, rights) =>
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
onClose={() => setShareTargetId(null)}
onShare={async (principalId, rights) => {
await onShare(shareTarget.id, principalId, rights as FileNodeRights | null);
if (treeShareTarget?.id === shareTarget.id) await reloadTreeShareTarget(shareTarget);
}}
onClose={() => { setShareTargetId(null); setTreeShareTarget(null); }}
/>
)}
</div>
+36 -4
View File
@@ -11,12 +11,15 @@ import {
Share2,
} from "@/components/icons";
import { cn } from "@/lib/utils";
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useFileStore, type FileResource } from "@/stores/file-store";
interface FolderNode {
id: string;
name: string;
path: string;
resource: FileResource;
}
interface FolderTreeSidebarProps {
@@ -25,13 +28,17 @@ interface FolderTreeSidebarProps {
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
width?: number;
isResizing?: boolean;
/** Whether the viewer may share this folder; gates the tree's context menu. */
canShare?: (resource: FileResource) => boolean;
onShare?: (resource: FileResource) => void;
}
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing, canShare, onShare }: FolderTreeSidebarProps) {
const t = useTranslations("files");
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<FileResource>();
const client = useFileStore(s => s.client);
// Loaded by the file browser, which shows them in either layout.
const sharedRoots = useFileStore(s => s.sharedRoots);
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
@@ -58,6 +65,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
id: r.id,
name: r.name,
path: folderPath,
resource: r,
};
});
@@ -84,8 +92,6 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
useEffect(() => {
if (client) {
loadChildren(null, "/");
// Discover folders shared with the user by other principals.
loadSharedRoots();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
@@ -132,6 +138,14 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
onNavigate(path, id);
}, [onNavigate]);
// Folders are not listed in the file list in this layout, so the tree is
// the only place to reach a folder's actions. Leave the browser menu alone
// when there is nothing to offer.
const handleFolderContextMenu = useCallback((e: React.MouseEvent, resource: FileResource) => {
if (!onShare || !canShare?.(resource)) return;
openContextMenu(e, resource);
}, [canShare, onShare, openContextMenu]);
return (
<div
className={cn(
@@ -182,6 +196,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
onToggleExpand={handleToggleExpand}
onFolderClick={handleFolderClick}
onLoadChildren={loadChildren}
onContextMenu={handleFolderContextMenu}
/>
))
)}
@@ -220,6 +235,19 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
</div>
)}
</div>
{onShare && (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem
icon={Share2}
label={t("share")}
onClick={() => {
if (contextMenu.data) onShare(contextMenu.data);
closeContextMenu();
}}
/>
</ContextMenu>
)}
</div>
);
}
@@ -234,6 +262,7 @@ function FolderTreeItem({
onToggleExpand,
onFolderClick,
onLoadChildren,
onContextMenu,
}: {
node: FolderNode;
depth: number;
@@ -244,6 +273,7 @@ function FolderTreeItem({
onToggleExpand: (folderId: string, folderPath: string) => void;
onFolderClick: (path: string, id: string | null) => void;
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
onContextMenu: (e: React.MouseEvent, resource: FileResource) => void;
}) {
const isExpanded = expandedIds.has(node.id);
const isSelected = currentPath === node.path;
@@ -272,6 +302,7 @@ function FolderTreeItem({
: "hover:bg-muted text-foreground",
depth === 0 && "font-medium"
)}
onContextMenu={(e) => onContextMenu(e, node.resource)}
>
{/* Expand/collapse chevron */}
{hasChildren ? (
@@ -327,6 +358,7 @@ function FolderTreeItem({
onToggleExpand={onToggleExpand}
onFolderClick={onFolderClick}
onLoadChildren={onLoadChildren}
onContextMenu={onContextMenu}
/>
))}
</div>