Files
mnote/docs/superpowers/plans/2026-05-09-local-folder-page-aggregate-next-phase.md
T
lix-2026 d148e1ccc2 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,并同步设计稿状态
2026-05-09 19:05:06 +08:00

327 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 本地文件夹收尾与 Page Aggregate 主线回归实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 先把当前 `local_folder / local_markdown` 的未收口点收成稳定 checkpoint,再把文档页读取真相继续推进到 Rust 原生 `Page Aggregate`,最后补齐 `3000` 主界面的 tree live stream 浏览器级闭环。
**Architecture:** 保持 `WorkspaceSource -> Rust kernel / mnote-web -> projection / command -> UI` 的单一路径。第一段只处理当前未提交工作已经暴露出的本地/云空间切换、Markdown 附件与 marks 回归问题;第二段让 Next 侧 `/api/documents/page` 优先消费 Rust 已存在的 `mnote.page_aggregate.v1` 快照,而不是继续本地拼装 `meta + content`;第三段以 `snapshot + delta + resync` 的 Rust Web tree stream 作为 `3000` 当前 sidebar / filetree / page subtree 的正式实时主链。
**Tech Stack:** Rust (`mnote-web`, `core-protocol`), TypeScript/React (`wolai-frontend`), existing browser smoke scripts, Rust unit tests, Vitest, Playwright smoke.
---
### Task 1: 收口本地/云工作区切换入口与最近目录体验
**Files:**
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
- Modify: `scripts/task164-desktop-hot-local-folder-main-entry-smoke.js`
- [x] **Step 1: 先把 smoke 断言补成失败用例**
```js
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
return JSON.parse(raw);
});
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-switch-cloud-workspace"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") !== "local_folder" &&
!url.searchParams.has("rootUri");
}, { timeout: UI_TIMEOUT_MS });
```
- [x] **Step 2: 运行 smoke,确认当前基线失败或缺断言**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js`
Expected: 失败,或日志中尚未覆盖“最近目录 / 切回云空间”链路。
- [x] **Step 3: 在 Rust SSR 壳里补最小实现**
```js
function rememberCloudWorkspaceId(workspaceId) {
var normalized = String(workspaceId || '').trim();
if (!normalized || normalized === 'local-folder') return;
try {
if (window.localStorage) window.localStorage.setItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY, normalized);
} catch (_) {}
}
function switchToCloudWorkspace() {
var targetUrl = new URL('/', window.location.origin);
var workspaceId = readLastCloudWorkspaceId();
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
targetUrl.searchParams.set('sourceKind', 'convex_workspace');
window.location.href = targetUrl.toString();
}
```
```css
.mnote-workspace-source-menu {
position: absolute;
inset-inline-start: 0;
top: calc(100% + 6px);
}
```
- [x] **Step 4: 重新运行 smoke,确认体验闭环**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js`
Expected: PASS,并打印 `task164 desktop hot local folder main entry smoke passed`
### Task 2: 收口 local markdown 附件 media 与 marks 可见化漂移
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
- Modify: `wolai-frontend/src/lib/documents/tiptap-content-converter.ts`
- Modify: `wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts`
- [x] **Step 1: 先写失败测试,钉死 Rust/前端两边都要可见且保留链接**
```rust
#[tokio::test]
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
assert!(html.contains("Spec"));
assert!(html.contains("assets/spec.pdf"));
}
```
```ts
expect(
tiptapDocFromBlocks([
{
id: "m1",
type: "media",
props: { name: "Spec", sourcePath: "assets/spec.pdf" },
content: [],
},
] as never),
).toEqual({
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "m1" },
content: [
{
type: "text",
text: "Spec",
marks: [{ type: "link", attrs: { href: "assets/spec.pdf" } }],
},
],
},
],
});
```
- [x] **Step 2: 运行最小测试,确认当前基线暴露漂移**
Run: `cd rust && cargo test -p mnote-web document_shell_renders_local_markdown_attachment_name_in_html -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/tiptap-content-converter.test.ts`
Expected: 前端测试失败,原因是 `media` 当前仍会丢掉 link mark;Rust 测试要么已过,要么暴露 HTML 可见化问题。
- [x] **Step 3: 做最小实现,统一 Rust adapter 与前端 converter 的 media 语义**
```ts
case "media": {
const sourcePath = toText(props.sourcePath ?? props.url ?? props.src).trim();
const name = toText(props.name ?? props.fileName ?? props.title).trim() || sourcePath;
return {
blockId,
blockType: "paragraph",
props: {},
contentNodes: name
? [{
type: "text",
text: name,
marks: sourcePath ? [{ type: "link", attrs: { href: sourcePath } }] : [],
}]
: contentNodes,
childBlockIds: [],
};
}
```
- [x] **Step 4: 跑回归,确认 local markdown 不再出现 Rust/前端双轨**
Run: `cd rust && cargo test -p mnote-web local_markdown -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/tiptap-content-converter.test.ts`
Expected: PASS。
### Task 3: 补齐 3-13 的浏览器回归矩阵,再同步设计稿状态
**Files:**
- Modify: `scripts/task163-local-folder-unified-tree-browser-smoke.js`
- Modify: `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
- Modify: `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
- [x] **Step 1: 给本地 Markdown smoke 补齐缺的可见语义断言**
```js
await expect(page.locator(".ProseMirror input[type='checkbox']")).toHaveCount(2);
await expect(page.locator(".ProseMirror code")).toContainText(["inline code"]);
await expect(page.locator(".ProseMirror strong")).toContainText(["bold"]);
await expect(page.locator(".ProseMirror em")).toContainText(["italic"]);
await expect(page.locator(".ProseMirror s")).toContainText(["strike"]);
await expect(page.locator(".ProseMirror a")).toContainText(["Link", "Spec"]);
await expect(page.locator(".ProseMirror table td code")).toContainText(["cell"]);
```
- [x] **Step 2: 运行浏览器 smoke,确认 3-13 里剩余未勾项真实失败点**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task163-local-folder-unified-tree-browser-smoke.js`
Expected: 至少暴露以下之一:checked/unchecked 断言不稳定、table cell marks 丢失、link/strike 渲染不完整。
- [x] **Step 3: 最小修复 parser / shell 映射**
```rust
// 在 local_markdown_parser.rs 里固定空段落 / 空单元格 / 空引用块的降级节点;
// 在 web_shell.rs 里保证 legacy <-> Tiptap 映射不把 inline marks 吞成纯文本。
```
- [x] **Step 4: 复跑 smoke,并回写设计稿勾选状态**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task163-local-folder-unified-tree-browser-smoke.js && rg -n "\\- \\[ \\]" design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
Expected: smoke PASS`3-13` 只保留真正还没完成的收尾项,不再误把已验证项留成未完成。
### Task 4: 让 Next `/api/documents/page` 优先消费 Rust `Page Aggregate` 快照
**Files:**
- Modify: `wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
- Modify: `wolai-frontend/src/app/api/documents/page/route.ts`
- Modify: `wolai-frontend/src/app/api/documents/page/route.test.ts`
- Modify: `wolai-frontend/src/lib/documents/page-aggregate-builder.ts`
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` (only if Rust snapshot contract 缺字段)
- [x] **Step 1: 先写失败测试,要求 happy path 直接吃 Rust 的 `mnote.page_aggregate.v1`**
```ts
it("documents/page 路由优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json();
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
expect(payload.meta.queryName).toBe("documents.page.get");
});
```
- [x] **Step 2: 运行路由测试,确认当前实现仍停留在 Next 本地组装**
Run: `cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/documents/page/route.test.ts`
Expected: 失败,原因是 loader 仍依赖 `api.documents.getMeta + documents.content.get + buildPageAggregateFromDocumentPayloads()`
- [x] **Step 3: 做最小 cutover,实现“Rust firstTS builder fallback only”**
```ts
const rustPage = await loadPageAggregateFromRustSnapshot({
request: input.request,
documentId: input.documentId,
workspaceId: input.workspaceId,
});
if (rustPage) {
return {
page: rustPage,
bridge: { requestId, traceId, queryName: "documents.page.get" },
};
}
return buildPageAggregateFromDocumentPayloads({ meta, contentPayload });
```
- [x] **Step 4: 跑 route + builder 回归,确认 `Page Aggregate` 读取真相继续向 Rust 收口**
Run: `cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/documents/page/route.test.ts src/lib/documents/page-aggregate-builder.test.ts`
Expected: PASS,并且 `page-aggregate-builder.ts` 退为 fallback / adapter 角色,而不是主 happy path。
### Task 5: 补齐 `3000` 当前主界面的 tree live stream 浏览器级闭环
**Files:**
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- Modify: `rust/crates/mnote-web/src/routes/stream_support.rs`
- Modify: `scripts/task120-rust-web-tree-integration-smoke.js`
- Modify: `scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`
- Modify: `scripts/task165-rust-web-dual-pane-smoke.js`
- [x] **Step 1: 先把浏览器 smoke 补成“当前壳必须真的应用 delta / resync”**
```js
const applied = await page.locator("html").getAttribute("data-mnote-tree-live-applied");
assert(["delta", "resync"].includes(applied || ""), `tree live 应应用 delta/resync,实际: ${applied}`);
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");
```
- [x] **Step 2: 运行 tree live smoke,确认当前 3000 主壳的缺口**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integration-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task165-rust-web-dual-pane-smoke.js`
Expected: 至少暴露以下之一:delta 只存在接口层、主壳未真正打到 DOM;resync fallback 未反映到属性或树快照;双 pane 重复建流。
- [x] **Step 3: 确认现有 `layout.rs` controller 与 delta/resync 选择逻辑已满足,本轮只补 smoke 断言**
```js
source.addEventListener('delta', function(event) {
var payload = parseTreeEventPayload(event);
dispatchTreeEvent('tree:delta', { payload: payload, revision: readPayloadRevision(payload), bootstrap: bootstrap });
});
source.addEventListener('resync', function(event) {
var payload = parseTreeEventPayload(event);
dispatchTreeEvent('tree:resync', { payload: payload, revision: readPayloadRevision(payload), bootstrap: bootstrap });
});
```
```rust
if let Some(delta) = change.delta {
return Some((Ok(stream_event("delta", &payload)), Some(state)));
}
return Some((Ok(stream_event("resync", &with_stream_kind(&snapshot_payload, "resync"))), Some(state)));
```
- [x] **Step 4: 复跑 smoke,确认 `3-3` 可以继续向 done 靠拢**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integration-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js && MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task165-rust-web-dual-pane-smoke.js`
Expected: PASS。
### Task 6: 做设计状态复核,清掉 `done/` 与 `[process]` 漂移
**Files:**
- Modify: `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- Modify: `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
- Modify: `design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md`
- Modify: `design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
- [x] **Step 1: 先跑状态检查,找出 `done/` 下仍写 `[process]` 的文档**
Run: `rg -n "^# .*\\[process\\]" design/03-rust-web/done design/04-tree-domain/done design/05-editor-mainline/done`
Expected: 能直接列出当前状态漂移文件。
- [x] **Step 2: 按真实验证结果更新头部与完成描述**
```md
# 4-21 [done] Local Folder / Convex 统一树源架构 v1
补充:当前 `3000` 主入口、本地文件夹打开、统一 `file_tree / page_tree / page_aggregate` 消费链已完成;
仍在推进的工作移交给 `3-13``3-14``3-3`,不再继续混写在本稿里。
```
- [x] **Step 3: 复跑一致性检查**
Run: `rg -n "^# .*\\[(process|done)\\]" design/03-rust-web/done design/04-tree-domain/done design/05-editor-mainline/done`
Expected: `done/` 目录下不再残留明显错误的 `[process]` 头部。