feat(tree): checkpoint resource lifecycle work

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

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
@@ -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",
});
});
});