feat: complete tree shell cutover and regression coverage

This commit is contained in:
lix-2026
2026-04-18 09:38:16 +08:00
parent d3876e56eb
commit 111a87d4fd
53 changed files with 5440 additions and 516 deletions
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
const TREE_SHELL_PATH = "/tree";
@@ -170,6 +171,7 @@ export function MnoteWebTreeShell({
const readyTimerRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
const [authCookieReady, setAuthCookieReady] = useState(false);
const shellUrl = useMemo(() => {
if (!baseUrl || !treeShellEnabled) return null;
@@ -198,7 +200,32 @@ export function MnoteWebTreeShell({
]);
useEffect(() => {
if (!shellUrl) return;
if (!shellUrl) {
setAuthCookieReady(false);
return;
}
let cancelled = false;
setAuthCookieReady(false);
setFailedShellUrl(null);
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (!cancelled) {
setAuthCookieReady(true);
}
} catch {
if (!cancelled) {
setFailedShellUrl(shellUrl);
}
}
})();
return () => {
cancelled = true;
};
}, [shellUrl]);
useEffect(() => {
if (!shellUrl || !authCookieReady) return;
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
}
@@ -213,7 +240,7 @@ export function MnoteWebTreeShell({
readyTimerRef.current = null;
}
};
}, [shellUrl]);
}, [authCookieReady, shellUrl]);
useEffect(() => {
if (!baseUrl || !treeShellEnabled) return;
@@ -332,7 +359,7 @@ export function MnoteWebTreeShell({
return () => window.removeEventListener("message", onMessage);
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
if (!shellUrl || failedShellUrl === shellUrl) {
if (!shellUrl || !authCookieReady || failedShellUrl === shellUrl) {
return <>{fallback}</>;
}
@@ -134,6 +134,18 @@ export function FileTree({
return (
<div
key={row.rowId}
data-testid={
row.kind === "doc"
? "filetree-doc-row"
: row.kind === "index"
? "filetree-index-row"
: row.kind === "asset-folder"
? "filetree-asset-folder-row"
: "filetree-asset-row"
}
data-row-id={row.rowId}
data-row-kind={row.kind}
data-doc-id={row.docId}
className={cn(
baseClass,
active && activeClass,
@@ -227,6 +239,7 @@ export function FileTree({
<button
type="button"
aria-label="展开或折叠"
data-testid="filetree-toggle"
onClick={(event) => {
event.stopPropagation();
onToggleExpand(row.docId);
@@ -248,6 +261,7 @@ export function FileTree({
<button
type="button"
aria-label="新建子页面"
data-testid="filetree-create"
onClick={(event) => {
event.stopPropagation();
onCreateChild(row.docId);
@@ -269,6 +283,7 @@ export function FileTree({
<button
type="button"
aria-label="展开或折叠"
data-testid="filetree-toggle"
onClick={(event) => {
event.stopPropagation();
onToggleAssetFolderExpand?.(row.asset.id);
@@ -200,6 +200,8 @@ function SortableTreeRow({
activeId === node.id && "bg-wolai-bg-active text-[#2563eb]",
isDragging && "opacity-60",
)}
data-testid="page-tree-row"
data-node-id={node.id}
data-active={activeId === node.id}
onContextMenu={onContextMenu}
>
@@ -220,6 +222,7 @@ function SortableTreeRow({
<button
type="button"
aria-label="展开或折叠"
data-testid="page-tree-toggle"
onClick={onToggleExpand}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
>
@@ -228,13 +231,18 @@ function SortableTreeRow({
) : (
<span className="w-5 h-5 shrink-0" />
)}
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
<Link
href={`/documents/${node.id}`}
data-testid="page-tree-open"
className="flex-1 truncate text-left"
>
{node.title || "无标题"}
</Link>
</div>
<button
type="button"
aria-label="新建子页面"
data-testid="page-tree-create"
onClick={onCreateChild}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
>
+113 -253
View File
@@ -5,7 +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 { useConvexAuth } from "convex/react";
import {
ArrowRightLeft,
ArrowUpRight,
@@ -38,7 +38,7 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import {
buildPageTreeProjectionItems,
@@ -47,26 +47,24 @@ import {
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";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
computeTreePaneDeleteTargets,
inferPasteTargetDocId,
isTextInputTarget,
readFileTreeClipboardPayload,
writeFileTreeClipboardPayload,
} from "@/lib/file-tree/clipboard";
normalizeTreePaneSelectionForVisibleRows,
readTreePaneClipboardPayload,
reduceTreePaneSelection,
type TreePaneRow,
type TreePaneSelectionState,
writeTreePaneClipboardPayload,
} from "@/components/sidebar/tree-pane-bindings";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
@@ -168,17 +166,12 @@ interface SidebarContentProps {
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
const convex = useConvex();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
useConvexAuth();
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(() => {
@@ -251,14 +244,13 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0);
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
const [resourceSelection, setResourceSelection] = useState<TreePaneSelectionState>(() => ({
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null,
}));
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
@@ -322,7 +314,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
setTreeShellReloadToken((prev) => prev + 1);
}, [sidebarQuery]);
const refreshShareSummary = useCallback(async () => {
@@ -487,7 +478,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}
return map;
}, [groupPublicSummary, nodeById]);
const filteredPrivateTree = useMemo(
const filteredPageNodes = useMemo(
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
[filter, privateTree],
);
@@ -496,8 +487,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[privateTree],
);
const filteredPrivatePageRows = useMemo(
() => buildPageTreeProjectionItems(filteredPrivateTree),
[filteredPrivateTree],
() => buildPageTreeProjectionItems(filteredPageNodes),
[filteredPageNodes],
);
const flattenedPrivate = useMemo(
() => filterVisiblePageTreeProjectionItems(privatePageRows, expanded),
@@ -614,7 +605,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const fileTreeRows = useMemo(
const resourceRows = useMemo(
() =>
buildVisibleRows({
pageRows: visibleFilteredPrivatePageRows,
@@ -632,12 +623,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
],
);
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
useEffect(() => {
setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds));
}, [fileTreeVisibleRowIds]);
setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
}, [resourceVisibleRowIds]);
const docParentById = useMemo(
() =>
@@ -679,14 +670,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[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;
@@ -840,17 +823,17 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
})();
}, [activeId, editorBridge, router, setOpen]);
const handleFileTreeBlankMouseDown = useCallback(() => {
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
const handleResourcePaneBlankMouseDown = useCallback(() => {
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}, []);
const handleFileTreeRowClick = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, {
const handleResourceRowClick = useCallback(
(row: TreePaneRow, event: React.MouseEvent) => {
setResourceSelection((prev) =>
reduceTreePaneSelection(prev, {
type: "click",
rowId: row.rowId,
visibleRowIds: fileTreeVisibleRowIds,
visibleRowIds: resourceVisibleRowIds,
modifiers: {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
@@ -871,23 +854,23 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
// doc/index/asset 都统一跳转到所属页面(index.md)
handleOpenDocument(targetDocId, "main");
},
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
[activeId, resourceVisibleRowIds, handleOpenDocument],
);
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
setFileTreeSelection((prev) => {
const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
setResourceSelection((prev) => {
if (prev.selectedRowIds.has(row.rowId)) return prev;
return reduceFileTreeSelection(prev, {
return reduceTreePaneSelection(prev, {
type: "click",
rowId: row.rowId,
visibleRowIds: fileTreeVisibleRowIds,
visibleRowIds: resourceVisibleRowIds,
modifiers: { shiftKey: false, ctrlKey: false, metaKey: false },
});
});
}, [fileTreeVisibleRowIds]);
}, [resourceVisibleRowIds]);
const handleFileTreeRowDoubleClick = useCallback(
(row: FileTreeRow, _event?: React.MouseEvent) => {
const handleResourceRowDoubleClick = useCallback(
(row: TreePaneRow, _event?: React.MouseEvent) => {
if (row.kind === "asset" || row.kind === "asset-folder") {
handleOpenAsset(row.asset);
return;
@@ -897,12 +880,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[handleOpenAsset, handleOpenDocument],
);
const handleFileTreeRowContextMenu = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => {
const handleResourceRowContextMenu = useCallback(
(row: TreePaneRow, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
setResourceSelection((prev) =>
reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
);
if (row.kind === "asset" || row.kind === "asset-folder") {
@@ -938,21 +921,21 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
return;
}
const container = fileTreeContainerRef.current;
const container = resourcePaneContainerRef.current;
const activeElement = document.activeElement;
if (!container || !activeElement || !container.contains(activeElement)) {
return;
}
if (isCopy) {
if (fileTreeSelection.selectedRowIds.size === 0) {
if (resourceSelection.selectedRowIds.size === 0) {
return;
}
event.preventDefault();
const orderedRowIds = fileTreeRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
const orderedRowIds = resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.map((row) => row.rowId);
await writeFileTreeClipboardPayload({
await writeTreePaneClipboardPayload({
type: "mnote-file-tree",
version: 1,
action: "copy",
@@ -963,14 +946,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
if (isPaste) {
event.preventDefault();
const payload = await readFileTreeClipboardPayload();
const payload = await readTreePaneClipboardPayload();
if (!payload || payload.rowIds.length === 0) {
return;
}
const targetDocId = inferPasteTargetDocId({
focusedRowId: fileTreeSelection.focusedRowId,
rowById: fileTreeRowById,
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
@@ -979,8 +962,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}
const rows = payload.rowIds
.map((rowId) => fileTreeRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[];
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
const docItemsMap = new Map<string, boolean>();
rows.forEach((row) => {
@@ -1011,7 +994,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
emitDocumentsChanged(targetDocId);
}
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (copyableAssetIds.length > 0) {
@@ -1041,10 +1024,10 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
return () => window.removeEventListener("keydown", handler);
}, [
activeId,
fileTreeRowById,
fileTreeRows,
fileTreeSelection.focusedRowId,
fileTreeSelection.selectedRowIds,
resourceRowById,
resourceRows,
resourceSelection.focusedRowId,
resourceSelection.selectedRowIds,
sidebarQuery,
]);
@@ -1301,10 +1284,10 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
);
const handleDeleteFileTreeSelection = useCallback(async () => {
const { docIds, assetIds } = computeFileTreeDeleteTargets({
visibleRows: fileTreeRows,
selectedRowIds: fileTreeSelection.selectedRowIds,
const handleDeleteResourceSelection = useCallback(async () => {
const { docIds, assetIds } = computeTreePaneDeleteTargets({
visibleRows: resourceRows,
selectedRowIds: resourceSelection.selectedRowIds,
parentById: docParentById,
});
@@ -1317,14 +1300,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
const isAssetRow = (
row: FileTreeRow,
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> =>
row: TreePaneRow,
): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
row.kind === "asset" || row.kind === "asset-folder";
const selectedAssetHints = Array.from(
new Map(
fileTreeRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
@@ -1379,15 +1362,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
await refreshTree();
setContextMenu(null);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
}, [
activeId,
docParentById,
fileTreeRows,
fileTreeSelection.selectedRowIds,
resourceRows,
resourceSelection.selectedRowIds,
handleDeleteAssets,
mediaAssets,
mindmapAssets,
@@ -1528,8 +1511,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[moveLocalNode, refreshTree, setExpanded],
);
const handleFileTreeDropFiles = useCallback(
(docId: string, files: FileList, targetRow?: FileTreeRow) => {
const handleResourcePaneDropFiles = useCallback(
(docId: string, files: FileList, targetRow?: TreePaneRow) => {
void (async () => {
const droppedFiles = Array.from(files ?? []);
if (droppedFiles.length === 0) return;
@@ -1552,8 +1535,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const inferredTargetDocId =
docId ||
inferPasteTargetDocId({
focusedRowId: fileTreeSelection.focusedRowId,
rowById: fileTreeRowById,
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
}) ||
"";
@@ -1618,16 +1601,16 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[
activeId,
editorBridge,
fileTreeRowById,
fileTreeSelection.focusedRowId,
resourceRowById,
resourceSelection.focusedRowId,
sidebarData.activeWorkspaceId,
sidebarData.documents,
sidebarQuery,
],
);
const handleFileTreeInternalDrop = useCallback(
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
const handleResourcePaneInternalDrop = useCallback(
(args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {
void (async () => {
const targetDocId = inferDropTargetDocId(args.targetRow);
if (!targetDocId) {
@@ -1660,11 +1643,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
});
const rows = uniqueRowIds
.map((rowId) => fileTreeRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[];
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (docIds.length === 0 && copyableAssetIds.length === 0) {
@@ -1771,7 +1754,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[
childrenCountByParentId,
docParentById,
fileTreeRowById,
resourceRowById,
moveLocalNode,
refreshTree,
sidebarQuery,
@@ -1803,7 +1786,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const handleDeleteFromContextMenuNode = useCallback(
async (node: SidebarTreeNode) => {
if (viewMode === "filesystem") {
await handleDeleteFileTreeSelection();
await handleDeleteResourceSelection();
return;
}
@@ -1816,7 +1799,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDelete, handleDeleteFileTreeSelection, viewMode],
[handleDelete, handleDeleteResourceSelection, viewMode],
);
const handleDeleteFromAssetContextMenu = useCallback(
@@ -1847,7 +1830,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
try {
await handleDeleteAssets(uniqueAssetIds, assetHint);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
@@ -2193,68 +2176,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
});
}, []);
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,
@@ -2596,42 +2517,17 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
</button>
{!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2">
{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 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>
)}
<SidebarTreeSurface
mode="page"
className="h-full"
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
) : (
<div className="px-4 pb-2 text-xs text-gray-400"></div>
@@ -2642,66 +2538,30 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<div className="flex-1 px-1 pb-2">
<div
ref={fileTreeContainerRef}
ref={resourcePaneContainerRef}
data-testid="file-tree-container"
className="h-full w-full min-w-0 overflow-x-hidden"
>
{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>
)}
<SidebarTreeSurface
mode="filetree"
className="h-full"
rows={resourceRows}
activeId={activeId}
selectedRowIds={resourceSelection.selectedRowIds}
onRowClick={handleResourceRowClick}
onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
onRowContextMenu={handleResourceRowContextMenu}
onRowDragStart={(row, event) => {
void event;
handleResourceRowDragStart(row);
}}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleResourcePaneBlankMouseDown}
onDropFiles={handleResourcePaneDropFiles}
onInternalDrop={handleResourcePaneInternalDrop}
/>
</div>
</div>
</div>
@@ -0,0 +1,15 @@
"use client";
export type { FileTreeSelectionState as TreePaneSelectionState } from "@/lib/file-tree/selection";
export {
normalizeFileTreeSelectionForVisibleRows as normalizeTreePaneSelectionForVisibleRows,
reduceFileTreeSelection as reduceTreePaneSelection,
} from "@/lib/file-tree/selection";
export type { FileTreeRow as TreePaneRow } from "@/lib/file-tree/types";
export { computeFileTreeDeleteTargets as computeTreePaneDeleteTargets } from "@/lib/file-tree/delete";
export {
inferPasteTargetDocId,
isTextInputTarget,
readFileTreeClipboardPayload as readTreePaneClipboardPayload,
writeFileTreeClipboardPayload as writeTreePaneClipboardPayload,
} from "@/lib/file-tree/clipboard";
@@ -0,0 +1,150 @@
"use client";
import type { DragEvent, MouseEvent } from "react";
import { FileTree } from "@/components/sidebar/file-tree";
import { PrivateTree } from "@/components/sidebar/private-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { FileTreeRow } from "@/lib/file-tree/types";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import { cn } from "@/lib/utils";
type SidebarPageTreeSurfaceProps = {
mode: "page";
rows: PageTreeProjectionItem[];
expanded: Set<string>;
activeId: string;
className?: string;
onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
};
type SidebarFileTreeSurfaceProps = {
mode: "filetree";
rows: FileTreeRow[];
activeId: string;
selectedRowIds: Set<string>;
className?: string;
onRowClick: (row: FileTreeRow, event: MouseEvent) => void;
onRowDoubleClick: (row: FileTreeRow, event: MouseEvent) => void;
onRowContextMenu: (row: FileTreeRow, event: MouseEvent) => void;
onRowDragStart?: (row: FileTreeRow, event: DragEvent) => void;
onToggleExpand: (docId: string) => void;
onToggleAssetFolderExpand?: (assetId: string) => void;
onCreateChild: (parentId: string | null) => void;
onBlankMouseDown?: (event: MouseEvent) => void;
onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
};
export type SidebarTreeSurfaceProps =
| SidebarPageTreeSurfaceProps
| SidebarFileTreeSurfaceProps;
export type TreePickerSurfaceItem =
| { kind: "root"; id: null; title: string; subtitle?: string }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
type TreePickerSurfaceProps = {
items: TreePickerSurfaceItem[];
highlighted: number;
className?: string;
emptyText?: string;
onHighlight: (index: number) => void;
onPick: (targetId: string | null) => void;
};
export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
const surfaceTestId =
props.mode === "page"
? "sidebar-page-tree-shell"
: "sidebar-file-tree-shell";
return (
<div
data-testid={surfaceTestId}
data-shell-mode={props.mode}
className={cn(
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
props.className,
)}
>
{props.mode === "page" ? (
<PrivateTree
rows={props.rows}
expanded={props.expanded}
activeId={props.activeId}
onToggleExpand={props.onToggleExpand}
onMove={props.onMove}
onCreateChild={props.onCreateChild}
onContextMenu={props.onContextMenu}
/>
) : (
<FileTree
rows={props.rows}
activeId={props.activeId}
selectedRowIds={props.selectedRowIds}
onRowClick={props.onRowClick}
onRowDoubleClick={props.onRowDoubleClick}
onRowContextMenu={props.onRowContextMenu}
onRowDragStart={props.onRowDragStart}
onToggleExpand={props.onToggleExpand}
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
onCreateChild={props.onCreateChild}
onBlankMouseDown={props.onBlankMouseDown}
onDropFiles={props.onDropFiles}
onInternalDrop={props.onInternalDrop}
/>
)}
</div>
);
}
export function TreePickerSurface({
items,
highlighted,
className,
emptyText = "没有匹配结果",
onHighlight,
onPick,
}: TreePickerSurfaceProps) {
if (items.length === 0) {
return <div className="p-4 text-sm text-gray-400">{emptyText}</div>;
}
return (
<div
data-testid="tree-picker-surface"
className={cn("py-2", className)}
>
{items.map((item, index) => {
const isRoot = item.kind === "root";
return (
<button
key={isRoot ? "root" : item.id}
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
data-node-id={isRoot ? "" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
index === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => onHighlight(index)}
onClick={() => onPick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle ? (
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
) : null}
</button>
);
})}
</div>
);
}