mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show and share shared folders in both folder layouts #1181
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user