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:
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user