feat: 收口本地文件夹入口并推进 page aggregate rust-first

- 为 Rust Web 主入口补齐本地文件夹/云空间切换、最近目录与路径回填体验\n- 对齐 local markdown media 与 inline marks 的 Rust shell / TipTap converter 语义\n- 让 documents/page 优先消费 Rust page aggregate snapshot,并保留 TS fallback\n- 补强 tree live、local markdown 与主入口 smoke,并同步设计稿状态
This commit is contained in:
lix-2026
2026-05-09 19:05:06 +08:00
parent 71146de5f0
commit d148e1ccc2
20 changed files with 1635 additions and 374 deletions
@@ -19,8 +19,8 @@ function fileUrl(localPath) {
}
function treeUrl(root, mode) {
const url = new URL(`${BASE_URL}/tree`);
url.searchParams.set("mode", mode);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
@@ -34,8 +34,8 @@ function documentUrl(root, documentId) {
}
function convexTreeUrl(mode = "filetree") {
const url = new URL(`${BASE_URL}/tree`);
url.searchParams.set("mode", mode);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("workspaceId", "ws_demo");
url.searchParams.set("sourceKind", "convex_workspace");
return url.toString();
@@ -56,17 +56,17 @@ async function waitForText(page, text) {
}
async function waitForContextMenu(page) {
const menu = page.locator('[data-testid="filetree-context-menu"]').first();
const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
async function clickMenuAction(page, action) {
await page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
}
async function expectMenuAction(page, action, options = {}) {
const item = page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).first();
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const disabled = await item.isDisabled();
if (options.disabled !== undefined) {
@@ -81,18 +81,18 @@ async function expectMenuAction(page, action, options = {}) {
async function closeContextMenu(page) {
await page.keyboard.press("Escape");
try {
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
} catch {
await page.evaluate(() => {
document.querySelectorAll('[data-testid="filetree-context-menu"]').forEach((element) => element.remove());
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
});
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
}
async function dispatchRootContextMenu(page) {
await page.evaluate(() => {
const root = document.querySelector(".tree-root");
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
@@ -238,7 +238,7 @@ async function run() {
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
fs.writeFileSync(
path.join(root, "docs", "blocks.md"),
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph\n\n- Bullet item\n1. Numbered item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n",
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph with `inline code`, **bold**, *italic*, ~~strike~~, and [Link](https://example.com).\n\n- Bullet item\n1. Numbered item\n- [ ] Unchecked item\n- [x] Checked item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n\n| Name | Value |\n| --- | --- |\n| Mark | `cell` |\n",
"utf8",
);
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
@@ -257,7 +257,12 @@ async function run() {
const page = await context.newPage();
const requests = [];
page.on("request", (request) => {
if (request.url().includes("/api/tree/commands")) {
if (
request.url().includes("/api/tree/commands") ||
request.url().includes("/api/documents/title") ||
request.url().includes("/api/documents/save") ||
request.url().includes("/api/documents/options")
) {
requests.push({
url: request.url(),
method: request.method(),
@@ -381,6 +386,52 @@ async function run() {
await waitForText(page, "Quote item");
await waitForText(page, "console.log('hi')");
await waitForText(page, "Spec");
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const checkboxStates = await page.locator(".ProseMirror input[type='checkbox']").evaluateAll((nodes) =>
nodes.map((node) => Boolean(node.checked))
);
assert(checkboxStates.length === 2, `本地 Markdown 应渲染两个 checkbox,实际 ${checkboxStates.length}`);
assert(checkboxStates.includes(true), "本地 Markdown 应渲染 checked checkbox");
assert(checkboxStates.includes(false), "本地 Markdown 应渲染 unchecked checkbox");
assert(await page.locator(".ProseMirror p code", { hasText: "inline code" }).count() >= 1, "inline code 应渲染为 code mark");
assert(await page.locator(".ProseMirror strong", { hasText: "bold" }).count() >= 1, "strong 应渲染为 strong 标签");
assert(await page.locator(".ProseMirror em", { hasText: "italic" }).count() >= 1, "em 应渲染为 em 标签");
assert(await page.locator(".ProseMirror s, .ProseMirror del", { hasText: "strike" }).count() >= 1, "strike 应渲染为删除线标签");
const linkTexts = await page.locator(".ProseMirror a").evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() || ""));
assert(linkTexts.includes("Link"), `普通链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
assert(linkTexts.includes("Spec"), `附件链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
assert(await page.locator(".ProseMirror table td code", { hasText: "cell" }).count() >= 1, "table cell inline code 应保留 code mark");
if (process.env.MNOTE_TASK163_RUN_EXTENDED_TREE_OPS !== "1") {
const filetreeRows = await page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]').evaluateAll((nodes) =>
nodes.map((node) => ({
rowId: node.getAttribute("data-row-id") || "",
rowKind: node.getAttribute("data-row-kind") || "",
}))
);
assert(filetreeRows.some((row) => row.rowId === "local:folder:docs" && row.rowKind === "folder"), "当前主入口下的 filetree 容器应继续渲染 docs folder 行");
assert(filetreeRows.some((row) => row.rowId === "local:markdown:README.md" && row.rowKind === "markdown"), "当前主入口下的 filetree 容器应继续渲染 README markdown 行");
const pageAggregateResult = await page.evaluate(async ({ rootUri }) => {
const response = await fetch(`/api/page-aggregate/${encodeURIComponent("local-md:docs~2Fblocks.md")}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`, {
headers: { accept: "application/json" },
});
const payload = await response.json().catch(() => null);
return {
ok: response.ok,
status: response.status,
payload,
};
}, { rootUri: fileUrl(root) });
assert(pageAggregateResult.ok, `page aggregate 路由应返回 200,实际 ${pageAggregateResult.status}`);
assert(pageAggregateResult.payload?.schema === "mnote.page_aggregate.v1", `page aggregate schema 应保持稳定,实际 ${JSON.stringify(pageAggregateResult.payload)}`);
assert(pageAggregateResult.payload?.result?.title === "Complex Title", "page aggregate 应继续返回本地 Markdown 标题");
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "本地 Markdown 保存写操作必须透传 sourceKind=local_folder");
await browser.close();
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
fs.rmSync(root, { recursive: true, force: true });
console.log("task163 local markdown parser browser smoke passed");
return;
}
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
@@ -569,7 +620,7 @@ async function run() {
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
await closeContextMenu(page);
await page.evaluate(() => {
const root = document.querySelector(".tree-root");
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
});