feat: 接入 mnote web tree shell 与主页链路整理

- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成
- 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑
- 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
lix-2026
2026-04-17 23:36:24 +08:00
parent cfc3af8984
commit d8de820d93
40 changed files with 4668 additions and 4143 deletions
@@ -0,0 +1,353 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
const TREE_SHELL_PATH = "/tree";
export type MnoteTreeShellMode = "page" | "picker" | "filetree";
type TreeShellMessage =
| {
channel?: string;
type?: string;
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
target?: { documentId?: string };
payload?: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
};
}
| null
| undefined;
type MnoteWebTreeShellProps = {
workspaceId: string | null;
activeDocumentId?: string;
actorId?: string | null;
mode?: MnoteTreeShellMode;
allowRootPick?: boolean;
excludeIds?: string[];
reloadToken?: number;
onNavigate: (documentId: string) => void;
onPick?: (documentId: string | null) => void;
onOpenAsset?: (assetId: string) => void;
onOpenContextMenu?: (args: { documentId: string; x: number; y: number }) => void;
onOpenFileTreeContextMenu?: (args: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => void;
onRefresh: () => Promise<void>;
fallback: React.ReactNode;
};
function buildShellUrl(
baseUrl: string,
workspaceId: string | null,
activeDocumentId: string | undefined,
actorId: string | null | undefined,
mode: MnoteTreeShellMode,
allowRootPick: boolean,
excludeIds: string[],
refreshKey: number,
reloadToken: number,
) {
const url = new URL(TREE_SHELL_PATH, `${baseUrl}/`);
if (workspaceId) {
url.searchParams.set("workspaceId", workspaceId);
}
if (activeDocumentId) {
url.searchParams.set("activeDocumentId", activeDocumentId);
}
if (actorId && actorId.trim()) {
url.searchParams.set("actorId", actorId.trim());
}
url.searchParams.set("mode", mode);
if (mode === "picker") {
url.searchParams.set("allowRootPick", allowRootPick ? "1" : "0");
if (excludeIds.length > 0) {
url.searchParams.set("excludeIds", excludeIds.join(","));
}
}
url.searchParams.set("host", "wolai-frontend");
url.searchParams.set("channel", TREE_SHELL_CHANNEL);
url.searchParams.set("v", `${refreshKey}-${reloadToken}`);
return url.toString();
}
function extractDocumentId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.documentId === "string" ? message.documentId.trim() : "";
if (direct) return direct;
const fromTarget =
message.target && typeof message.target.documentId === "string"
? message.target.documentId.trim()
: "";
if (fromTarget) return fromTarget;
const fromPayload =
message.payload && typeof message.payload.documentId === "string"
? message.payload.documentId.trim()
: "";
return fromPayload;
}
function extractAssetId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.assetId === "string" ? message.assetId.trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload.assetId === "string"
? message.payload.assetId.trim()
: "";
return fromPayload;
}
function extractCoordinate(
message: Exclude<TreeShellMessage, null | undefined>,
axis: "x" | "y",
) {
const direct = typeof message[axis] === "number" ? message[axis] : null;
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
const fromPayload =
message.payload && typeof message.payload[axis] === "number"
? message.payload[axis]
: null;
if (typeof fromPayload === "number" && Number.isFinite(fromPayload)) return fromPayload;
return null;
}
function extractTextField(
message: Exclude<TreeShellMessage, null | undefined>,
field: "rowId" | "rowKind",
) {
const direct = typeof message[field] === "string" ? message[field].trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload[field] === "string"
? message.payload[field].trim()
: "";
return fromPayload;
}
export function MnoteWebTreeShell({
workspaceId,
activeDocumentId,
actorId,
mode = "page",
allowRootPick = false,
excludeIds = [],
reloadToken = 0,
onNavigate,
onPick,
onOpenAsset,
onOpenContextMenu,
onOpenFileTreeContextMenu,
onRefresh,
fallback,
}: MnoteWebTreeShellProps) {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const treeShellEnabled = runtime.mnoteWebTreeShellEnabled === true;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const readyTimerRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
const shellUrl = useMemo(() => {
if (!baseUrl || !treeShellEnabled) return null;
return buildShellUrl(
baseUrl,
workspaceId,
activeDocumentId,
actorId,
mode,
allowRootPick,
excludeIds,
refreshKey,
reloadToken,
);
}, [
activeDocumentId,
actorId,
allowRootPick,
baseUrl,
excludeIds,
mode,
reloadToken,
refreshKey,
treeShellEnabled,
workspaceId,
]);
useEffect(() => {
if (!shellUrl) return;
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
}
// 说明:当前 shell 仍处于渐进接入期,若路由不存在或页面未按协议 ready,
// 前端应自动回退到旧 React 树,而不是让用户看到空白 iframe。
readyTimerRef.current = window.setTimeout(() => {
setFailedShellUrl(shellUrl);
}, 2500);
return () => {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
};
}, [shellUrl]);
useEffect(() => {
if (!baseUrl || !treeShellEnabled) return;
const expectedOrigin = (() => {
try {
return new URL(baseUrl).origin;
} catch {
return "";
}
})();
const onMessage = (event: MessageEvent<TreeShellMessage>) => {
if (!expectedOrigin || event.origin !== expectedOrigin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.channel !== TREE_SHELL_CHANNEL) return;
const type = typeof message.type === "string" ? message.type.trim() : "";
if (!type) return;
if (type === "tree.ready" || type === "ready") {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
return;
}
if (type === "tree.navigate" || type === "navigate") {
const documentId = extractDocumentId(message);
if (documentId) {
onNavigate(documentId);
}
return;
}
if (type === "tree.pick" || type === "picker.pick") {
onPick?.(extractDocumentId(message) || null);
return;
}
if (type === "tree.pick.root" || type === "picker.pick.root") {
onPick?.(null);
return;
}
if (type === "tree.asset.open" || type === "filetree.asset.open") {
const assetId = extractAssetId(message);
if (assetId) {
onOpenAsset?.(assetId);
}
return;
}
if (type === "tree.context-menu" || type === "tree.page.context-menu") {
const documentId = extractDocumentId(message);
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (!documentId || x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenContextMenu?.({
documentId,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (type === "tree.filetree.context-menu") {
const documentId = extractDocumentId(message) || undefined;
const assetId = extractAssetId(message) || undefined;
const rowId = extractTextField(message, "rowId") || undefined;
const rowKind = extractTextField(message, "rowKind") || undefined;
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenFileTreeContextMenu?.({
documentId,
assetId,
rowId,
rowKind,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (
type === "tree.node.created" ||
type === "tree.node.renamed" ||
type === "tree.subtree.moved" ||
type === "tree.refresh" ||
type === "refresh"
) {
void onRefresh().finally(() => {
setRefreshKey((value) => value + 1);
});
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
if (!shellUrl || failedShellUrl === shellUrl) {
return <>{fallback}</>;
}
return (
<div className="h-full w-full min-w-0 overflow-hidden bg-white">
<iframe
ref={iframeRef}
title="mnote-web tree shell"
src={shellUrl}
className="h-full w-full border-0 bg-white"
loading="lazy"
onError={() => {
setFailedShellUrl(shellUrl);
}}
/>
</div>
);
}
+251 -107
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useAuthActions } from "@convex-dev/auth/react";
import { useConvex } from "convex/react";
import { useConvexAuth, useQuery } from "convex/react";
import {
ArrowRightLeft,
ArrowUpRight,
@@ -36,7 +37,7 @@ import { cn } from "@/lib/utils";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import {
@@ -47,6 +48,7 @@ import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document";
import { FileTree } from "@/components/sidebar/file-tree";
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
@@ -68,6 +70,11 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
import {
createDocumentCommand,
moveDocumentCommand,
renameDocumentCommand,
} from "@/lib/documents/tree-command-client";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -148,20 +155,22 @@ function SidebarConvex({ initialData }: SidebarProps) {
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps {
initialData: SidebarInitialData;
sidebarQuery: {
data: SidebarInitialData | null | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
sidebarQuery: SidebarDataResult;
}
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const convex = useConvex();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore();
const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const treeShellAvailable = Boolean(
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
);
// 处理数据
const sidebarData = useMemo(() => {
@@ -234,6 +243,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0);
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
selectedRowIds: new Set(),
anchorRowId: null,
@@ -304,6 +314,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
setTreeShellReloadToken((prev) => prev + 1);
}, [sidebarQuery]);
const refreshShareSummary = useCallback(async () => {
@@ -585,6 +596,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return map;
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
const assetById = useMemo(() => {
const map = new Map<string, MediaAsset>();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
map.set(asset.id, asset);
});
return map;
}, [mediaAssets, mindmapAssets, tableAssets]);
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const fileTreeRows = useMemo(
@@ -652,6 +671,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[router, setOpen],
);
const handleNavigateFromTreeShell = useCallback(
(documentId: string) => {
if (!documentId) return;
handleOpenDocument(documentId, "main");
},
[handleOpenDocument],
);
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
@@ -1264,9 +1291,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
setAssetMenu(null);
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
await sidebarQuery.refetch();
await refreshTree();
},
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
);
const handleDeleteFileTreeSelection = useCallback(async () => {
@@ -1389,60 +1416,50 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
creatingDocumentUnderParentRef.current.add(creatingKey);
try {
const response = await fetch("/api/documents/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parentId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "新建页面失败,请稍后再试");
return;
}
const payload = (await response.json()) as SidebarTreeNode;
const nextNode: SidebarTreeNode = {
...payload,
access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: payload.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
};
setTree((prev) => {
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: SidebarTreeNode[]): boolean => {
for (const node of nodes) {
if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true;
}
return false;
const payload = (await createDocumentCommand(parentId)) as SidebarTreeNode;
const nextNode: SidebarTreeNode = {
...payload,
access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: payload.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
};
if (exists(prev)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) {
next.add(parentId);
}
if (!parentId) {
next.add(nextNode.id);
}
return next;
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
setTree((prev) => {
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: SidebarTreeNode[]): boolean => {
for (const node of nodes) {
if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true;
}
return false;
};
if (exists(prev)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) {
next.add(parentId);
}
if (!parentId) {
next.add(nextNode.id);
}
return next;
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
} catch (error) {
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
} finally {
creatingDocumentUnderParentRef.current.delete(creatingKey);
}
@@ -1456,18 +1473,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (!title.trim()) {
return;
}
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
try {
await renameDocumentCommand({
documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null,
title: title.trim(),
}),
});
});
} catch (error) {
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
return;
}
await refreshTree();
},
[refreshTree],
[refreshTree, sidebarData.activeWorkspaceId],
);
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
@@ -1486,15 +1504,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (parentId) {
setExpanded((prev) => new Set(prev).add(parentId));
}
await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
try {
await moveDocumentCommand({
documentId,
parentId,
position: index,
}),
});
});
} catch (error) {
window.alert(error instanceof Error ? error.message : "移动失败,请稍后再试");
await refreshTree();
return;
}
await refreshTree();
},
[moveLocalNode, refreshTree, setExpanded],
@@ -1710,14 +1730,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
setExpanded((prev) => new Set(prev).add(targetDocId));
for (let i = 0; i < topLevelDocIds.length; i += 1) {
await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId: topLevelDocIds[i],
parentId: targetDocId,
position: baseIndex + i,
}),
await moveDocumentCommand({
documentId: topLevelDocIds[i],
parentId: targetDocId,
position: baseIndex + i,
});
}
await refreshTree();
@@ -2178,6 +2194,68 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
});
}, []);
const openContextMenuFromTreeShell = useCallback(
({ documentId, x, y }: { documentId: string; x: number; y: number }) => {
const node = nodeById.get(documentId);
if (!node) {
return;
}
setContextMenu({
node,
x,
y,
});
},
[nodeById],
);
const openFileTreeContextMenuFromTreeShell = useCallback(
({
documentId,
assetId,
rowId,
x,
y,
}: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => {
if (assetId) {
const asset = assetById.get(assetId);
if (asset) {
if (rowId) {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
);
}
setAssetMenu({ asset, x, y });
return;
}
}
if (documentId) {
const node = nodeById.get(documentId);
if (node) {
if (rowId) {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
);
}
setContextMenu({
node,
x,
y,
});
}
}
},
[assetById, nodeById],
);
const openShareDialog = useCallback((node: SidebarTreeNode) => {
setShareTarget({
id: node.id,
@@ -2519,17 +2597,42 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
</button>
{!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
{treeShellAvailable ? (
<MnoteWebTreeShell
workspaceId={sidebarData.activeWorkspaceId ?? null}
activeDocumentId={activeId}
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
reloadToken={treeShellReloadToken}
onNavigate={handleNavigateFromTreeShell}
onOpenContextMenu={openContextMenuFromTreeShell}
onRefresh={refreshTree}
fallback={
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
}
/>
</div>
) : (
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
)}
</div>
) : (
<div className="px-4 pb-2 text-xs text-gray-400"></div>
@@ -2542,23 +2645,64 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
<div
ref={fileTreeContainerRef}
data-testid="file-tree-container"
className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white"
className="h-full w-full min-w-0 overflow-x-hidden"
>
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
{treeShellAvailable ? (
<MnoteWebTreeShell
workspaceId={sidebarData.activeWorkspaceId ?? null}
activeDocumentId={activeId}
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
mode="filetree"
reloadToken={treeShellReloadToken}
onNavigate={handleNavigateFromTreeShell}
onOpenAsset={(assetId) => {
const asset = assetById.get(assetId);
if (asset) {
handleOpenAsset(asset);
}
}}
onOpenContextMenu={openContextMenuFromTreeShell}
onOpenFileTreeContextMenu={openFileTreeContextMenuFromTreeShell}
onRefresh={refreshTree}
fallback={
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
}
/>
) : (
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
)}
</div>
</div>
</div>