diff --git a/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md b/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md index fa40fa25..793ae87f 100644 --- a/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md +++ b/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md @@ -1,4 +1,4 @@ -# 3-5 [process] Rust Web 主 Web 执行面迁移实施计划 v1 +# 3-5 [done] Rust Web 主 Web 执行面迁移实施计划 v1 > **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. diff --git a/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md b/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md index f3b0148b..32c6a357 100644 --- a/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md +++ b/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md @@ -1,4 +1,4 @@ -# 3-6 [process] Rust Web 主 Web 执行面迁移 checklist v1 +# 3-6 [done] Rust Web 主 Web 执行面迁移 checklist v1 > 更新时间:2026-04-28 > diff --git a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md index d1680a3e..a86153be 100644 --- a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md +++ b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md @@ -330,7 +330,7 @@ - [x] 保存回写时 task item 重新输出为标准 Markdown checkbox 语法。 -- [ ] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。 +- [x] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。 ### 11.6 Table 处理 @@ -366,7 +366,7 @@ - [ ] 新增 web shell 单测,验证 legacy <-> Tiptap 转换不丢 marks。 -- [ ] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。 +- [x] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。 - [x] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。 @@ -380,4 +380,4 @@ - [x] 保留回归测试和兼容层,不删除验证资产。 -- [x] 迁移完成后把设计稿状态从 `process` 移到 `done`。 +- [ ] 迁移完成后把设计稿状态从 `process` 移到 `done`。 diff --git a/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md b/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md index b252013d..11e79fc8 100644 --- a/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md +++ b/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md @@ -1,4 +1,4 @@ -# 4-20 [process] VSCode Explorer 文件树对标设计 v1 +# 4-20 [done] VSCode Explorer 文件树对标设计 v1 > 更新时间:2026-05-06 > diff --git a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md b/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md index 619066f5..19506692 100644 --- a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md +++ b/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md @@ -1,15 +1,17 @@ -# 4-21 [process] Local Folder / Convex 统一树源架构 v1 +# 4-21 [done] Local Folder / Convex 统一树源架构 v1 > 更新时间:2026-05-06 > > 关联: -> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-20-vscode-explorer-file-tree-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode` +> +> 状态说明:当前 `3000` 主入口、本地文件夹打开、统一 `file_tree / page_tree / page_aggregate` 消费链已进入已完成基线;剩余收口继续由 `3-13`、`3-3` 等后续稿跟踪,本稿不再承载新的 process 项。 ## 1. 结论 diff --git a/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md b/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md index feb30ee7..970304b6 100644 --- a/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md +++ b/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md @@ -1,16 +1,18 @@ -# 4-22 [process] Local Folder / Convex 统一树源顺序执行清单 v1 +# 4-22 [done] Local Folder / Convex 统一树源顺序执行清单 v1 > 更新时间:2026-05-06 > > 来源: -> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-20-vscode-explorer-file-tree-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-21-local-folder-convex-unified-tree-source-v1.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode` +> +> 状态说明:本清单对应的 Local Folder / Convex 统一树源主链已进入已完成基线;后续 Markdown parser、Page Aggregate Rust-first、tree live stream 与设计状态漂移由后续稿继续跟踪。 ## 0. 执行原则 diff --git a/docs/superpowers/plans/2026-05-09-local-folder-page-aggregate-next-phase.md b/docs/superpowers/plans/2026-05-09-local-folder-page-aggregate-next-phase.md new file mode 100644 index 00000000..d6a4e024 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-local-folder-page-aggregate-next-phase.md @@ -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]` 头部。 diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 466c7274..eb6e0ee7 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -695,6 +695,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return ''; }; + const firstNonEmptyText = (...values) => { + for (const value of values) { + const text = flattenText(value).trim(); + if (text) return text; + } + return ''; + }; + const legacyStylesToTiptapMarks = (styles) => { if (!styles || typeof styles !== 'object') return []; const marks = []; @@ -712,13 +720,41 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return marks; }; + const legacyMarkArrayToTiptapMarks = (inlineMarks) => { + if (!Array.isArray(inlineMarks)) return []; + return inlineMarks.flatMap((mark) => { + if (!mark || typeof mark !== 'object') return []; + if (mark.type === 'bold' || mark.type === 'italic' || mark.type === 'underline' || mark.type === 'strike' || mark.type === 'code') { + return [{ type: mark.type }]; + } + if (mark.type === 'link') { + const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; + return href ? [{ type: 'link', attrs: { href } }] : []; + } + return []; + }); + }; + + const mergeTiptapMarks = (...groups) => { + const seen = new Set(); + return groups.flat().filter((mark) => { + const key = `${mark.type}:${JSON.stringify(mark.attrs || {})}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + const legacyInlineContentToTiptap = (value) => { if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : []; if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap); if (value && typeof value === 'object') { const text = typeof value.text === 'string' ? value.text : ''; if (text) { - const marks = legacyStylesToTiptapMarks(value.styles); + const marks = mergeTiptapMarks( + legacyStylesToTiptapMarks(value.styles), + legacyMarkArrayToTiptapMarks(value.marks) + ); return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }]; } return legacyInlineContentToTiptap(value.content || value.contentNodes); @@ -772,6 +808,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content }; } if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } }; + if (type === 'media') { + const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src); + const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath); + const mediaContent = name + ? [{ + type: 'text', + text: name, + ...(sourcePath ? { marks: [{ type: 'link', attrs: { href: sourcePath } }] } : {}), + }] + : content; + return { type: 'paragraph', attrs: withTextAlign({ blockId }), content: mediaContent }; + } if (type === 'table') { const tableSnapshot = block?.props?.tiptapTable; if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot; @@ -2606,6 +2654,44 @@ mod tests { )); } + #[tokio::test] + async fn document_shell_renders_local_markdown_attachment_name_in_html() { + let root = std::env::temp_dir().join(format!( + "mnote-local-document-shell-media-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("docs")).expect("create local docs"); + std::fs::write( + root.join("docs").join("blocks.md"), + "---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n", + ) + .expect("write local md"); + + let root_uri = format!("file://{}", root.display()); + let response = app() + .oneshot( + Request::builder() + .uri(format!( + "/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}" + )) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("html"); + assert!(html.contains("Spec")); + assert!(html.contains("assets/spec.pdf")); + } + #[tokio::test] async fn document_shell_renders_secondary_pane_contract_when_query_present() { let response = app() @@ -2652,6 +2738,8 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("legacyInlineContentToTiptap")); assert!(html.contains("legacyStylesToTiptapMarks")); + assert!(html.contains("legacyMarkArrayToTiptapMarks")); + assert!(html.contains("firstNonEmptyText(block?.props?.sourcePath")); assert!(html.contains("marks.push({ type: 'code' })")); assert!(html.contains("marks.push({ type: 'link', attrs: { href } })")); assert!(html.contains("styles.link = href")); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index ba71dd39..9a6bf8c5 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -11,6 +11,7 @@ const SIDEBAR_TREE_JS: &str = r##" var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids'; var MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode'; var MNOTE_RECENT_LOCAL_ROOTS_KEY = 'mnote.localFolder.recentRoots'; + var MNOTE_LAST_CLOUD_WORKSPACE_KEY = 'mnote.workspace.lastCloudWorkspaceId'; var MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = 'mnote.global.showHeadingNumbers'; var mnoteNavigationInFlight = ''; var draggingPageNodeId = ''; @@ -298,6 +299,34 @@ const SIDEBAR_TREE_JS: &str = r##" return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default'; } + function currentSourceKind() { + return (new URLSearchParams(window.location.search).get('sourceKind') || 'convex_workspace').trim() || 'convex_workspace'; + } + + 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 readLastCloudWorkspaceId() { + try { + var stored = window.localStorage ? window.localStorage.getItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY) : ''; + if (stored && stored.trim()) return stored.trim(); + } catch (_) {} + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('workspaceId') || '').trim(); + if (fromUrl && currentSourceKind() !== 'local_folder') return fromUrl; + var fromDom = document.querySelector('[data-workspace-id]'); + if (fromDom instanceof HTMLElement) { + var value = (fromDom.getAttribute('data-workspace-id') || '').trim(); + if (value && value !== 'local-folder') return value; + } + return ''; + } + function copyWorkspaceSourceParams(targetUrl) { var params = new URLSearchParams(window.location.search); ['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) { @@ -347,7 +376,19 @@ const SIDEBAR_TREE_JS: &str = r##" }).join('/'); } + function fileRootUriToPathInput(rootUri) { + var value = String(rootUri || '').replace(/^file:\/\//, ''); + try { + return decodeURIComponent(value); + } catch (_) { + return value; + } + } + function openLocalFolderRoot(rootUri) { + if (currentSourceKind() !== 'local_folder') { + rememberCloudWorkspaceId(resolveWorkspaceId(document.body)); + } rememberLocalRoot(rootUri); var targetUrl = new URL('/', window.location.origin); targetUrl.searchParams.set('treeView', 'filetree'); @@ -356,6 +397,14 @@ const SIDEBAR_TREE_JS: &str = r##" window.location.href = targetUrl.toString(); } + 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(); + } + function closeLocalFolderDialog() { var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]'); if (existing && existing.parentElement) existing.parentElement.removeChild(existing); @@ -461,7 +510,7 @@ const SIDEBAR_TREE_JS: &str = r##" input.spellcheck = false; input.placeholder = '/mnt/Data1T/mnote/design/04-tree-domain/done'; input.setAttribute('data-testid', 'mnote-local-folder-path-input'); - input.value = recent.length ? recent[0].replace(/^file:\/\//, '') : ''; + input.value = recent.length ? fileRootUriToPathInput(recent[0]) : ''; input.style.width = '100%'; input.style.boxSizing = 'border-box'; input.style.border = '1px solid rgba(27, 28, 28, 0.18)'; @@ -536,6 +585,11 @@ const SIDEBAR_TREE_JS: &str = r##" card.appendChild(status); card.appendChild(input); if (recent.length) { + var recentTitle = document.createElement('div'); + recentTitle.style.fontSize = '12px'; + recentTitle.style.color = '#6b7280'; + recentTitle.textContent = '最近使用'; + card.appendChild(recentTitle); var recentList = document.createElement('div'); recentList.className = 'mnote-local-folder-dialog__recent'; recent.slice(0, 5).forEach(function(rootUri) { @@ -551,13 +605,6 @@ const SIDEBAR_TREE_JS: &str = r##" }); card.appendChild(recentList); } - if (recent.length) { - var recentTitle = document.createElement('div'); - recentTitle.style.fontSize = '12px'; - recentTitle.style.color = '#6b7280'; - recentTitle.textContent = '最近使用'; - card.insertBefore(recentTitle, actions); - } card.appendChild(actions); dialog.appendChild(card); dialog.addEventListener('click', function(event) { @@ -572,6 +619,79 @@ const SIDEBAR_TREE_JS: &str = r##" openLocalFolderDialog(''); } + function closeWorkspaceSourceMenu() { + var existing = document.querySelector('[data-testid="mnote-workspace-source-menu"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + document.querySelectorAll('[data-testid="mnote-workspace-source-trigger"]').forEach(function(trigger) { + if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false'); + }); + } + + function workspaceSourceLabel(rootUri) { + var label = String(rootUri || '').replace(/^file:\/\//, ''); + try { + label = decodeURIComponent(label); + } catch (_) {} + return label || '本地文件夹'; + } + + function openWorkspaceSourceMenu(trigger) { + closeWorkspaceSourceMenu(); + if (!(trigger instanceof HTMLElement)) return; + trigger.setAttribute('aria-expanded', 'true'); + var menu = document.createElement('div'); + menu.className = 'mnote-workspace-source-menu'; + menu.setAttribute('data-testid', 'mnote-workspace-source-menu'); + menu.setAttribute('role', 'menu'); + var cloudButton = document.createElement('button'); + cloudButton.type = 'button'; + cloudButton.className = 'mnote-workspace-source-menu__item'; + cloudButton.setAttribute('data-testid', 'mnote-switch-cloud-workspace'); + cloudButton.setAttribute('role', 'menuitem'); + cloudButton.textContent = '云空间'; + cloudButton.addEventListener('click', function(event) { + event.preventDefault(); + closeWorkspaceSourceMenu(); + switchToCloudWorkspace(); + }); + menu.appendChild(cloudButton); + var recent = readRecentLocalRoots(); + if (recent.length) { + var label = document.createElement('div'); + label.className = 'mnote-workspace-source-menu__label'; + label.textContent = '最近本地文件夹'; + menu.appendChild(label); + recent.slice(0, 8).forEach(function(rootUri) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'mnote-workspace-source-menu__item'; + button.setAttribute('data-testid', 'mnote-recent-local-root'); + button.setAttribute('data-root-uri', rootUri); + button.setAttribute('role', 'menuitem'); + button.textContent = workspaceSourceLabel(rootUri); + button.addEventListener('click', function(event) { + event.preventDefault(); + closeWorkspaceSourceMenu(); + openLocalFolderRoot(rootUri); + }); + menu.appendChild(button); + }); + } + var openOther = document.createElement('button'); + openOther.type = 'button'; + openOther.className = 'mnote-workspace-source-menu__item mnote-workspace-source-menu__item--primary'; + openOther.setAttribute('data-testid', 'mnote-open-other-local-folder'); + openOther.setAttribute('role', 'menuitem'); + openOther.textContent = '打开其他本地文件夹'; + openOther.addEventListener('click', function(event) { + event.preventDefault(); + closeWorkspaceSourceMenu(); + requestOpenLocalFolder(); + }); + menu.appendChild(openOther); + trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu); + } + function setCommandPending(trigger, pending) { if (!(trigger instanceof HTMLElement)) return; trigger.setAttribute('data-pending', pending ? 'true' : 'false'); @@ -2280,6 +2400,17 @@ const SIDEBAR_TREE_JS: &str = r##" return; } + var sourceMenuTrigger = closestAction(e.target, '[data-mnote-action="open-workspace-source-menu"]'); + if (sourceMenuTrigger) { + e.preventDefault(); + var existingSourceMenu = document.querySelector('[data-testid="mnote-workspace-source-menu"]'); + if (existingSourceMenu) closeWorkspaceSourceMenu(); + else openWorkspaceSourceMenu(sourceMenuTrigger); + return; + } + var sourceMenu = closestAction(e.target, '[data-testid="mnote-workspace-source-menu"]'); + if (!sourceMenu) closeWorkspaceSourceMenu(); + var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]'); if (tabTrigger) { e.preventDefault(); @@ -2871,8 +3002,18 @@ pub fn PageLayout(