From 992ede9500a89137e4ba531eb3f292700cdfe99a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:27:56 +0200 Subject: [PATCH] fix: show and share shared folders in both folder layouts #1181 --- .../file-browser-shared-folders.test.tsx | 172 ++++++++++++++++++ components/files/file-browser.tsx | 119 +++++++++++- components/files/folder-tree-sidebar.tsx | 40 +++- 3 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 components/files/__tests__/file-browser-shared-folders.test.tsx diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx new file mode 100644 index 000000000..6e1d549db --- /dev/null +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -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 | null }) => ( +
{collectionName}:{Object.keys(shareWith ?? {}).join(',')}
+ ), +})); + +// 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 { + 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 } = {}) { + 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( + ''} + 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(); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 6e769db92..fd7eb32d5 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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(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} /> ) : ( @@ -1152,6 +1186,8 @@ export function FileBrowser({ listByParentId={listByParentId} width={sidebarWidth} isResizing={isResizing} + canShare={canShare} + onShare={handleTreeShare} /> { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} @@ -1277,7 +1313,7 @@ export function FileBrowser({

{t("no_accounts")}

- ) : resources.length === 0 && !searchQuery && currentPath === '/' ? ( + ) : resources.length === 0 && sharedFolders.length === 0 && !searchQuery && currentPath === '/' ? ( { setIsUploading(true); @@ -1329,9 +1365,9 @@ export function FileBrowser({ .. )} - {displayResources.length === 0 && searchQuery ? ( + {displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? (

{t("no_results")}

- ) : ( + ) : displayResources.length === 0 ? null : (
{ @@ -1400,6 +1436,31 @@ export function FileBrowser({ ))}
)} + {sharedFolders.length > 0 && ( +
0 && "mt-4")}> +

+ + {t("shared_with_me")} +

+
+ {sharedFolders.map((resource) => ( + + ))} +
+
+ )} ) : ( /* ======= LIST VIEW ======= */ @@ -1482,7 +1543,7 @@ export function FileBrowser({ )} - {displayResources.length === 0 && searchQuery ? ( + {displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? ( {t("no_results")} @@ -1565,6 +1626,44 @@ export function FileBrowser({ ))} + {sharedFolders.length > 0 && ( + + + + + {t("shared_with_me")} + + + + )} + {sharedFolders.map((resource) => ( + openSharedFolder(resource)} + title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : undefined} + > + +
+
+ {getFileIcon(resource)} + + +
+ + - + + {formatDate(resource.lastModified)} + + + + ))} )} @@ -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); }} /> )}
diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx index af8339c69..d6810a820 100644 --- a/components/files/folder-tree-sidebar.tsx +++ b/components/files/folder-tree-sidebar.tsx @@ -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; 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(); 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(null); const [expandedIds, setExpandedIds] = useState>(new Set(["root"])); const [loadingIds, setLoadingIds] = useState>(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 (
)) )} @@ -220,6 +235,19 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
)} + + {onShare && ( + + { + if (contextMenu.data) onShare(contextMenu.data); + closeContextMenu(); + }} + /> + + )} ); } @@ -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; + 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} /> ))}