4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
+171 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
import { buildVisibleRows } from "./rows";
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
import { parseFileTreeRowId } from "./types";
describe("buildVisibleRows", () => {
@@ -283,6 +283,176 @@ describe("buildVisibleRows", () => {
},
});
});
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
const fileTreeItems = [
{
rowId: "doc:page_root",
rowKind: "document",
nodeId: "page_root",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "根页面",
depth: 0,
position: 0,
childCount: 3,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:page_root",
rowKind: "index",
nodeId: "index:page_root",
parentNodeId: "page_root",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 1,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset_folder",
nodeId: "asset-folder:mind_1",
parentNodeId: "page_root",
nodeType: "mindmap",
projectionKind: "file_tree",
title: "头脑风暴",
depth: 1,
position: 1,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open-asset", "select"],
resourceMeta: {
resourceKind: "mindmap",
documentId: "page_root",
assetId: "mind_1",
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
},
iconHint: "mindmap",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
nodeId: "asset:asset_child_1",
parentNodeId: "asset-folder:mind_1",
nodeType: "asset",
projectionKind: "file_tree",
title: "节点图片.png",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "asset",
documentId: "page_root",
assetId: "asset_child_1",
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
},
iconHint: "image",
},
{
rowId: "doc:page_child",
rowKind: "document",
nodeId: "page_child",
parentNodeId: "page_root",
nodeType: "page",
projectionKind: "file_tree",
title: "子页面",
depth: 1,
position: 2,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "page_child",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:page_child",
rowKind: "index",
nodeId: "index:page_child",
parentNodeId: "page_child",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "page_child",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
] as const;
const filteredItems = filterKernelFileTreeProjectionItems({
fileTreeItems: [...fileTreeItems],
visibleDocumentIds: new Set(["page_root", "page_child"]),
expandedDocumentIds: new Set(["page_root"]),
expandedAssetFolderIds: new Set(["mind_1"]),
});
expect(filteredItems.map((item) => item.rowId)).toEqual([
"doc:page_root",
"index:page_root",
"asset-folder:mind_1",
"asset:asset_child_1",
"doc:page_child",
]);
const rows = buildVisibleRows({
fileTreeItems: filteredItems,
expanded: new Set(["page_root"]),
expandedAssetFolderIds: new Set(["mind_1"]),
});
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
"doc:doc:page_root",
"index:index:page_root",
"asset-folder:asset-folder:mind_1",
"asset:asset:asset_child_1",
"doc:doc:page_child",
]);
});
});
describe("parseFileTreeRowId", () => {
+34
View File
@@ -12,6 +12,40 @@ import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
export function filterKernelFileTreeProjectionItems(input: {
fileTreeItems: KernelFileTreeProjectionItem[];
visibleDocumentIds: ReadonlySet<string>;
expandedDocumentIds: ReadonlySet<string>;
expandedAssetFolderIds?: ReadonlySet<string>;
}): KernelFileTreeProjectionItem[] {
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
return input.fileTreeItems.filter((item) => {
const docId = getDocIdFromFileTreeItem(item);
if (!input.visibleDocumentIds.has(docId)) {
return false;
}
switch (item.rowKind) {
case "document":
return true;
case "index":
case "asset_folder":
return input.expandedDocumentIds.has(docId);
case "asset": {
if (!input.expandedDocumentIds.has(docId)) {
return false;
}
const parentNodeId = String(item.parentNodeId ?? "").trim();
if (parentNodeId.startsWith("asset-folder:")) {
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
}
return true;
}
}
});
}
function buildRowsFromKernelFileTreeProjection(input: {
fileTreeItems: KernelFileTreeProjectionItem[];
expanded: Set<string>;
@@ -0,0 +1,294 @@
import { describe, expect, it } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import {
buildFileTreeShellRowById,
buildFileTreeShellVisibleRowIds,
computeFileTreeShellDeleteTargets,
getOrderedFileTreeShellRows,
inferFileTreeShellTargetDocumentId,
resolveFileTreeShellMindmapTargetId,
} from "./shell";
describe("file-tree shell helpers", () => {
const nodeById = new Map<string, SidebarTreeNode>([
[
"doc_root",
{
id: "doc_root",
title: "根页面",
} as SidebarTreeNode,
],
]);
const assetById = new Map<string, MediaAsset>([
[
"mind_1",
{
id: "mind_1",
document_id: "doc_root",
workspace_id: "ws_1",
asset_type: "mindmap",
file_name: "mindmap.json",
storage_path: "mindmaps/mind_1/mindmap.json",
} as MediaAsset,
],
[
"asset_child_1",
{
id: "asset_child_1",
document_id: "doc_root",
workspace_id: "ws_1",
asset_type: "file",
file_name: "node.png",
storage_path: "mindmaps/mind_1/assets/node.png",
} as MediaAsset,
],
[
"pdf_1",
{
id: "pdf_1",
document_id: "doc_root",
workspace_id: "ws_1",
asset_type: "file",
file_name: "guide.pdf",
storage_path: "uploads/guide.pdf",
} as MediaAsset,
],
]);
const fileTreeItems: KernelFileTreeProjectionItem[] = [
{
rowId: "doc:doc_root",
rowKind: "document",
nodeId: "doc_root",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "根页面",
depth: 0,
position: 0,
childCount: 3,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_root",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:doc_root",
rowKind: "index",
nodeId: "index:doc_root",
parentNodeId: "doc_root",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 1,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "doc_root",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset_folder",
nodeId: "asset-folder:mind_1",
parentNodeId: "doc_root",
nodeType: "mindmap",
projectionKind: "file_tree",
title: "mindmap",
depth: 1,
position: 1,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open-asset", "select"],
resourceMeta: {
resourceKind: "mindmap",
documentId: "doc_root",
assetId: "mind_1",
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
},
iconHint: "mindmap",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
nodeId: "asset:asset_child_1",
parentNodeId: "asset-folder:mind_1",
nodeType: "asset",
projectionKind: "file_tree",
title: "node.png",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
},
iconHint: "image",
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
nodeId: "asset:pdf_1",
parentNodeId: "doc_root",
nodeType: "pdf",
projectionKind: "file_tree",
title: "guide.pdf",
depth: 1,
position: 2,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "pdf",
documentId: "doc_root",
assetId: "pdf_1",
workspaceId: "ws_1",
assetKind: "pdf",
iconHint: "pdf",
},
iconHint: "pdf",
},
];
it("应直接从 kernel file_tree items 构造宿主 row map", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(buildFileTreeShellVisibleRowIds([...fileTreeItems])).toEqual([
"doc:doc_root",
"index:doc_root",
"asset-folder:mind_1",
"asset:asset_child_1",
"asset:pdf_1",
]);
expect(rowById.get("doc:doc_root")).toMatchObject({
rowId: "doc:doc_root",
rowKind: "doc",
documentId: "doc_root",
node: expect.objectContaining({
id: "doc_root",
}),
});
expect(rowById.get("asset-folder:mind_1")).toMatchObject({
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
asset: expect.objectContaining({
id: "mind_1",
}),
});
});
it("应正确解析 file tree shell 的导图投放目标", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset-folder:mind_1") ?? null)).toBe(
"mind_1",
);
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:asset_child_1") ?? null)).toBe(
"mind_1",
);
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:pdf_1") ?? null)).toBeNull();
});
it("应能仅凭 focusedRowId 从 shell row map 推回目标页面", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(
inferFileTreeShellTargetDocumentId({
focusedRowId: "asset-folder:mind_1",
rowById,
activeDocId: null,
}),
).toBe("doc_root");
expect(
inferFileTreeShellTargetDocumentId({
focusedRowId: "asset:pdf_1",
rowById,
activeDocId: null,
}),
).toBe("doc_root");
expect(
inferFileTreeShellTargetDocumentId({
focusedRowId: null,
rowById,
activeDocId: "doc_root",
}),
).toBe("doc_root");
});
it("应按当前可见顺序返回选中的 shell rows", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(
getOrderedFileTreeShellRows({
rowIds: ["asset:pdf_1", "doc:doc_root", "missing"],
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
rowById,
}).map((row) => row.rowId),
).toEqual(["doc:doc_root", "asset:pdf_1"]);
});
it("删除目标计算应跳过被父页面覆盖的附件", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = computeFileTreeShellDeleteTargets({
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
rowById,
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
parentById: new Map([["doc_root", null]]),
});
expect(result.docIds).toEqual(["doc_root"]);
expect(result.assetIds).toEqual([]);
expect(result.assetHints).toEqual([]);
});
});
+207
View File
@@ -0,0 +1,207 @@
"use client";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import {
getDocIdFromFileTreeItem,
resolveFileTreeRowAsset,
resolveFileTreeRowNode,
} from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import { filterTopLevelDocIds } from "./dnd";
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
export type FileTreeShellRow = {
rowId: string;
rowKind: FileTreeShellRowKind;
documentId: string;
assetId: string | null;
node: SidebarTreeNode | null;
asset: MediaAsset | null;
};
export type FileTreeShellDeleteTargets = {
docIds: string[];
assetIds: string[];
assetHints: MediaAsset[];
};
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
switch (item.rowKind) {
case "document":
return "doc";
case "asset_folder":
return "asset-folder";
default:
return item.rowKind;
}
}
export function buildFileTreeShellVisibleRowIds(
fileTreeItems: readonly KernelFileTreeProjectionItem[],
): string[] {
return fileTreeItems
.map((item) => item.rowId)
.filter((rowId): rowId is string => typeof rowId === "string" && rowId.trim().length > 0);
}
export function buildFileTreeShellRowById(input: {
fileTreeItems: readonly KernelFileTreeProjectionItem[];
nodeById?: Map<string, SidebarTreeNode>;
assetById?: Map<string, MediaAsset>;
}): Map<string, FileTreeShellRow> {
const rowById = new Map<string, FileTreeShellRow>();
input.fileTreeItems.forEach((item) => {
const rowId = typeof item.rowId === "string" ? item.rowId.trim() : "";
if (!rowId || rowById.has(rowId)) {
return;
}
const rowKind = toShellRowKind(item);
const documentId = getDocIdFromFileTreeItem(item);
const isDocumentRow = rowKind === "doc" || rowKind === "index";
rowById.set(rowId, {
rowId,
rowKind,
documentId,
assetId: isDocumentRow ? null : item.resourceMeta.assetId ?? null,
node: isDocumentRow ? resolveFileTreeRowNode(item, input.nodeById) : null,
asset: isDocumentRow ? null : resolveFileTreeRowAsset(item, input.assetById),
});
});
return rowById;
}
export function getOrderedFileTreeShellRows(input: {
rowIds: Iterable<string>;
visibleRowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
}): FileTreeShellRow[] {
const selectedRowIds = new Set<string>();
for (const rowId of input.rowIds) {
if (typeof rowId !== "string" || rowId.trim().length === 0) {
continue;
}
if (!input.rowById.has(rowId)) {
continue;
}
selectedRowIds.add(rowId);
}
return input.visibleRowIds
.map((rowId) => (selectedRowIds.has(rowId) ? input.rowById.get(rowId) ?? null : null))
.filter((row): row is FileTreeShellRow => Boolean(row));
}
export function extractMindmapAssetIdFromStoragePath(
storagePath: string | null | undefined,
): string | null {
if (!storagePath) return null;
const normalized = storagePath.replaceAll("\\", "/");
const prefix = "mindmaps/";
if (normalized.startsWith(prefix)) {
const rest = normalized.slice(prefix.length);
const id = rest.split("/")[0];
return id ? id : null;
}
const marker = "/mindmaps/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
const rest = normalized.slice(idx + marker.length);
const id = rest.split("/")[0];
return id ? id : null;
}
export function resolveFileTreeShellMindmapTargetId(
row: FileTreeShellRow | null,
): string | null {
if (!row?.asset) {
return null;
}
if (row.rowKind === "asset-folder" && row.asset.asset_type === "mindmap") {
return row.asset.id;
}
if (row.rowKind === "asset") {
return extractMindmapAssetIdFromStoragePath(row.asset.storage_path);
}
return null;
}
export function computeFileTreeShellDeleteTargets(input: {
visibleRowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
selectedRowIds: ReadonlySet<string>;
parentById: Map<string, string | null>;
}): FileTreeShellDeleteTargets {
const rows = getOrderedFileTreeShellRows({
rowIds: input.selectedRowIds,
visibleRowIds: input.visibleRowIds,
rowById: input.rowById,
});
const docCandidates: string[] = [];
const assetCandidates: string[] = [];
const assetDocIdByAssetId = new Map<string, string>();
const assetHintById = new Map<string, MediaAsset>();
rows.forEach((row) => {
if (row.rowKind === "doc" || row.rowKind === "index") {
docCandidates.push(row.documentId);
return;
}
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
assetCandidates.push(row.assetId);
assetDocIdByAssetId.set(row.assetId, row.documentId);
if (row.asset) {
assetHintById.set(row.assetId, row.asset);
}
}
});
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
const docIdSet = new Set(docIds);
const seenAssets = new Set<string>();
const assetIds: string[] = [];
const assetHints: MediaAsset[] = [];
assetCandidates.forEach((assetId) => {
if (!assetId || seenAssets.has(assetId)) {
return;
}
seenAssets.add(assetId);
const ownerDocId = assetDocIdByAssetId.get(assetId);
if (ownerDocId && docIdSet.has(ownerDocId)) {
return;
}
assetIds.push(assetId);
const assetHint = assetHintById.get(assetId);
if (assetHint) {
assetHints.push(assetHint);
}
});
return { docIds, assetIds, assetHints };
}
export function inferFileTreeShellTargetDocumentId(input: {
focusedRowId: string | null;
rowById: Map<string, FileTreeShellRow>;
activeDocId: string | null;
}): string | null {
if (input.focusedRowId) {
const row = input.rowById.get(input.focusedRowId) ?? null;
if (row?.documentId) {
return row.documentId;
}
}
return input.activeDocId || null;
}