feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchKernelFileTreeProjection } from "./projection-client";
describe("fetchKernelFileTreeProjection", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("通过 3000 同源 file_tree projection endpoint 请求 Rust 搜索 projection", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
result: {
projectionId: "kernel_projection:file_tree:page_root",
projection: "file_tree",
rootNodeId: "page_root",
items: [{ rowId: "asset:table_1" }],
edges: [],
},
}),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
const result = await fetchKernelFileTreeProjection({
workspaceId: "ws_1",
rootNodeId: "page_root",
depth: 3,
query: " 预算 ",
maxResults: 12,
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
expect.objectContaining({
method: "GET",
credentials: "include",
}),
);
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
});
it("失败时透出服务端错误消息", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "获取 projection 失败" }), { status: 502 }),
),
);
await expect(fetchKernelFileTreeProjection({ workspaceId: "ws_1" })).rejects.toThrow(
"获取 projection 失败",
);
});
});
@@ -0,0 +1,48 @@
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
export type FetchKernelFileTreeProjectionInput = {
workspaceId: string;
rootNodeId?: string | null;
depth?: number | null;
query?: string | null;
maxResults?: number | null;
};
export async function fetchKernelFileTreeProjection(
input: FetchKernelFileTreeProjectionInput,
): Promise<KernelFileTreeProjection> {
const params = new URLSearchParams();
params.set("workspaceId", input.workspaceId);
const rootNodeId = input.rootNodeId?.trim();
if (rootNodeId) {
params.set("rootNodeId", rootNodeId);
}
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
params.set("depth", String(input.depth));
}
const query = input.query?.trim();
if (query) {
params.set("query", query);
}
if (typeof input.maxResults === "number" && Number.isFinite(input.maxResults)) {
params.set("maxResults", String(Math.max(1, Math.floor(input.maxResults))));
}
const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
method: "GET",
credentials: "include",
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message =
typeof payload?.error === "string" ? payload.error : "获取 file_tree projection 失败";
throw new Error(message);
}
const payload = (await response.json()) as { result?: KernelFileTreeProjection };
if (!payload.result) {
throw new Error("file_tree projection 响应缺少 result");
}
return payload.result;
}
@@ -0,0 +1,317 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyFileTreeResourceAssets,
deleteFileTreeResourceAssets,
preflightFileTreeDelete,
preflightFileTreeInternalDrop,
preflightFileTreePaste,
preflightFileTreeUploadTarget,
moveFileTreeResourceAssets,
renameFileTreeResourceAsset,
restoreFileTreeResourceAssets,
uploadFileTreeResourceAsset,
} from "./resource-command-client";
describe("file-tree resource command client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("copy/move 应通过统一资源 command client 发送到 media batch route", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ items: [{ id: "asset_1" }] }),
} as Response);
await copyFileTreeResourceAssets({
assetIds: ["asset_1", "asset_1", " asset_2 "],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
});
await moveFileTreeResourceAssets({
assetIds: ["asset_3"],
targetDocumentId: "doc_target_2",
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"/api/media/batch",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "copy",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"/api/media/batch",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: ["asset_3"],
targetDocumentId: "doc_target_2",
}),
}),
);
});
it("rename/delete/restore 也应复用同一 batch transport 边界", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true }),
} as Response);
await renameFileTreeResourceAsset({ assetId: "asset_1", newName: "新文件.pdf" });
await deleteFileTreeResourceAssets(["asset_1", "asset_2"]);
await restoreFileTreeResourceAssets(["asset_3"]);
expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([
{ action: "rename", assetIds: ["asset_1"], newName: "新文件.pdf" },
{ action: "delete", assetIds: ["asset_1", "asset_2"] },
{ action: "restore", assetIds: ["asset_3"] },
]);
});
it("后端返回错误时应抛出稳定 fallback 或服务端消息", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
json: async () => ({ error: "Rust resource preflight rejected" }),
} as Response);
await expect(
moveFileTreeResourceAssets({
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
}),
).rejects.toThrow("Rust resource preflight rejected");
});
it("upload 应通过统一资源 command client 构造 FormData transport", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ asset: { id: "asset_upload_1" } }),
} as Response);
const file = new File(["content"], "demo.pdf", { type: "application/pdf" });
await uploadFileTreeResourceAsset({
file,
workspaceId: "ws_1",
documentId: "doc_1",
mindmapId: "mind_1",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/media/upload",
expect.objectContaining({
method: "POST",
body: expect.any(FormData),
}),
);
const body = fetchMock.mock.calls[0]?.[1]?.body as FormData;
expect(body.get("file")).toBe(file);
expect(body.get("workspaceId")).toBe("ws_1");
expect(body.get("documentId")).toBe("doc_1");
expect(body.get("mindmapId")).toBe("mind_1");
});
it("internal drop preflight 应发送到 tree filetree drop route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
plan: {
copy: false,
targetDocumentId: "doc_target",
targetMindmapId: null,
targetSubPath: null,
rowIds: ["doc:doc_1"],
docIds: ["doc_1"],
topLevelDocIds: ["doc_1"],
copyableAssetIds: [],
sourceAssetDocumentIds: [],
documentTransferPlan: {
action: "move",
targetParentId: "doc_target",
documentIds: ["doc_1"],
topLevelDocumentIds: ["doc_1"],
copyItems: [{ documentId: "doc_1", recursive: true }],
},
resourceTransferPlan: null,
},
}),
} as Response);
const plan = await preflightFileTreeInternalDrop({
workspaceId: "ws_1",
copy: false,
targetDocumentId: "doc_target",
targetRowId: null,
focusedRowId: null,
activeDocumentId: null,
rowIds: ["doc:doc_1"],
rows: [],
documentParents: [],
});
expect(plan.topLevelDocIds).toEqual(["doc_1"]);
expect(plan.documentTransferPlan?.topLevelDocumentIds).toEqual(["doc_1"]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/drop-preflight",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
copy: false,
targetDocumentId: "doc_target",
targetRowId: null,
focusedRowId: null,
activeDocumentId: null,
rowIds: ["doc:doc_1"],
rows: [],
documentParents: [],
}),
}),
);
});
it("delete preflight 应发送到 tree filetree delete route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
plan: {
rowIds: ["doc:doc_1", "asset:asset_1"],
docIds: ["doc_1"],
assetIds: ["asset_1"],
assetDocumentIds: ["doc_other"],
},
}),
} as Response);
const plan = await preflightFileTreeDelete({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
rows: [],
documentParents: [],
});
expect(plan.docIds).toEqual(["doc_1"]);
expect(plan.assetIds).toEqual(["asset_1"]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/delete-preflight",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
rows: [],
documentParents: [],
}),
}),
);
});
it("paste preflight 应发送到 tree filetree paste route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
plan: {
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
docItems: [{ documentId: "doc_1", recursive: false }],
copyableAssetIds: ["asset_1"],
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
}),
} as Response);
const plan = await preflightFileTreePaste({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_1", "asset:asset_1"],
rows: [],
});
expect(plan.docItems).toEqual([{ documentId: "doc_1", recursive: false }]);
expect(plan.resourceTransferPlan?.targetSubPath).toBe("mindmaps/mind_1");
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/paste-preflight",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_1", "asset:asset_1"],
rows: [],
}),
}),
);
});
it("upload target preflight 应发送到 tree filetree upload-target route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
plan: {
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
},
}),
} as Response);
const plan = await preflightFileTreeUploadTarget({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: null,
activeDocumentId: "doc_active",
rows: [],
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
});
expect(plan).toEqual({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/upload-target-preflight",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: null,
activeDocumentId: "doc_active",
rows: [],
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
}),
}),
);
});
});
@@ -0,0 +1,315 @@
"use client";
import type { MediaAsset } from "@/types/media";
import type {
FileTreeShellDeletePreflightPayload,
FileTreeShellInternalDropPreflightPayload,
FileTreeShellPastePreflightPayload,
FileTreeShellUploadTargetPreflightPayload,
} from "@/lib/file-tree/shell";
type ResourceCommandAction = "copy" | "move" | "rename" | "delete" | "restore";
type ResourceCommandErrorPayload = {
error?: string;
};
type ResourceCommandResponse = {
ok?: boolean;
items?: MediaAsset[];
asset?: MediaAsset;
};
type TransferResourceAssetsInput = {
assetIds: readonly string[];
targetDocumentId: string;
targetSubPath?: string | null;
};
type RenameResourceAssetInput = {
assetId: string;
newName: string;
};
type UploadResourceAssetInput = {
file: File;
workspaceId: string;
documentId: string;
mindmapId?: string | null;
};
export type FileTreeInternalDropPreflightPlan = {
copy: boolean;
targetDocumentId: string;
targetMindmapId: string | null;
targetSubPath?: string | null;
rowIds: string[];
docIds: string[];
topLevelDocIds: string[];
copyableAssetIds: string[];
sourceAssetDocumentIds: string[];
documentTransferPlan?: {
action: "copy" | "move";
targetParentId: string;
documentIds: string[];
topLevelDocumentIds: string[];
copyItems: Array<{ documentId: string; recursive: boolean }>;
} | null;
resourceTransferPlan?: {
action: "copy" | "move";
assetIds: string[];
targetDocumentId: string;
targetSubPath?: string | null;
} | null;
};
type FileTreeInternalDropPreflightResponse = {
plan?: FileTreeInternalDropPreflightPlan;
};
export type FileTreeDeletePreflightPlan = {
rowIds: string[];
docIds: string[];
assetIds: string[];
assetDocumentIds: string[];
};
type FileTreeDeletePreflightResponse = {
plan?: FileTreeDeletePreflightPlan;
};
export type FileTreePastePreflightPlan = {
targetDocumentId: string;
targetMindmapId: string | null;
targetSubPath?: string | null;
rowIds: string[];
docItems: Array<{ documentId: string; recursive: boolean }>;
copyableAssetIds: string[];
resourceTransferPlan?: {
action: "copy";
assetIds: string[];
targetDocumentId: string;
targetSubPath?: string | null;
} | null;
};
type FileTreePastePreflightResponse = {
plan?: FileTreePastePreflightPlan;
};
export type FileTreeUploadTargetPreflightPlan = {
workspaceId: string;
targetDocumentId: string;
targetMindmapId: string | null;
targetSubPath?: string | null;
};
type FileTreeUploadTargetPreflightResponse = {
plan?: FileTreeUploadTargetPreflightPlan;
};
function normalizeAssetIds(assetIds: readonly string[]): string[] {
return Array.from(
new Set(
assetIds
.map((assetId) => (typeof assetId === "string" ? assetId.trim() : ""))
.filter(Boolean),
),
);
}
async function postResourceCommand<TResult>(
payload: Record<string, unknown>,
fallbackMessage: string,
path = "/api/media/batch",
): Promise<TResult> {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| TResult
| ResourceCommandErrorPayload
| null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
async function postResourceForm<TResult>(
path: string,
formData: FormData,
fallbackMessage: string,
): Promise<TResult> {
const response = await fetch(path, {
method: "POST",
body: formData,
});
const body = (await response.json().catch(() => null)) as
| TResult
| ResourceCommandErrorPayload
| null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
function buildTransferPayload(
action: Extract<ResourceCommandAction, "copy" | "move">,
input: TransferResourceAssetsInput,
) {
const payload: Record<string, unknown> = {
action,
assetIds: normalizeAssetIds(input.assetIds),
targetDocumentId: input.targetDocumentId,
};
const targetSubPath = input.targetSubPath?.trim();
if (targetSubPath) {
payload.targetSubPath = targetSubPath;
}
return payload;
}
export async function copyFileTreeResourceAssets(
input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
buildTransferPayload("copy", input),
"复制附件失败",
);
}
export async function moveFileTreeResourceAssets(
input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
buildTransferPayload("move", input),
"移动附件失败",
);
}
export async function renameFileTreeResourceAsset(
input: RenameResourceAssetInput,
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
{
action: "rename",
assetIds: normalizeAssetIds([input.assetId]),
newName: input.newName,
},
"重命名失败",
);
}
export async function deleteFileTreeResourceAssets(
assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
{
action: "delete",
assetIds: normalizeAssetIds(assetIds),
},
"删除失败",
);
}
export async function restoreFileTreeResourceAssets(
assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
{
action: "restore",
assetIds: normalizeAssetIds(assetIds),
},
"恢复附件失败,请稍后再试",
);
}
export async function uploadFileTreeResourceAsset(
input: UploadResourceAssetInput,
): Promise<ResourceCommandResponse> {
const formData = new FormData();
formData.append("file", input.file);
formData.append("workspaceId", input.workspaceId);
formData.append("documentId", input.documentId);
const mindmapId = input.mindmapId?.trim();
if (mindmapId) {
formData.append("mindmapId", mindmapId);
}
return postResourceForm<ResourceCommandResponse>(
"/api/media/upload",
formData,
"上传失败",
);
}
export async function preflightFileTreeInternalDrop(
input: FileTreeShellInternalDropPreflightPayload,
): Promise<FileTreeInternalDropPreflightPlan> {
const response = await postResourceCommand<FileTreeInternalDropPreflightResponse>(
input,
"文件树拖放预检失败",
"/api/tree/filetree/drop-preflight",
);
if (!response.plan) {
throw new Error("文件树拖放预检失败");
}
return response.plan;
}
export async function preflightFileTreeDelete(
input: FileTreeShellDeletePreflightPayload,
): Promise<FileTreeDeletePreflightPlan> {
const response = await postResourceCommand<FileTreeDeletePreflightResponse>(
input,
"文件树删除预检失败",
"/api/tree/filetree/delete-preflight",
);
if (!response.plan) {
throw new Error("文件树删除预检失败");
}
return response.plan;
}
export async function preflightFileTreePaste(
input: FileTreeShellPastePreflightPayload,
): Promise<FileTreePastePreflightPlan> {
const response = await postResourceCommand<FileTreePastePreflightResponse>(
input,
"文件树粘贴预检失败",
"/api/tree/filetree/paste-preflight",
);
if (!response.plan) {
throw new Error("文件树粘贴预检失败");
}
return response.plan;
}
export async function preflightFileTreeUploadTarget(
input: FileTreeShellUploadTargetPreflightPayload,
): Promise<FileTreeUploadTargetPreflightPlan> {
const response = await postResourceCommand<FileTreeUploadTargetPreflightResponse>(
input,
"文件树上传目标预检失败",
"/api/tree/filetree/upload-target-preflight",
);
if (!response.plan) {
throw new Error("文件树上传目标预检失败");
}
return response.plan;
}
+14 -207
View File
@@ -1,10 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
import { buildVisibleRows } from "./rows";
import { parseFileTreeRowId } from "./types";
describe("buildVisibleRows", () => {
it("按展开状态稳定生成可见行", () => {
it("缺少 kernel file_tree items 时不再回退 pageRows + assets 重建对象语义", () => {
const a = {
access_scope: "private" as const,
id: "a",
@@ -34,7 +33,17 @@ describe("buildVisibleRows", () => {
};
const rows = buildVisibleRows({
pageRows: buildPageTreeProjectionItems([a]),
pageRows: [
{
nodeId: a.id,
parentNodeId: null,
depth: 0,
childCount: 1,
position: 0,
title: a.title,
node: a,
},
],
expanded: new Set(["a"]),
assetsByDoc: {
a: [
@@ -82,40 +91,7 @@ describe("buildVisibleRows", () => {
},
});
expect(rows.map((r) => `${r.kind}:${r.depth}:${r.rowId}`)).toEqual([
"doc:0:doc:a",
"index:1:index:a",
"asset:1:asset:x",
"asset:1:asset:y",
"doc:1:doc:b",
]);
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
});
it("输入树包含重复 docId 时应自动去重", () => {
const a = {
access_scope: "private" as const,
id: "a",
workspace_id: "w",
title: "A",
parent_id: null,
sort_order: 0,
is_starred: null,
is_template: false,
created_at: "",
updated_at: null,
children: [],
};
const rows = buildVisibleRows({
pageRows: buildPageTreeProjectionItems([a, a]),
expanded: new Set(["a"]),
assetsByDoc: {},
});
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
expect(rows).toEqual([]);
});
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
@@ -284,175 +260,6 @@ 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", () => {
+2 -111
View File
@@ -12,40 +12,6 @@ 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>;
@@ -124,16 +90,13 @@ function buildRowsFromKernelFileTreeProjection(input: {
export function buildVisibleRows({
fileTreeItems,
pageRows,
expanded,
assetsByDoc,
assetChildrenByAssetId,
expandedAssetFolderIds,
nodeById,
assetById,
}: {
fileTreeItems?: KernelFileTreeProjectionItem[];
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
// 兼容旧调用签名;主路径必须提供 kernel file_tree items,不能再从这些字段重建对象语义。
pageRows?: PageTreeProjectionItem[];
expanded: Set<string>;
assetsByDoc?: Record<string, MediaAsset[]>;
@@ -152,77 +115,5 @@ export function buildVisibleRows({
});
}
const safePageRows = pageRows ?? [];
const safeAssetsByDoc = assetsByDoc ?? {};
const rows: FileTreeRow[] = [];
safePageRows.forEach((item) => {
const assets = safeAssetsByDoc[item.nodeId] ?? [];
const hasChildren = item.childCount > 0 || assets.length > 0;
const isExpanded = expanded.has(item.nodeId);
rows.push({
kind: "doc",
rowId: makeDocRowId(item.nodeId),
depth: item.depth,
docId: item.nodeId,
parentDocId: item.parentNodeId,
node: item.node,
hasChildren,
isExpanded,
});
if (!isExpanded) {
return;
}
rows.push({
kind: "index",
rowId: makeIndexRowId(item.nodeId),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
node: item.node,
});
assets.forEach((asset) => {
if (asset.asset_type === "mindmap") {
const children = assetChildrenByAssetId?.[asset.id] ?? [];
const hasChildren = children.length > 0;
const isExpanded = expandedAssetFolderIds?.has(asset.id) ?? false;
rows.push({
kind: "asset-folder",
rowId: makeAssetFolderRowId(asset.id),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
asset,
hasChildren,
isExpanded,
});
if (hasChildren && isExpanded) {
children.forEach((child) => {
rows.push({
kind: "asset",
rowId: makeAssetRowId(child.id),
depth: item.depth + 2,
docId: item.nodeId,
parentDocId: item.nodeId,
asset: child,
});
});
}
return;
}
rows.push({
kind: "asset",
rowId: makeAssetRowId(asset.id),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
asset,
});
});
});
return rows;
return [];
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { FileTreeSelectionState } from "./selection";
import {
createEmptyFileTreeSelectionState,
materializeRendererSelectionSnapshot,
resolveActiveFileTreeSelection,
} from "./selection-source";
function selection(
selectedRowIds: string[],
anchorRowId: string | null,
focusedRowId: string | null,
): FileTreeSelectionState {
return {
selectedRowIds: new Set(selectedRowIds),
anchorRowId,
focusedRowId,
};
}
describe("selection-source", () => {
it("rust_family 应优先消费 renderer selection snapshot", () => {
const legacySelection = selection(["legacy"], "legacy", "legacy");
const rendererSelection = selection(["renderer"], "renderer", "renderer");
expect(
resolveActiveFileTreeSelection({
preferRendererSnapshot: true,
legacySelection,
rendererSelection,
}),
).toBe(rendererSelection);
expect(
resolveActiveFileTreeSelection({
preferRendererSnapshot: false,
legacySelection,
rendererSelection,
}),
).toBe(legacySelection);
});
it("renderer event snapshot 只过滤未知 row,不在宿主侧重算 focus/anchor", () => {
const snapshot = materializeRendererSelectionSnapshot({
payload: {
selectedRowIds: ["doc:a", "missing"],
anchorRowId: "missing",
focusedRowId: "doc:a",
},
hasRowId: (rowId) => rowId === "doc:a",
});
expect(Array.from(snapshot.selectedRowIds)).toEqual(["doc:a"]);
expect(snapshot.anchorRowId).toBeNull();
expect(snapshot.focusedRowId).toBe("doc:a");
});
it("空 selection 工厂应返回互不共享的 Set 实例", () => {
const a = createEmptyFileTreeSelectionState();
const b = createEmptyFileTreeSelectionState();
a.selectedRowIds.add("doc:a");
expect(a.selectedRowIds.has("doc:a")).toBe(true);
expect(b.selectedRowIds.has("doc:a")).toBe(false);
expect(a.selectedRowIds).not.toBe(b.selectedRowIds);
});
});
@@ -0,0 +1,46 @@
import type { FileTreeSelectionState } from "./selection";
export type FileTreeSelectionSnapshotPayload = {
selectedRowIds: readonly string[];
anchorRowId: string | null;
focusedRowId: string | null;
};
export function createEmptyFileTreeSelectionState(): FileTreeSelectionState {
return {
selectedRowIds: new Set<string>(),
anchorRowId: null,
focusedRowId: null,
};
}
export function materializeRendererSelectionSnapshot(input: {
payload: FileTreeSelectionSnapshotPayload;
hasRowId: (rowId: string) => boolean;
}): FileTreeSelectionState {
const selectedRowIds = new Set(
input.payload.selectedRowIds.filter((rowId) => input.hasRowId(rowId)),
);
return {
selectedRowIds,
anchorRowId:
input.payload.anchorRowId && input.hasRowId(input.payload.anchorRowId)
? input.payload.anchorRowId
: null,
focusedRowId:
input.payload.focusedRowId && input.hasRowId(input.payload.focusedRowId)
? input.payload.focusedRowId
: null,
};
}
export function resolveActiveFileTreeSelection(input: {
preferRendererSnapshot: boolean;
legacySelection: FileTreeSelectionState;
rendererSelection: FileTreeSelectionState;
}): FileTreeSelectionState {
return input.preferRendererSnapshot
? input.rendererSelection
: input.legacySelection;
}
+238 -9
View File
@@ -3,9 +3,13 @@ import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import {
buildFileTreeShellDeletePreflightPayload,
buildFileTreeShellInternalDropPreflightPayload,
buildFileTreeShellPastePreflightPayload,
buildFileTreeShellUploadTargetPreflightPayload,
buildFileTreeShellRowById,
buildFileTreeShellVisibleRowIds,
computeFileTreeShellDeleteTargets,
collectFileTreeShellAssetHints,
getOrderedFileTreeShellRows,
inferFileTreeShellTargetDocumentId,
resolveFileTreeShellMindmapTargetId,
@@ -273,22 +277,247 @@ describe("file-tree shell helpers", () => {
).toEqual(["doc:doc_root", "asset:pdf_1"]);
});
it("删除目标计算应跳过被父页面覆盖的附件", () => {
it("内部拖放 preflight payload 应只收集 Rust 所需的行与父子快照", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = computeFileTreeShellDeleteTargets({
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
const result = buildFileTreeShellInternalDropPreflightPayload({
workspaceId: "ws_1",
copy: false,
targetDocumentId: null,
targetRowId: "asset-folder:mind_1",
focusedRowId: null,
activeDocId: null,
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
rowById,
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
parentById: new Map([["doc_root", null]]),
parentById: new Map([
["doc_root", null],
["doc_child", "doc_root"],
]),
});
expect(result.docIds).toEqual(["doc_root"]);
expect(result.assetIds).toEqual([]);
expect(result.assetHints).toEqual([]);
expect(result).toEqual({
workspaceId: "ws_1",
copy: false,
targetDocumentId: null,
targetRowId: "asset-folder:mind_1",
focusedRowId: null,
activeDocumentId: null,
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
rows: [
{
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "mindmaps/mind_1/assets/node.png",
},
{
rowId: "doc:doc_root",
rowKind: "doc",
documentId: "doc_root",
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
},
],
documentParents: [
{ documentId: "doc_root", parentId: null },
{ documentId: "doc_child", parentId: "doc_root" },
],
});
});
it("删除 preflight payload 应只收集选中行与父子快照", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = buildFileTreeShellDeletePreflightPayload({
workspaceId: "ws_1",
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
rowById,
parentById: new Map([
["doc_root", null],
["doc_child", "doc_root"],
]),
});
expect(result).toEqual({
workspaceId: "ws_1",
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
rows: [
{
rowId: "doc:doc_root",
rowKind: "doc",
documentId: "doc_root",
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
},
],
documentParents: [
{ documentId: "doc_root", parentId: null },
{ documentId: "doc_child", parentId: "doc_root" },
],
});
});
it("粘贴 preflight payload 应只收集剪贴板行与当前 focused 目标行", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = buildFileTreeShellPastePreflightPayload({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocId: "doc_active",
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
rowById,
});
expect(result).toEqual({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
rows: [
{
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
},
{
rowId: "index:doc_root",
rowKind: "index",
documentId: "doc_root",
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
},
],
});
});
it("上传目标 preflight payload 应只收集目标行、focused 行与文档工作区快照", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = buildFileTreeShellUploadTargetPreflightPayload({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: "asset-folder:mind_1",
activeDocId: "doc_active",
rowById,
documentWorkspaceById: new Map([
["doc_root", "ws_1"],
["doc_active", "ws_active"],
]),
});
expect(result).toEqual({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rows: [
{
rowId: "asset:asset_child_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "mindmaps/mind_1/assets/node.png",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
},
],
documentWorkspaces: [
{ documentId: "doc_root", workspaceId: "ws_1" },
{ documentId: "doc_active", workspaceId: "ws_active" },
],
});
});
it("应能按 assetId 从 shell row map 回填 asset 与 asset-folder 的提示元数据", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(
collectFileTreeShellAssetHints({
rowById,
assetIds: ["mind_1", "pdf_1", "missing"],
}).map((asset) => asset.id),
).toEqual(["mind_1", "pdf_1"]);
});
});
+245 -64
View File
@@ -8,7 +8,6 @@ import {
} 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";
@@ -21,10 +20,52 @@ export type FileTreeShellRow = {
asset: MediaAsset | null;
};
export type FileTreeShellDeleteTargets = {
docIds: string[];
assetIds: string[];
assetHints: MediaAsset[];
export type FileTreeShellInternalDropPreflightRow = {
rowId: string;
rowKind: FileTreeShellRowKind;
documentId: string | null;
assetId: string | null;
assetDocumentId: string | null;
assetType: string | null;
storagePath: string | null;
};
export type FileTreeShellInternalDropPreflightPayload = {
workspaceId: string | null;
copy: boolean;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
documentParents: Array<{ documentId: string; parentId: string | null }>;
};
export type FileTreeShellDeletePreflightPayload = {
workspaceId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
documentParents: Array<{ documentId: string; parentId: string | null }>;
};
export type FileTreeShellPastePreflightPayload = {
workspaceId: string | null;
targetDocumentId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
};
export type FileTreeShellUploadTargetPreflightPayload = {
workspaceId: string | null;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rows: FileTreeShellInternalDropPreflightRow[];
documentWorkspaces: Array<{ documentId: string; workspaceId: string | null }>;
};
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
@@ -133,65 +174,6 @@ export function resolveFileTreeShellMindmapTargetId(
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>;
@@ -205,3 +187,202 @@ export function inferFileTreeShellTargetDocumentId(input: {
}
return input.activeDocId || null;
}
function normalizeShellText(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function buildFileTreeShellDropPreflightRow(
row: FileTreeShellRow,
): FileTreeShellInternalDropPreflightRow {
return {
rowId: row.rowId,
rowKind: row.rowKind,
documentId: normalizeShellText(row.documentId),
assetId: normalizeShellText(row.assetId),
assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
assetType: normalizeShellText(row.asset?.asset_type ?? null),
storagePath: normalizeShellText(row.asset?.storage_path ?? null),
};
}
export function buildFileTreeShellInternalDropPreflightPayload(input: {
workspaceId: string | null;
copy: boolean;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocId: string | null;
rowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
parentById: Map<string, string | null>;
}): FileTreeShellInternalDropPreflightPayload {
const rowIdSet = new Set<string>();
const appendRowId = (value: string | null | undefined) => {
const rowId = normalizeShellText(value);
if (rowId) {
rowIdSet.add(rowId);
}
};
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
const targetRowId = normalizeShellText(input.targetRowId);
const focusedRowId = normalizeShellText(input.focusedRowId);
appendRowId(targetRowId);
appendRowId(focusedRowId);
rowIds.forEach(appendRowId);
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
copy: input.copy,
targetDocumentId: normalizeShellText(input.targetDocumentId),
targetRowId,
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rowIds,
rows,
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
documentId,
parentId: normalizeShellText(parentId),
})),
};
}
export function buildFileTreeShellDeletePreflightPayload(input: {
workspaceId: string | null;
rowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
parentById: Map<string, string | null>;
}): FileTreeShellDeletePreflightPayload {
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
const rowIdSet = new Set<string>();
rowIds.forEach((rowId) => {
const normalized = normalizeShellText(rowId);
if (normalized) {
rowIdSet.add(normalized);
}
});
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
rowIds,
rows,
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
documentId,
parentId: normalizeShellText(parentId),
})),
};
}
export function buildFileTreeShellPastePreflightPayload(input: {
workspaceId: string | null;
targetDocumentId: string | null;
focusedRowId: string | null;
activeDocId: string | null;
rowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
}): FileTreeShellPastePreflightPayload {
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
const rowIdSet = new Set<string>();
const focusedRowId = normalizeShellText(input.focusedRowId);
if (focusedRowId) {
rowIdSet.add(focusedRowId);
}
rowIds.forEach((rowId) => {
const normalized = normalizeShellText(rowId);
if (normalized) {
rowIdSet.add(normalized);
}
});
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
targetDocumentId: normalizeShellText(input.targetDocumentId),
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rowIds,
rows,
};
}
export function buildFileTreeShellUploadTargetPreflightPayload(input: {
workspaceId: string | null;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocId: string | null;
rowById: Map<string, FileTreeShellRow>;
documentWorkspaceById: Map<string, string | null>;
}): FileTreeShellUploadTargetPreflightPayload {
const rowIdSet = new Set<string>();
const appendRowId = (value: string | null | undefined) => {
const rowId = normalizeShellText(value);
if (rowId) {
rowIdSet.add(rowId);
}
};
const targetRowId = normalizeShellText(input.targetRowId);
const focusedRowId = normalizeShellText(input.focusedRowId);
appendRowId(targetRowId);
appendRowId(focusedRowId);
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
targetDocumentId: normalizeShellText(input.targetDocumentId),
targetRowId,
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rows,
documentWorkspaces: Array.from(input.documentWorkspaceById.entries()).map(
([documentId, workspaceId]) => ({
documentId,
workspaceId: normalizeShellText(workspaceId),
}),
),
};
}
export function collectFileTreeShellAssetHints(input: {
rowById: Map<string, FileTreeShellRow>;
assetIds: readonly string[];
}): MediaAsset[] {
const hints: MediaAsset[] = [];
const seen = new Set<string>();
input.assetIds.forEach((assetId) => {
const normalized = normalizeShellText(assetId);
if (!normalized || seen.has(normalized)) {
return;
}
const asset =
input.rowById.get(`asset:${normalized}`)?.asset ??
input.rowById.get(`asset-folder:${normalized}`)?.asset ??
null;
if (!asset) {
return;
}
seen.add(normalized);
hints.push(asset);
});
return hints;
}