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:
@@ -0,0 +1,326 @@
|
||||
# 本地文件夹收尾与 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 first,TS 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]` 头部。
|
||||
Reference in New Issue
Block a user