feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
+2
View File
@@ -13,6 +13,7 @@ import type * as _utils_auth from "../_utils/auth.js";
import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js";
import type * as _utils_documentRecord from "../_utils/documentRecord.js";
import type * as _utils_documentTree from "../_utils/documentTree.js";
import type * as _utils_documentVisibility from "../_utils/documentVisibility.js";
import type * as _utils_id from "../_utils/id.js";
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
import type * as _utils_lightrag from "../_utils/lightrag.js";
@@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{
"_utils/documentMoveOrder": typeof _utils_documentMoveOrder;
"_utils/documentRecord": typeof _utils_documentRecord;
"_utils/documentTree": typeof _utils_documentTree;
"_utils/documentVisibility": typeof _utils_documentVisibility;
"_utils/id": typeof _utils_id;
"_utils/ingestJobs": typeof _utils_ingestJobs;
"_utils/lightrag": typeof _utils_lightrag;
+223 -23
View File
@@ -36,6 +36,34 @@ type CopyTreeDocument = {
deleted_at: string | null;
};
type RestoreLocationDocument = {
_id?: any;
id: string;
user_id: string;
workspace_id: string;
parent_id: string | null;
sort_order: number | null;
created_at?: string | null;
deleted_at?: string | null;
restore_parent_id?: string | null;
restore_sort_order?: number | null;
};
export type DocumentRestoreLocation = {
parentId: string | null;
sortOrder: number | null;
fallbackReason: "none" | "parent_missing_or_deleted";
};
export type DocumentRestoreOrderAssignment = {
id: string;
_id?: any;
parentId: string | null;
sortOrder: number;
previousSortOrder: number | null;
isRestored: boolean;
};
function normalizeTitle(title: string | null | undefined): string {
const safe = String(title ?? "").trim();
return safe.length > 0 ? safe : "无标题";
@@ -77,6 +105,111 @@ function extractBlocksFromContent(content: unknown): unknown[] {
return [];
}
export function buildDocumentTrashLocationPatch(doc: {
parent_id?: string | null;
sort_order?: number | null;
}) {
return {
restore_parent_id: doc.parent_id ?? null,
restore_sort_order: typeof doc.sort_order === "number" ? doc.sort_order : null,
};
}
function clampRestoreSortOrder(sortOrder: number | null | undefined, siblingCount: number): number | null {
if (typeof sortOrder !== "number" || !Number.isFinite(sortOrder)) {
return siblingCount;
}
return Math.max(0, Math.min(Math.floor(sortOrder), siblingCount));
}
export function resolveDocumentRestoreLocation(input: {
doc: RestoreLocationDocument;
ownedDocs: RestoreLocationDocument[];
restoringIds?: Set<string>;
}): DocumentRestoreLocation {
const requestedParentId = input.doc.restore_parent_id ?? input.doc.parent_id ?? null;
const restoringIds = input.restoringIds ?? new Set<string>();
const parentAlive =
requestedParentId == null ||
restoringIds.has(requestedParentId) ||
input.ownedDocs.some(
(candidate) =>
candidate.id === requestedParentId &&
candidate.user_id === input.doc.user_id &&
candidate.workspace_id === input.doc.workspace_id &&
candidate.deleted_at == null,
);
const parentId = parentAlive ? requestedParentId : null;
const siblingCount = input.ownedDocs.filter(
(candidate) =>
candidate.id !== input.doc.id &&
candidate.user_id === input.doc.user_id &&
candidate.workspace_id === input.doc.workspace_id &&
candidate.deleted_at == null &&
(candidate.parent_id ?? null) === parentId,
).length;
return {
parentId,
sortOrder: clampRestoreSortOrder(input.doc.restore_sort_order ?? input.doc.sort_order, siblingCount),
fallbackReason: parentAlive ? "none" : "parent_missing_or_deleted",
};
}
export function buildDocumentRestoreOrderAssignments(input: {
restoringDocs: RestoreLocationDocument[];
ownedDocs: RestoreLocationDocument[];
locationsById: Map<string, DocumentRestoreLocation>;
restoringIds?: Set<string>;
}): DocumentRestoreOrderAssignment[] {
const restoringIds = input.restoringIds ?? new Set(input.restoringDocs.map((doc) => doc.id));
const affectedParentIds = new Set<string | null>();
for (const location of input.locationsById.values()) {
affectedParentIds.add(location.parentId);
}
const assignments: DocumentRestoreOrderAssignment[] = [];
for (const parentId of affectedParentIds) {
const rows = [
...input.ownedDocs
.filter((candidate) => !restoringIds.has(candidate.id))
.filter((candidate) => candidate.deleted_at == null)
.filter((candidate) => (candidate.parent_id ?? null) === parentId)
.map((candidate) => ({
doc: candidate,
desiredSortOrder: typeof candidate.sort_order === "number" ? candidate.sort_order : Number.MAX_SAFE_INTEGER,
isRestored: false,
})),
...input.restoringDocs
.filter((candidate) => input.locationsById.get(candidate.id)?.parentId === parentId)
.map((candidate) => {
const location = input.locationsById.get(candidate.id);
return {
doc: candidate,
desiredSortOrder: typeof location?.sortOrder === "number" ? location.sortOrder : Number.MAX_SAFE_INTEGER,
isRestored: true,
};
}),
].sort((a, b) => {
if (a.desiredSortOrder !== b.desiredSortOrder) return a.desiredSortOrder - b.desiredSortOrder;
if (a.isRestored !== b.isRestored) return a.isRestored ? -1 : 1;
return String(a.doc.created_at ?? a.doc.id).localeCompare(String(b.doc.created_at ?? b.doc.id));
});
rows.forEach((row, index) => {
assignments.push({
id: row.doc.id,
_id: row.doc._id,
parentId,
sortOrder: index,
previousSortOrder: typeof row.doc.sort_order === "number" ? row.doc.sort_order : null,
isRestored: row.isRestored,
});
});
}
return assignments;
}
function composeContentWithBlocks(content: unknown, blocks: unknown[]): unknown {
if (Array.isArray(content)) {
return blocks as unknown[];
@@ -682,13 +815,13 @@ async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentI
}
export const getMeta = query({
args: { id: v.string() },
args: { id: v.string(), includeDeleted: v.optional(v.boolean()) },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
if (doc.deleted_at != null && args.includeDeleted !== true) return null;
try {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
} catch {
@@ -746,7 +879,7 @@ export const getMeta = query({
export const getPermissionForUser = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
@@ -774,7 +907,7 @@ export const getPermissionForUser = query({
export const getMetaForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
@@ -805,7 +938,7 @@ export const getContent = query({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
try {
@@ -847,7 +980,7 @@ export const getContent = query({
export const getContentForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
@@ -1299,7 +1432,7 @@ export const updateContent = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
@@ -1419,7 +1552,7 @@ export const move = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
@@ -1581,7 +1714,7 @@ export const softDelete = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
@@ -1597,7 +1730,12 @@ export const softDelete = mutation({
let moved = 0;
for (const item of subtree) {
if (item.deleted_at != null) continue;
await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
await ctx.db.patch(item._id, {
deleted_at: ts,
deleted_by: userId,
...buildDocumentTrashLocationPatch(item),
updated_at: ts,
});
// 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。
// 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
@@ -1613,42 +1751,98 @@ export const restore = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
const restoreDeletedAt = doc.deleted_at;
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = restoreDeletedAt != null ? collectSubtree(owned, doc.id) : [doc];
const restoringIds = new Set(
subtree
.filter((item) => item.id === doc.id || item.deleted_at === restoreDeletedAt)
.map((item) => item.id)
.filter((id): id is string => typeof id === "string"),
);
const restoringDocs = subtree.filter((item) => restoringIds.has(item.id));
const locationsById = new Map<string, DocumentRestoreLocation>();
for (const item of restoringDocs) {
locationsById.set(
item.id,
resolveDocumentRestoreLocation({
doc: item,
ownedDocs: owned,
restoringIds,
}),
);
}
const orderAssignments = buildDocumentRestoreOrderAssignments({
restoringDocs,
ownedDocs: owned,
locationsById,
restoringIds,
});
const assignmentById = new Map(orderAssignments.map((assignment) => [assignment.id, assignment]));
for (const assignment of orderAssignments) {
if (assignment.isRestored) continue;
if (assignment.previousSortOrder === assignment.sortOrder) continue;
await ctx.db.patch(assignment._id, { sort_order: assignment.sortOrder });
}
const restoreLocation = locationsById.get(doc.id) ?? {
parentId: null,
sortOrder: null,
fallbackReason: "parent_missing_or_deleted" as const,
};
const restoreAssignment = assignmentById.get(doc.id);
const restoredParentId = restoreAssignment?.parentId ?? restoreLocation.parentId;
const restoredSortOrder = restoreAssignment?.sortOrder ?? restoreLocation.sortOrder;
await ctx.db.patch(doc._id, {
deleted_at: null,
deleted_by: null,
parent_id: null,
parent_id: restoredParentId,
sort_order: restoredSortOrder,
restore_parent_id: null,
restore_sort_order: null,
access_scope: "private",
updated_at: ts,
});
// 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。
if (restoreDeletedAt != null) {
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = collectSubtree(owned, doc.id);
for (const item of subtree) {
if (item.id === doc.id) continue;
if (item.deleted_at !== restoreDeletedAt) continue;
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
const childRestoreLocation = locationsById.get(item.id);
const childRestoreAssignment = assignmentById.get(item.id);
await ctx.db.patch(item._id, {
deleted_at: null,
deleted_by: null,
parent_id: childRestoreAssignment?.parentId ?? childRestoreLocation?.parentId ?? null,
sort_order: childRestoreAssignment?.sortOrder ?? childRestoreLocation?.sortOrder ?? null,
restore_parent_id: null,
restore_sort_order: null,
updated_at: ts,
});
}
}
return {
ok: true,
updated_at: ts,
restore_location: {
parent_id: restoredParentId,
sort_order: restoredSortOrder,
fallback_reason: restoreLocation.fallbackReason,
},
document: toDocumentDeltaRecord({
...doc,
deleted_at: null,
deleted_by: null,
parent_id: null,
parent_id: restoredParentId,
sort_order: restoredSortOrder,
access_scope: "private",
updated_at: ts,
}),
@@ -1685,7 +1879,13 @@ export const purge = mutation({
});
export const emptyTrashByWorkspace = mutation({
args: { workspaceId: v.string() },
args: {
workspaceId: v.string(),
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
+3
View File
@@ -81,6 +81,9 @@ export default defineSchema({
// 软删除(阶段 4 先不实现垃圾桶逻辑,但字段先留好,便于后续迁移)。
deleted_at: v.union(v.string(), v.null()),
deleted_by: v.union(v.string(), v.null()),
// 垃圾箱恢复位置快照:删除时记录原父节点与排序,恢复时尽量回放。
restore_parent_id: v.optional(v.union(v.string(), v.null())),
restore_sort_order: v.optional(v.union(v.number(), v.null())),
// 兼容旧逻辑:Supabase documents.mindmap_data。
mindmap_data: v.optional(v.any()),
@@ -92,7 +92,7 @@ describe("/api/media/batch route", () => {
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
mockBuildDocumentCommandEnvelope.mockReset().mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_asset_1",
idempotencyKey: null,
@@ -231,4 +231,108 @@ describe("/api/media/batch route", () => {
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
it("delete/restore/rename 应通过正式 tree.resource 生命周期命令执行", async () => {
const client = {
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:listByIds") {
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
return [
{
id: "asset_1",
workspace_id: "ws_1",
document_id: "doc_1",
file_name: "old.pdf",
},
];
}
return null;
}),
mutation: vi.fn(),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
mockResolveRustBridgeCommandPlan.mockResolvedValueOnce({
kind: "command",
commandName: "tree.resource.archive",
commandId: "cmd_asset_archive",
functionName: "mediaAssets:patchById",
argsJson: {
domainEventPlan: { eventType: "tree.resource.archived" },
},
});
mockResolveRustBridgeCommandPlan.mockResolvedValueOnce({
kind: "command",
commandName: "tree.resource.restore",
commandId: "cmd_asset_restore",
functionName: "mediaAssets:patchById",
argsJson: {
domainEventPlan: { eventType: "tree.resource.restored" },
},
});
mockResolveRustBridgeCommandPlan.mockResolvedValueOnce({
kind: "command",
commandName: "tree.resource.rename",
commandId: "cmd_asset_rename",
functionName: "mediaAssets:patchById",
argsJson: {
domainEventPlan: { eventType: "tree.resource.renamed" },
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true });
const { POST } = await import("./route");
for (const body of [
{ action: "delete", assetIds: ["asset_1"] },
{ action: "restore", assetIds: ["asset_1"] },
{ action: "rename", assetIds: ["asset_1"], newName: "new" },
]) {
const response = await POST(
new Request("http://localhost/api/media/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(response.status).toBe(200);
}
expect(mockBuildDocumentCommandEnvelope).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
name: "tree.resource.archive",
payload: {
resourceKind: "file",
assetId: "asset_1",
},
target: {
workspaceId: "ws_1",
pageId: "doc_1",
},
}),
);
expect(mockBuildDocumentCommandEnvelope).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
name: "tree.resource.restore",
payload: {
resourceKind: "file",
assetId: "asset_1",
},
}),
);
expect(mockBuildDocumentCommandEnvelope).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
name: "tree.resource.rename",
payload: {
resourceKind: "file",
assetId: "asset_1",
newName: "new.pdf",
},
}),
);
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledTimes(3);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(3);
expect(client.mutation).not.toHaveBeenCalled();
});
});
+80 -26
View File
@@ -46,6 +46,56 @@ function sanitizeTransferredAssetForBrowser(request: Request, asset: any) {
};
}
async function executeResourceLifecycleCommand(input: {
request: Request;
client: never;
asset: any;
authUserId: string;
commandName: "tree.resource.archive" | "tree.resource.restore" | "tree.resource.rename";
payload: Record<string, unknown>;
}) {
const workspaceId = String(input.asset?.workspace_id ?? "").trim();
if (!workspaceId) {
throw new Error("缺少资源工作空间");
}
const documentId = String(input.asset?.document_id ?? "").trim();
const context = await buildDocumentBridgeContext({
request: input.request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: input.commandName,
payload: input.payload,
context,
target: {
workspaceId,
pageId: documentId || undefined,
},
reason: `media-batch ${input.commandName}`,
refs: ["file-tree-resource-command", "media-batch-compat-alias"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport({
client: input.client,
plan,
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: input.client,
plan,
result,
});
} catch (error) {
console.warn("[media.batch] Rust lifecycle bridge artifacts skipped:", error);
}
return result;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -73,30 +123,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
const nowIso = () => new Date().toISOString();
try {
switch (payload.action) {
case "delete": {
for (const a of assets) {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(a.id),
patch: { deleted_at: nowIso(), deleted_by: auth.userId },
});
}
return NextResponse.json({ ok: true });
}
case "restore": {
for (const a of assets) {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(a.id),
patch: { deleted_at: null, deleted_by: null, purged_at: null },
});
}
return NextResponse.json({ ok: true });
}
case "rename": {
if (payload.assetIds.length !== 1 || !payload.newName) {
return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 });
@@ -107,13 +135,39 @@ export async function POST(request: Request) {
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
const safeName = newFileName.replace(/[\\/]/g, "_");
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: { file_name: safeName },
await executeResourceLifecycleCommand({
request,
client: client as never,
asset,
authUserId: auth.userId,
commandName: "tree.resource.rename",
payload: {
resourceKind: "file",
assetId: String(asset.id),
newName: safeName,
},
});
return NextResponse.json({ ok: true });
}
case "delete":
case "restore": {
const commandName =
payload.action === "delete" ? "tree.resource.archive" : "tree.resource.restore";
for (const asset of assets) {
await executeResourceLifecycleCommand({
request,
client: client as never,
asset,
authUserId: auth.userId,
commandName,
payload: {
resourceKind: "file",
assetId: String(asset.id),
},
});
}
return NextResponse.json({ ok: true });
}
case "copy":
case "move": {
if (!payload.targetDocumentId) {
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
listByIds: "mediaAssets:listByIds",
purgeById: "mediaAssets:purgeById",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) =>
mockExecuteRustBridgeMutationTransport(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
}));
describe("/api/media/purge route", () => {
beforeEach(() => {
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({ userId: "user_1" });
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockReset().mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_asset_purge",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.purge",
commandId: "cmd_asset_purge",
functionName: "mediaAssets:purgeById",
argsJson: {
domainEventPlan: { eventType: "tree.resource.purged" },
},
});
mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({ ok: true, deleted: 1 });
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("应通过正式 tree.resource.purge 命令执行附件永久删除", async () => {
const client = {
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
expect(name).toBe("mediaAssets:listByIds");
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
return [{ id: "asset_1", workspace_id: "ws_1", document_id: "doc_1" }];
}),
mutation: vi.fn(),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/media/purge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ assetId: "asset_1" }),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ success: true, ok: true, deleted: 1 });
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.purge",
payload: {
resourceKind: "file",
assetId: "asset_1",
},
target: {
workspaceId: "ws_1",
pageId: "doc_1",
},
}),
);
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith(
expect.objectContaining({
client,
plan: expect.objectContaining({
commandName: "tree.resource.purge",
functionName: "mediaAssets:purgeById",
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(1);
expect(client.mutation).not.toHaveBeenCalled();
});
});
+57 -20
View File
@@ -3,6 +3,15 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
executeRustBridgeMutationTransport,
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
@@ -10,23 +19,6 @@ interface PurgePayload {
assetId?: string;
}
function resolveGraceSeconds(): number {
const raw =
process.env.DELETE_GRACE_SECONDS ??
process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ??
"600";
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 600;
}
return Math.floor(parsed);
}
function makeExpiredDeletedAt(): string {
const graceSeconds = resolveGraceSeconds();
return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString();
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -45,11 +37,56 @@ export async function POST(request: Request) {
}
const client = await getConvexAuthedHttpClient();
const res = await client.mutation(api.mediaAssets.purgeById, {
const assets = (await client.query(api.mediaAssets.listByIds, {
userId: auth.userId,
id: assetId,
expiredDeletedAt: makeExpiredDeletedAt(),
ids: [assetId],
})) as any[];
const asset = assets?.[0];
if (!asset) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
const workspaceId = String(asset.workspace_id ?? "").trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少资源工作空间" }, { status: 400 });
}
const documentId = String(asset.document_id ?? "").trim();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.resource.purge",
payload: {
resourceKind: "file",
assetId,
},
context,
target: {
workspaceId,
pageId: documentId || undefined,
},
reason: "media-purge tree.resource.purge",
refs: ["file-tree-resource-command", "media-purge-compat-alias"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const res = await executeRustBridgeMutationTransport({
client: client as never,
plan,
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result: res,
});
} catch (error) {
console.warn("[media.purge] Rust lifecycle bridge artifacts skipped:", error);
}
return NextResponse.json({ success: true, ...(res as any) });
}
@@ -1529,10 +1529,15 @@ describe("/api/tree/commands route", () => {
it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async () => ({
id: "doc_restore_1",
workspace_id: "ws_1",
})),
query: vi.fn(async (name: string, args: { id?: string; includeDeleted?: boolean }) => {
if (name === "documents:getMeta" && args.id === "doc_restore_1" && args.includeDeleted === true) {
return {
id: "doc_restore_1",
workspace_id: "ws_1",
};
}
return null;
}),
};
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
@@ -1634,6 +1639,10 @@ describe("/api/tree/commands route", () => {
);
expect(response.status).toBe(200);
expect(client.query).toHaveBeenCalledWith("documents:getMeta", {
id: "doc_restore_1",
includeDeleted: true,
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.node.restore",
@@ -317,7 +317,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
async function handleMove(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId, includeDeleted: true });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
@@ -483,7 +483,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
async function handleRestore(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId, includeDeleted: true });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
describe("sidebar VSCode explorer context menu source", () => {
it("文件树/页面右键菜单应暴露 VSCode Explorer 常见项或禁用态", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const contextMenuStart = source.indexOf("function ContextMenu(");
expect(contextMenuStart).toBeGreaterThanOrEqual(0);
const contextMenuSource = source.slice(contextMenuStart);
for (const label of ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Copy Relative Path", "Reveal"]) {
expect(contextMenuSource).toContain(label);
}
expect(contextMenuSource).toContain("onCopyRelativePath");
expect(contextMenuSource).toContain("disabled title=");
expect(contextMenuSource).not.toContain("window.location.reload");
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const DOM_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-dom-host.tsx");
const IFRAME_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-iframe-host.tsx");
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
const SSR_LAYOUT_SOURCE = path.join(process.cwd(), "..", "rust/crates/mnote-web/src/ssr/pages/layout.rs");
describe("sidebar filetree DnD modifier source", () => {
it("copy modifier 应同时支持 Alt / Ctrl / Meta", () => {
const domHost = fs.readFileSync(DOM_HOST_SOURCE, "utf8");
const iframeHost = fs.readFileSync(IFRAME_HOST_SOURCE, "utf8");
const ssrLayout = fs.readFileSync(SSR_LAYOUT_SOURCE, "utf8");
expect(domHost).toContain('event.altKey || event.ctrlKey || event.metaKey ? "copy" : "move"');
expect(domHost).toContain("const copy = Boolean(event.altKey || event.ctrlKey || event.metaKey)");
expect(iframeHost).toContain("copy: event?.altKey === true || event?.ctrlKey === true || event?.metaKey === true");
expect(ssrLayout).toContain("var copyModifier = event.altKey || event.ctrlKey || event.metaKey");
expect(ssrLayout).toContain("copy: event.altKey === true || event.ctrlKey === true || event.metaKey === true");
});
it("命名冲突 preflight 必须确认后才执行 drop", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("function confirmFileTreeDropConflicts");
expect(source).toContain("plan.requiresConfirmation");
expect(source).toContain("目标位置已有同名对象,是否继续移动/复制?");
expect(source).toContain("if (!confirmFileTreeDropConflicts(dropPlan))");
});
});
@@ -9,7 +9,7 @@ describe("sidebar file tree paste preflight source", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const preflightIndex = source.indexOf("preflightFileTreePaste(");
const rustBranchStart = source.lastIndexOf("if (isRustFamilyTreeRenderer) {", preflightIndex);
const legacyBranchStart = source.indexOf("const targetDocId = inferPasteTargetDocId", preflightIndex);
const legacyBranchStart = source.indexOf("const targetDocId =", preflightIndex);
expect(preflightIndex).toBeGreaterThanOrEqual(0);
expect(rustBranchStart).toBeGreaterThanOrEqual(0);
@@ -24,4 +24,47 @@ describe("sidebar file tree paste preflight source", () => {
expect(rustPasteBranch).not.toContain("copyableAssetIds");
expect(source).not.toContain("getOrderedFileTreeShellRows");
});
it("rust_family cut paste 应走 Rust drop preflight 并执行 move,不走 copy paste", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const cutBranchStart = source.indexOf('if (payload.action === "cut") {');
const copyBranchStart = source.indexOf("let pastePlan;", cutBranchStart);
expect(cutBranchStart).toBeGreaterThanOrEqual(0);
expect(copyBranchStart).toBeGreaterThan(cutBranchStart);
const cutBranch = source.slice(cutBranchStart, copyBranchStart);
expect(cutBranch).toContain("preflightFileTreeInternalDrop(");
expect(cutBranch).toContain("buildFileTreeShellInternalDropPreflightPayload(");
expect(cutBranch).toContain("copy: false");
expect(cutBranch).toContain("moveDocumentCommand(");
expect(cutBranch).toContain("moveFileTreeResourceAssets(");
expect(cutBranch).toContain("clearTreePaneClipboardPayload()");
expect(cutBranch).not.toContain("copyTreeCommand(");
expect(cutBranch).not.toContain("copyFileTreeResourceAssets(");
});
it("右键 Paste Into 应使用菜单节点作为 action target 并复用 Rust paste preflight", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("onPasteInto: (node: SidebarTreeNode) => void;");
expect(source).toContain("onPasteInto={(node) => void executeFileTreePaste(node.id)}");
const contextMenuStart = source.indexOf("function ContextMenu(");
expect(contextMenuStart).toBeGreaterThanOrEqual(0);
const contextMenuSource = source.slice(contextMenuStart);
expect(contextMenuSource).toContain("onPasteInto");
expect(contextMenuSource).toContain("handleAction(() => onPasteInto(node))");
expect(contextMenuSource).not.toContain("右键 Paste Into 待接 selection target");
const pasteHelperStart = source.indexOf("const executeFileTreePaste = useCallback(");
expect(pasteHelperStart).toBeGreaterThanOrEqual(0);
const pasteHelperEnd = source.indexOf("useEffect(() => {", pasteHelperStart);
expect(pasteHelperEnd).toBeGreaterThan(pasteHelperStart);
const pasteHelperSource = source.slice(pasteHelperStart, pasteHelperEnd);
expect(pasteHelperSource).toContain("targetDocumentIdOverride");
expect(pasteHelperSource).toContain("targetDocumentId: targetDocumentIdOverride ?? null");
expect(pasteHelperSource).toContain("preflightFileTreePaste(");
expect(pasteHelperSource).toContain("preflightFileTreeInternalDrop(");
});
});
+423 -139
View File
@@ -85,6 +85,7 @@ import {
renameFileTreeResourceAsset,
restoreFileTreeResourceAssets,
uploadFileTreeResourceAsset,
type FileTreeInternalDropPreflightPlan,
} from "@/lib/file-tree/resource-command-client";
import { buildParentById } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
@@ -105,6 +106,7 @@ import {
} from "@/lib/file-tree/selection-source";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
clearTreePaneClipboardPayload,
computeTreePaneDeleteTargets,
inferPasteTargetDocId,
isTextInputTarget,
@@ -169,6 +171,18 @@ interface ContextMenuState {
y: number;
}
function confirmFileTreeDropConflicts(plan: FileTreeInternalDropPreflightPlan): boolean {
if (!plan.requiresConfirmation || !plan.conflicts || plan.conflicts.length === 0) {
return true;
}
const titles = plan.conflicts
.map((conflict) => `- ${conflict.title}`)
.slice(0, 6)
.join("\n");
const suffix = plan.conflicts.length > 6 ? "\n..." : "";
return window.confirm(`目标位置已有同名对象,是否继续移动/复制?\n${titles}${suffix}`);
}
export function Sidebar({ initialData, sidebarData, sidebarQuery, treeStream }: SidebarProps) {
if (sidebarQuery && treeStream) {
return (
@@ -840,6 +854,39 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[router, setOpen],
);
const revealRestoredDocument = useCallback(
(documentId: string, parentId?: string | null) => {
pageTreeFocusedDocumentIdRef.current = documentId;
setFilter("");
setExpanded((prev) => {
const next = new Set(prev);
const documentById = new Map(documents.map((doc) => [doc.id, doc]));
let cursor: string | null = parentId ?? null;
while (cursor) {
next.add(cursor);
cursor = documentById.get(cursor)?.parent_id ?? null;
}
return next;
});
if (typeof window === "undefined") {
return;
}
window.requestAnimationFrame(() => {
const escapeSelector =
typeof window.CSS?.escape === "function"
? window.CSS.escape.bind(window.CSS)
: (value: string) => value.replace(/["\\]/g, "\\$&");
const selector = `[data-node-id="${escapeSelector(documentId)}"], [data-row-id="index:${escapeSelector(documentId)}"]`;
const row = document.querySelector(selector);
if (row instanceof HTMLElement) {
row.scrollIntoView({ block: "nearest" });
row.focus({ preventScroll: true });
}
});
},
[documents],
);
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
@@ -855,6 +902,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
await copyText(node.id, "页面 ID 已复制");
}, []);
const handleCopyPagePath = useCallback(
async (node: SidebarTreeNode, relative = false) => {
const documentById = new Map(documents.map((doc) => [doc.id, doc]));
const parts: string[] = [];
let cursor: string | null = node.id;
while (cursor) {
const doc = documentById.get(cursor);
if (!doc) break;
parts.unshift(doc.title || "无标题");
cursor = doc.parent_id ?? null;
}
const path = parts.join("/") || (node.title || "无标题");
await copyText(relative ? path : `/${path}`, relative ? "相对路径已复制" : "页面路径已复制");
},
[documents],
);
const handleDuplicateDocument = useCallback(
async (node: SidebarTreeNode) => {
const response = await fetch("/api/documents/duplicate", {
@@ -1324,18 +1388,309 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
void refreshTree();
}, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]);
const executeFileTreePaste = useCallback(
async (targetDocumentIdOverride: string | null = null) => {
const payload = await readTreePaneClipboardPayload();
if (!payload || payload.rowIds.length === 0) {
if (targetDocumentIdOverride) {
setTimeout(() => window.alert("剪贴板没有可粘贴的文件树内容"), 0);
}
return;
}
const focusedRowId = targetDocumentIdOverride ? null : resourceSelection.focusedRowId;
if (isRustFamilyTreeRenderer) {
if (payload.action === "cut") {
let dropPlan;
try {
dropPlan = await preflightFileTreeInternalDrop(
buildFileTreeShellInternalDropPreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
copy: false,
targetDocumentId: targetDocumentIdOverride ?? null,
targetRowId: focusedRowId,
focusedRowId,
activeDocId: activeId || null,
rowIds: payload.rowIds,
rowById: resourceShellRowById,
parentById: docParentById,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : "文件树移动预检失败";
setTimeout(() => window.alert(message), 0);
return;
}
if (!confirmFileTreeDropConflicts(dropPlan)) {
return;
}
const {
targetDocumentId: targetDocId,
documentTransferPlan,
resourceTransferPlan,
sourceAssetDocumentIds,
} = dropPlan;
if (documentTransferPlan && documentTransferPlan.topLevelDocumentIds.length > 0) {
const topLevelDocIds = documentTransferPlan.topLevelDocumentIds;
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
setTree((prev) => {
let next = prev;
topLevelDocIds.forEach((id, offset) => {
next = moveLocalNode(next, id, documentTransferPlan.targetParentId, baseIndex + offset);
});
return next;
});
setDocuments((prev) => {
let next = prev;
topLevelDocIds.forEach((id, offset) => {
next = moveSidebarDocumentRecord(next, id, documentTransferPlan.targetParentId, baseIndex + offset);
});
return next;
});
setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId));
try {
for (let i = 0; i < topLevelDocIds.length; i += 1) {
await moveDocumentCommand({
documentId: topLevelDocIds[i],
parentId: documentTransferPlan.targetParentId,
position: baseIndex + i,
});
}
} catch (error) {
await refreshTree();
const message = error instanceof Error ? error.message : "移动页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await refreshTree();
emitDocumentsChanged(targetDocId);
}
if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) {
try {
await moveFileTreeResourceAssets({
assetIds: resourceTransferPlan.assetIds,
targetDocumentId: resourceTransferPlan.targetDocumentId,
targetSubPath: resourceTransferPlan.targetSubPath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "移动附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
sourceAssetDocumentIds.forEach((id) => emitAssetsChanged(id));
emitAssetsChanged(targetDocId);
}
await clearTreePaneClipboardPayload();
return;
}
let pastePlan;
try {
pastePlan = await preflightFileTreePaste(
buildFileTreeShellPastePreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
targetDocumentId: targetDocumentIdOverride ?? null,
focusedRowId,
activeDocId: activeId || null,
rowIds: payload.rowIds,
rowById: resourceShellRowById,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : "文件树粘贴预检失败";
setTimeout(() => window.alert(message), 0);
return;
}
if (pastePlan.docItems.length > 0) {
try {
await copyTreeCommand({
items: pastePlan.docItems,
targetParentId: pastePlan.targetDocumentId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(pastePlan.targetDocumentId);
}
if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) {
try {
await copyFileTreeResourceAssets({
assetIds: pastePlan.resourceTransferPlan.assetIds,
targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId,
targetSubPath: pastePlan.resourceTransferPlan.targetSubPath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId);
}
return;
}
const targetDocId =
targetDocumentIdOverride ??
inferPasteTargetDocId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
return;
}
const docItemsMap = new Map<string, boolean>();
const movableDocIds = new Set<string>();
const copyableAssetIds: string[] = [];
const rows = payload.rowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
movableDocIds.add(row.docId);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
}
movableDocIds.add(row.docId);
}
});
rows
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.forEach((asset) => {
copyableAssetIds.push(asset.id);
});
if (payload.action === "cut") {
if (movableDocIds.size > 0) {
try {
let offset = 0;
for (const documentId of movableDocIds) {
await moveDocumentCommand({
documentId,
parentId: targetDocId,
position: (childrenCountByParentId.get(targetDocId) ?? 0) + offset,
});
offset += 1;
}
} catch (error) {
const message = error instanceof Error ? error.message : "移动页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await refreshTree();
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
try {
await moveFileTreeResourceAssets({
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "移动附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(targetDocId);
} else if (movableDocIds.size === 0) {
setTimeout(() => window.alert("没有可移动的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0);
return;
}
await clearTreePaneClipboardPayload();
return;
}
if (docItemsMap.size > 0) {
try {
await copyTreeCommand({
items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
documentId,
recursive,
})),
targetParentId: targetDocId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
try {
await copyFileTreeResourceAssets({
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(targetDocId);
} else if (docItemsMap.size === 0) {
setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0);
}
},
[
activeId,
childrenCountByParentId,
docParentById,
isRustFamilyTreeRenderer,
moveLocalNode,
refreshTree,
resourceRowById,
resourceSelection.focusedRowId,
resourceShellRowById,
sidebarData.activeWorkspaceId,
sidebarQuery,
],
);
useEffect(() => {
const handler = async (event: KeyboardEvent) => {
const isCopy =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
(event.key === "c" || event.key === "C");
const isCut =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
(event.key === "x" || event.key === "X");
const isPaste =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
(event.key === "v" || event.key === "V");
if (!isCopy && !isPaste) {
if (!isCopy && !isCut && !isPaste) {
return;
}
@@ -1349,7 +1704,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
if (isCopy) {
if (isCopy || isCut) {
if (resourceSelection.selectedRowIds.size === 0) {
return;
}
@@ -1360,7 +1715,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
await writeTreePaneClipboardPayload({
type: "mnote-file-tree",
version: 1,
action: "copy",
action: isCut ? "cut" : "copy",
rowIds: orderedRowIds,
});
return;
@@ -1368,148 +1723,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
if (isPaste) {
event.preventDefault();
const payload = await readTreePaneClipboardPayload();
if (!payload || payload.rowIds.length === 0) {
return;
}
if (isRustFamilyTreeRenderer) {
let pastePlan;
try {
pastePlan = await preflightFileTreePaste(
buildFileTreeShellPastePreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
targetDocumentId: null,
focusedRowId: resourceSelection.focusedRowId,
activeDocId: activeId || null,
rowIds: payload.rowIds,
rowById: resourceShellRowById,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : "文件树粘贴预检失败";
setTimeout(() => window.alert(message), 0);
return;
}
if (pastePlan.docItems.length > 0) {
try {
await copyTreeCommand({
items: pastePlan.docItems,
targetParentId: pastePlan.targetDocumentId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(pastePlan.targetDocumentId);
}
if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) {
try {
await copyFileTreeResourceAssets({
assetIds: pastePlan.resourceTransferPlan.assetIds,
targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId,
targetSubPath: pastePlan.resourceTransferPlan.targetSubPath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId);
}
return;
}
const targetDocId = inferPasteTargetDocId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
return;
}
const docItemsMap = new Map<string, boolean>();
const copyableAssetIds: string[] = [];
const rows = payload.rowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
}
}
});
rows
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.forEach((asset) => {
copyableAssetIds.push(asset.id);
});
if (docItemsMap.size > 0) {
try {
await copyTreeCommand({
items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
documentId,
recursive,
})),
targetParentId: targetDocId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
try {
await copyFileTreeResourceAssets({
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(targetDocId);
} else if (docItemsMap.size === 0) {
setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0);
}
await executeFileTreePaste(null);
return;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeId,
isRustFamilyTreeRenderer,
executeFileTreePaste,
resourceSelectionVisibleRowIds,
resourceShellRowById,
resourceRowById,
resourceSelection.focusedRowId,
resourceSelection.selectedRowIds,
resourceShellVisibleRowIds,
sidebarData.activeWorkspaceId,
sidebarQuery,
]);
const handleCopyAssetLink = useCallback(
@@ -2180,6 +2404,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
setTimeout(() => window.alert(message), 0);
return;
}
if (!confirmFileTreeDropConflicts(dropPlan)) {
return;
}
const {
targetDocumentId: targetDocId,
@@ -2373,13 +2600,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
if (!confirmTrashAction("确认恢复该页面吗?")) {
return;
}
await restoreDocumentCommand({
const restoreResult = await restoreDocumentCommand({
documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null,
});
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
revealRestoredDocument(documentId, restoreResult.parentId ?? null);
if (restoreResult.fallbackReason === "parent_missing_or_deleted") {
window.alert("原父页面已不存在,已恢复到根目录");
}
},
[confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery],
[confirmTrashAction, refreshTree, revealRestoredDocument, sidebarData.activeWorkspaceId, sidebarQuery],
);
const handlePurgeFromTrash = useCallback(
@@ -3145,10 +3376,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
onCopyLink={handleCopyLink}
onCopyReference={handleCopyReference}
onCopyId={handleCopyId}
onCopyPath={handleCopyPagePath}
onCopyRelativePath={(node) => handleCopyPagePath(node, true)}
onPasteInto={(node) => void executeFileTreePaste(node.id)}
onDuplicate={handleDuplicateDocument}
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
onCreateChild={() => void handleCreate(contextMenu.node.id)}
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
onRefresh={() => void refreshTree()}
onCollapseAll={() => setExpanded(new Set())}
onReveal={() => revealRestoredDocument(contextMenu.node.id, contextMenu.node.parent_id ?? null)}
onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)}
/>
)}
@@ -3447,10 +3684,16 @@ interface ContextMenuProps {
onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void;
onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void;
onCopyId: (node: SidebarTreeNode) => void;
onCopyPath: (node: SidebarTreeNode) => void;
onCopyRelativePath: (node: SidebarTreeNode) => void;
onPasteInto: (node: SidebarTreeNode) => void;
onDuplicate: (node: SidebarTreeNode) => void;
onRename: () => void;
onCreateChild: () => void;
onConvertChild: () => void;
onRefresh: () => void;
onCollapseAll: () => void;
onReveal: () => void;
onDelete: () => void;
}
@@ -3464,10 +3707,16 @@ function ContextMenu({
onCopyLink,
onCopyReference,
onCopyId,
onCopyPath,
onCopyRelativePath,
onPasteInto,
onDuplicate,
onRename,
onCreateChild,
onConvertChild,
onRefresh,
onCollapseAll,
onReveal,
onDelete,
}: ContextMenuProps) {
const { node } = contextMenu;
@@ -3501,6 +3750,8 @@ function ContextMenu({
const buttonClass =
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
const disabledButtonClass =
"flex w-full cursor-not-allowed items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-400";
return (
<div
@@ -3594,6 +3845,39 @@ function ContextMenu({
<Copy className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(() => onCopyPath(node))}>
<Copy className="h-4 w-4 text-gray-500" />
<span>Copy Path</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(() => onCopyRelativePath(node))}>
<Copy className="h-4 w-4 text-gray-500" />
<span>Copy Relative Path</span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => handleAction(onCreateChild)}>
<Plus className="h-4 w-4 text-gray-500" />
<span>New File</span>
</button>
<button type="button" className={disabledButtonClass} disabled title="页面树暂不区分文件夹,待 Resource Tree folder 合同收口">
<Plus className="h-4 w-4" />
<span>New Folder</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(() => onPasteInto(node))}>
<Copy className="h-4 w-4 text-gray-500" />
<span>Paste Into</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onRefresh)}>
<SearchIcon className="h-4 w-4 text-gray-500" />
<span>Refresh</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onCollapseAll)}>
<ChevronRight className="h-4 w-4 text-gray-500" />
<span>Collapse All</span>
</button>
<button type="button" className={buttonClass} onClick={() => handleAction(onReveal)}>
<SearchIcon className="h-4 w-4 text-gray-500" />
<span>Reveal</span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => handleAction(onRename)}>
<Edit3 className="h-4 w-4 text-gray-500" />
@@ -8,6 +8,7 @@ export {
export type { FileTreeRow as TreePaneRow } from "@/lib/file-tree/types";
export { computeFileTreeDeleteTargets as computeTreePaneDeleteTargets } from "@/lib/file-tree/delete";
export {
clearFileTreeClipboardPayload as clearTreePaneClipboardPayload,
inferPasteTargetDocId,
isTextInputTarget,
readFileTreeClipboardPayload as readTreePaneClipboardPayload,
@@ -153,6 +153,36 @@ const normalizeString = (value: unknown, defaultValue = "") => {
return trimmed || defaultValue;
};
const cssEscapeIdent = (value: string) => {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(value);
}
return value.replace(/["\\]/g, "\\$&");
};
const FILETREE_RENAME_ILLEGAL_PATTERN = /[\\/:*?"<>|\x00-\x1F]/;
const normalizeRenameComparable = (value: string) =>
value.trim().normalize("NFC").toLocaleLowerCase("zh-CN");
const extensionFromFileName = (fileName: string) => {
const trimmed = fileName.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === trimmed.length - 1) {
return "";
}
return trimmed.slice(dotIndex);
};
const normalizeFileTreeRenameTitle = (item: TreeShellDomProjectionItem, nextDraft: string) => {
const title = nextDraft.trim();
if (toShellRowKind(item) !== "asset" || !title || title.includes(".")) {
return title;
}
const extension = extensionFromFileName(item.title || "");
return extension ? `${title}${extension}` : title;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -358,6 +388,9 @@ export function TreeShellRustDomShellHost({
const [pickerState, setPickerState] = useState<PickerRuntimeState>(() => ({
activeItemKey: activePickerItemKey,
}));
const [renamingFileTreeRowId, setRenamingFileTreeRowId] = useState<string | null>(null);
const [fileTreeRenameDraft, setFileTreeRenameDraft] = useState("");
const [fileTreeRenameError, setFileTreeRenameError] = useState("");
const pickerCommandSeqRef = useRef<number | null>(null);
const fileTreeStateRef = useRef(fileTreeState);
const fileTreeSelectionVersionRef = useRef(0);
@@ -818,6 +851,124 @@ export function TreeShellRustDomShellHost({
[workspaceId],
);
useEffect(() => {
if (!renamingFileTreeRowId) return;
window.requestAnimationFrame(() => {
const input = document.querySelector<HTMLInputElement>(
`.tree-rename-input[data-rename-id="${cssEscapeIdent(renamingFileTreeRowId)}"]`,
);
input?.focus();
input?.select();
});
}, [renamingFileTreeRowId]);
const beginFileTreeRename = useCallback((item: TreeShellDomProjectionItem) => {
const rowId = toRowId(item);
const rowKind = toShellRowKind(item);
if (rowKind !== "doc" && rowKind !== "index" && rowKind !== "asset") {
return;
}
setRenamingFileTreeRowId(rowId);
setFileTreeRenameDraft(item.title || "无标题");
setFileTreeRenameError("");
}, []);
const cancelFileTreeRename = useCallback(() => {
setRenamingFileTreeRowId(null);
setFileTreeRenameDraft("");
setFileTreeRenameError("");
}, []);
const validateFileTreeRename = useCallback(
(item: TreeShellDomProjectionItem, nextDraft: string) => {
const title = normalizeFileTreeRenameTitle(item, nextDraft);
if (!title) {
return "名称不能为空";
}
if (FILETREE_RENAME_ILLEGAL_PATTERN.test(title)) {
return '名称不能包含 / \\ : * ? " < > |';
}
const rowId = toRowId(item);
const comparableTitle = normalizeRenameComparable(title);
const hasDuplicateSibling = fileTreeItems.some((candidate) => {
if (toRowId(candidate) === rowId) return false;
if ((candidate.parentNodeId ?? null) !== (item.parentNodeId ?? null)) return false;
return normalizeRenameComparable(candidate.title || "无标题") === comparableTitle;
});
if (hasDuplicateSibling) {
return "同级已存在同名项目";
}
return "";
},
[fileTreeItems],
);
const commitFileTreeRename = useCallback(
async (item: TreeShellDomProjectionItem, nextDraft = fileTreeRenameDraft) => {
const rowId = toRowId(item);
if (renamingFileTreeRowId !== rowId) return;
const title = normalizeFileTreeRenameTitle(item, nextDraft);
const validationError = validateFileTreeRename(item, nextDraft);
if (validationError) {
setFileTreeRenameError(validationError);
return;
}
if (title === (item.title || "无标题")) {
cancelFileTreeRename();
return;
}
setFileTreeRenameError("");
const rowKind = toShellRowKind(item);
const documentId = toDocumentId(item);
const assetId = toAssetId(item);
try {
if ((rowKind === "doc" || rowKind === "index") && documentId) {
const result = await runTreeCommand({ action: "rename", documentId, title });
const commandResult = readTreeCommandResult(result);
onTreeMutation?.({
type: "tree.node.renamed",
documentId,
title: normalizeString(commandResult.title, title),
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
} else if (rowKind === "asset" && assetId) {
const response = await fetch("/api/media/batch", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "rename", assetIds: [assetId], newName: title }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload && typeof payload.error === "string" ? payload.error : "重命名附件失败");
}
window.dispatchEvent(
new CustomEvent("wolai:assets-changed", {
detail: { docId: documentId || undefined, assetIds: [assetId] },
}),
);
}
} catch (error) {
window.alert(error instanceof Error ? error.message : "重命名失败");
return;
} finally {
cancelFileTreeRename();
}
},
[
cancelFileTreeRename,
fileTreeRenameDraft,
onTreeMutation,
renamingFileTreeRowId,
runTreeCommand,
validateFileTreeRename,
workspaceId,
],
);
const dispatchPageCommandEvents = useCallback(
async (commandEvents?: Array<Record<string, unknown>>) => {
for (const event of commandEvents ?? []) {
@@ -1057,13 +1208,17 @@ export function TreeShellRustDomShellHost({
const handleFileTreeKeyDown = useCallback(
async (item: TreeShellDomProjectionItem, event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key === "F2") {
event.preventDefault();
beginFileTreeRename(item);
return;
}
const actionByKey: Record<string, Record<string, unknown> | undefined> = {
ArrowDown: { kind: "focusNext" },
ArrowUp: { kind: "focusPrevious" },
Home: { kind: "focusFirst" },
End: { kind: "focusLast" },
Enter: { kind: "openFocused" },
F2: { kind: "beginRenameFocused" },
Delete: { kind: "deleteSelection" },
Backspace: { kind: "deleteSelection" },
Escape: { kind: "escape" },
@@ -1096,7 +1251,7 @@ export function TreeShellRustDomShellHost({
rowKind: toShellRowKind(item),
});
},
[applyFileTreeHostEvents, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
[applyFileTreeHostEvents, beginFileTreeRename, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
);
const handleFileTreeDrop = useCallback(
@@ -1114,7 +1269,7 @@ export function TreeShellRustDomShellHost({
}
const rowIds = readFiletreeDragRowIds(event.dataTransfer);
if (rowIds.length === 0) return;
const copy = Boolean(event.altKey);
const copy = Boolean(event.altKey || event.ctrlKey || event.metaKey);
const { result } = await reduceFileTreeAction({ kind: "dispatchInternalDrop", targetRowId: rowId, rowIds, copy });
applyFileTreeHostEvents(result?.hostEvents, {
rowId,
@@ -1276,15 +1431,58 @@ export function TreeShellRustDomShellHost({
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
event.dataTransfer.dropEffect = event.altKey || event.ctrlKey || event.metaKey ? "copy" : "move";
void reduceFileTreeAction({ kind: "updateDropTarget", rowId });
}}
onDrop={(event) => void handleFileTreeDrop(item, event)}
>
<span className="tree-spacer h-5 w-5 shrink-0" aria-hidden="true" />
<button type="button" className="tree-link min-w-0 flex-1 truncate text-left" onClick={() => void handleFileTreeOpen(item)}>
{item.title || "无标题"}
</button>
{renamingFileTreeRowId === rowId ? (
<span className="min-w-0 flex-1">
<input
type="text"
className={cn(
"tree-rename-input w-full min-w-0 rounded border bg-white px-1 text-sm text-gray-800 outline-none",
fileTreeRenameError ? "border-red-400" : "border-[#93c5fd]",
)}
data-rename-id={rowId}
value={fileTreeRenameDraft}
aria-label={`重命名 ${item.title || "无标题"}`}
aria-invalid={fileTreeRenameError ? "true" : "false"}
aria-describedby={fileTreeRenameError ? `tree-rename-error-${rowId}` : undefined}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => {
setFileTreeRenameDraft(event.target.value);
setFileTreeRenameError("");
}}
onBlur={(event) => void commitFileTreeRename(item, event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
void commitFileTreeRename(item, event.currentTarget.value);
} else if (event.key === "Escape") {
event.preventDefault();
cancelFileTreeRename();
}
}}
/>
{fileTreeRenameError ? (
<span
id={`tree-rename-error-${rowId}`}
data-testid="tree-rename-validation"
className="mt-0.5 block truncate text-xs text-red-600"
>
{fileTreeRenameError}
</span>
) : null}
</span>
) : (
<button type="button" className="tree-link min-w-0 flex-1 truncate text-left" onClick={() => void handleFileTreeOpen(item)}>
{item.title || "无标题"}
</button>
)}
<button
type="button"
data-testid="filetree-action-menu"
@@ -122,6 +122,27 @@ describe("tree-shell-host", () => {
iconHint: "page",
},
},
{
projectionKind: "file_tree",
rowId: "asset:asset_pdf",
rowKind: "asset",
nodeId: "asset_pdf",
parentNodeId: "doc_2",
title: "附件.pdf",
depth: 1,
childCount: 0,
position: 3,
expandedByDefault: false,
iconHint: "pdf",
capabilities: ["select"],
resourceMeta: {
resourceKind: "pdf",
documentId: "doc_2",
assetId: "asset_pdf",
workspaceId: "ws_1",
iconHint: "pdf",
},
},
];
function queryFileTreeRow(rowId: string) {
@@ -142,6 +163,7 @@ describe("tree-shell-host", () => {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onTreeMutation?: (payload: any) => void;
} = {}) {
act(() => {
root.render(
@@ -152,6 +174,7 @@ describe("tree-shell-host", () => {
activeDocumentId={input.activeDocumentId ?? null}
inlineFileTreeItems={fileTreeItems}
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
onTreeMutation={input.onTreeMutation}
/>,
);
});
@@ -367,4 +390,141 @@ describe("tree-shell-host", () => {
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
it("F2 应进入 filetree 行内重命名,并用 tree.node.rename 提交页面标题", async () => {
const handleTreeMutation = vi.fn();
renderDomHost({ onTreeMutation: handleTreeMutation });
const row2 = queryFileTreeRow("index:doc_2");
expect(row2).not.toBeNull();
act(() => {
row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
});
await flushRuntimeDispatch();
const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
act(() => {
input!.value = "Doc 2 Renamed";
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
await Promise.resolve();
});
const renameRequest = pendingRuntimeRequests.find(
(entry) => (entry.request as { action?: unknown }).action === "rename",
);
expect(renameRequest?.request).toMatchObject({
action: "rename",
documentId: "doc_2",
title: "Doc 2 Renamed",
workspaceId: "ws_1",
});
await act(async () => {
renameRequest?.resolve({
result: {
documentId: "doc_2",
title: "Doc 2 Renamed",
workspaceId: "ws_1",
updatedAt: "2026-05-15T00:00:00Z",
},
});
await Promise.resolve();
});
expect(handleTreeMutation).toHaveBeenCalledWith(
expect.objectContaining({
type: "tree.node.renamed",
documentId: "doc_2",
title: "Doc 2 Renamed",
}),
);
});
it("F2 行内重命名应在输入框内阻断空名、非法字符和同级重名", async () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
expect(row2).not.toBeNull();
act(() => {
row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
});
await flushRuntimeDispatch();
const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
act(() => {
input!.value = " ";
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
await Promise.resolve();
});
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("名称不能为空");
expect(input?.getAttribute("aria-invalid")).toBe("true");
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
act(() => {
input!.value = "非法/名称";
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
await Promise.resolve();
});
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("不能包含");
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
act(() => {
input!.value = "Doc 1 索引";
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
await Promise.resolve();
});
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("同级已存在");
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
});
it("F2 重命名附件未输入扩展名时应保留原扩展名", async () => {
renderDomHost();
const assetRow = queryFileTreeRow("asset:asset_pdf");
expect(assetRow).not.toBeNull();
act(() => {
assetRow?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
});
await flushRuntimeDispatch();
const input = container.querySelector('.tree-rename-input[data-rename-id="asset:asset_pdf"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
act(() => {
input!.value = "附件新名";
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
await Promise.resolve();
});
const renameRequest = pendingRuntimeRequests.find((entry) => {
const request = entry.request as { action?: unknown; assetIds?: unknown };
return request.action === "rename" && Array.isArray(request.assetIds);
});
expect(renameRequest?.request).toMatchObject({
action: "rename",
assetIds: ["asset_pdf"],
newName: "附件新名.pdf",
});
});
});
@@ -2545,7 +2545,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
kind: "dispatch_internal_drop",
targetRowId: normalizeText(target?.rowId) || null,
rowIds: internalRowIds,
copy: event?.altKey === true,
copy: event?.altKey === true || event?.ctrlKey === true || event?.metaKey === true,
};
const fallbackHostEvent = hasExternalFiles
? {
@@ -2558,7 +2558,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
kind: "internalDrop",
target,
rowIds: internalRowIds,
copy: event?.altKey === true,
copy: event?.altKey === true || event?.ctrlKey === true || event?.metaKey === true,
runtimeRequired: true,
};
const runtimeState = readFileTreeRuntimeState();
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
buildDocumentRestoreOrderAssignments,
buildDocumentTrashLocationPatch,
resolveDocumentRestoreLocation,
} from "../../../convex/documents";
const baseDoc = {
user_id: "user_1",
workspace_id: "ws_1",
deleted_at: null,
};
describe("document trash restore location", () => {
it("删除时应记录原父节点与排序位置", () => {
expect(buildDocumentTrashLocationPatch({ parent_id: "parent_1", sort_order: 3 })).toEqual({
restore_parent_id: "parent_1",
restore_sort_order: 3,
});
expect(buildDocumentTrashLocationPatch({ parent_id: null, sort_order: null })).toEqual({
restore_parent_id: null,
restore_sort_order: null,
});
});
it("父节点仍存在时应恢复到原父节点与原排序", () => {
const doc = {
...baseDoc,
id: "doc_1",
parent_id: "parent_1",
sort_order: 1,
deleted_at: "2026-05-15T00:00:00Z",
restore_parent_id: "parent_1",
restore_sort_order: 1,
};
const location = resolveDocumentRestoreLocation({
doc,
ownedDocs: [
doc,
{ ...baseDoc, id: "parent_1", parent_id: null, sort_order: 0 },
{ ...baseDoc, id: "sibling_1", parent_id: "parent_1", sort_order: 0 },
{ ...baseDoc, id: "sibling_2", parent_id: "parent_1", sort_order: 2 },
],
});
expect(location).toEqual({
parentId: "parent_1",
sortOrder: 1,
fallbackReason: "none",
});
});
it("父节点已不存在或仍在垃圾箱时应显式 fallback 到根目录", () => {
const doc = {
...baseDoc,
id: "doc_1",
parent_id: "parent_1",
sort_order: 5,
deleted_at: "2026-05-15T00:00:00Z",
restore_parent_id: "parent_1",
restore_sort_order: 5,
};
const location = resolveDocumentRestoreLocation({
doc,
ownedDocs: [
doc,
{
...baseDoc,
id: "parent_1",
parent_id: null,
sort_order: 0,
deleted_at: "2026-05-15T00:00:00Z",
},
{ ...baseDoc, id: "root_1", parent_id: null, sort_order: 0 },
],
});
expect(location).toEqual({
parentId: null,
sortOrder: 1,
fallbackReason: "parent_missing_or_deleted",
});
});
it("随同恢复的父子树应允许子节点回到正在恢复的父节点下", () => {
const parent = {
...baseDoc,
id: "parent_1",
parent_id: null,
sort_order: 0,
deleted_at: "2026-05-15T00:00:00Z",
restore_parent_id: null,
restore_sort_order: 0,
};
const child = {
...baseDoc,
id: "child_1",
parent_id: "parent_1",
sort_order: 0,
deleted_at: "2026-05-15T00:00:00Z",
restore_parent_id: "parent_1",
restore_sort_order: 0,
};
const location = resolveDocumentRestoreLocation({
doc: child,
ownedDocs: [parent, child],
restoringIds: new Set(["parent_1", "child_1"]),
});
expect(location).toEqual({
parentId: "parent_1",
sortOrder: 0,
fallbackReason: "none",
});
});
it("恢复到已被占用排序位时应重排兄弟节点,避免 sort_order 重复", () => {
const restoring = {
...baseDoc,
id: "doc_1",
parent_id: "parent_1",
sort_order: 1,
deleted_at: "2026-05-15T00:00:00Z",
restore_parent_id: "parent_1",
restore_sort_order: 1,
};
const siblingBefore = {
...baseDoc,
id: "sibling_0",
parent_id: "parent_1",
sort_order: 0,
};
const siblingAtOldSlot = {
...baseDoc,
id: "sibling_1",
parent_id: "parent_1",
sort_order: 1,
};
const locationsById = new Map([
[
restoring.id,
{
parentId: "parent_1",
sortOrder: 1,
fallbackReason: "none" as const,
},
],
]);
expect(
buildDocumentRestoreOrderAssignments({
restoringDocs: [restoring],
ownedDocs: [restoring, siblingBefore, siblingAtOldSlot],
locationsById,
restoringIds: new Set([restoring.id]),
}).map((item) => ({
id: item.id,
sortOrder: item.sortOrder,
isRestored: item.isRestored,
})),
).toEqual([
{ id: "sibling_0", sortOrder: 0, isRestored: false },
{ id: "doc_1", sortOrder: 1, isRestored: true },
{ id: "sibling_1", sortOrder: 2, isRestored: false },
]);
});
});
@@ -21,6 +21,8 @@ vi.mock("@/lib/convex/api", () => ({
mediaAssets: {
batchCopy: "mediaAssets.batchCopy",
batchMove: "mediaAssets.batchMove",
patchById: "mediaAssets.patchById",
purgeById: "mediaAssets.purgeById",
},
mindmaps: {
get: "mindmaps.get",
@@ -530,6 +532,122 @@ describe("executeRustBridgeMutationTransport", () => {
},
});
});
it("tree.resource 生命周期命令应映射到媒体资源 transport", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const basePlan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.resource.archive",
commandId: "cmd_asset_archive",
functionName: "mediaAssets:patchById",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
userId: "user_1",
id: "asset_1",
resourceKind: "file",
resourceLifecyclePlan: {
action: "archive",
resourceKind: "file",
assetId: "asset_1",
documentId: null,
mindmapId: null,
tableId: null,
newName: null,
},
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: basePlan,
});
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: {
...basePlan,
commandName: "tree.resource.restore",
commandId: "cmd_asset_restore",
argsJson: {
...basePlan.argsJson,
resourceLifecyclePlan: {
...(basePlan.argsJson.resourceLifecyclePlan as Record<string, unknown>),
action: "restore",
},
},
},
});
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: {
...basePlan,
commandName: "tree.resource.rename",
commandId: "cmd_asset_rename",
argsJson: {
...basePlan.argsJson,
newName: "renamed.pdf",
resourceLifecyclePlan: {
...(basePlan.argsJson.resourceLifecyclePlan as Record<string, unknown>),
action: "rename",
newName: "renamed.pdf",
},
},
},
});
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: {
...basePlan,
commandName: "tree.resource.purge",
commandId: "cmd_asset_purge",
functionName: "mediaAssets:purgeById",
argsJson: {
...basePlan.argsJson,
resourceLifecyclePlan: {
...(basePlan.argsJson.resourceLifecyclePlan as Record<string, unknown>),
action: "purge",
},
},
},
});
expect(mutation).toHaveBeenNthCalledWith(1, "mediaAssets.patchById", {
userId: "user_1",
id: "asset_1",
patch: expect.objectContaining({
deleted_by: "user_1",
purged_at: null,
}),
});
expect(mutation).toHaveBeenNthCalledWith(2, "mediaAssets.patchById", {
userId: "user_1",
id: "asset_1",
patch: {
deleted_at: null,
deleted_by: null,
purged_at: null,
},
});
expect(mutation).toHaveBeenNthCalledWith(3, "mediaAssets.patchById", {
userId: "user_1",
id: "asset_1",
patch: {
file_name: "renamed.pdf",
},
});
expect(mutation).toHaveBeenNthCalledWith(4, "mediaAssets.purgeById", {
userId: "user_1",
id: "asset_1",
expiredDeletedAt: expect.any(String),
});
});
});
describe("materializeRustTreeStreamDelta", () => {
@@ -1259,6 +1259,8 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
mediaAssets: {
batchCopy: unknown;
batchMove: unknown;
patchById: unknown;
purgeById: unknown;
};
mindmaps: {
applyCommand: unknown;
@@ -1361,6 +1363,45 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
),
},
);
case "mediaAssets:patchById": {
const lifecyclePlan = readOptionalRecordArg(input.plan.argsJson, "resourceLifecyclePlan");
const action = typeof lifecyclePlan?.action === "string" ? lifecyclePlan.action : "";
let patch: Record<string, unknown>;
if (action === "archive") {
patch = {
deleted_at: new Date().toISOString(),
deleted_by: input.plan.actorId,
purged_at: null,
};
} else if (action === "restore") {
patch = {
deleted_at: null,
deleted_by: null,
purged_at: null,
};
} else if (action === "rename") {
patch = {
file_name: assertStringArg(input.plan.argsJson, "newName"),
};
} else {
throw new DocumentBridgeError(
`不支持的媒体资源生命周期动作: ${action || "unknown"}`,
500,
"TRANSPORT_ERROR",
);
}
return mutation(runtimeApi.mediaAssets.patchById, {
userId: input.plan.actorId,
id: assertStringArg(input.plan.argsJson, "id"),
patch,
});
}
case "mediaAssets:purgeById":
return mutation(runtimeApi.mediaAssets.purgeById, {
userId: input.plan.actorId,
id: assertStringArg(input.plan.argsJson, "id"),
expiredDeletedAt: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString(),
});
case "mediaAssets:replaceStorageFromUpload":
return mutation(api.mediaAssets.replaceStorageFromUpload, {
userId: assertStringArg(input.plan.argsJson, "userId"),
@@ -104,4 +104,30 @@ describe("tree-command-client", () => {
await expect(deleteDocumentCommand({ documentId: "doc_1" })).rejects.toThrow("删除失败");
});
it("restoreDocumentCommand 应返回恢复位置与 fallback 原因", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
ok: true,
result: {
action: "restore",
execution: {
restore_location: {
parent_id: "parent_1",
sort_order: 2,
fallback_reason: "none",
},
},
},
}),
} as Response);
await expect(restoreDocumentCommand({ documentId: "doc_1" })).resolves.toMatchObject({
success: true,
parentId: "parent_1",
sortOrder: 2,
fallbackReason: "none",
});
});
});
@@ -116,6 +116,14 @@ type RestoreDocumentInput = {
workspaceId?: string | null;
};
export type RestoreDocumentCommandResult = {
success: true;
parentId?: string | null;
sortOrder?: number | null;
fallbackReason?: "none" | "parent_missing_or_deleted" | string | null;
meta?: DocumentCommandMeta;
};
type PurgeDocumentInput = {
documentId: string;
};
@@ -308,7 +316,7 @@ export async function deleteDocumentCommand(
export async function restoreDocumentCommand(
input: RestoreDocumentInput,
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
): Promise<RestoreDocumentCommandResult> {
const response = await postTreeCommand<TreeCommandResponse>(
{
action: "restore",
@@ -317,9 +325,19 @@ export async function restoreDocumentCommand(
},
"恢复失败,请稍后再试",
);
const restoreLocation = response.result?.execution?.restore_location as
| {
parent_id?: string | null;
sort_order?: number | null;
fallback_reason?: string | null;
}
| undefined;
return {
success: true,
parentId: restoreLocation?.parent_id,
sortOrder: restoreLocation?.sort_order,
fallbackReason: restoreLocation?.fallback_reason,
meta: {
requestId: response.requestId,
traceId: response.traceId,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
clearFileTreeClipboardPayload,
decodeFileTreeClipboardPayload,
encodeFileTreeClipboardPayload,
inferPasteTargetDocId,
@@ -11,8 +12,19 @@ describe("file-tree clipboard payload", () => {
const payload = { type: "mnote-file-tree" as const, version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
const text = encodeFileTreeClipboardPayload(payload);
expect(decodeFileTreeClipboardPayload(text)).toEqual(payload);
const cutPayload = { ...payload, action: "cut" as const };
expect(decodeFileTreeClipboardPayload(encodeFileTreeClipboardPayload(cutPayload))).toEqual(cutPayload);
expect(
decodeFileTreeClipboardPayload(
encodeFileTreeClipboardPayload({ ...payload, action: "move" as any }),
),
).toBeNull();
expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull();
});
it("可清空内存剪贴板", async () => {
await clearFileTreeClipboardPayload();
});
});
describe("inferPasteTargetDocId", () => {
+13 -2
View File
@@ -3,7 +3,7 @@
import type { FileTreeRow } from "./types";
import { parseFileTreeRowId } from "./types";
export type FileTreeClipboardAction = "copy";
export type FileTreeClipboardAction = "copy" | "cut";
export type FileTreeClipboardPayloadV1 = {
type: "mnote-file-tree";
@@ -45,7 +45,7 @@ export function decodeFileTreeClipboardPayload(text: string): FileTreeClipboardP
const raw = decodeBase64(base64);
const parsed = JSON.parse(raw) as Partial<FileTreeClipboardPayloadV1>;
if (parsed?.type !== "mnote-file-tree" || parsed.version !== 1) return null;
if (parsed.action !== "copy") return null;
if (parsed.action !== "copy" && parsed.action !== "cut") return null;
if (!Array.isArray(parsed.rowIds) || parsed.rowIds.some((id) => typeof id !== "string")) return null;
return parsed as FileTreeClipboardPayloadV1;
} catch {
@@ -78,6 +78,17 @@ export async function readFileTreeClipboardPayload(): Promise<FileTreeClipboardP
return decodeFileTreeClipboardPayload(text);
}
export async function clearFileTreeClipboardPayload(): Promise<void> {
memoryClipboardText = null;
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText("");
} catch {
// ignore, memory fallback 已清空
}
}
}
export function isTextInputTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
@@ -39,6 +39,20 @@ type UploadResourceAssetInput = {
};
export type FileTreeInternalDropPreflightPlan = {
allowed?: boolean;
blockedReason?: string | null;
requiresConfirmation?: boolean;
conflicts?: Array<{
rowKind: string;
sourceRowId: string;
sourceDocumentId: string | null;
sourceAssetId: string | null;
existingDocumentId: string | null;
existingAssetId: string | null;
targetDocumentId: string;
title: string;
policy: string;
}>;
copy: boolean;
targetDocumentId: string;
targetMindmapId: string | null;
+35 -1
View File
@@ -302,7 +302,9 @@ describe("file-tree shell helpers", () => {
expect(result).toEqual({
workspaceId: "ws_1",
copy: false,
targetDocumentId: null,
sourceCapabilities: ["read", "write", "move"],
targetCapabilities: ["read", "write", "drop"],
targetDocumentId: "doc_root",
targetRowId: "asset-folder:mind_1",
focusedRowId: null,
activeDocumentId: null,
@@ -316,6 +318,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
title: "mindmap.json",
},
{
rowId: "asset:asset_child_1",
@@ -325,6 +328,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "mindmaps/mind_1/assets/node.png",
title: "node.png",
},
{
rowId: "doc:doc_root",
@@ -334,6 +338,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: null,
assetType: null,
storagePath: null,
title: "根页面",
},
{
rowId: "asset:pdf_1",
@@ -343,12 +348,34 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
title: "guide.pdf",
},
],
targetChildren: [
{
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
title: "mindmap.json",
},
{
rowKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
title: "node.png",
},
{
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
title: "guide.pdf",
},
],
documentParents: [
{ documentId: "doc_root", parentId: null },
{ documentId: "doc_child", parentId: "doc_root" },
],
conflictPolicy: "prompt",
});
});
@@ -381,6 +408,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: null,
assetType: null,
storagePath: null,
title: "根页面",
},
{
rowId: "asset:pdf_1",
@@ -390,6 +418,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
title: "guide.pdf",
},
],
documentParents: [
@@ -430,6 +459,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
title: "mindmap.json",
},
{
rowId: "index:doc_root",
@@ -439,6 +469,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: null,
assetType: null,
storagePath: null,
title: "根页面",
},
{
rowId: "asset:pdf_1",
@@ -448,6 +479,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
title: "guide.pdf",
},
],
});
@@ -488,6 +520,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "mindmaps/mind_1/assets/node.png",
title: "node.png",
},
{
rowId: "asset-folder:mind_1",
@@ -497,6 +530,7 @@ describe("file-tree shell helpers", () => {
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
title: "mindmap.json",
},
],
documentWorkspaces: [
+61 -1
View File
@@ -28,18 +28,31 @@ export type FileTreeShellInternalDropPreflightRow = {
assetDocumentId: string | null;
assetType: string | null;
storagePath: string | null;
title: string | null;
operationProfile?: string | null;
};
export type FileTreeShellInternalDropPreflightTargetChild = {
rowKind: FileTreeShellRowKind;
documentId: string | null;
assetId: string | null;
title: string | null;
};
export type FileTreeShellInternalDropPreflightPayload = {
workspaceId: string | null;
copy: boolean;
sourceCapabilities: string[];
targetCapabilities: string[];
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
targetChildren: FileTreeShellInternalDropPreflightTargetChild[];
documentParents: Array<{ documentId: string; parentId: string | null }>;
conflictPolicy: "prompt";
};
export type FileTreeShellDeletePreflightPayload = {
@@ -194,6 +207,13 @@ function normalizeShellText(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function getFileTreeShellRowTitle(row: FileTreeShellRow): string | null {
if (row.rowKind === "doc" || row.rowKind === "index") {
return normalizeShellText(row.node?.title ?? null);
}
return normalizeShellText(row.asset?.file_name ?? null);
}
function buildFileTreeShellDropPreflightRow(
row: FileTreeShellRow,
): FileTreeShellInternalDropPreflightRow {
@@ -205,9 +225,34 @@ function buildFileTreeShellDropPreflightRow(
assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
assetType: normalizeShellText(row.asset?.asset_type ?? null),
storagePath: normalizeShellText(row.asset?.storage_path ?? null),
title: getFileTreeShellRowTitle(row),
};
}
function buildFileTreeShellTargetChildren(input: {
targetDocumentId: string | null;
rowById: Map<string, FileTreeShellRow>;
parentById: Map<string, string | null>;
}): FileTreeShellInternalDropPreflightTargetChild[] {
const targetDocumentId = normalizeShellText(input.targetDocumentId);
if (!targetDocumentId) {
return [];
}
return Array.from(input.rowById.values())
.filter((row) => {
if (row.rowKind === "doc" || row.rowKind === "index") {
return input.parentById.get(row.documentId) === targetDocumentId;
}
return row.documentId === targetDocumentId;
})
.map((row) => ({
rowKind: row.rowKind,
documentId: normalizeShellText(row.documentId),
assetId: normalizeShellText(row.assetId),
title: getFileTreeShellRowTitle(row),
}));
}
export function buildFileTreeShellInternalDropPreflightPayload(input: {
workspaceId: string | null;
copy: boolean;
@@ -237,20 +282,35 @@ export function buildFileTreeShellInternalDropPreflightPayload(input: {
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
const targetDocumentId =
normalizeShellText(input.targetDocumentId) ??
inferFileTreeShellTargetDocumentId({
focusedRowId: targetRowId ?? focusedRowId,
rowById: input.rowById,
activeDocId: input.activeDocId,
});
return {
workspaceId: normalizeShellText(input.workspaceId),
copy: input.copy,
targetDocumentId: normalizeShellText(input.targetDocumentId),
sourceCapabilities: ["read", "write", input.copy ? "copy" : "move"],
targetCapabilities: ["read", "write", "drop"],
targetDocumentId,
targetRowId,
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rowIds,
rows,
targetChildren: buildFileTreeShellTargetChildren({
targetDocumentId,
rowById: input.rowById,
parentById: input.parentById,
}),
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
documentId,
parentId: normalizeShellText(parentId),
})),
conflictPolicy: "prompt",
};
}
@@ -254,6 +254,73 @@ describe("useSidebarTreeStream", () => {
expect(MockEventSource.instances[0]?.closed).toBe(true);
});
it("resync_required delta 只推进 cursor,后续 resync 才替换数据", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);
await flush();
await flush();
});
expect(MockEventSource.instances).toHaveLength(1);
await act(async () => {
MockEventSource.instances[0]?.emit("snapshot", buildSnapshotEnvelope("旧标题"));
await flush();
await flush();
});
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "live",
cursor: "evt_2",
data: expect.objectContaining({
documents: [expect.objectContaining({ title: "旧标题" })],
}),
}),
);
await act(async () => {
MockEventSource.instances[0]?.emit("delta", {
stream: "workspace",
workspaceId: "ws_1",
cursor: "evt_3",
projection: "sidebar_tree",
data: {
op: "resync_required",
reason: "documents_empty_trash",
},
});
await flush();
await flush();
});
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "live",
cursor: "evt_3",
data: expect.objectContaining({
documents: [expect.objectContaining({ title: "旧标题" })],
}),
}),
);
await act(async () => {
MockEventSource.instances[0]?.emit("resync", buildSnapshotEnvelope("新标题"));
await flush();
await flush();
});
expect(onState).toHaveBeenLastCalledWith(
expect.objectContaining({
status: "live",
cursor: "evt_2",
data: expect.objectContaining({
documents: [expect.objectContaining({ title: "新标题" })],
}),
}),
);
});
it("收到 snapshot 后连接中断也应切到 fallback,并保留最近一次 stream 数据", async () => {
await act(async () => {
root.render(<Harness onState={onState} />);