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