diff --git a/.agent-templates/result.md b/.agent-templates/result.md new file mode 100644 index 00000000..43619392 --- /dev/null +++ b/.agent-templates/result.md @@ -0,0 +1,47 @@ +# Result: + +## Metadata + +- Run ID: `<run-id>` +- Lane: `<lane>` +- Worktree: `<absolute-worktree-path>` +- Branch: `<branch-name>` +- Worker: `codex-<lane>` + +## Summary + +Briefly state what changed. + +## Completed work + +- [ ] ... + +## Modified files + +- `path`: reason + +## Validation performed + +```bash +# command +``` + +Result: + +```text +... +``` + +## Suggested smoke test + +- URL: +- Actions: +- Expected result: + +## Risks / unfinished items + +- ... + +## Notes for reviewer + +- ... diff --git a/.agent-templates/review.md b/.agent-templates/review.md new file mode 100644 index 00000000..de225a38 --- /dev/null +++ b/.agent-templates/review.md @@ -0,0 +1,59 @@ +# Review: <title> + +## Metadata + +- Run ID: `<run-id>` +- Reviewer: `Hermes` +- Main repo: `/mnt/Data1T/mnote` +- Related worktrees: + - `...` + +## Inputs reviewed + +- PLAN.md: +- RESULT.md files: +- SMOKE.md: +- Git diffs: + +## Integration decision + +MERGE / REQUEST_FIX / REJECT / HOLD + +## Summary + +Short decision summary. + +## Diff review + +- File/path: + - Observation: + - Risk: + - Decision: + +## Smoke review + +- Smoke result: +- Evidence sufficient: yes/no +- Gaps: + +## Validation run by reviewer + +```bash +# command +``` + +Result: + +```text +... +``` + +## Follow-up tasks + +- [ ] ... + +## Cleanup / recycle + +- Worktrees kept: +- Worktrees moved to recycle: +- Branches kept/deleted: diff --git a/.agent-templates/smoke.md b/.agent-templates/smoke.md new file mode 100644 index 00000000..570f6126 --- /dev/null +++ b/.agent-templates/smoke.md @@ -0,0 +1,49 @@ +# Smoke: <title> + +## Metadata + +- Run ID: `<run-id>` +- Tester: `reasonix-flash` +- Target worktree / branch: `<branch-or-main>` +- Evidence directory: `<absolute-evidence-dir>` +- URL: `<url>` + +## Scope + +What this smoke test validates. + +## Browser actions + +- [ ] Navigate to `<url>` +- [ ] Login / setup state if needed +- [ ] Click: ... +- [ ] Hover: ... +- [ ] Drag: ... +- [ ] Select: ... + +## Result + +PASS / FAIL / BLOCKED + +## Evidence + +- Final URL: +- Screenshot: +- Console errors: +- Network errors: +- Visible text sample: + +## Findings + +### Issue 1 + +- Expected: +- Actual: +- Reproduction: +- Evidence: + +## Cleanup + +- Test data created: +- Test data cleaned: +- Residual state: diff --git a/.agent-templates/task.md b/.agent-templates/task.md new file mode 100644 index 00000000..62f83889 --- /dev/null +++ b/.agent-templates/task.md @@ -0,0 +1,54 @@ +# Task: <title> + +## Metadata + +- Run ID: `<run-id>` +- Lane: `<lane>` +- Worker: `codex-<lane>` +- Main repo: `/mnt/Data1T/mnote` +- Worktree: `<absolute-worktree-path>` +- Branch: `<branch-name>` +- Result path: `<run-dir>/codex-<lane>/RESULT.md` + +## Goal + +Describe the concrete outcome expected from this worker. + +## Context + +- Relevant design docs: + - `...` +- Relevant code paths: + - `...` + +## Allowed edit scope + +- `path/or/dir/**` + +## Forbidden scope + +- Do not edit files outside allowed scope. +- Do not edit user data. +- Do not modify unrelated recycle/history directories. +- Do not commit, push, reset, or revert unless explicitly instructed. + +## Requirements + +- ... + +## Validation commands + +```bash +# command 1 +# command 2 +``` + +## Expected deliverable + +Write `RESULT.md` with: + +- completed work +- modified files +- validation commands and outputs +- risks / unfinished items +- suggested smoke scope diff --git a/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-a-store.md b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-a-store.md new file mode 100644 index 00000000..7549c679 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-a-store.md @@ -0,0 +1,79 @@ +# Reasonix Worker A:SQLite control-plane store 骨架 + +Project root: `/mnt/Data1T/mnote` + +你不是独自在代码库中工作。其他 worker 可能并行修改 auth、access policy 或迁移脚本。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。 + +## 背景 + +上位设计稿: +`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` + +目标是用 Rust + SQLite 完全替换 Convex 控制面。你的任务只负责 SQLite store 骨架,不接运行时 route。 + +## Ownership + +允许修改: +- 优先新增 `rust/crates/control-plane/**` +- 必要时修改 `rust/Cargo.toml` +- 必要时新增/修改 `rust/crates/mnote-web/Cargo.toml` 仅用于依赖引用建议 + +禁止修改: +- `rust/crates/mnote-web/src/routes/gateway.rs` +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- `rust/crates/mnote-web/src/routes/session.rs` +- `rust/crates/mnote-web/src/ssr/**` +- `infra/convex/**` +- `.env*` + +如果发现 workspace crate 接入风险过大,先实现为可编译独立 crate;不要跨模块硬接 runtime。 + +## 任务 + +1. 新增 `control-plane` Rust crate 或等价最小模块,优先独立 crate。 +2. 定义核心 model 与 `ControlPlaneStore` trait。 +3. 实现 `SqliteControlPlaneStore`,支持临时路径 DB。 +4. 实现 migration v1,包含至少: + - `users` + - `auth_identities` + - `auth_sessions` + - `workspaces` + - `workspace_members` + - `directory_grants` + - `share_links` + - `sync_state` + - `ai_policies` + - `audit_log` + - `outbox_events` + - `legacy_id_map` +5. 实现最小 CRUD: + - upsert user + - create/get session + - ensure default workspace + - grant/revoke directory access + - append/drain outbox +6. 补单测:migration 幂等、默认 workspace、owner grant、outbox drain。 + +## 验收命令 + +优先运行: + +```bash +cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture +cargo fmt --check --all +``` + +如果没有新增独立 crate,而是放入 mnote-web: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web control_plane -- --nocapture +cargo fmt --check --all +``` + +## 最终回复格式 + +请列出: +- 修改文件 +- 新增表和核心 API +- 实际运行过的命令及结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-b-auth-session.md b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-b-auth-session.md new file mode 100644 index 00000000..b0794372 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-b-auth-session.md @@ -0,0 +1,60 @@ +# Reasonix Worker B:Auth/session SQLite 切换准备 + +Project root: `/mnt/Data1T/mnote` + +你不是独自在代码库中工作。其他 worker 可能并行修改 SQLite store、access policy 或迁移脚本。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。 + +## 背景 + +上位设计稿: +`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` + +当前用户报告过新注册账号显示“开发用户的空间”、不能新建页面。根因方向之一是真实 actor/profile/workspace name 没有统一来自控制面。本任务只处理 auth/session 切换准备,不处理 local folder access。 + +## Ownership + +允许修改: +- `rust/crates/mnote-web/src/routes/session.rs` +- 可新增 `rust/crates/mnote-web/src/routes/auth_sqlite.rs` +- 可新增 `rust/crates/mnote-web/src/control_plane_auth_adapter.rs` +- `rust/crates/mnote-web/src/ssr/pages/auth.rs` 仅限必要表单字段/测试适配 +- focused tests in same files + +只读参考但不要直接改,除非确实必要并最小修改: +- `rust/crates/mnote-web/src/routes/gateway.rs` +- `rust/crates/mnote-web/src/middleware/request_context.rs` +- `rust/crates/mnote-web/src/context.rs` + +禁止修改: +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- `rust/crates/mnote-web/src/ssr/pages/admin.rs` +- `rust/crates/mnote-web/src/ssr/styles.rs` +- `infra/convex/**` +- `.env*` + +## 任务 + +1. 梳理现有 signIn/signUp/session cookie 链,写在代码注释或测试名中,不写长文档。 +2. 增加 SQLite session adapter 的 route/helper 骨架: + - 从 `mnote_session` 查 session。 + - 生成/刷新 `mnote_actor_id/name/email/type` display cookies。 + - `/api/session` 优先使用 SQLite session,缺失时保留现有 fallback。 +3. 注册/登录 adapter 应明确输出真实 `user_id/email/name/actor_type`。 +4. 新用户注册测试必须断言不回退到 `DEV_USER_NAME`。 +5. 不要求本轮完全删除 Convex auth fallback。 + +## 验收命令 + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web session -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web auth -- --nocapture +cargo fmt --check --all +``` + +## 最终回复格式 + +请列出: +- 修改文件 +- 新增/调整的 cookie 与 session 规则 +- 实际运行过的命令及结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-c-access-policy.md b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-c-access-policy.md new file mode 100644 index 00000000..28c1e689 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-c-access-policy.md @@ -0,0 +1,56 @@ +# Reasonix Worker C:Access policy SQLite adapter + +Project root: `/mnt/Data1T/mnote` + +你不是独自在代码库中工作。其他 worker 可能并行修改 SQLite store、auth/session 或迁移脚本。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。 + +## 背景 + +上位设计稿: +`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` + +当前 `access-policy.json` 是本地目录授权事实源,但完全替换 Convex 后,应迁到 SQLite。你的任务是抽象 access policy adapter,保留 JSON fallback,为 SQLite grant 接入打接口。 + +## Ownership + +允许修改: +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- 可新增 `rust/crates/mnote-web/src/local_access_sqlite.rs` +- 可新增 focused tests + +禁止修改: +- `rust/crates/mnote-web/src/routes/session.rs` +- `rust/crates/mnote-web/src/routes/gateway.rs` +- `rust/crates/mnote-web/src/ssr/pages/admin.rs` +- `rust/crates/mnote-web/src/ssr/styles.rs` +- `infra/convex/**` +- `.env*` + +## 任务 + +1. 找出 `ensure_local_workspace_read_access` / write access / admin access 的现有调用和策略读取点。 +2. 抽象一个 access policy provider/adapter: + - JSON provider 维持现有行为。 + - SQLite provider 可先是 trait/mock/stub,不要求真实 DB。 +3. 增加测试覆盖: + - 默认 workspace owner 有 write。 + - read grant 禁写。 + - write grant 可写。 + - 未授权拒绝。 +4. 为后续 SQLite store 接入预留最小接口,不要重写 tree command 或 UI。 + +## 验收命令 + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web local_access_policy -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web local_workspace_access -- --nocapture +cargo fmt --check --all +``` + +## 最终回复格式 + +请列出: +- 修改文件 +- 新增 adapter/trait 设计 +- 实际运行过的命令及结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-d-migration-guard.md b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-d-migration-guard.md new file mode 100644 index 00000000..ee623d8d --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-d-migration-guard.md @@ -0,0 +1,62 @@ +# Reasonix Worker D:迁移工具与 Convex 下线 guard + +Project root: `/mnt/Data1T/mnote` + +你不是独自在代码库中工作。其他 worker 可能并行修改 Rust control-plane、auth/session 或 access policy。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。 + +## 背景 + +上位设计稿: +`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` + +目标是给完全替换 Convex 准备迁移 dry-run 工具和 guard,不执行真实破坏性迁移。 + +## Ownership + +允许修改: +- 新增 `scripts/migrate-control-plane-to-sqlite.js` +- 修改 `scripts/check-local-first-convex-guard.js` 仅限增加 SQLite 迁移期 guard 规则或建议输出 +- 可新增 `scripts/task*-sqlite-control-plane-*.js` focused smoke +- 可向设计稿 `2-8` 追加“执行记录”,不得改设计结论 + +禁止修改: +- `rust/crates/**` +- `infra/convex/docker-compose.yml` +- `.env*` +- 不删除任何 Convex 数据、Docker volume 或本地数据文件 + +## 任务 + +1. 实现 dry-run 迁移工具: + - 读取 `/mnt/Data1T/Mnote_data/control-plane/access-policy.json`,路径可用参数覆盖。 + - 扫描 `/mnt/Data1T/Mnote_data/users/*/workspaces/*/.mnote/workspace.json`,路径可用参数覆盖。 + - 生成 `migration-report.json`,包含 users/workspaces/grants/legacy_id_map 建议,不写 DB。 +2. 支持命令: + +```bash +node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json +``` + +3. 更新 guard 或输出建议:新增 Convex 控制面代码应被标记为禁止或迁移例外。 +4. 补最小 node smoke,断言 dry-run report 可生成。 + +## 验收命令 + +```bash +node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json +npm run check:local-first-convex-guard +``` + +如新增 smoke: + +```bash +node scripts/<new-smoke>.js +``` + +## 最终回复格式 + +请列出: +- 修改文件 +- dry-run report 字段 +- 实际运行过的命令及结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-e-share-audit.md b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-e-share-audit.md new file mode 100644 index 00000000..6c153c83 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-22-sqlite-control-plane-worker-e-share-audit.md @@ -0,0 +1,55 @@ +# Reasonix Worker E:control-plane share_links / audit / outbox 扩展 + +Project root: `/mnt/Data1T/mnote` + +你不是独自在代码库中工作。其他 worker 可能并行修改 `mnote-web` 适配层、auth/session 或 access policy。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。 + +## 背景 + +上位设计稿: +`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` + +当前 `control-plane` crate 已有 users/auth/workspaces/directory_grants/sync_state 的骨架,但 share_links、audit_log、outbox 的能力还不够完整。 + +## Ownership + +允许修改: +- `rust/crates/control-plane/src/model.rs` +- `rust/crates/control-plane/src/store.rs` +- `rust/crates/control-plane/src/sqlite.rs` +- `rust/crates/control-plane/src/migrations.rs` +- `rust/crates/control-plane/migrations/*.sql` +- `rust/crates/control-plane/src/error.rs` 仅限必要错误映射 + +禁止修改: +- `rust/crates/mnote-web/**` +- `infra/convex/**` +- `.env*` +- `scripts/**` + +## 任务 + +1. 补强 `control-plane` 模型与 store trait: + - `share_links` 的 create/list/revoke/resolve + - `audit_log` append/list 最小能力 + - `outbox_events` drain/mark-delivered 的稳定方法 +2. 为 share_links 增加 SQLite 实现与单测: + - token 只保存 hash + - revoke 后不可再 resolve +3. 为 audit_log 增加最小单测: + - 创建分享、撤销授权、AI/控制面关键事件可追加 +4. 保持 schema 迁移幂等。 + +## 验收命令 + +```bash +cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture +``` + +## 最终回复格式 + +请列出: +- 修改文件 +- 新增 API / 表能力 +- 实际运行过的命令及结果 +- 未完成项 / 风险 diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 4ff1cee1..00000000 --- a/.mcp.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "mcpServers": { - "supabase_local": { - "type": "stdio", - "command": "cmd", - "args": ["/c", "npx", "-y", "mcp-remote@latest", "http://127.0.0.1:18000/mcp"], - "startup_timeout_ms": 60000, - "env": { - "PROGRAMFILES": "C:\\Program Files", - "SystemRoot": "C:\\Windows" - } - }, - "chrome-devtools": { - "type": "stdio", - "command": "cmd", - "args": ["/c", "npx", "-y", "chrome-devtools-mcp@latest"], - "startup_timeout_ms": 60000, - "env": { - "PROGRAMFILES": "C:\\Program Files", - "SystemRoot": "C:\\Windows" - } - }, - "context7": { - "type": "stdio", - "command": "cmd", - "args": ["/c", "npx", "-y", "@upstash/context7-mcp", "--api-key", "ctx7sk-8beae3e8-38a0-497a-a44d-a67c05669318"], - "startup_timeout_ms": 40000, - "env": { - "SystemRoot": "C:\\Windows" - } - } - } -} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7ad4f56b..b7f18239 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,7 +23,7 @@ 3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime stream;Next App Router 已降为 legacy compat / island bundle source,不再是当前主入口 4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island 5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留 -6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、agent runtime 管理、文件变更同步;Hermes / Reasonix 直接在授权工作区内编辑本地文件。`page_ai_workflow` 与 `mnote.doc.*` / `mnote.block.*` 保留为兼容 / cloud / 复杂结构辅助层,不再作为 local-first 普通正文编辑默认主路径 +6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、agent runtime 管理、文件变更同步;Hermes / Reasonix 直接在授权工作区内编辑本地文件。`page_ai_workflow` 只保留为 debug / 历史兼容门面,`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;`mnote.block.*` 只保留为复杂结构辅助层 一句话收口: @@ -274,7 +274,7 @@ OnlyOffice 仍然是: 对应设计稿: - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` -- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` ### 8.2 Tree Command Cutover @@ -325,18 +325,19 @@ OnlyOffice 仍然是: 关键原则(2026-05-18 口径更新): - **local-first 主路径**:当前页面解析成授权 `.md` 文件,Hermes / Reasonix 在白名单目录内直接读写,MNote 负责权限、审计和前台同步 -- **兼容两层模型**(参考 CLI Main Lark Doc):`mnote.doc.markdown_edit` 作为文本级兼容 / fallback,`mnote.doc.apply_block_ops` 作为块级结构性辅助;local-first 正常编辑优先走“授权文件引用 + agent 原生 patch/diff + 文件版本冲突模型” +- **local-first 文件编辑模型**:普通 Markdown 编辑走“页面定位 + 授权文件引用 + agent 原生 patch/diff + 文件版本冲突模型”;MNote 不提供普通 Markdown 编辑工具,`mnote.doc.markdown_edit` 不再作为默认、fallback 或 remote fallback,`mnote.doc.apply_block_ops` 仅作为块级结构性辅助。CLI Main Lark Doc 只能作为 skill/workflow 纪律的有限参考,不作为当前编辑架构主参考。 - `mnote.block.*` 降级为结构性辅助(拖拽排序、精确块删除等),不删除 - 当前 `local_rule` planner(`direct_block_edit_operations`)是过渡实现,应继续退役 -- cloud / remote agent 无法直接访问本地文件时,才回到 `resolve_source` → Convex | LocalFS 的受控代理写入 +- cloud / remote agent 无法直接访问本地文件时,不默认回退到 `mnote.doc.markdown_edit`;需要另行设计显式同步 / cloud source 边界 - Markdown 既是 AI 编辑格式也是人类可读格式,不需要 XML 中间层 - 流式 apply + suggest/review(参考 BlockNote AI 的 `StreamToolExecutor` + `suggestChanges`)仅作为 Phase C 设计冻结,当前不实施 对应设计稿: -- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +- `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`(历史执行记录;不再作为 active 入口) - `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md` -- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +- `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` +- `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`(历史回收稿;不再作为当前实现依据) ### 8.5 定向 Bug Hunt 与质量基线 diff --git a/bugs/03-rust-web/done/3-23-dev-hot-inotify-limit-local-folder-events-500-v1.md b/bugs/03-rust-web/done/3-23-dev-hot-inotify-limit-local-folder-events-500-v1.md new file mode 100644 index 00000000..1d7cc844 --- /dev/null +++ b/bugs/03-rust-web/done/3-23-dev-hot-inotify-limit-local-folder-events-500-v1.md @@ -0,0 +1,112 @@ +# 3-23 [done][bug] dev:hot 启动 inotify 限制导致 local-folder events 500 v1 + +> 发现时间:2026-05-23 +> +> 状态:`[done]` +> +> 关联主线:`03-rust-web` + +## 1. 用户可见症状 + +执行: + +```bash +npm run dev:hot +``` + +启动日志出现: + +```text +WARN System notification limit is too small, falling back to polling mode. +WARN Polling for changes every 500ms +ERROR response failed classification=Status code: 500 Internal Server Error latency=0 ms +``` + +`mnote-web` 本身能启动,但首屏随后会打一条 500。 + +## 2. 根因 + +浏览器网络请求定位到失败 endpoint: + +```text +GET /api/local-folder/events?rootUri=... +``` + +该请求返回的错误体为: + +```json +{ + "code": "internal_error", + "message": "本地文件夹监听失败: OS file watch limit reached. about [\"/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space\"]" +} +``` + +因此根因不是 route 业务逻辑回归,而是 Linux `inotify` watcher 容量不足: + +- `cargo watch` 在启动时检测到系统通知额度不足,降级为 polling。 +- `mnote-web` 首屏建立本地工作区 SSE watcher 时也申请 watcher。 +- 当前用户已有 watcher 占用接近系统上限,导致 `/api/local-folder/events` 创建监听失败并返回 500。 + +## 3. 修复 + +已在系统层即时提高并持久化 inotify 容量: + +```bash +sudo sysctl fs.inotify.max_user_watches=1048576 fs.inotify.max_user_instances=1024 fs.inotify.max_queued_events=32768 +``` + +持久配置写入: + +```text +/etc/sysctl.d/99-mnote-dev.conf +``` + +内容: + +```text +fs.inotify.max_user_watches=1048576 +fs.inotify.max_user_instances=1024 +fs.inotify.max_queued_events=32768 +``` + +## 4. 验证 + +验证系统参数: + +```bash +sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances fs.inotify.max_queued_events +``` + +结果: + +```text +fs.inotify.max_user_watches = 1048576 +fs.inotify.max_user_instances = 1024 +fs.inotify.max_queued_events = 32768 +``` + +验证 SSE: + +```text +/api/local-folder/events?... 返回 200,并收到 ready 事件。 +``` + +验证启动: + +```bash +FRONTEND_PORT=3107 timeout 10s npm run dev:hot +``` + +结果:无 `System notification limit` warning,无启动期 500。 + +重新启动真实入口: + +```bash +npm run dev:hot +``` + +结果:`http://localhost:3000` 正常启动;浏览器首屏网络请求中 `/api/local-folder/events?...` 为 200。 + +## 5. 剩余边界 + +这是系统资源配置问题,本仓库代码不需要为本次 inotify 限制做业务改动。若后续再次出现同类 500,应先统计当前 inotify watcher 占用和后台进程,再判断是继续提高系统额度还是清理异常 watcher 进程。 diff --git a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md new file mode 100644 index 00000000..a8081209 --- /dev/null +++ b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md @@ -0,0 +1,38 @@ +# 4-49 [process][bug] 文件树拖拽上传文件落到同级而非目标文件夹下 v1 + +> 发现时间:2026-05-23 +> +> 状态:`[process]` +> +> 关联主线:`04-tree-domain` + +## 1. 用户可见症状 + +原始记录: + +```text +我的空间:向文件树中拖动文件上传文件,能正常在主编辑 md 中显示,点击也能正常打开,但是见 /mnt/Data1T/mnote/tmp/image copy 65.png,文件是和主文件夹同级(应该在我的文件夹下级)。 +``` + +## 2. 初步判断 + +这是文件树拖拽上传目标解析问题,不是附件点击打开问题: + +- 上传后的附件块能正常插入主编辑器。 +- 点击附件能正常打开。 +- 异常点是资源文件在文件树中的落点:实际落到主文件夹同级,而不是拖拽目标文件夹下级。 + +## 3. 待排查方向 + +- 文件树拖拽上传时的 target row / parent folder 是否正确写入 upload preflight。 +- `local_folder/assets/upload` 或 upload target plan 是否丢失目标目录。 +- 页面文件夹 / 当前页面资源归属和文件树手动目标目录之间是否存在优先级覆盖。 + +## 4. 验收建议 + +后续修复时应补 smoke: + +- 在文件树中创建一个目标文件夹。 +- 将附件拖拽到该文件夹行。 +- 断言附件在文件树中位于目标文件夹下级。 +- 断言主编辑器附件块仍能点击在 tab 打开。 diff --git a/bugs/05-editor-mainline/done/5-38-secondary-pane-markdown-attachment-upload-tab-open-v1.md b/bugs/05-editor-mainline/done/5-38-secondary-pane-markdown-attachment-upload-tab-open-v1.md new file mode 100644 index 00000000..be716d47 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-38-secondary-pane-markdown-attachment-upload-tab-open-v1.md @@ -0,0 +1,61 @@ +# 5-38 [done][bug] 第二视图 Markdown 附件上传后主编辑区点击失效 v1 + +> 发现时间:2026-05-23 +> +> 状态:`[done]` +> +> 关联主线:`05-editor-mainline` + +## 1. 用户可见症状 + +打开第一视图和第二视图后,在第二视图上传 Markdown 附件会出现一组连续问题: + +- 第一次点击第二视图上传后的文件时,第一视图菜单会短暂弹出后消失。 +- 上传第二个 Markdown 文件后,页面会卡住或主编辑区附件点击无反应。 +- 刷新后上传的 `.md` 附件曾退化成普通链接,只能新窗口打开。 +- 修复退化为链接后,仍存在“上传第二个文件后,之前所有附件都不能从主编辑器点击在 tab 打开”的问题。 +- 同时点击左侧文件树会把焦点跳到当前文件夹最上方文件,但文件树直接打开文件仍可工作。 + +## 2. 根因 + +问题不是第二视图和主视图是否应完全隔离这么简单,而是附件打开、上传插入、刷新重建三条链路没有共享同一个 pane-aware 资源 tab 合同: + +- 编辑器内上传后的本地 `.md` 附件在 Markdown 回写 / 重新解析时没有稳定保留为 `data-mnote-attachment-link` 语义,刷新后容易回退成普通链接。 +- 资源 openTarget=side 和 document secondary pane 的 URL / DOM 状态边界不完整,导致 secondary 资源 tab 打开时可能污染 primary 的 slash menu / active tab。 +- 主编辑器内附件点击只识别部分增强后的链接形态,第二次上传后 DOM 重建路径会让既有附件链接失去可点击的 tab 打开行为。 +- 文件树 active/reveal 逻辑和编辑器附件打开逻辑共享了一部分全局焦点状态,导致点击文件树时出现跳到顶部文件的伴随现象。 + +## 3. 修复 + +本轮修复把上传、解析、回写、点击打开统一到附件资源 tab 语义: + +- 本地 Markdown 页面和 Markdown 资源读取时,根据 `.mnote` 元数据把属于当前文档的上传附件路径传给 Markdown parser,恢复附件链接语义。 +- Markdown 回写时支持把 `/api/local-folder/files/open?...` 这类本地打开 URL 改写成相对路径,避免刷新后退化为不可识别的普通链接。 +- 编辑器附件增强逻辑扩大到 `.ProseMirror a[href*="/api/local-folder/files/open"]`,即使 DOM 重建后仍能恢复 `data-mnote-attachment-link`、附件样式和 tab 打开行为。 +- 上传插入使用当前编辑器 root / pane role 作为目标上下文,secondary 上传只插入 secondary 资源编辑器,不污染 primary。 +- 资源 `openTarget=side` 与 `secondaryDocumentId` 分离:资源 tab 可在 secondary 打开,但不冒充 secondary 文档页。 +- smoke 覆盖连续上传两个真实 `.md` 附件、刷新后点击两个附件、再次上传第二个资源并确认都在对应 pane 的 tab 内打开。 + +## 4. 验证 + +已执行过的关键验证: + +```bash +cargo check -p mnote-web +node scripts/task459-local-markdown-attachment-tab-smoke.js +node scripts/task472-side-target-secondary-pane-smoke.js +``` + +本次提交前已重新执行: + +```bash +cargo check -p mnote-web +node scripts/task459-local-markdown-attachment-tab-smoke.js +node scripts/task472-side-target-secondary-pane-smoke.js +``` + +结果:全部通过。 + +## 5. 剩余边界 + +本记录只覆盖本地文件夹 Markdown 附件在主编辑器 / secondary pane / resource tab 中的上传和打开链路。OnlyOffice 编辑保存、Office 插件噪声和远端 cloud source 附件合同仍由对应 Office / Rust Web 缺陷记录继续跟踪。 diff --git a/design/01-05-current-priority-overview.md b/design/01-05-current-priority-overview.md index a7e7203b..a9546ef1 100644 --- a/design/01-05-current-priority-overview.md +++ b/design/01-05-current-priority-overview.md @@ -20,9 +20,9 @@ > - 旧的“Convex 作为默认自托管存储 / 实时 / 文件底座”口径只作为当前代码过渡态理解,不再作为新增能力默认方向。 > > 2026-05-16 口径补充: -> - `5-4` 的默认页面内 `leptos-tiptap` island 主链切流已完成并迁入 `done/`;官方模板视觉和菜单细节继续由 `5-2 / 5-7 / 5-9` 承接。 +> - `5-4` 的默认页面内 `leptos-tiptap` island 主链切流已完成并迁入 `done/`;官方模板视觉和菜单细节继续由 `5-2 / 5-7 / 5-9` 作为参考材料承接。 > - Convex File Tree 默认可见页面正文行已由 `4-35` 收口为 `doc:<documentId>` + `{title}.md`,旧 `index.md` 可见 UI 口径只作为历史记录理解。 -> - 页面/块 AI tools 最小闭环已启动,执行验收继续由 `7-10` 承接,不改变 `Page Aggregate / tree command / tree realtime` 三条架构优先级。 +> - 页面/块 AI tools 最小闭环已启动;旧 `7-10` 已降级为历史执行记录,`7-14` 也已移入 `old/`,不再作为当前 AI 编辑实现依据;当前 AI 编辑 active 稿为 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`,直接基于 SQLite control-plane + local-first `.md` + allowed roots + agent 原生 patch/diff,不改变 `Page Aggregate / tree command / tree realtime` 三条架构优先级。 这份总览只做一件事: @@ -32,10 +32,10 @@ 下面三份仍然是当前架构判断的上位依据: -- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` -- `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +- `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` 补充:旧稿 `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` 已降级为 `[recycle][legacy]` 历史过渡判断,只解释“不要无计划硬拆 Convex”,不再作为新增能力默认存储口径。 @@ -78,7 +78,7 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是 当前状态: - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` -- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` 阶段性结论: @@ -107,7 +107,9 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是 当前状态: -- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` +- `/mnt/Data1T/mnote/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md` +- `/mnt/Data1T/mnote/design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md` +- `/mnt/Data1T/mnote/design/03-rust-web/done/3-18-local-folder-tree-live-consumer-convergence-checklist-v1.md` 阶段性结论: @@ -115,14 +117,14 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是 - `3000` 主壳已接入 WS snapshot / delta / resync consumer,SSE 仅作为 fallback - 双浏览器 page/file/resource/trash 操作已有 no-refresh smoke 证据 - 本地 folder sidebar watch 已从整页 HTML refetch 改为 projection API 刷新 -- 下一步重点是把 local-folder watcher 与 tree live cache 进一步统一,减少 polling 型补偿 +- 当前重点已从“是否接入 live stream”转为减少补偿链、缩薄 legacy consumer 和补稳定 smoke。 -## 3. 当前仍应保留但不在第一线的 process +## 3. 当前仍应保留但不在第一线的 reference / done -下面这些文档仍然有效,但当前不应排在第一优先;引用时必须叠加 2026-05-21 的 local-first MVP 后阶段口径: +下面这些文档仍然有效,但当前不应排在第一优先;引用时必须叠加 2026-05-21 之后的 local-first MVP 后阶段口径: -- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` -- `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md` +- `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md` - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-2-tiptap-notion-like-template-adoption-v1.md` @@ -177,7 +179,7 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是 持续推进 checklist: -- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md` +- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md` - MVP 后阶段 process 执行总序: `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` diff --git a/design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md index f300ae29..1ea4e82d 100644 --- a/design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md @@ -64,7 +64,7 @@ Owner: Owner: -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` - `scripts/task444-convex-workspace-export-local-fixture-smoke.js` - 可新增 `scripts/task48*-convex-export-*.js` diff --git a/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md index 4f971479..165518fd 100644 --- a/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md @@ -12,7 +12,7 @@ > - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` --- diff --git a/design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md index 1f368101..3abfe934 100644 --- a/design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md @@ -58,7 +58,7 @@ Owner: Owner: -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` - `rust/crates/mnote-web/src/document_buffer_store.rs` - `rust/crates/mnote-web/src/routes/documents.rs` - `rust/crates/mnote-web/src/routes/web_shell.rs` @@ -84,7 +84,7 @@ Owner: Owner: -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` - `rust/crates/core-protocol/src/command.rs` - `rust/crates/mnote-web/src/ssr/pages/layout.rs` - `scripts/task476-filetree-editor-context-menu-download-smoke.js` diff --git a/design/01-tree-first-graph-kernel/process/1-13-batch-f-p1-p2-active-tail-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md similarity index 96% rename from design/01-tree-first-graph-kernel/process/1-13-batch-f-p1-p2-active-tail-checklist-v1.md rename to design/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md index 6679fa43..f57cb131 100644 --- a/design/01-tree-first-graph-kernel/process/1-13-batch-f-p1-p2-active-tail-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md @@ -54,7 +54,7 @@ Owner: Owner: - `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` -- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` - 只读审查:`rust/crates/mnote-web/src/routes/ws.rs`、`rust/crates/mnote-web/src/routes/tree_events.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`、相关 tree live smoke。 目标: @@ -73,8 +73,8 @@ Owner: Owner: -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` -- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md` - `rust/crates/core-protocol/src/command.rs` - `rust/crates/mnote-web/src/hermes_tools/doc.rs` - `rust/crates/mnote-web/src/hermes_tools/page.rs` diff --git a/design/01-tree-first-graph-kernel/process/1-14-batch-h-p2-live-cache-command-context-closure-v1.md b/design/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md similarity index 96% rename from design/01-tree-first-graph-kernel/process/1-14-batch-h-p2-live-cache-command-context-closure-v1.md rename to design/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md index c6ee21b1..c8345d6c 100644 --- a/design/01-tree-first-graph-kernel/process/1-14-batch-h-p2-live-cache-command-context-closure-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md @@ -42,8 +42,8 @@ Owner: Owner: -- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md` -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` 目标: diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md similarity index 98% rename from design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md rename to design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md index 03f5bde8..a26b0783 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md @@ -337,8 +337,8 @@ ### 7.1 导出与备份 -- [x] 设计 Web 导出入口:选择 Convex workspace,选择目标本地 root。 - - 设计:`design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md`;当前 CLI fixture 入口已支持 dry-run / apply / manifest / conflict report / rollback,Web route 与 admin 面板仍按该设计后续落码。 +- [x] 冻结旧 Web 导出入口设想,不再作为当前落码入口。 + - 历史设计:`design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md`;当前 CLI fixture 入口已支持 dry-run / apply / manifest / conflict report / rollback。由于 Convex 默认运行时与根 functions 已退役,Web route 与 admin 面板不再按该旧稿继续落码,未来如需 cloud/import UI 应按 SQLite control-plane 与 local-first 口径新建 checklist。 - [x] 导出前创建 manifest,记录迁移计划、目标 root、workspace、操作列表、冲突、created files、backup dir 与索引刷新结果。 - 实现:`scripts/export-convex-workspace-to-local.js --manifest <file>`;apply 成功后同时写入 `<root>/.mnote/migration-manifest.json`。 - [x] 导出页面为 `.md`,资源写为本地文件,附件保持相对路径。 @@ -467,7 +467,7 @@ - [x] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。 - 证据:资源命令面、资源对象壳、AI resource tools 与 changed-files 审计已闭环;`node scripts/task456-resource-object-shell-sync-smoke.js` 已通过。 - [x] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。 - - 证据:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js`;`node scripts/task455-convex-export-plan-rollback-smoke.js`;Web 入口设计见 `design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md`。 + - 证据:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js`;`node scripts/task455-convex-export-plan-rollback-smoke.js`;旧 Web 入口设想已退到 `design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md`,不作为当前 reference 或实施入口。 - [x] P7 Page Aggregate / tree command / realtime 兼容链完成阶段性瘦身,并把被替代 process 稿移入 `old/` 或 `done/`。 - 证据:Page Aggregate refresh、tree command、local folder projection refresh 与 realtime 双浏览器 smoke 均有验证;`5-5`、`5-6`、`3-3`、`4-34` 仍作为未完全 kernel-native / local-folder no-refresh 深水区过程稿保留在 `process/`,本 checklist 记录阶段性瘦身结果。 - [x] `01-05-current-priority-overview.md` 同步更新状态,不再把已完成项描述为当前第一优先级。 diff --git a/design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md similarity index 77% rename from design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md rename to design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md index 07d7fb42..d71d15a3 100644 --- a/design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md @@ -2,11 +2,13 @@ > 创建时间:2026-05-19 > -> 当前状态:`PARTIALLY_DONE`(Reasonix 已补 A2/A7 基础设施与 A2 保存/watcher 接入;A2 浏览器刷新可见读回、A0 browser smoke、A7 UI 接入、B2/C1 产品化仍未闭环) +> 当前状态:`DONE`(A0/A2/A7/B2/C1 已完成;`task431` 只读 DnD smoke 仅作为可选回归项,不再阻塞本清单归档) +> +> 2026-05-22 归档治理补充:上位路线图 `1-4` 与顺序清单 `1-5` 已移动到 `reference/`;本文只保留真实仍需收口的缺口与证据尾项,不再承接完整下一阶段路线图职责。 > > 上位依据: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md` > > 目的:基于 Reasonix 执行汇总和本轮核对结果,列出仍不能标完成的缺口,形成可继续顺序执行的补充清单。 @@ -25,12 +27,12 @@ 本轮 Reasonix 执行后新增: -- [x] **Gap A0(部分)**: local-first guard 已通过 ✅(`npm run check:local-first-convex-guard` → `{ok: true, guard: "local-first-convex"}`);browser smoke 尚未复跑。 +- [x] **Gap A0(主路径完成)**: local-first guard 已通过 ✅(`npm run check:local-first-convex-guard` → `{ok: true, guard: "local-first-convex"}`);`task451` / `task452` / `task453` 已复跑,旧 `task431` 仅保留为可选回归项。 - [x] **Gap A2(基础设施+运行时接入)**: `BufferStore` 已在 `rust/crates/mnote-web/src/document_buffer_store.rs` 实现,并加入 `AppState.buffer_store`。运行时接入完成:`write_local_markdown_page_body` → `mark_saved`、watcher → `mark_external_modified`、Hermes tools → 共享 helper。11 个单测全部通过(含 3 个新集成测试)。 - [x] **Gap A2(运行时接入—完成)**: `write_local_markdown_page_body` 已接入 `BufferStore.mark_saved()`;`documents.rs` `page_body_write` 和 `save` 路由已传递 `state.buffer_store`;Hermes `doc_apply_block_ops` 和 `page_command` 已传递 `state.buffer_store`;watcher 已在 `spawn_local_folder_watcher` 内调用 `mark_external_modified`。3 个新集成测试覆盖运行时入口。 - [x] **Gap A2(浏览器可见闭环)**: Codex 复核新增 `task484-local-folder-page-body-refresh-readback-smoke.js`,验证 `POST /api/page-body/write` 后磁盘 `.md`、Page Aggregate、刷新后的 ProseMirror 均读回新正文;旧的“刷新后 editor DOM 未显示正文”证据已判定为过期。 - [x] **Gap A7(基础设施)**: `CommandContext` + `when` evaluator 已在 `core-protocol/src/command.rs` 实现。7 个单测全部通过(含 3 个必补测试)。 -- [ ] **Gap A7(UI/命令接入)**: File Tree 右键菜单、快捷键、AI 写入能力尚未消费 `CommandContext`。 +- [x] **Gap A7(UI/命令接入)**: File Tree 右键菜单、Delete/Backspace 快捷键、AI 写入能力已消费 `CommandContext` / `workspace.readonly` / `ai.canWrite`。 - [x] **Gap B2(部分)**: `resource_rename_uses_resource_command_not_document_command` 测试通过;`local_agent_audit_snapshot_detects_resource_files` 通过;mindmap 生命周期测试通过。 - [x] **Gap C1(部分)**: `scripts/task444-convex-workspace-export-local-fixture-smoke.js` 通过;`export-convex-workspace-to-local.js` 既有 `--dry-run`、`--manifest`、`--conflict-report`、`--rollback` 能力已确认,但本轮未新增对应 smoke。 - [x] **core-protocol 全量测试**: 41 单元 + 6 editor bridge + 2 resource tree = 49 通过 ✅ @@ -62,16 +64,17 @@ - 编号已存在脚本目录 - 验收:已复跑 browser smoke;`local_agent_audit_snapshot_detects_changed_files` 和 `detects_resource_files` 只能覆盖 audit 单元路径。 - **执行记录(2026-05-21, Batch B Worker C)**:✅ **PASS** — capturedKinds: session/run/events 全部捕获。结果文件:`tmp/task453-local-folder-page-ai-changed-files-smoke/result.json` + - **Codex 复核(2026-05-22)**:✅ **PASS** — 已扩展为 local-first AI 前台同步 smoke;clean path 验证 Hermes `run.completed.agentAudit.changedFiles` 后工具卡、磁盘 `.md`、Page Aggregate、ProseMirror 均出现 marker 且不进冲突态;dirty path 验证同一路径触发 `external-change-conflict`、冲突面板显示 agent run、冲突信封包含 `externalActor / dirtyState / bufferFileVersion`,diff 同时显示本地未保存 token 与 AI 写盘 token。 ### 1.2 收尾 - [x] guard 输出已记录(见上) -- [x] browser smokes 需完整后端环境运行(mnote-web + Convex + Playwright) +- [x] browser smokes 已完成主路径复跑;`task443` / `task445` 已迁移到 local-first fixture 并作为归档证据。 - [x] 失败归属:本轮已无 A0 smoke 失败;`task452` 为旧断言假失败 --- -## 2. Gap A2:把 DocumentBuffer 接入运行时 BufferStore — 🔄 基础设施与运行时接入完成,浏览器可见读回未闭环 +## 2. Gap A2:把 DocumentBuffer 接入运行时 BufferStore — ✅ 完成 ### 2.1 当前代码锚点(更新后) @@ -87,7 +90,7 @@ - [x] 接入 tiptap 保存链:`write_local_markdown_page_body` / `documents.rs` `save` 路由已调用 `state.buffer_store.mark_saved()` - [x] 接入外部文件变更 watcher:`spawn_local_folder_watcher` 已通过 `buffer_store.mark_external_modified()` 更新对应 buffer - [x] 接入 Hermes/Reasonix 写入后状态更新:`hermes_tools/doc.rs` 和 `hermes_tools/page.rs` 已通过共享 helper `write_local_markdown_page_body` 自动消费 BufferStore -- [ ] conflict UI 数据源改为 buffer state:API 已就绪,未接入 +- [x] conflict UI 数据源改为 buffer state:冲突信封已包含 `externalActor / dirtyState / bufferFileVersion`,`task451` 已覆盖 - [x] 浏览器可见读回:`task484-local-folder-page-body-refresh-readback-smoke.js` 已验证保存后刷新 ProseMirror 可见正文不丢失 ### 2.3 必补测试 @@ -118,7 +121,7 @@ --- -## 3. Gap A7:落地 command context / context key — 🔄 基础设施完成,UI/命令接入未完成 +## 3. Gap A7:落地 command context / context key — ✅ 主路径完成 ### 3.1 当前代码锚点(更新后) @@ -149,7 +152,7 @@ - [x] readonly / grant / resource kind 的禁用态可测试 - [x] **FileTree 右键菜单已接入**(Batch C Worker C):delete-trash、rename、new-file、new-folder、paste-into 携带 `when: '!workspace.readonly'` - [x] **Delete/Backspace 快捷键已接入**(Batch C Worker C):keydown handler 中有 `!workspace.readonly && tree.selectionCount` 守卫 -- [ ] **未完成**:下载、多选删除、粘贴、只读阻断的 smoke 保持回退兼容 +- [x] 下载、多选删除、粘贴、只读阻断的主路径 smoke 已由 `task471` / `task476` 在 Batch H 复跑覆盖;`task431` 作为更旧 DnD readonly smoke 仍保留为可选回归项。 - [x] **已完成**:AI 写入能力 (`ai.canWrite`) 已接入 hermes_tools 写入守卫;`mnote.block.*`、`mnote.doc.markdown_edit`、`mnote.page.*`、`mnote.artifact.*`、`mnote.mindmap.apply_ops` 写入链共享 `ensure_write_authorized`,只读 scope、`workspace.readonly=true` 或 `ai.canWrite=false` 均会拒绝写入。 ### 3.5 下一步 @@ -157,6 +160,7 @@ - `layout.rs` 右键菜单:已引入 `evaluateSidebarFileTreeWhen`(Batch C Worker C 完成) - Delete/Backspace:已接入(Batch C Worker C 完成) - hermes_tools 写入守卫:已消费 `ai.canWrite` / `workspace.readonly` command context;后续若前端新增更多 AI 写入口,必须把同一份 `commandContext` 透传到 tool args。 +- 归档前可选补跑 `task431-vscode-explorer-dnd-readonly-conflict-smoke.js`,但它不再代表 A7 主路径缺失。 ### 3.6 Batch F 复核记录 @@ -187,13 +191,18 @@ - [x] `tree_resource_rename_does_not_emit_document_command` — 被现有测试覆盖: - `resource_rename_uses_resource_command_not_document_command` ✅(验证 canonicalCommand 为 `tree.resource.rename` 而非 `tree.node.rename`) -- [ ] `mindmap_resource_trash_restore_keeps_markdown_body` — 部分覆盖: +- [x] `mindmap_resource_trash_restore_keeps_markdown_body` — 已覆盖: - `mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command` ✅(验证 mindmap 生命周期不携带 documentId 从而不影响 markdown 页面) - - 尚未验证文档正文在 trash/restore 后是否完整 + - `local_folder_mindmap_delete_moves_resource_file_to_trash` ✅(2026-05-22 补断言:archive / restore 后 `Page.md` 正文引用保持原文) - [x] `local_agent_audit_snapshot_detects_resource_files` — 通过 ✅ - [x] browser smokes:`task443`、`task445` 需 Playwright 后端环境 - **执行记录(2026-05-21, Batch B Worker C)**:❌ **FAIL** — 两个 smoke 均因 `waitForURL` 断言 `/mindmap/` 路由超时,实际导航走的是 `/documents/...?resourceTab=...`。归因:**脚本过时**(mindmap 路由改为 resourceTab,smoke 未同步)。结果文件:`tmp/task443-filetree-mindmap-click-active-row-smoke/result.json`、`tmp/task445-filetree-mindmap-switch-no-flicker-smoke/result.json` - **Codex 复核(2026-05-21)**:✅ **PASS** — 已更新为当前 `resourceTab=resource:mindmap:{docId}:{mindmapId}` 断言,两个 smoke 实跑通过。`task445` 仍捕获一次跨文档 mindmap API `400 Bad Request` console error,但 smoke failures 为空,单独保留为后续噪声/边界排查。 + - **Codex 复核(2026-05-22)**:✅ **代码级修复** — `task445` 的 400 不是纯噪声,根因是跨文档后 asset row 打开时沿用了当前 URL 的 document,而不是资源 owner document。已补 `sidebar_filetree_asset_open_uses_owner_document_id` 回归测试,并让 filetree asset row 输出/消费 `data-owner-document-id`;`task445` 同步增加 owner URL 断言。浏览器复跑当前被旧 Convex fixture 阻塞:`workspaces:ensureDefaultWorkspace` function 已退役,返回 502。 + - **Codex 复核(2026-05-22 续)**:✅ **PASS** — `task443` / `task445` 已迁移为 local-first fixture(临时 root + `.mnote/workspace.json` + 本地 `.md` / `.mindmap.json`),不再依赖退役 Convex function。复跑命令: + - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task443-filetree-mindmap-click-active-row-smoke.js` + - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js` + - 两者结果均为 PASS,结果文件分别为 `tmp/task443-filetree-mindmap-click-active-row-smoke/result.json`、`tmp/task445-filetree-mindmap-switch-no-flicker-smoke/result.json`。 ### 4.4 完成标准 @@ -265,19 +274,19 @@ - 结果:`tmp/task452-local-search-index-browser-smoke/result.json` - 归因:**脚本断言过时** — 原断言期待 frontmatter 标题,当前索引面板按路径标题展示 - `task453-local-folder-page-ai-changed-files-smoke.js` - - **PASS** ✅ — capturedKinds: session/run/events 全部捕获 + - **PASS** ✅ — clean AI 写入后 Page Aggregate / ProseMirror 前台同步,dirty AI 写入触发 conflict UI 并显示 BufferStore 信封 - 命令:同上 - 结果:`tmp/task453-local-folder-page-ai-changed-files-smoke/result.json` - `task443-filetree-mindmap-click-active-row-smoke.js` - - **PASS** ✅ — mindmap 资源行点击后 URL 使用 `/documents/...&resourceTab=resource:mindmap:...`,且对应 asset row 选中 + - **PASS** ✅ — local-first fixture 下,mindmap 资源行点击后 URL 使用 `/documents/...&resourceTab=resource:mindmap:...`,且对应 asset row 选中 - 命令:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task443-filetree-mindmap-click-active-row-smoke.js` - 结果:`tmp/task443-filetree-mindmap-click-active-row-smoke/result.json` - - 归因:**脚本过时后已更新** — mindmap 已改为 resourceTab 路由 + - 归因:**脚本过时后已更新并去 Convex fixture 化** — mindmap 已改为 resourceTab 路由,fixture 改为 local_folder - `task445-filetree-mindmap-switch-no-flicker-smoke.js` - - **PASS** ✅ — 页面和 mindmap 来回切换时 file tree root 稳定,选中态正确 + - **PASS** ✅ — local-first fixture 下,页面和 mindmap 来回切换时 file tree root 稳定,选中态正确;跨文档资源重新打开时 URL pathname 使用资源 owner document - 命令:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js` - 结果:`tmp/task445-filetree-mindmap-switch-no-flicker-smoke/result.json` - - 归因:**脚本过时后已更新** — 仍有一次 400 console noise,后续单独排查 + - 归因:**脚本过时后已更新并去 Convex fixture 化** — 400 根因已收口到 ResourceTab owner document URL 同步 ### 6.2.2 本次执行汇总 @@ -285,9 +294,9 @@ |-------|------|------| | task451 | ✅ PASS | — | | task452 | ✅ PASS | 脚本断言过时,已按当前路径标题和链接片段语义更新 | -| task453 | ✅ PASS | — | -| task443 | ✅ PASS | 脚本过时,已改为 resourceTab 路由断言 | -| task445 | ✅ PASS | 脚本过时,已改为 resourceTab 路由断言;仍有 400 console noise | +| task453 | ✅ PASS | 已扩展为 AI clean/dirty 前台同步与 conflict UI 证据 | +| task443 | ✅ PASS | 已改为 resourceTab 路由断言,并迁移到 local-first fixture | +| task445 | ✅ PASS | 已改为 resourceTab 路由断言,并迁移到 local-first fixture;ResourceTab owner document URL 已修复 | --- @@ -295,8 +304,10 @@ | Gap | 状态 | 关键产出 | 剩余 | |-----|------|----------|------| -| A0 | 🔄 部分 | guard、task451、task452、task453 通过 | task431 等剩余 browser smokes 未复跑 | -| A2 | 🔄 运行时接入 + 浏览器读回完成 | `BufferStore` 实现 + 11 tests;task484 验证保存后磁盘、PageAggregate、刷新后 ProseMirror 全部读回 | conflict UI 未接 buffer state | -| A7 | 🔄 基础设施完成 | `CommandContext` + when evaluator + 7 tests | UI/快捷键/菜单接入 | -| B2 | 🔄 部分 | 资源生命周期 + agent audit 测试通过;task443/task445 通过 | mindmap body 测试、跨文档 ResourceTab 400 噪声排查 | +| A0 | ✅ 主路径完成 | guard、task451、task452、task453 通过 | task431 可选复跑 | +| A2 | ✅ 完成 | `BufferStore` 实现 + 11 tests;task451 覆盖 BufferStore 冲突信封;task484 验证保存后磁盘、PageAggregate、刷新后 ProseMirror 全部读回 | — | +| A7 | ✅ 主路径完成 | `CommandContext` + when evaluator + 7 tests;FileTree 菜单、Delete/Backspace、AI 写入守卫接入 | task431 可选复跑 | +| B2 | ✅ 主路径完成 | 资源生命周期 + agent audit 测试通过;mindmap 正文引用保持、ResourceTab owner 修复与 task443/task445 local-first browser 证据已补齐 | Resource Tree projection 产品形态深化另列后续项 | | C1 | ✅ 完成 | export/rollback/dry-run 脚本 + task444/task455 smoke 全部通过 | — | + +归档结论:本清单原本承担的 gap closure 已完成。后续活跃执行转入 `1-8` 的顺序表与 `7-14` 的 AI Markdown 编辑路径收敛,不再把本文件作为 active process。 diff --git a/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md b/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md index 86dcbdb1..c644b2ab 100644 --- a/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md @@ -4,6 +4,8 @@ > > 当前状态:`PROCESS` > +> 2026-05-22 归档治理补充:当前 active `process/` 只保留可直接执行的少量收口稿;长期架构、愿景、参考矩阵、Wolai 对标基线、Mindmap 总设计、旧 AI 块级执行稿和 Convex Web 迁移入口已分别移动到 `reference/`、`done/` 或 `old/`。本文是后续调度入口,不代表下列所有历史入口仍在 `process/`。 +> > 目标:把本轮 design governance 后剩余的主线 `process/` 文档排成可执行顺序,避免后续 worker 在 active process 中自行猜优先级。 > > 上位依据: @@ -115,9 +117,9 @@ 入口文档: -- `design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md` -- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md` -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` - `design/05-editor-mainline/done/5-26-resource-open-resolver-convergence-checklist-v1.md` 执行目标: @@ -141,9 +143,9 @@ 入口文档: -- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md` -- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md` +- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md` +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` 执行目标: @@ -165,7 +167,7 @@ 入口文档: -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` - `design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` - `design/03-rust-web/done/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` @@ -193,7 +195,7 @@ 入口文档: - `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` -- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` 执行目标: @@ -235,7 +237,7 @@ 入口文档: -- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md` - `design/04-tree-domain/done/4-44-filetree-action-layer-paste-drop-delete-v1.md` 执行目标: @@ -261,28 +263,36 @@ 入口文档: - `design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md` +- `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 执行目标: -1. 修正 online / compat `mnote.doc.markdown_edit` 的 final markdown 写回真相。 -2. 避免 full_content 和多 operation 场景仍从旧 blocks 二次推导。 -3. 保持 local-first 默认编辑仍走 agent 原生 patch/diff,不把 `markdown_edit` 升回默认主路径。 +1. 将 `7-27` 限定为历史 online / compat 回归证据,不再把 `mnote.doc.markdown_edit` 推回 active 设计。 +2. 以 `7-18` 为当前 AI 编辑执行入口:MNote 只提供文件定位、授权边界、selection/context、审计和前台同步。 +3. Hermes / Reasonix 用自身文件 read / write / patch / diff 能力在 allowed roots 内修改 `.md`。 +4. 先实现 Agent Target Resolver / target chip / target picker,解决多工作区、多 tab、多资源时到底把哪个对象发送给 agent 的问题。 验收: -- `cargo test -p mnote-web hermes_tools -- --test-threads=1` -- 覆盖 full_content、多 op、inline content、selection scope 的回归测试。 +- Page AI composer 可见 target chip,能展示 workspace、target title、resourceKind、scope、dirty / readonly 状态。 +- target picker 能在当前焦点、打开 tabs、树选中项之间选择;默认 target 来自 last focused editor / resource tab。 +- local-first agent 编辑 smoke 断言不调用 `mnote.doc.markdown_edit`。 +- Hermes / Reasonix run body 包含 `currentFile`、`allowedRoots`、`allowedFiles`、selection 和 dirty / readonly context。 +- Hermes / Reasonix run body 包含 `targetPackage`,且切 tab 后不影响已冻结 run target。 +- agent 写入后 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新。 +- dirty buffer 下 agent 写入不会静默覆盖。 归档条件: - `7-27` 的 6 步实施计划已完成并归档;后续只保留 `revisionRef` 注释可见性、复杂 GFM fallback、多余退役函数体清理等增强项,不阻塞 Batch E。 +- `7-18` Phase A0/A/B/C 完成后归档到 `design/07-ai/done/`。 ### P3.2 ACP runtime 后续步骤 入口文档: -- `design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` -- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +- `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` +- `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` - `design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md` 执行目标: @@ -307,8 +317,8 @@ 入口文档: -- `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` -- `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` +- `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` +- `design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` - `design/06-mindmap/reference/6-mindmap-leptos-adapter-reference-notes-v1.md` 执行目标: @@ -326,7 +336,7 @@ 入口文档: -- `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` +- `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` - `design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md` - `design/05-editor-mainline/reference/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md` @@ -344,8 +354,8 @@ 这些文档不进入当前直接执行序列,只有当产品优先级明确时再拆 checklist: -- `design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md` - - CLI 已有迁移闭环;Web 管理入口未排期。 +- `design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md` + - CLI 已有迁移闭环;Web 管理入口在当前 Convex 默认退役后已降级为历史设想,不作为 active 或 reference 执行入口。 - `design/04-tree-domain/reference/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md` - P3 候选,不能进入 local-first 默认主链。 - `design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md` @@ -410,16 +420,18 @@ ### Batch E:AI 与资源工具 -1. `7-27`(已归档到 `design/07-ai/done/`) -2. `7-15` 后续拆分(下一批拆 `7-34`) -3. `7-12` Phase B-F 拆分(下一批拆 `7-34`) -4. `7-28` 仅在 mindmap / office AI 明确排期后进入 +1. `7-18` +2. `7-27`(已归档到 `design/07-ai/done/`,只作为历史 online / compat 回归证据) +3. `7-15` 后续拆分(下一批拆 `7-34`) +4. `7-12` Phase B-F 拆分(下一批拆 `7-34`) +5. `7-28` 仅在 mindmap / office AI 明确排期后进入 -完成后预期:local-first AI 主路径稳定,cloud/compat 工具不再误导默认路径。 +完成后预期:local-first AI 主路径稳定,普通 Markdown 编辑不再依赖 MNote 专用编辑工具。 当前批次状态: - `7-27` 已由 Batch I 复核归档;`mnote.doc.markdown_edit` 在线写回以最终 markdown 为真源,`markdown_edit` 相关 targeted tests 与 manifest write contract 测试已通过。 +- `7-18` 已成为当前 active AI 编辑控制面稿;后续实现不再保留 `mnote.doc.markdown_edit` 作为 local-first 默认、fallback 或 remote fallback。 - `7-29` 已完成 Worker A/B/C 审查,结论是 `7-15` Step 15-17 与 `7-12` Phase B/F 不应继续压在一个大文档里推进。 - `7-34` 已完成并归档到 `design/07-ai/done/`:旧 HTTP proxy 默认关闭、`page_ai_workflow` 受共享 tool executor 守卫保护;profile disabled list 三处同源;`capabilityScope` 中心校验已完成;ACP abort API 改为 best-effort cancellation 并主动推送 `run.aborted`;`task488` 已在当前 3000 真实通过,覆盖 3 个 Reasonix ACP session、并发 run、abort 与 SSE terminal event。cache benchmark 仍是 P2 度量;Phase C Review Mode 继续冻结。 diff --git a/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md b/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md similarity index 98% rename from design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md rename to design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md index 24d738a9..5f112c55 100644 --- a/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md +++ b/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md @@ -1,11 +1,13 @@ -# 1-1 [process] Tree-First Graph 内核实施清单 v2 +# 1-1 [reference] Tree-First Graph 内核实施清单 v2 > 更新时间:2026-04-16 > +> 当前状态:`reference`。本文保留 kernel 分阶段全景与历史状态口径;当前执行顺序以 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 为准。 +> > 基于以下实际状态重写: > - 当前未提交代码 -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` > - `/mnt/Data1T/mnote/harness-tasks.json` ## 1. 这版为什么要重写 diff --git a/design/01-tree-first-graph-kernel/process/1-2-tree-first-graph-product-vision-progress-v1.md b/design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md similarity index 96% rename from design/01-tree-first-graph-kernel/process/1-2-tree-first-graph-product-vision-progress-v1.md rename to design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md index 47e93a40..09e30cee 100644 --- a/design/01-tree-first-graph-kernel/process/1-2-tree-first-graph-product-vision-progress-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md @@ -1,7 +1,9 @@ -# 1-2 [process] Tree-First Graph 产品愿景进展评估 v1 +# 1-2 [reference] Tree-First Graph 产品愿景进展评估 v1 > 更新时间:2026-05-19 > +> 当前状态:`reference`。本文保留产品愿景进展评估,不作为当前 active implementation checklist。 +> > **目的**:对照原始产品愿景(Wolai 编辑体验 + VSCode 文件树 + simple-mindmap + OnlyOffice + AI 操作一切),评估当前代码在 1.0 MVP 后的实际进展和剩余的缺口。 > > 关联: @@ -30,7 +32,7 @@ | 前端壳已切换到 Rust SSR(`:3000`)| DONE | `CURRENT_ARCHITECTURE.md` §3.1 | | Page Aggregate 写链(标题/正文/设置)smoke 通过 | DONE | `scripts/task110-*`, `task-page-aggregate-*-smoke.js` | | 页面设置运行时语义 / 页头回流 / AI 设置面未完全闭环 | PROCESS | `design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` | -| Wolai UI 行为持续对齐 | PROCESS | `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` | +| Wolai UI 行为持续对齐 | PROCESS | `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` | | `documents.content` 仍是兼容后备而非唯一真源 | PARTIAL | `CURRENT_ARCHITECTURE.md` §3.2 | #### 基础块类型覆盖(对照 Wolai 帮助中心) @@ -101,7 +103,7 @@ | 子能力 | 状态 | 关键证据 | |--------|------|----------| -| Phase 6 口径修正(Rust kernel + simple-mind-map adapter)| DONE | `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` §2 | +| Phase 6 口径修正(Rust kernel + simple-mind-map adapter)| DONE | `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` §2 | | Leptos UI shell | DONE | git `c64487c6` | | 即时显示修复 | DONE | git `3be102a4` | | Filetree 切换稳定 | DONE | git `b300562a` | diff --git a/design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md b/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md similarity index 93% rename from design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md rename to design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md index e1a7c345..39d3ef51 100644 --- a/design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md @@ -1,11 +1,13 @@ -# 1-4 [process] 下一阶段执行路线图 v1 +# 1-4 [reference] 下一阶段执行路线图 v1 > 创建时间:2026-05-19 > -> 当前状态:`PROCESS` +> 当前状态:`reference` +> +> 归档说明(2026-05-22):本文保留下一阶段路线推导;当前可执行入口已收敛到 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`,不再从本文直接派发实现。 > > 上位依据: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md` > - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` > - `/mnt/Data1T/mnote/ARCHITECTURE.md` diff --git a/design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md similarity index 98% rename from design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md rename to design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md index 69abb8a1..66e7a497 100644 --- a/design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md @@ -1,12 +1,14 @@ -# 1-5 [process] 下一阶段顺序执行 checklist v1 +# 1-5 [reference] 下一阶段顺序执行 checklist v1 > 创建时间:2026-05-19 > -> 当前状态:`PROCESS` +> 当前状态:`reference` +> +> 归档说明(2026-05-22):本文是历史顺序清单,部分条目已被后续 Batch / done 文档覆盖;当前只作为参考,不再作为 active process 队列。 > > 上位依据: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md` > > 目标:给出一份可以顺序执行、每一步都有完成验收、且标明参考源码位置的下一阶段执行清单。 diff --git a/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md b/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md similarity index 96% rename from design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md rename to design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md index 2f5dc6c4..84cdc2e0 100644 --- a/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md @@ -1,13 +1,15 @@ -# 1 [process] Tree-First Graph 内核方案 v1 +# 1 [reference] Tree-First Graph 内核方案 v1 > 更新时间:2026-04-22 > +> 当前状态:`reference`。本文是 tree-first graph kernel 的长期架构边界,不是当前可直接执行的 checklist;执行入口见 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`。 +> > 当前优先级入口: > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > > 关联文档: -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` > - `/mnt/Data1T/mnote/ARCHITECTURE.md` ## 1. 文档目的 diff --git a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md index 9611b901..a6e3c7f0 100644 --- a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md +++ b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md @@ -18,7 +18,7 @@ > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/done/3-15-local-markdown-asset-upload-relative-path-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` > - `/mnt/Data1T/mnote/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md` --- diff --git a/design/02-convex-rust-long-term-architecture/process/2-7-auth-profile-access-management-ui-v1.md b/design/02-convex-rust-long-term-architecture/done/2-7-auth-profile-access-management-ui-v1.md similarity index 100% rename from design/02-convex-rust-long-term-architecture/process/2-7-auth-profile-access-management-ui-v1.md rename to design/02-convex-rust-long-term-architecture/done/2-7-auth-profile-access-management-ui-v1.md diff --git a/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md b/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md index 16002ebc..b5ba2058 100644 --- a/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md +++ b/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md @@ -20,7 +20,7 @@ > - 当前 shell parity checklist:`/mnt/Data1T/mnote/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md` -> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` ## 1. 当前结论 diff --git a/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md b/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md index 1e8698ca..96c286a8 100644 --- a/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md +++ b/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md @@ -6,7 +6,7 @@ > > 参考主线: > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md` ## 1. 根因确认 diff --git a/design/03-rust-web/done/3-14-rust-web-dual-pane-editable-document-shell-v1.md b/design/03-rust-web/done/3-14-rust-web-dual-pane-editable-document-shell-v1.md index 35a43c0f..9e0530b2 100644 --- a/design/03-rust-web/done/3-14-rust-web-dual-pane-editable-document-shell-v1.md +++ b/design/03-rust-web/done/3-14-rust-web-dual-pane-editable-document-shell-v1.md @@ -7,11 +7,11 @@ > - 下文第 2 节保留的是实现前问题基线;当前真实完成状态与验证证据以第 15-16 节为准 > > 关联文档: -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` ## 1. 结论 diff --git a/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md b/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md index ed3dfebc..02c53d51 100644 --- a/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md +++ b/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md @@ -5,10 +5,10 @@ > 关联: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > > 2026-05-13 口径修正: diff --git a/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md b/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md index 940de88b..edcc86a0 100644 --- a/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md +++ b/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md @@ -8,9 +8,9 @@ > - `3104` 已按 `/mnt/Data1T/mnote/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md` 退役为默认/公开边界 > > 关联文档: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` ## 0. 本轮判断 diff --git a/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md b/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md index 5bdbc5b5..20b252a8 100644 --- a/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md +++ b/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md @@ -13,8 +13,8 @@ > 关联文档: > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` > - `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`(历史过渡背景) -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` ## 1. 目标 diff --git a/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md b/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md index 57b2a8da..ac76898f 100644 --- a/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md +++ b/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md @@ -5,7 +5,7 @@ > 关联文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` 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 93e4f921..cb935f7c 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 @@ -514,7 +514,7 @@ Expected: **Files:** - Modify: `ARCHITECTURE.md` -- Modify: `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +- Modify: `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` - Modify: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md` - Move when complete: `design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md` - Move when complete: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md` diff --git a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md b/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md similarity index 98% rename from design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md rename to design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md index d02acdbe..f9dfe475 100644 --- a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md +++ b/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md @@ -1,13 +1,15 @@ -# 3-1 [process] Rust Web 长期架构实施清单 v2 +# 3-1 [reference] Rust Web 长期架构实施清单 v2 > 更新时间:2026-04-29 > +> 当前状态:`reference`。本文保留 Rust Web 分阶段全景,不再作为 active implementation checklist。 +> > 基于以下实际状态重写: > - 当前未提交代码 > - `/mnt/Data1T/mnote/harness-tasks.json` 中 `task-059` ~ `task-069` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/old/03-rust-web/process/rust-web-long-term-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` ## 1. 这版为什么要重写 diff --git a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md b/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md similarity index 97% rename from design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md rename to design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md index d2f49df1..c036d665 100644 --- a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md +++ b/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md @@ -1,7 +1,9 @@ -# 3 [process] Rust Web 长期架构方案 v1 +# 3 [reference] Rust Web 长期架构方案 v1 > 更新时间:2026-05-09 > +> 当前状态:`reference`。本文保留 Rust Web 长期架构边界;当前实现队列以 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 和对应 `done/` 证据为准。 +> > 相关文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/old/08-legacy-rust-kernel/done/rust-kernel-cutover-v1.md` @@ -352,7 +354,7 @@ Leptos Islands 很适合承接下面这类长期目标: - Rust Web 已能承接主 API、页面壳和主流式链路 - `BlockNote` 已降级为最后的重交互孤岛 -对应的执行细节、逐阶段清单和可验证项,当前应先以 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` 确认优先级;`/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` 保留为 Rust Web 分阶段全景参考。 +对应的执行细节、逐阶段清单和可验证项,当前应先以 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` 确认优先级;`/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` 保留为 Rust Web 分阶段全景参考。 ### 8.4 当前已落地的最小里程碑(2026-05-09) diff --git a/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md b/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md index 37d43854..7ce5f396 100644 --- a/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md +++ b/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md @@ -5,7 +5,7 @@ > 关联文档: > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` > - `/mnt/Data1T/mnote/design/old/04-tree-domain/process/4-1-sidebar-pagetree-filetree-product-gap-analysis-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` ## 1. 文档目的 diff --git a/design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md b/design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md index 5bba109d..66fd6439 100644 --- a/design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md +++ b/design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md @@ -3,8 +3,8 @@ > 更新时间:2026-04-16 > > 关联文档: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` ## 1. 文档目的 diff --git a/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md b/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md index 5a51e66d..99ad4265 100644 --- a/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md +++ b/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md @@ -4,7 +4,7 @@ > > 关联: > - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` ## 1. 目的 diff --git a/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md b/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md index b9af4df3..c5d6db92 100644 --- a/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md +++ b/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md @@ -4,7 +4,7 @@ > > 关联文档: > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` ## 1. 文档目标 diff --git a/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md b/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md index a4685119..fd7f905d 100644 --- a/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md +++ b/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md @@ -6,8 +6,8 @@ > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > > 关联文档: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md` > - `/mnt/Data1T/mnote/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md` > - `/mnt/Data1T/mnote/design/90-reference/90-2-yemianshu.md` > - `/mnt/Data1T/mnote/design/90-reference/90-1-filetree.md` @@ -40,7 +40,7 @@ 这份方案必须完全服从: -- [tree-first-graph-kernel-v1.md](/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md) +- [tree-first-graph-kernel-v1.md](/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md) 里面已经固定的几条原则。 diff --git a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md b/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md index 40129cc2..c5e1cb11 100644 --- a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md +++ b/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md @@ -11,9 +11,9 @@ > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md` diff --git a/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md b/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md index e343b665..939bacce 100644 --- a/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md +++ b/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md @@ -11,7 +11,7 @@ > - `/mnt/Data1T/mnote/bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` +> - `/mnt/Data1T/mnote/design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` ## 1. 目标 diff --git a/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md b/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md index e5027c28..0fe60bf6 100644 --- a/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md +++ b/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md @@ -7,10 +7,10 @@ > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` > > 代码依据: > - `/mnt/Data1T/mnote/rust/crates/core-protocol/src/editor/model.rs` diff --git a/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md b/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md index eb2687a9..1445cd93 100644 --- a/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md +++ b/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md @@ -9,8 +9,8 @@ > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-1-main-editor-cutover-entry-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-2-tiptap-notion-like-template-adoption-v1.md` > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` ## 1. 文档目的 diff --git a/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md b/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md index 1b7b9b06..9ee090eb 100644 --- a/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md +++ b/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md @@ -4,7 +4,7 @@ > > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` ## 1. 文档目的 diff --git a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md b/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md similarity index 82% rename from design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md rename to design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md index 46e758d2..db1d1c3e 100644 --- a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md +++ b/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md @@ -11,6 +11,13 @@ > - 写侧必须退出 `/api/documents/save` 长期主路径,收口到带 `expectedFileVersion` 的 `page.body.write` / LocalFS executor。 > - AI 直改文件、tiptap autosave、外部编辑器修改必须共用 VSCode-like 文件版本冲突模型。 > +> 2026-05-22 复核补充: +> - local_folder 文档页、Hermes `mnote.page.save`、历史 Hermes `mnote.doc.markdown_edit` 的本地写入链已回到 `write_local_markdown_page_body` / `page.body.write`,并携带 `expectedFileVersion` 或聚合快照中的 file version。2026-05-22 之后,`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;当前 AI 编辑口径以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。 +> - BufferStore 已进入保存、watcher、Hermes 写入与冲突信封;`task451` 已覆盖冲突信封中的 `externalActor / dirtyState / bufferFileVersion`,`task484` 已覆盖保存后刷新读回。 +> - cloud / compat `/api/documents/save` 边界已固定为非 local-first 正文主写链;local-first 默认写侧走 `/api/page-body/write` / LocalFS executor。 +> - 2026-05-22 继续补证:`acp_runtime_env_limits_*`、`hermes_client_run_body_local_source_uses_file_scope_not_full_page_context`、`ensure_write_authorized_*`、`local_path_read_access_rejects_root_escape`、`local_file_open_rejects_root_escape` 均通过;ResourceTab 跨文档 owner 错配已由 `sidebar_filetree_asset_open_uses_owner_document_id` 修复并覆盖;旧 cloud `task443` / `task445` browser smoke 已迁移为 local-first fixture 并复跑通过。 +> - 2026-05-22 收尾补证:`task453-local-folder-page-ai-changed-files-smoke.js` 已扩展为 AI 写入前台同步 smoke;clean path 验证 Hermes `run.completed.agentAudit.changedFiles` 后 changed-files 工具卡、磁盘 `.md`、Page Aggregate、ProseMirror 和 clean editor 非冲突态;dirty path 验证同一路径触发 `external-change-conflict`、冲突面板显示 agent run、冲突信封包含 `externalActor / dirtyState / bufferFileVersion`,diff 同时显示本地未保存 token 与 AI 写盘 token。 +> > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` @@ -47,12 +54,12 @@ ### 2.2 还没有成立的事实 -- [ ] 页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区还没有消费同一份 page aggregate projection。 +- [x] 页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区已经在主路径上消费同一份 page aggregate / preferred sidebar projection;后续新增字段仍可能需要补 loader -> state -> consumer 链,但不再作为 P1 阻塞。 - [x] 标题 / 正文 / 页面设置已经开始统一到同一组 page aggregate command family。 -- [ ] 写侧还没有完全退出 `/api/documents/save` 兼容面;`page.body.write` / LocalFS executor 与文件版本冲突模型仍待落地。 -- [ ] tiptap dirty 状态与 AI / 外部文件变更之间还没有完整 VSCode-like conflict UI。 -- [ ] `pageOptions` 还没有整体收口到 `leptos-tiptap` island 的正式运行时语义层。 -- [ ] AI 写入口还没有完整对齐“授权文件引用 + 白名单目录 + 后台文件写入 + 前台同步”模型。 +- [x] local-first 写侧已退出 `/api/documents/save` 长期主路径;文档页保存使用 `/api/page-body/write`,Hermes 本地正文写入回到 `write_local_markdown_page_body`,并复用文件版本冲突模型。 +- [x] tiptap dirty 状态与 AI / 外部文件变更已具备 VSCode-like conflict UI 与 BufferStore 信封;`task451` 覆盖 accept-disk / keep-current / merge-save / agent-conflict-source。 +- [x] `pageOptions` 已整体收口到 `leptos-tiptap` island 的正式运行时语义层;`protectEditing / showBlockRefCount` 仍是 planned / 降级展示项,不再冒充正式完成。 +- [x] AI 写入口已经补齐前台同步回归证明:local-first 下 allowedRoots / path escape / commandContext 有 targeted tests;`task453` 覆盖 AI 写入后 changed files、Page Aggregate、ProseMirror、clean 非冲突态和 dirty conflict UI。 补充:本轮已新增前端统一 `page-command-client`,并把 `DocumentContent` 的标题 / 页面设置写入、AI 正文写回、以及 `BlockNote` / `leptos-tiptap` 各 host 的正文保存统一到 `page.head.updateTitle / page.layout.updateOptions / page.body.save`。同时,Next route 侧已新增统一 `page-write-command-adapter`,`/api/documents/title`、`/api/documents/options`、`/api/documents/save` 三条页面写链路已开始共用同一层执行器。这里勾选的是“命令执行面开始收口”,不等于页面域真相链已经完全统一。 @@ -137,7 +144,7 @@ ### 5.3 退出标准 - [x] 页面加载时不再能明显看出“这是几份子结果拼出来的页面”。 -- [ ] 后续页面新增字段时,不再需要继续向外层 props 链同时塞多种局部真相。 +- [x] 后续页面新增字段时不再需要继续向外层 props 链同时塞多种局部真相;现有模式是补 Page Aggregate -> client state -> island/consumer 链。新增字段仍要改 consumer,但不再扩第二份页面事实源。 补充:当前首屏 SSR 与客户端内容重试补拉都已经直接走 `/api/page-aggregate/:id -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。 @@ -156,7 +163,7 @@ ### 6.1 必须先分类 - [x] 把当前页面设置分成:页面壳布局类、阅读视图类、编辑器 runtime 语义类。 -- [ ] 明确哪些项应继续保留在页面设置面板中,哪些项应降级或隐藏,哪些项必须进入 island。 +- [x] 明确哪些项应继续保留在页面设置面板中,哪些项应降级或隐藏,哪些项必须进入 island。 补充:本轮已新增 `page-option-semantics.ts`,把 `pageOptions` 的运行时语义正式收口为代码契约,不再让这套规则继续散落在 `page-options-sidebar.tsx`、`editor-host-types.ts` 与 `leptos-tiptap-island-editor-host.tsx` 里各写一份。当前至少已经明确: @@ -272,7 +279,7 @@ - [x] 人类编辑与 AI 编辑共享同一块语义边界。 - [x] AI 改写结果能通过主编辑区 island 正式回显。 -- [ ] AI 改写后树标题 / 页面头部 / 页面设置不再走各自独立副作用链。 +- [x] AI 改写后树标题 / 页面头部 / 页面设置已回到同一组 page aggregate command family;剩余验证重点转为前台同步 smoke 的 local-first fixture 化。 补充:进入这一步时,最大的真实 blocker 已经明确下来;以下为历史 blocker 记录,不再作为 2026-05-14 之后的当前状态描述: @@ -291,7 +298,7 @@ ### 8.3 退出标准 - [x] AI 写入口已经可以被明确描述为“必须经过授权 scope 与页面写入仲裁”,而不是“绕过系统写编辑器”。 -- [ ] local-first 下,普通正文 AI 写入必须进一步落到“授权文件 patch 或带 `expectedFileVersion` 的 `page.body.write`”,不能继续以 `/api/documents/save` 兼容面作为长期验收终点。 +- [x] local-first 下,普通正文 AI 写入已落到“授权文件 patch 或带 `expectedFileVersion` 的 `page.body.write`”;`mnote.page.save` 与历史 `mnote.doc.markdown_edit` 在 local_folder 分支均回到 `write_local_markdown_page_body`。当前 active 口径进一步收口为 `7-18`:普通 Markdown 编辑不再保留 MNote 专用编辑工具。 注:历史 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-14 起这只保留为过渡证据;新的主线已由 Hermes 发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后: @@ -316,10 +323,10 @@ 约束如下: - [x] 没有完成 `Phase F` 之前,不再继续零散补标题 / 宽度 / 页面设置。 -- [ ] 没有完成 `Phase G` 之前,不把当前文档页描述成“已经统一聚合完成”。 -- [ ] 没有完成 `Phase H` 之前,不把页面设置大量打钩为“正式可用”。 -- [ ] 没有完成 `Phase I` 之前,不把标题问题视为已从底层解决。 -- [ ] 没有完成 `Phase J` 之前,不把 AI 直接写主编辑区描述成已经具备正式稳定入口。 +- [x] 没有完成 `Phase G` 之前,不把当前文档页描述成“已经统一聚合完成”。 +- [x] 没有完成 `Phase H` 之前,不把页面设置大量打钩为“正式可用”。 +- [x] 没有完成 `Phase I` 之前,不把标题问题视为已从底层解决。 +- [x] Phase J 的 local-first browser 回归已补齐;AI 写入主编辑区可按 local-first 主路径描述为稳定入口,仍需遵守 allowed roots、文件版本和冲突 UI 约束。 --- @@ -330,16 +337,16 @@ > **“主编辑区与树域已经基本统一到 Rust 主导的 page aggregate 单一真源架构”。** - [x] 文档页消费统一 `page aggregate projection` -- [ ] 标题 / 正文 / 页面设置不再是三条分裂真相链 +- [x] 标题 / 正文 / 页面设置不再是三条分裂真相链 - [x] `leptos-tiptap` island 真正消费 editor-related `page_layout` - [x] 树标题与页头标题来自同一份 projection - [x] AI 写入口开始围绕 page aggregate command family 实现 -补充:当前未勾选“标题 / 正文 / 页面设置不再是三条分裂真相链”的原因,不再只是命令名或 route 分裂。虽然前端页面写入口、Next route 执行器、`DocumentContent` 内部的 `body/layout/tree` client state、以及 `pageOptions` 的代码级运行时分类都已经开始收口,但 projection 回流与 AI 对页面设置面的正式写入口仍未完全统一。也就是说,命令执行面、页面本地状态面、页面设置语义面都已开始统一,但页面域单一真源仍未闭环。 +补充:2026-05-22 复核后,标题 / 正文 / 页面设置三条链已经在 local-first 主路径上回到 Page Aggregate / page command family / client aggregate state;`documents.save` 只作为 cloud / compat 边界保留。AI 写入后的前台同步 browser 证据、dirty conflict UI 真实 Hermes changed-files 路径均已由 `task453` 补齐,因此本文件可从 `process/` 归档到 `done/`。 -在此之前,正确口径都应保持为: +归档后的正确口径为: -> **`leptos-tiptap` 主编辑区已基本可用,但页面聚合仍未收口,当前仍处于从混合态向 Rust 单一真源过渡的过程中。** +> **local-first 主编辑区、页面聚合、BufferStore 冲突模型与 AI 写入同步已经在 Rust 主导的 Page Aggregate 主路径上基本闭环;后续新增字段仍按 Page Aggregate -> client state -> island/consumer 链扩展,不再新增第二份页面事实源。** --- @@ -353,25 +360,25 @@ | G(主文档页消费统一聚合) | ✅ 基本完成 | 加载链路、散落 props、退出标准 1 已满足;exit 2(新增字段不扩容 props 链)为 P2 方向目标 | | H(pageOptions 进入 island) | ✅ 基本完成 | 优先级项已 wired,Inspector 口径统一,AI 写回白名单限制;`protectEditing/showBlockRefCount` 仍为 planned | | I(树域与页面域标题统一) | ✅ 全部完成 | task110 smoke 验证全部标题同步场景 | -| J(AI 写入口对齐 Page Aggregate) | ⚠️ 命令面完成,权限面待验 | 命令名已收口;`allowedRoots` / 路径 escape 检查不在本审计范围内 | +| J(AI 写入口对齐 Page Aggregate) | ✅ 完成 | local_folder 写入已回 `page.body.write`;`allowedRoots` / 路径 escape / commandContext targeted tests 已覆盖;`task453` 覆盖 AI clean 前台同步和 dirty conflict UI | ### 11.2 2.2 节未成立事实的状态更新 | 项 | 审计状态 | 说明 | |---|---------|------| -| 页面树/文件树/页面头部/页面设置/主编辑区尚未消费同一份 projection | ⚠️ 部分解决 | 主编辑区和页头已统一;页面树/文件树的标题已同源,但页面设置面板与主编辑区的 `pageOptions` 运行时消费仍有 gap | -| 写侧未完全退出 `/api/documents/save` | ⚠️ 部分解决 | `local_folder` 已切到 `/api/page-body/write`;Convex workspace 仍走 `/api/documents/save`(作为正式路径而非 compat) | -| tiptap dirty + AI/外部变更的 VSCode-like conflict UI | ❌ 未完成 | DOM 侧已经插入 conflict 面板元素(`web_shell.rs:4990-5015`),但前端测试/Playwright smoke 未覆盖 | -| `pageOptions` 收口到 island 运行时语义 | ⚠️ 部分完成 | `page-option-semantics.ts` 分类已落地;`protectEditing/showBlockRefCount` 为 planned | -| AI 写入口对齐授权/白名单/后台写入/前台同步 | ⚠️ 命令面已收口,权限面待验 | 本审计未确认 Hermes 侧的 `allowedRoots` 和路径 escape 实现 | +| 页面树/文件树/页面头部/页面设置/主编辑区尚未消费同一份 projection | ✅ 主路径完成 | 主编辑区、页头、标题投影、pageOptions runtime 语义已统一;新增字段仍需补 consumer 链但不扩第二事实源 | +| 写侧未完全退出 `/api/documents/save` | ✅ local-first 完成 | `local_folder` 已切到 `/api/page-body/write`;`/api/documents/save` 仅保留 cloud / compat 边界 | +| tiptap dirty + AI/外部变更的 VSCode-like conflict UI | ✅ 完成 | `task451` 覆盖冲突 UI 四路径,并断言 BufferStore 字段进入冲突信封 | +| `pageOptions` 收口到 island 运行时语义 | ✅ 基本完成 | `page-option-semantics.ts` 分类、Inspector 口径、AI 写回白名单和可见效果 smoke 已覆盖;`protectEditing/showBlockRefCount` 为 planned / 降级展示 | +| AI 写入口对齐授权/白名单/后台写入/前台同步 | ✅ 完成 | targeted tests 已覆盖 ACP env、file scope、CommandContext 写守卫和 LocalFS root escape;`task453` 汇总 AI 写入后 Page Aggregate / ProseMirror / changed files / clean 非冲突态 / dirty conflict UI | ### 11.3 退出标准状态 -- Section 9:"当前推荐实施顺序" 中的约束:Phase G 和 H 的 [ ] 项可以部分追认——Phase G 和 H 的主体工作已完成,但 exit criterion 的严格表述("已经统一聚合完成" / "大量打钩为正式可用")仍不完全满足。建议保持现状不勾选,等 J 完成后统一收尾。 -- Section 10 总退出标准:"标题/正文/页面设置不再是三条分裂真相链" 仍 [ ]——命令执行面已统一,但 projection 回流和 AI 页面设置写入口未完全统一,符合当前描述。 +- Section 9:Phase G/H/I/J 约束均已可追认完成。 +- Section 10:标题 / 正文 / 页面设置三条分裂真相链已在 local-first 主路径收口;AI 前台同步浏览器证据已补齐。 ### 11.4 建议 -1. **Phase G exit 2 降级为 P2**:新增字段仍需改 loader→state→consumer 链是当前架构的自然限制,不应作为 P1 block。 -2. **补充 Conflict UI Playwright smoke**:DOM 元素已经存在,只差自动化验收流程。 -3. **明确 `/api/documents/save` 的正式定位**:如果它是 Convex workspace 的正式路径,则在文档中调整口径;如果它是要移除的 compat,则排期删除。 +1. **归档到 `done/`**:本清单不再占用 active `process/`。 +2. **下一执行包转向**:当前新稿为 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`,直接围绕 allowed roots、file refs、agent 原生 patch/diff、watcher、BufferStore、Page Aggregate 前台同步和审计链设计;`design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` 仅作为反例 / 历史材料。 +3. **归档前复跑文档检查**:`git diff --check -- design rust/crates/mnote-web/src/ssr/pages/layout.rs rust/crates/mnote-web/src/routes/web_shell.rs scripts/task443-filetree-mindmap-click-active-row-smoke.js scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js scripts/task453-local-folder-page-ai-changed-files-smoke.js`,并同步 CodeGraph。 diff --git a/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md b/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md index cb504819..080181c9 100644 --- a/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md +++ b/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md @@ -7,8 +7,8 @@ > 关联背景: > - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` > > 本地参考源码: diff --git a/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md b/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md index abd75a29..e9bdc9f8 100644 --- a/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md +++ b/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md @@ -20,7 +20,7 @@ > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` ## 1. 文档目的 diff --git a/design/05-editor-mainline/reference/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md b/design/05-editor-mainline/reference/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md index 637557de..d5ee0857 100644 --- a/design/05-editor-mainline/reference/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md +++ b/design/05-editor-mainline/reference/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md @@ -9,18 +9,18 @@ > 关联文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` > - `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`(历史过渡背景) -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-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-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-2-tiptap-notion-like-template-adoption-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` ## 1. 文档目的 @@ -852,7 +852,7 @@ P2 做复杂但可逐步推进的部分: - 若截图肉眼可见差异但 smoke 未捕获,先增强 smoke 或 checklist 断言,再继续实现。 - 后续可加入截图 diff,但不能用截图 diff 替代真实交互烟测。 - 每次改 Sidebar、topbar、document canvas、editor runtime 后都要重新截首屏,并同步更新连续 checklist 的状态。 -- 连续执行清单见 `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`。 +- 连续执行清单见 `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`。 owner 态交互验收清单: diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md similarity index 99% rename from design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md rename to design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md index 9ca51f4d..928e405d 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md @@ -1,7 +1,9 @@ -# 5-9 [process] Wolai-aline 连续执行 checklist v1 +# 5-9 [reference] Wolai-aline 连续执行 checklist v1 > 更新时间:2026-05-18 > +> 当前状态:`reference`。本文保留 Wolai-aline 连续项基线;新对标任务必须按 `wolai-aline` skill 和 `design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md` 另拆具体 checklist。 +> > 2026-05-18 口径补充: > - 本 checklist 的 Wolai 体验对标仍有效,但数据真相口径跟随 local-first workspace:本地 `.md` 是默认正文真相,Convex-backed 路径只作为兼容 / cloud source。 > - 历史条目中“写回 Convex-backed 持久化底座”的表述只代表当时 smoke 的在线路径,不再作为新增编辑能力默认目标。 diff --git a/design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md b/design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md similarity index 98% rename from design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md rename to design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md index 8c321237..d7ca700e 100644 --- a/design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md +++ b/design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md @@ -36,7 +36,7 @@ Rust kernel truth ### 当前主线文档 -- `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` +- `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` - `design/06-mindmap/reference/6-mindmap-leptos-adapter-reference-notes-v1.md` - `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md` @@ -110,9 +110,9 @@ Rust kernel truth ### Task 1: 冻结新阶段边界 **Files:** -- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` +- Modify: `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` - Modify: `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md` -- Create/Modify: `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` +- Create/Modify: `design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` - [x] 明确当前 Phase 6 不是“Rust 重写 lx-doc/mind-map”,也不是“直接嵌入 lx-doc Vue app”。 - 2026-05-11 记录:本文件“当前判断”和总设计稿第 3 节均保留三路线定位,Phase 6 主线为 `leptos-mindmap`。 @@ -449,9 +449,9 @@ Rust kernel truth ### Task 14: 文档与执行记录 **Files:** -- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` +- Modify: `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` - Modify: `design/06-mindmap/reference/6-mindmap-leptos-adapter-reference-notes-v1.md` -- Modify: `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` +- Modify: `design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` - Modify: `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md` - [x] 在总设计稿中明确:UI shell reuse 是 `leptos-mindmap` 的 Phase 6 后续收口,不改变 kernel-first 主合同。 diff --git a/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md b/design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md similarity index 96% rename from design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md rename to design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md index 00d3a985..6c9c0b3b 100644 --- a/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md +++ b/design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md @@ -1,7 +1,9 @@ -# 6 [process] Mindmap Kernel Phase 6:leptos-mindmap Projection Editor v1 +# 6 [reference] Mindmap Kernel Phase 6:leptos-mindmap Projection Editor v1 > 更新时间:2026-05-10 > +> 当前状态:`reference`。本文保留 mindmap Phase 6 总设计;已完成 UI shell reuse 归档到 `design/06-mindmap/done/`,后续新工作需另拆小 checklist。 +> > 当前口径:`leptos-mindmap` > > 旧稿归档: @@ -9,8 +11,8 @@ > > 当前主线依据: > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` > > 参考实现: @@ -394,7 +396,7 @@ Phase 6.1 的硬性完成判定: Phase 6 后续 UI 可用度收口转入: -- `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` +- `/mnt/Data1T/mnote/design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` 本轮已经把默认宿主修正为 floating overlay canvas shell,并把 toolbar、sidebar、count、navigator 迁到 Leptos/Rust shell 边界: diff --git a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md index b31ec089..1cb6bcab 100644 --- a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md +++ b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md @@ -5,7 +5,7 @@ > 上位依据: > - `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md` > - `/mnt/Data1T/mnote/ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > > 状态说明: > - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/` diff --git a/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md b/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md index fa80e5ec..56964b43 100644 --- a/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md +++ b/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md @@ -9,9 +9,9 @@ > 本稿目的:在 7-12 已排除第二套 AI runtime 的前提下,补上 Hermes tool execution → Convex 持久化之间缺失的 Rust 编辑运行时中继层,实现「内存态 apply → 编辑器就地 patch → Convex 异步持久化 → 事件增量通知」的四步闭环。 > > 关联文档: -> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` @@ -395,7 +395,7 @@ mnote-web 重启 | 5-13 块身份合同 | EditorBlockDocument 就是 blockDocument 的内存态 | | 4-6 tree command cutover | EditorRuntimeActor 不碰 tree 命令;page 级和 block 级命令保持独立 | | 3-3 tree realtime event stream | Phase C 新增 `block.delta` event,扩展而非替代 resync_required | -| 7-14 markdown 编辑收敛 | EditorRuntimeActor 后续需适配 markdown_edit:内部做 markdown diff 后复用 BlockDelta 通道推送编辑器更新。当前先走完整 markdown → blocks → apply 路径 | +| 7-14 markdown 编辑收敛 | 历史口径。`7-14` 已移入 old;当前 `7-18` 口径下,普通 Markdown 编辑不再要求 EditorRuntimeActor 适配 `markdown_edit`,而是由 agent 原生文件 patch/diff 后经 watcher / BufferStore / Page Aggregate 回流 | --- @@ -407,8 +407,8 @@ mnote-web 重启 - 不要求编辑器同步等待 Convex 写入完成才展示 AI 编辑结果。 - 不改变已有的 `ensure_write_contract` 校验链。 - 不新增写工具;Phase A/B/C 只加速已有工具的落地速度。 -- **按 7-14**:`mnote.doc.markdown_edit` 的新增不违反本禁止项——它是新增工具,但复用 EditorRuntimeActor 的 delta 通道,属于 Phase D 适配范围。 -- delta channel 不改写 Tiptap 的协作/undo/redo 栈;仅新增 AI 编辑的增量入口。markdown_edit 的 delta 推送同样遵守此约束。 +- **按 7-18**:local-first 普通 Markdown 编辑不再新增或依赖 `mnote.doc.markdown_edit`,EditorRuntimeActor 不承担普通 Markdown 工具写入适配。 +- delta channel 不改写 Tiptap 的协作/undo/redo 栈;若后续只用于结构性辅助,也必须遵守此约束。 --- diff --git a/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md b/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md similarity index 99% rename from design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md rename to design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md index b058ebac..1fe5ebec 100644 --- a/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md +++ b/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md @@ -17,8 +17,8 @@ > > 关联文档: > - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/7-5-hermes-client-proxy-contract-v1.md`(已退役历史背景) -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main/` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/` diff --git a/design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md b/design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md index de11c242..ae11d3c1 100644 --- a/design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md +++ b/design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md @@ -7,8 +7,8 @@ > 归档说明(2026-05-21):本文范围内可执行的真实页面 smoke 已完成;剩余 UI / Review Mode 属于 Phase C 冻结范围,解冻时应新建设计稿承接。 > > 来源: -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` > - `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md` --- @@ -19,8 +19,8 @@ 本文只承接这些剩余 smoke,不新增 AI 功能面,不改变当前主路径: -- local-first 普通 Markdown 编辑主路径仍是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;本文只验证 mnote tools 的结构性辅助和 compat fallback。 -- `mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback 的 smoke 对象。 +- local-first 普通 Markdown 编辑主路径仍是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;本文只验证 mnote tools 的历史结构性辅助和兼容证据。 +- 2026-05-22 之后,`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;本文中的 smoke 只代表历史在线 / compat 回归证据。 - `mnote.block.*` 仍只作为结构性辅助。 - `mnote.page.save` 仍只作为页面级粗粒度兜底。 - Phase C review / streaming apply 仍冻结,当前只验证基础合同和安全边界。 diff --git a/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md b/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md index 2a6a2291..9d9ddf4d 100644 --- a/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md +++ b/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md @@ -4,9 +4,9 @@ > > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md` > diff --git a/design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md b/design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md index 172dc09d..cd8a0756 100644 --- a/design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md +++ b/design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md @@ -4,16 +4,20 @@ > > 2026-05-19 local-first 口径补充: > - 本文仍适用于 `convex_workspace` / 在线文档的 `mnote.doc.markdown_edit` 修复,但当前默认产品形态已切到 local-first workspace。 -> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;`mnote.doc.markdown_edit` 只作为本地受控代理 fallback、cloud / remote agent 或 compat 路径。 +> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”。 > - 后续新增 AI 编辑能力默认先保证本地 `.md` 与 `{mdBase}.assets/` 相对路径不被改写为 Convex media asset;在线 Convex 文档路径只作为可选 cloud / sync / share source。 > +> 2026-05-22 口径修正:`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback。本文只保留在线 / compat 写回修复的历史证据;当前 active 设计以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。 +> > 当前状态:`DONE`(已归档,代码验证通过 2026-05-21) > > 关联缺陷:`bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md` > `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md` > `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md` > -> 上位设计:`design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> 历史上位设计:`design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` +> +> 当前 active 设计:`design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` > > 参考实现:`design/05-editor-mainline/reference-code/cli-main/shortcuts/doc/`(str_replace skill) > `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`(已有 GFM→blocks 解析器) diff --git a/design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md b/design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md index 6d9cb90e..196bf417 100644 --- a/design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md +++ b/design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md @@ -82,8 +82,8 @@ Owner: 只读参考: -- `design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` -- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +- `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` +- `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` - `rust/crates/mnote-web/src/acp_client.rs` - `rust/crates/mnote-web/src/acp_session_manager.rs` - `rust/crates/mnote-web/src/acp_runtime.rs` diff --git a/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md b/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md index 02318de9..c5a61069 100644 --- a/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md +++ b/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md @@ -5,9 +5,9 @@ > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` > - `/mnt/Data1T/mnote/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md` diff --git a/design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md b/design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md index 130e20d2..255b1e93 100644 --- a/design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md +++ b/design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md @@ -94,7 +94,7 @@ Owner: 只读范围: -- `design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` +- `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` - `design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md` - `scripts/` - `rust/crates/mnote-web/src/acp_*.rs` diff --git a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md index b5436c82..00f19427 100644 --- a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md +++ b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md @@ -7,10 +7,10 @@ > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` @@ -564,7 +564,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm - `rust/crates/mnote-web/src/hermes_tools/page.rs` - `rust/crates/bridge-runtime/src/lib.rs` -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 仅回填完成证据 +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` 仅回填完成证据 - smoke: - `scripts/task-hermes-page-ai-title-options-smoke.js` @@ -789,10 +789,10 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm - `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` - `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` -- `design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +- `design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` - `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -- `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` - `design/old/07-ai/process/*` 执行步骤: @@ -1010,7 +1010,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm 2026-05-14 K1 执行证据: - [x] rg 命中已归类:`design/old/**` 与 `design/07-ai/done/7-1**` 为历史证据;`scripts/task-hermes-page-ai-retirement-guard.js` 与 `rust/crates/mnote-web/src/routes/compat.rs` 为 legacy guard;旧 E27/phase7/page-ai smoke 已默认退役;`wolai-frontend` 中 Mindmap / OnlyOffice / generic AI 面板调用点归为非当前页面 AI 主链的 legacy domain 调用点,后续按各自 domain 迁移。 -- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs`、`scripts/task-hermes-page-ai-retirement-guard.js`、历史退役 smoke 当前本机归档于 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`。 +- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs`、`scripts/task-hermes-page-ai-retirement-guard.js`、历史退役 smoke 当前本机归档于 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`、`design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`。 - [x] 旧 smoke 默认退役验证:上述 6 个历史 smoke 均返回 `{ ok: true, retired: true }`。 **验收标准:** @@ -1094,8 +1094,8 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm | 参考目的 | 具体文件 | 具体位置 / 搜索点 | 处理方式 | | --- | --- | --- | --- | -| Page Aggregate 口径 | `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` | 搜索 `/api/ai-agent/run`、`mnote-cli host`、`页面 AI` | 改成历史残留 / 已覆盖 / 待迁移证据,不写成长期主线 | -| Wolai-aline 过渡基线 | `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` | 搜索 `E27`、`mnote-cli`、`页面 AI` | 保留为对标基线时必须标明过渡状态 | +| Page Aggregate 口径 | `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` | 搜索 `/api/ai-agent/run`、`mnote-cli host`、`页面 AI` | 改成历史残留 / 已覆盖 / 待迁移证据,不写成长期主线 | +| Wolai-aline 过渡基线 | `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` | 搜索 `E27`、`mnote-cli`、`页面 AI` | 保留为对标基线时必须标明过渡状态 | | 二级域 review 建议 | `design/10-review/04-secondary-domains-and-design-governance-review.md` | 搜索 `Hermes`、`plugin`、`mnote-cli`、`CLI-first` | 指向 Hermes plugin / Rust bridge 的当前口径 | | 07-ai 历史稿 | `design/old/07-ai/process/` | 搜索 `CLI-first`、`mnote-cli` | 继续标 `[recycle]`,不迁回活跃 process | @@ -1111,7 +1111,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm 2026-05-14 N1 执行证据: - [x] `rg -n "mnote-cli.*唯一|唯一长期 agent|CLI-first|/api/ai-agent/run|provider=hermes" design --glob '*.md'` 已跑完,活跃命中已逐项归类。 -- [x] 活跃命中主要落在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`、`design/10-review/06-execution-checklist-and-acceptance.md`、`design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`、`design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`、`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`、`design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`、`design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`。 +- [x] 活跃命中主要落在 `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`、`design/10-review/06-execution-checklist-and-acceptance.md`、`design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md`、`design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`、`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`、`design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`、`design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`。 - [x] `design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 与同目录旧稿继续保留 `[recycle]`,只作为历史证据。 - [x] 本轮对 `5-6`、`5-9`、`10-review/04`、`10-review/06`、`1-1`、`3-1` 的旧口径改写已完成;活跃文档现已统一为 Hermes 页面内客户端 + mnote Hermes plugin/tool 口径。 - [x] 本文件仍保留在 `process/`,原因是它是执行清单而不是代码实现文件;若后续需要归档,可按 `N1.5` 的规则移动并同步更新引用。 diff --git a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md index 5c012920..e374252e 100644 --- a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md +++ b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md @@ -6,7 +6,9 @@ > > Hermes Web UI 参考:`packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/services/hermes/plugins.ts`、`packages/server/src/services/hermes/chat-run-socket.ts` > -> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系路线图已归档到 `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`,执行验收继续以 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 为准。 +> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系路线图已归档到 `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`。 +> +> 2026-05-22 归档治理补充:旧 `7-10` 已移入 `design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`,不再作为当前执行验收入口;`7-14` 也已移入 `design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`。当前 AI 编辑 active 口径以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准,MNote 不再提供普通 Markdown 编辑工具。 ## 1. 总边界 diff --git a/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md b/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md index 4915cea7..3a1602c0 100644 --- a/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md +++ b/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md @@ -7,9 +7,9 @@ > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` > - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` diff --git a/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md b/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md index 036fe63c..01ed2a4a 100644 --- a/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md +++ b/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md @@ -20,12 +20,12 @@ > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` --- @@ -964,7 +964,7 @@ Rust 内部结构化文档模型,是 PageXML/PageMarkdown 到 Page Aggregate / 本文作为页面/块 AI 工具体系路线图与合同已经归档为 `DONE`;执行验收不再由本文继续承接,而是转入: -- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +- `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 当前已成立的 done 边界: diff --git a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md new file mode 100644 index 00000000..90c1318b --- /dev/null +++ b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md @@ -0,0 +1,332 @@ +# 7-18 [process] local-first agent 文件编辑控制面 v1 + +> 创建时间:2026-05-22 +> +> 当前状态:`PROCESS` +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> +> 覆盖旧稿:`/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` +> +> 口径说明:旧 `7-14` 仍受 CLI Main / Lark Doc / tool-first 写入模型影响。CLI Main 可以参考 agent workflow 纪律,但不作为 MNote local-first Markdown 编辑架构主参考。 + +## 1. 第一结论 + +MNote 不提供普通 Markdown 正文编辑工具。 + +MNote 在 local-first 下只提供: + +- 页面和文件定位。 +- agent target 选择与冻结。 +- `AiAccessScope`、allowed roots、allowed files、readonly / dirty / permission context。 +- selection、当前光标附近上下文、相关资源引用。 +- agent runtime session、审计、changed files / diff 回收。 +- watcher、BufferStore、Page Aggregate、ProseMirror 前台同步。 + +Hermes / Reasonix 是已知 agent,使用自身文件 read / write / patch / diff 能力在授权目录内完成普通 `.md` 编辑。 + +## 2. 明确退役项 + +### 2.1 `mnote.doc.markdown_edit` + +`mnote.doc.markdown_edit` 不再作为: + +- local-first 普通 Markdown 编辑默认入口。 +- local-first 普通 Markdown 编辑 fallback。 +- remote fallback。 +- 新 agent runtime 的推荐工具。 +- 新 checklist 的验收目标。 + +已有代码和 smoke 若仍覆盖 `markdown_edit`,只代表历史在线 / compat 路径的回归证据;不得再由它反推出“local-first 应继续提供 Markdown 编辑工具”。 + +### 2.2 `mnote.doc.fetch` + +`mnote.doc.fetch` 不作为普通正文读取主入口。 + +local-first 默认路径中,agent 直接读取授权文件。MNote 可以把 selection、文件摘要、block projection 或结构化上下文放进 run input,但不要求 agent 通过 `mnote.doc.fetch` 再读正文。 + +### 2.3 `page_ai_workflow` / `mnote.block.*` / `mnote.page.save` + +- `page_ai_workflow` 只保留为 debug / 历史兼容门面,不进入 local-first 普通编辑主路径。 +- `mnote.block.*` 只保留为复杂结构辅助,例如拖拽排序、精确块删除、非 Markdown 结构资源辅助。 +- `mnote.page.save` 只保留为页面级兜底写入工具,不作为精确 Markdown 编辑入口。 + +## 3. 主路径数据流 + +```text +Page / active editor + -> Agent Target Resolver freezes selected target package + -> resolve canonical .md file / resource file + -> build AiAccessScope + -> freeze allowed roots / allowed files / readonly / dirty / selection + -> start Hermes / Reasonix run with file refs and context + -> agent native patch / diff / write inside allowed roots + -> MNote collects changed files / diff / audit + -> watcher observes file changes + -> BufferStore arbitrates clean / dirty / conflict state + -> Page Aggregate rebuilds projection + -> ProseMirror / tiptap island refreshes visible document +``` + +关键点: + +- MNote 只给位置、权限和上下文,不给普通 Markdown 编辑工具。 +- 多工作区、多 tab、多资源同时打开时,默认 target 只能来自最后获得编辑焦点的 editor / resource tab;不允许用“第一个 tab”“树选中项”或 URL documentId 静默猜测。 +- 写入动作发生在 agent 自身文件能力内,并受 allowed roots / allowed files 限制。 +- 前台同步由 watcher / BufferStore / Page Aggregate 接回,不由 agent 直接推 UI 状态。 +- dirty buffer 时不得静默覆盖,必须进入冲突或 review 状态。 + +## 4. Agent Target Resolver + +Agent Target Resolver 是 MNote 发送给 agent 前的唯一目标解析层。它的职责是把当前 UI 状态冻结为一份明确的 `targetPackage`,而不是让 agent 或 API 自行猜“当前页”。 + +### 4.1 选择原则 + +- **workspace 是权限边界**:默认一次 run 只属于一个 workspace;跨 workspace 必须由用户显式多选,并生成多个 `allowedRoots`。 +- **tab 是视图,不是真相**:tab 只帮助判断用户正在看的对象;最终 target 必须落到 `objectIdentity`、`resourceKind` 和 canonical path。 +- **last focused editor 优先**:默认 target 是最后获得编辑焦点的 editor / resource tab。 +- **selection 覆盖 whole file**:存在真实选区时,默认 scope 为 selection;没有选区时才是 whole file / current resource。 +- **树选中项只作为候选**:FileTree / PageTree selection 不自动成为 agent target,除非用户在 target picker 中选择“树选中项”。 +- **歧义时不发送**:如果焦点不明确、target dirty 且会被写入、或跨 workspace 未显式确认,发送按钮进入需要选择状态。 + +### 4.2 targetPackage 合同 + +```json +{ + "schema": "mnote.agent_target_package.v1", + "primaryTargetId": "target-1", + "targets": [ + { + "targetId": "target-1", + "workspaceId": "local:design", + "workspaceSource": "local_folder", + "workspaceRoot": "/workspace/design", + "editorGroupId": "main", + "tabId": "tab-design-md", + "focusRank": 1, + "objectIdentity": "local_folder:/workspace/design/design.md", + "resourceKind": "markdown_page", + "title": "design.md", + "canonicalPath": "/workspace/design/design.md", + "uri": "file:///workspace/design/design.md", + "version": "mtime:size:hash", + "scope": "selection", + "selection": { + "text": "当前选区文本", + "anchor": "pm:block:p_1:0..12" + }, + "dirty": false, + "readonly": false, + "ownerPage": null, + "relatedResources": [] + } + ], + "allowedRoots": ["/workspace/design"], + "allowedFiles": ["/workspace/design/design.md"], + "policy": { + "writeMode": "agent_native_file_patch", + "requiresConfirmation": false, + "crossWorkspace": false + } +} +``` + +### 4.3 resourceKind + +`resourceKind` 至少需要区分: + +- `markdown_page`:普通 `.md` 页面正文。 +- `mindmap`:mindmap 资源文件;可带 `ownerPage`,但不伪装成正文。 +- `office`:OnlyOffice 资源;默认只读上下文,除非明确进入 office 编辑 agent flow。 +- `raw_file`:普通文件。 +- `folder`:目录范围;只在用户显式选择“把这个文件夹作为上下文”时出现。 + +## 5. UI 设计 + +当前右侧页面 AI 抽屉已有 runtime / profile / context scope,但“上下文:当前页/选区/块/页面设置”不够表达多工作区、多 tab 和资源 tab。需要把它升级为明确的 target UI。 + +### 5.1 默认展示 + +AI composer 上方固定显示 target chip,不放到设置页深处: + +```text +[工作区: design] [design.md] [当前选区] [可写] +``` + +如果当前焦点是截图中的右侧 mindmap,则显示: + +```text +[工作区: local] [新页面165037 / KMIND] [mindmap] [可写] +``` + +chip 必须可点击,打开 target picker。chip 内容优先显示用户能识别的名称,不优先显示内部 id;hover / detail 再显示 path、workspace root、object identity。 + +### 5.2 target picker + +点击 target chip 后打开轻量 popover,不跳转页面。结构为: + +```text +发送给 Agent + +当前焦点 + ● design.md markdown_page 当前选区 /workspace/design/design.md + +打开的 Tab + ○ 新页面223322 markdown_page 全文 + ○ design.md markdown_page 当前选区 + ○ 新页面165037 / KMIND mindmap 当前资源 + +树选中项 + ○ 3 个文件 只读上下文 + +范围 + ( ) 当前选区 ( ) 当前文件 ( ) 当前资源 ( ) 多选 tab ( ) 文件夹 + +[取消] [使用这个目标] +``` + +交互规则: + +- 单选是默认;多选必须用户点击“多选 tab”或勾选多个候选。 +- 多 workspace 候选默认折叠在各自 workspace 组下,跨 workspace 多选时出现权限提示。 +- dirty target 显示 `未保存` 标记;如果 agent 可能写入,发送前需要确认。 +- readonly target 显示 `只读`,发送按钮文案变成“只读提问”或禁用写入型 intent。 +- resource target 不能显示成“当前页”;必须显示 `mindmap` / `office` / `raw file`。 + +### 5.3 composer 中的 context scope + +原 `context scope` select 不应继续承担 target 选择职责。它只控制 primary target 内部范围: + +- `selection`:当前选区。 +- `file`:当前文件。 +- `resource`:当前资源文件。 +- `related`:当前页面引用的相关资源,只读附加上下文。 + +不再把 `page / block / options` 混成同一层 target 选择。页面设置属于 target 的辅助上下文,不是普通 Markdown 编辑范围。 + +### 5.4 发送前状态 + +发送按钮按 targetPackage 状态变化: + +- `发送`:单 target、clean、可写或只读问答。 +- `选择目标`:没有明确 last focused target,或当前焦点和 URL / active tab 冲突。 +- `确认发送`:跨 workspace、多 target、dirty write、folder scope。 +- `只读提问`:readonly target 或用户选择只读上下文。 + +发送后第一条用户消息旁边显示冻结的 target 摘要,避免 run 过程中用户切 tab 后误以为 agent 改了新 tab: + +```text +目标:design.md / 当前选区 / local_folder / 12:31:05 冻结 +``` + +## 6. SQLite control-plane 职责 + +Rust SQLite control-plane 是默认控制面,负责: + +- auth / session / membership。 +- share grants / sync state。 +- AI policy、workspace policy、allowed roots / allowed files。 +- ACP / Hermes / Reasonix runtime session。 +- targetPackage 审计与用户确认记录。 +- permission decision、approval decision、tool / file access audit。 +- changed files / diff / command context 记录。 + +它不持有普通 Markdown 正文真相。本地 `.md` 文件仍是正文真相。 + +## 7. 运行时输入合同 + +每次从页面触发 agent 编辑时,run input 至少应包含: + +```json +{ + "workspaceSource": "local_folder", + "targetPackage": { + "schema": "mnote.agent_target_package.v1", + "primaryTargetId": "target-1", + "targets": [] + }, + "currentFile": { + "path": "/workspace/page.md", + "uri": "file:///workspace/page.md", + "version": "mtime:size:hash" + }, + "allowedRoots": ["/workspace"], + "allowedFiles": ["/workspace/page.md"], + "selection": { + "text": "当前选区文本", + "anchor": "可选的 editor/block/offset 引用" + }, + "context": { + "readonly": false, + "dirty": false, + "resourceRefs": [] + } +} +``` + +后续可以补充 page id、object identity、resource refs、outline、nearby context,但不得把普通正文写入重新包装成 `mnote.doc.markdown_edit`。 + +## 8. 验收清单 + +### Phase A0:target selection 与 UI + +- [ ] Page AI composer 可见 target chip,显示 workspace、target title、resourceKind、scope、readonly / dirty 状态。 +- [ ] 点击 target chip 打开 target picker,可在当前焦点、打开 tabs、树选中项之间选择。 +- [ ] 默认 target 来自 last focused editor / resource tab,不来自第一个 tab、URL documentId 或树选中项。 +- [ ] mindmap / office / raw file target 显示真实 resourceKind,不伪装成 markdown page。 +- [ ] 多 tab / 跨 workspace / dirty write 时发送前需要显式确认。 +- [ ] 发送后消息记录冻结 target 摘要,切换 tab 不影响已启动 run。 + +### Phase A:运行时输入与权限边界 + +- [ ] 从文档页发起 Hermes / Reasonix run 时,request body 包含 `currentFile`、`allowedRoots`、`allowedFiles`、selection 和 dirty / readonly context。 +- [ ] run body 包含 `targetPackage`,且 `allowedRoots` / `allowedFiles` 从 targetPackage 推导。 +- [ ] local-first 普通 Markdown 编辑 run 不包含 `mnote.doc.markdown_edit` 推荐工具。 +- [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。 +- [ ] readonly 页面不会启动可写 run,或只启动只读问答 run。 + +### Phase B:写入回收与前台同步 + +- [ ] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。 +- [ ] clean buffer 下 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新通过 browser smoke。 +- [ ] dirty buffer 下外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。 +- [ ] changed files / diff 与 session / run / actor 关联写入审计。 + +### Phase C:旧工具退役 + +- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `/api/documents/save`。 +- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `mnote.doc.markdown_edit`。 +- [ ] Hermes / Reasonix manifest 或 system prompt 不再鼓励普通 Markdown 编辑调用 `mnote.doc.markdown_edit`。 +- [ ] 旧 `markdown_edit` 测试只保留为历史 online / compat 回归,并在文档中标明不指导新实现。 + +### Phase D:结构辅助边界 + +- [ ] `mnote.block.*` 只在复杂结构辅助场景出现,例如结构块排序、非 Markdown 资源辅助。 +- [ ] `mnote.page.save` 只作为页面级兜底写入,不作为默认精确编辑入口。 +- [ ] `mnote.doc.fetch` 不作为 local-first 正文读取主入口;需要结构化上下文时由 MNote 在 run input 中提供摘要或 projection。 + +## 9. 不做事项 + +- 不参考 CLI Main 的 Lark Doc 在线文档写入架构来设计 local-first 普通 Markdown 编辑。 +- 不新增 MNote 普通 Markdown 编辑工具。 +- 不把 Convex 恢复为默认正文或 AI 会话全文主存储。 +- 不实现 Phase C Review Mode / StreamApplyController / GhostTextOverlay。 +- 不让前端维护第二份正文真相。 +- 不把所有打开 tab 默认发送给 agent。 +- 不把跨 workspace 自动合并为一个隐式上下文。 + +## 10. 归档条件 + +满足以下条件后,本稿可归档到 `design/07-ai/done/`: + +- Phase A0 target UI 有真实浏览器 smoke 证据。 +- Phase A / B / C 至少各有一条真实浏览器或 targeted test 证据。 +- local-first agent 文件编辑主路径已能在 clean buffer 下完成真实 `.md` 修改并同步回 tiptap。 +- dirty conflict 不静默覆盖。 +- 文档和 manifest 不再把 `mnote.doc.markdown_edit` 描述为 local-first fallback。 diff --git a/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md b/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md similarity index 95% rename from design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md rename to design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md index 617a63bc..9f494f54 100644 --- a/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md +++ b/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md @@ -1,14 +1,16 @@ -# 7-12 [process] 页面 AI Hermes 工具路由与编辑审阅面设计 v1 +# 7-12 [reference] 页面 AI Hermes 工具路由与编辑审阅面设计 v1 > 更新时间:2026-05-19 > -> 当前状态:`PROCESS` +> 当前状态:`reference / frozen` +> +> 归档说明(2026-05-22):本文保留 Hermes 工具路由、manifest 和 review surface 的设计边界;Phase C Review Mode 继续冻结。`7-14` 已移入 `design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`,当前 active AI 编辑口径以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。 > > 本稿目的:修正“页面 AI 快速块编辑”后续方向,明确 mnote 不再建设独立 AI agent runtime;mnote 只建设 Hermes 可消费的编辑工具路由、工具 manifest、上下文冻结、dry-run/review 和 Rust 写入安全边界。 > > 关联文档: > - `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main` @@ -22,7 +24,7 @@ 本稿仍作为 Hermes 工具路由与审阅面设计保留在 `process/`,但以下口径已经更新: - `/api/page-ai/block-edit-workflow` 当前不再以 `local_rule -> apply_block_ops` 作为主路径;local-first 普通 Markdown 编辑默认给 Hermes / Reasonix 授权文件引用,由 agent 使用自身 patch / diff / 文件编辑能力写本地 `.md`。 -- `mnote.doc.markdown_edit` 保留为 cloud / remote agent / compat fallback;模型生成 markdown `search/replace` 或 `full_content` 后调用该工具,只适用于 agent 不能直接访问授权文件或需要受控代理写入的场景。 +- `mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;本文后续出现的 `markdown_edit` 只代表历史 tool / review surface 证据,不指导新 agent 文件编辑主路径。当前 active 口径见 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`。 - `page_ai_workflow` 已复用统一 mnote tool executor,不再绕过 Hermes tool toggle / audit / write contract。 - `mnote.doc.apply_block_ops` / `mnote.block.*` 保留为结构性块操作辅助,不再作为普通正文 search/replace 的优先入口。 - 本稿中的 `PageAIReviewSession` 只定义 Phase C 的安全合同和状态机边界;当前 Phase C 仍冻结,不实施流式 apply 或新的审阅 UI。 @@ -653,7 +655,7 @@ profile disabled mnote.block.fetch `7-10` 是页面块 AI 工具执行 checklist,包含真实代码和 smoke 证据。它仍然有效,继续保留在: ```text -design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md +design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md ``` 但后续执行必须按本文修正口径: diff --git a/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md b/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md index 594c59b1..2c0005ec 100644 --- a/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md +++ b/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md @@ -15,9 +15,9 @@ > 4. 为后续项目基本完成后扩展共享能力预留 schema、API 和验证边界。 > > 关联文档: -> - `/mnt/Data1T/mnote/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` > - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/` --- diff --git a/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md b/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md index 66623ca7..2f5292d4 100644 --- a/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md +++ b/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md @@ -7,11 +7,11 @@ > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` +> - `/mnt/Data1T/mnote/design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` > > 外部参考: > - `https://github.com/siyuan-note/siyuan` diff --git a/design/10-review/done/01-rust-kernel-web-review.md b/design/10-review/done/01-rust-kernel-web-review.md index 7d801fd2..9c84771f 100644 --- a/design/10-review/done/01-rust-kernel-web-review.md +++ b/design/10-review/done/01-rust-kernel-web-review.md @@ -10,11 +10,11 @@ - `ARCHITECTURE.md` - `design/01-05-current-priority-overview.md` -- `design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -- `design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` +- `design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` +- `design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md` - `design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` -- `design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` -- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +- `design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` +- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` - `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` - `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` - `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` diff --git a/design/10-review/done/02-frontend-editor-tree-review.md b/design/10-review/done/02-frontend-editor-tree-review.md index f7ca114b..1afe3f1a 100644 --- a/design/10-review/done/02-frontend-editor-tree-review.md +++ b/design/10-review/done/02-frontend-editor-tree-review.md @@ -17,7 +17,7 @@ - `design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md` - `design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` - `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` - `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` 重点读取代码: diff --git a/design/10-review/done/03-convex-realtime-storage-review.md b/design/10-review/done/03-convex-realtime-storage-review.md index d125f0df..7310d0db 100644 --- a/design/10-review/done/03-convex-realtime-storage-review.md +++ b/design/10-review/done/03-convex-realtime-storage-review.md @@ -19,7 +19,7 @@ - `design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` - `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` - `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` - `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` - `recycle/20260522-convex-runtime-retirement/infra/convex/README.md` - `wolai-frontend/convex/` diff --git a/design/10-review/done/04-secondary-domains-and-design-governance-review.md b/design/10-review/done/04-secondary-domains-and-design-governance-review.md index a822ef53..243b8e43 100644 --- a/design/10-review/done/04-secondary-domains-and-design-governance-review.md +++ b/design/10-review/done/04-secondary-domains-and-design-governance-review.md @@ -28,7 +28,7 @@ ### Mindmap -- 设计口径清晰:`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` 明确 Phase 6 主线是 `Rust kernel truth -> mindmap.simple_mind_map_scene.v1 -> leptos-mindmap editor island -> simple-mind-map runtime -> command bridge`,并禁止把 runtime data 当唯一事实源。 +- 设计口径清晰:`design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` 明确 Phase 6 主线是 `Rust kernel truth -> mindmap.simple_mind_map_scene.v1 -> leptos-mindmap editor island -> simple-mind-map runtime -> command bridge`,并禁止把 runtime data 当唯一事实源。 - 实现已对齐主线壳:`rust/crates/mnote-web/src/routes/mindmap_shell.rs:105-123` 输出 `mnote.mindmap_shell.v1`、`mindmap.simple_mind_map_scene.get` 与 `mindmap.command.apply`;`rust/crates/mnote-web/src/ssr/pages/mindmap.rs:50-56` 提供 standalone island 挂载点。 - 旧 React 块已自我标注为 legacy/compat/reference:`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx:3-5` 明确 3000 文档页默认主链是 `leptos-tiptap NodeView + leptos-mindmap adapter`。 - 仍有 compat 数据层:`wolai-frontend/convex/schema.ts:164-184` 的 `mindmaps.data: v.any()` 仍承载导图数据;这与当前过渡态兼容,但不应被描述为长期 canonical truth。 diff --git a/design/10-review/done/06-execution-checklist-and-acceptance.md b/design/10-review/done/06-execution-checklist-and-acceptance.md index 7a693354..28201328 100644 --- a/design/10-review/done/06-execution-checklist-and-acceptance.md +++ b/design/10-review/done/06-execution-checklist-and-acceptance.md @@ -145,7 +145,7 @@ - `rust/crates/mnote-web/src/routes/web_shell.rs` - `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` - `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` 执行项: @@ -166,7 +166,7 @@ - `rust/crates/bridge-runtime/src/lib.rs`:`page.aggregate.get` 根据 query data 是否包含 `meta + content` 选择 `CompatMetaContentJoin`,否则保留 `KernelProjection`。 - `rust/crates/mnote-web/src/routes/web_shell.rs`:`/api/page-aggregate/:id` 测试断言 `x-mnote-page-aggregate-owner=compat-join` 且 `result.source=CompatMetaContentJoin`。 - `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`:补充 Rust route 主读链与完整 kernel-native projection 的边界。 -- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`:补充 `CompatMetaContentJoin` / `KernelProjection` 验收口径。 +- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`:补充 `CompatMetaContentJoin` / `KernelProjection` 验收口径。 - `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`:新增 `source` / provenance 契约说明。 - 已通过:`cargo test -p bridge-runtime page_aggregate_get_query_executes_into_core_projection -- --nocapture`。 - 已通过:`cargo test -p mnote-web page_aggregate_endpoint_returns_snapshot_contract -- --nocapture`。 @@ -421,7 +421,7 @@ - `design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,且与第 2 / 第 8 项 fallback、compat 退场证据一致。 - `design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,并已有 `task167-mindmap-kmind-parity-smoke.js` 等 Phase 6 KMind parity 证据。 - 已验证:`rg -n "需要我给你|我可以|你要不要|是否需要|请告诉我|如果你愿意|要我|我来" design/90-reference` 无匹配。 -- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`、`design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`、`design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/`;`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`、`design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`、`design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` 已迁入 `done/`,后续活跃 AI 体验面由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 承接;`design/old/**` 不纳入活跃 process/done 迁移判断。 +- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`、`design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`、`design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`、`design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`、`design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/`;`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`、`design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`、`design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`、`design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` 已迁入 `done/`,后续活跃 AI 体验面由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 承接;`design/old/**` 不纳入活跃 process/done 迁移判断。 ## 11. 全局 Done Gate diff --git a/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md b/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md index 5490cb25..0fb7040d 100644 --- a/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md +++ b/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md @@ -9,10 +9,10 @@ > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` @@ -154,7 +154,7 @@ - [x] 修改页面设置后检查 Page Aggregate 与 island runtime page options 同步。 - [x] 刷新页面后检查标题、正文、页面设置不回退到旧快照。 - [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。 -- [x] 在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。 +- [x] 在 `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。 2026-05-16 静态审计证据(历史 Next 前端退役前快照): @@ -234,8 +234,8 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi - [x] 给写工具补重复 idempotencyKey 的端到端用例。 - [x] 检查 `mnote.page.save` 在 manifest / UI 中继续标为页面级兜底,不显示为精确块编辑主入口。 - [x] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。 -- [x] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。 -- [x] 更新 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。 +- [x] 更新 `design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。 +- [x] 更新 `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。 2026-05-18 `mnote.block.insert_after` 多块插入证据: @@ -269,7 +269,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi 2026-05-18 Review Session 合同定义: -- 已在 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 定义 `mnote.page_ai_review_session.v1` 最小 schema。 +- 已在 `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 定义 `mnote.page_ai_review_session.v1` 最小 schema。 - 已明确状态:`draft/planning/previewing/awaiting_user/accepted/rejected/applying/applied/failed/aborted/stale`。 - 已明确动作约束:`accept` 必须重新读 Page Aggregate 并校验 revision / conflictDetectionKey / revisionRef,`reject/abort` 不产生写入,`retry` 必须生成新 proposal 或 dry-run preview,yolo 模式也应生成同构 audit 数据。 - 边界:这里只完成合同定义,不实施 Phase C 流式 apply 或新的审阅 UI。 @@ -502,7 +502,7 @@ node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js - 已进入 `3.2 Page Block AI Tooling` 的只读审计阶段,但尚未勾选 3.2 的任何新 checkbox。 - 已确认现有代码中 `mnote.doc.fetch` 已支持 `scope=selection`、`format=page_xml/text/markdown/json`、`schema=mnote.page_ai_context.v1`、`allowedTargetBlockIds`、truncation/warnings/continuation 等基础字段;`mnote.block.fetch` 已支持 `format=page_xml/text`;manifest 已包含 `readonly/destructive/idempotent/requiresApproval/approvalMode/runtimeOwner/writeOwner/selectionEffect` 等 annotations。 - 已确认 `scripts/task-page-block-ai-tools-smoke.js` 覆盖基础块工具闭环,但还没有专门覆盖 `scope=selection`、`page_xml/text`、manifest annotations、选区外写入阻断、review session / conflict / idempotency 的完整验收。 -- 用户目标中提到的 `design/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md` 当前在 `design/07-ai/process/` 下不存在;实际可读历史参考位于 `design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md`,当前执行口径应继续以 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 为准。 +- 用户目标中提到的 `design/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md` 当前在 `design/07-ai/process/` 下不存在;实际可读历史参考位于 `design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md`,当前执行口径应继续以 `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 为准。 恢复时的下一步建议: @@ -552,7 +552,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300 2026-05-18 `7-12` runtime 口径闭合证据: -- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 已明确 `7-11` 旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`,当前执行口径以 `7-12` 为准。 +- `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 已明确 `7-11` 旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`,当前执行口径以 `7-12` 为准。 - `7-12` 已定义 `PageAIContextBuilder`、`MnoteAIToolManifestProvider`、`PageAICommandRouter`、`PageAIReviewSession` 的边界,并明确 Phase C review / streaming apply 只做设计冻结,当前不实施。 - `7-12` Phase B 到 Phase F 保留 manifest / router / review session / event / smoke 的后续 checklist;其中 selection 外写入 blocked 已有真实 smoke 证据,其余未实施项继续保留未勾。 @@ -601,5 +601,5 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300 ## 6. 给后续 /goal 的持续执行 Prompt ```text -/goal objective: 持续核验 /mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md 的已归档结论,按 P0 -> P1 顺序推进 MNOTE 架构防回归、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源防回归;2) tree command cutover 防回归;3) tree realtime live cache 防回归;4) AI 基础工具与审阅底座,当前简单正文编辑主路径已是 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executor,Phase C(流式 apply + suggest/review)仅设计冻结不实施;继续补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 scoped git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。 +/goal objective: 持续核验 /mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md 的已归档结论,按 P0 -> P1 顺序推进 MNOTE 架构防回归、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源防回归;2) tree command cutover 防回归;3) tree realtime live cache 防回归;4) AI 基础工具与审阅底座,当前简单正文编辑主路径已是 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executor,Phase C(流式 apply + suggest/review)仅设计冻结不实施;继续补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 scoped git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。 ``` diff --git a/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md b/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md index 1b08b0d1..d3cecab2 100644 --- a/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md +++ b/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md @@ -5,8 +5,8 @@ > 日期:2026-05-16 > > 关联主线: -> - `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` -> - `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` > - `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` > - `design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` diff --git a/design/10-review/done/15-design-governance-mvp-post-review-v1.md b/design/10-review/done/15-design-governance-mvp-post-review-v1.md index 4b24aa6a..4d01474d 100644 --- a/design/10-review/done/15-design-governance-mvp-post-review-v1.md +++ b/design/10-review/done/15-design-governance-mvp-post-review-v1.md @@ -104,7 +104,7 @@ Codex 已复核 worker 输出,并额外核对 `3-15` 的真实浏览器 smoke 以下文档已迁入 `reference/`,不再作为当前直接执行 checklist: -- `design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md` +- `design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md` - `design/04-tree-domain/reference/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md` - `design/05-editor-mainline/reference/5-2-tiptap-notion-like-template-adoption-v1.md` - `design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` diff --git a/design/10-review/reference/mnote-dev-squad-protocol-v1.md b/design/10-review/reference/mnote-dev-squad-protocol-v1.md new file mode 100644 index 00000000..e4a0a059 --- /dev/null +++ b/design/10-review/reference/mnote-dev-squad-protocol-v1.md @@ -0,0 +1,136 @@ +# [reference] mnote-dev-squad 协作协议 v1 + +## 目的 + +把 mnote 开发固定为一个轻量协作组:Hermes 主控,多个 Codex worker 在独立 worktree 实现,Reasonix flash 做真实浏览器 smoke,每个环节都用 Markdown 交接。 + +本协议不使用 Codex harness;不允许多个写入型 agent 同时改同一个 checkout。 + +## 角色 + +### Hermes 主控 + +- 读取 mnote 当前设计与 AGENTS.md。 +- 拆分任务 lane。 +- 创建 run 目录与 task/result/smoke/review Markdown。 +- 创建或指定 Codex worktree。 +- 启动/观察 Codex worker。 +- 启动 Reasonix smoke。 +- 阅读 RESULT.md、SMOKE.md、git diff 后做合并裁决。 + +### Codex worker + +- 一个 worker 只负责一个 lane。 +- 一个 worker 只在自己的 worktree 中写入。 +- 默认不提交、不推送、不改允许范围外文件。 +- 必须输出 RESULT.md。 + +### Reasonix smoke tester + +- 默认使用 Reasonix flash。 +- 负责真实浏览器 smoke、截图、console、final URL、可见文本证据。 +- 默认不改代码。 +- 必须输出 SMOKE.md。 + +### Reviewer / 仲裁 + +- 默认由 Hermes + 主控 Codex完成。 +- 有争议时升级到 gpt-5.5 或更强模型。 + +## 目录约定 + +每轮协作创建: + +```text +/mnt/Data1T/mnote/tmp/agent-runs/<run-id>/ + PLAN.md + codex-<lane>/TASK.md + codex-<lane>/RESULT.md + smoke/SMOKE.md + REVIEW.md +``` + +模板位于: + +```text +/mnt/Data1T/mnote/.agent-templates/task.md +/mnt/Data1T/mnote/.agent-templates/result.md +/mnt/Data1T/mnote/.agent-templates/smoke.md +/mnt/Data1T/mnote/.agent-templates/review.md +``` + +## Worktree 约定 + +Codex worker 使用独立 worktree,例如: + +```text +/mnt/Data1T/mnote-wt-<lane>-<YYYYMMDD-HHMM> +``` + +推荐 lane: + +- `rust-kernel` +- `mnote-web` +- `tree-domain` +- `editor-interaction` +- `onlyoffice` +- `ai-runtime` +- `docs-review` +- `qa-smoke` + +## Ownership 规则 + +每个 TASK.md 必须写清: + +- 允许修改范围 +- 禁止修改范围 +- 验收命令 +- 交付物路径 + +若两个 lane 需要改同一个核心文件,必须停下来由 Hermes 重新拆分或指定唯一 owner。 + +## 交付标准 + +Codex worker 完成不等于可合并。可合并必须满足: + +1. RESULT.md 存在。 +2. 修改文件符合 ownership。 +3. 验证命令已执行或明确说明阻塞原因。 +4. Reasonix smoke 已完成,或明确说明该任务不需要浏览器 smoke。 +5. Hermes / reviewer 已读 diff。 + +## Smoke 标准 + +涉及 UI、文档页、编辑器、树、拖拽、选择、hover、click、快捷键等,SMOKE.md 必须包含: + +- 测试 URL / 环境 +- 浏览器动作 +- final URL +- screenshot 路径 +- console error 数量 +- PASS / FAIL +- 问题与复现步骤 + +块编辑器验收中,hover / click / drag / select 是强制步骤;缺任一步只能算未完成验收。 + +## 升级规则 + +升级到 gpt-5.5 / 主控 Codex 深度复核的情况: + +- Reasonix smoke 与 Codex 结论冲突。 +- smoke evidence 不足。 +- 失败原因涉及架构边界。 +- 涉及 AGENTS.md 中的长期方向判断。 +- 需要合并多个 worktree 的重叠改动。 + +## 回收规则 + +旧 worktree、失败 worktree、临时实验目录优先软删除到 recycle,不直接硬删。 + +推荐: + +```text +/mnt/Data1T/recycle/<reason>-<timestamp>/ +``` + +保留 MANIFEST 或 README,说明来源与恢复方法。 diff --git a/design/01-tree-first-graph-kernel/process/1-9-batch-b-p1-identity-buffer-pageaggregate-execution-checklist-v1.md b/design/old/01-tree-first-graph-kernel/process/1-9-batch-b-p1-identity-buffer-pageaggregate-execution-checklist-v1.md similarity index 96% rename from design/01-tree-first-graph-kernel/process/1-9-batch-b-p1-identity-buffer-pageaggregate-execution-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/process/1-9-batch-b-p1-identity-buffer-pageaggregate-execution-checklist-v1.md index 532076f9..1051aace 100644 --- a/design/01-tree-first-graph-kernel/process/1-9-batch-b-p1-identity-buffer-pageaggregate-execution-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/process/1-9-batch-b-p1-identity-buffer-pageaggregate-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-9 [process] Batch B P1 Identity / BufferStore / Page Aggregate Execution Checklist v1 +# 1-9 [recycle] Batch B P1 Identity / BufferStore / Page Aggregate Execution Checklist v1 > 创建时间:2026-05-21 > @@ -6,7 +6,9 @@ > > 阶段:Batch B / P1 工作区身份、BufferStore、Page Aggregate 收口 > -> 当前状态:`PROCESS` +> 当前状态:`recycle / old` +> +> 归档说明(2026-05-22):本文的 Batch B 尾项已被后续 Batch / `done/` 文档和 `1-6`、`5-6` 的当前缺口口径覆盖;保留为历史执行记录,不再作为 active process。 ## 1. 目标 diff --git a/design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md b/design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md similarity index 88% rename from design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md rename to design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md index ab959c45..93b65254 100644 --- a/design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md +++ b/design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md @@ -1,8 +1,8 @@ -# 3-17 Convex Export Web Entry v1 +# 3-17 [recycle] Convex Export Web Entry v1 -状态:reference +状态:`recycle / old` -归档说明(2026-05-21):CLI 迁移闭环已具备并有 smoke 证据;本文只保留为后续 Web 管理入口设计参考,当前不作为 active implementation checklist。若决定短期落地 Web UI,应新建执行 checklist。 +归档说明(2026-05-22):CLI 迁移闭环已具备并有 smoke 证据;但当前 Convex 默认运行时、根 functions 与自托管入口已退役到历史 / compat / sync replica 边界,本文的 Web 管理入口设想不再作为 active 或 reference 执行入口。若未来重新做 cloud/import UI,应按当前 SQLite control-plane 和 local-first 口径新建设计稿。 ## 目标 diff --git a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md b/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md similarity index 98% rename from design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md rename to design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md index 0062f76d..58ade2f4 100644 --- a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md +++ b/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md @@ -1,8 +1,10 @@ -# 7-10 [process] 页面块 AI 工具执行 checklist v1 +# 7-10 [recycle] 页面块 AI 工具执行 checklist v1 > 更新时间:2026-05-16 > -> 当前状态:`PROCESS`。 +> 当前状态:`recycle / old`。 +> +> 归档说明(2026-05-22):本文的块级 AI 执行路线已被 local-first Markdown 编辑收敛口径覆盖;当前 active AI 编辑入口以 `design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` 为准。 > > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` diff --git a/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md b/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md similarity index 78% rename from design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md rename to design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md index 35973c66..54901081 100644 --- a/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md +++ b/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md @@ -1,13 +1,20 @@ -# 7-14 [process] 在线 / 本地文档 AI Markdown 编辑路径收敛 v2 +# 7-14 [recycle][process] Local-first AI Markdown 编辑路径收敛 v2 > 创建时间:2026-05-16 > -> 更新时间:2026-05-16(v3:深度参考 CLI Main skill 系统,补全成熟度采纳清单) +> 更新时间:2026-05-22(recycle:本文仍受 CLI Main / tool-first 旧口径影响,已移入 old,不再作为当前 AI 编辑实现依据) +> +> 归档说明(2026-05-22):本文试图从旧在线 / 本地收敛稿修正为 local-first,但仍把 CLI Main 与 `mnote.doc.*` 兼容工具放在过高位置,不符合当前 SQLite control-plane + local-first `.md` + agent 原生文件 patch/diff 主线。后续应新建更小的 active 设计稿,直接基于 SQLite、allowed roots、file refs、watcher、BufferStore、Page Aggregate 前台同步和审计链设计。 > > 2026-05-18 口径补充: -> - local-first workspace 已成为早期产品默认形态;本地 `.md` 是默认 AI 编辑目标,在线 Convex 文档降级为可选 cloud / sync / share source。 -> - 本文早期把 `mnote.doc.markdown_edit` 描述为统一主路径;最新口径改为:local-first 普通 Markdown 编辑优先给 agent 授权文件引用,由 agent 使用自身成熟的 diff / apply_patch / 文件编辑能力完成;`mnote.doc.markdown_edit` 保留为 cloud / remote agent / compat fallback。 -> - 页面内图片 / 附件上传已在 local source 下写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,AI 后续处理附件引用时也应保留相对路径,不改写为 Convex media asset。 +> - local-first workspace 已成为早期产品默认形态;本地 `.md` 是默认 AI 编辑目标;旧 Convex 文档只作为历史迁移源或显式 compat/source replica 边界。 +> - 本文早期把 `mnote.doc.markdown_edit` 描述为统一主路径;最新口径改为:local-first 普通 Markdown 编辑优先给 agent 授权文件引用,由 agent 使用自身成熟的 diff / apply_patch / 文件编辑能力完成;`mnote.doc.markdown_edit` 保留为显式 remote agent / compat fallback。 +> - 页面内图片 / 附件上传已在 local source 下写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,AI 后续处理附件引用时也应保留相对路径,不改写为历史 media asset。 +> +> 2026-05-22 口径纠正: +> - 当前 Convex runtime / functions 已退役,不再作为当前正文主链、默认控制面、AI 会话主存储或新增能力目标。 +> - 本文中的“在线文档”仅可理解为历史迁移源、显式 cloud compat / remote agent 边界或未来 sync replica,不再与 local-first `.md` 并列为当前主路径。 +> - 当前主线应写成:本地 `.md` 文件 + Rust SQLite control-plane + allowed roots / file references + agent 原生 patch/diff + watcher / BufferStore / Page Aggregate 前台同步。 > > 当前状态:`PROCESS` > @@ -15,12 +22,12 @@ > 1. 纠正 7-9 / 7-10 / 7-12 / 7-13 中隐含的「块级编辑是 AI 唯一写入路径」假设 > 2. 基于 CLI Main 参考实现,确立 mnote 的「agent 原生文件 patch/diff 为 local-first 默认路径,MNote 文本级兼容工具 + 块级结构性操作为 fallback / 辅助」两层模型 > 3. 规划 BlockNote AI 流式/review 能力的远期方向(当前不实施) -> 4. 统一 cloud / remote / compat 文档和本地 `.md` 文件的 AI 读取、权限、冲突与回读口径 +> 4. 明确历史 cloud / remote / compat 文档边界,并把当前 AI 读取、权限、冲突与回读口径收口到 local-first `.md` 文件 > > 关联文档: > - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md` @@ -42,13 +49,13 @@ ## 1. 结论 -**在线 Convex 文档和本地 `.md` 文件本质上是同一个东西:一段 markdown 文本,Tiptap 只是块级 UI 表现层。** 进一步切到 local-first 后,本地 `.md` 已经是普通文件,因此不需要再为常规正文编辑发明一套 MNote 专用工具。当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题: +**当前事实源是 local-first `.md` 文件,Tiptap 只是块级 UI 表现层。** Convex 相关正文链已经退役为历史 / compat 边界,不能再和本地 `.md` 并列描述为当前主路径。既然默认对象已经是普通 Markdown 文件,就不需要再为常规正文编辑发明一套 MNote 专用块级工具。当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题: 1. **AI 被迫在块级操作**:对「改写这段话」「补充一段总结」「调整语气」「把所有 TODO 改成 DONE」等自然请求,AI 必须产出 `{ op: "replace", blockId: "block_1", ... }` 格式的块操作,而不能直接产出修改后的文本或搜索替换对。这增加了 AI 的认知负担和出错概率。 -2. **本地 `.md` 文件没有 AI 能力**:本地文件没有稳定的 `blockId`(每次解析重新分配),因此块级工具无法应用于本地文件。当前 Hermes 的 13 个工具没有一个是面向本地文件的。 +2. **旧块级工具不适合 local-first `.md`**:本地文件没有稳定的 `blockId`(每次解析重新分配),因此块级工具不能作为普通 Markdown 编辑默认路径。 -3. **两套口径维护**:在线文档(块操作)和本地文档(无 AI 路径)长期走两套路径,维护成本翻倍。 +3. **历史 compat 容易污染当前主线**:如果继续把 Convex / online 文档写成并列路径,后续实现会误把 `mnote.doc.*` 或 `documents.save` 当成新增能力入口,而不是 local-first 的 fallback / 迁移边界。 ### 参考实现的验证 @@ -64,11 +71,11 @@ ### 正确方向 -> mnote 的 AI 编辑路线:**本地授权文件 + agent 原生 patch/diff 为主;MNote 兼容工具为 cloud/remote/结构化辅助;BlockNote AI 的流式/review 只作为远期交互参考。** +> mnote 的 AI 编辑路线:**本地授权文件 + agent 原生 patch/diff 为主;MNote 兼容工具仅用于显式 cloud/remote compat 或结构化辅助;BlockNote AI 的流式/review 只作为远期交互参考。** 具体: -- **当前实施**(Phase A/B):local-first 页面 AI 只传当前文件引用、可选 selection 和用户指令;MNote 校验 `AiAccessScope` 后让 agent 在受限目录中使用原生 patch/diff 编辑 `.md`。`mnote.doc.markdown_edit` 作为兼容 / 远端代理 fallback,`mnote.block.*` 作为复杂结构辅助。 +- **当前实施**(Phase A/B):local-first 页面 AI 只传当前文件引用、可选 selection 和用户指令;MNote 校验 `AiAccessScope` 后让 agent 在受限目录中使用原生 patch/diff 编辑 `.md`。`mnote.doc.markdown_edit` 只作为显式 cloud / remote agent / compat fallback,`mnote.block.*` 只作为复杂结构辅助。 - **远期规划**(Phase C):BlockNote AI 的流式增量 apply + suggest/review 层。当前先设计,不实施。 --- @@ -109,7 +116,7 @@ docs +update --command block_move_after --block-id "block_3" --target-block-id " **对 mnote 的启示**: 1. **两层操作模型直接适用**。mnote 的 `markdown_edit` = CLI Main 的 `str_replace`(文本级),`apply_block_ops` = CLI Main 的 `block_*`(块级)。 -2. **但 mnote 的主格式是 markdown 而非 XML**。在线文档的持久化格式(Convex `documents.content`)可以投影为 markdown,本地文件本身就是 markdown。所以我们不需要 XML 这一层——markdown 既是 AI 编辑格式,也是人类可读格式。 +2. **但 mnote 的主格式是 markdown 而非 XML**。当前 local-first 正文事实源就是本地 `.md`,历史 cloud / compat 内容也只能先投影为 markdown 后进入兼容工具。所以我们不需要 XML 这一层——markdown 既是 AI 编辑格式,也是人类可读格式。 3. **CLI Main 的 skill 格式**(YAML frontmatter + Markdown body,定义 tool shortcuts + 上下文格式规范)值得学习。mnote 的 Hermes `mnote` plugin 可以用类似方式组织。 #### 2.1.1 Skill YAML 前端元数据 @@ -268,7 +275,7 @@ CLI Main 的 lark-doc skill 定义了嵌入式资源的显式路由表: ``` **→ mnote 采纳**(远期,当文档内嵌入其他资源类型时): -- 在线文档中嵌入的思维导图 → 路由到 `mnote-mindmap` skill +- local-first Markdown 中嵌入的思维导图资源 → 路由到 `mnote-mindmap` skill - 嵌入的附件/文件 → 路由到 `mnote-file` skill - 当前 Phase A 不实施,预留路由表字段 @@ -284,7 +291,7 @@ CLI Main 的格式选择规则: | 整篇导入/导出 | Markdown | 人类可读,和 `.md` 互通 | | AI 对话引用 | Markdown | 模型理解 markdown 远好于 XML | -**→ mnote 采纳**:CLI Main 用 XML 做默认格式是因为飞书文档本身是 XML 存储。mnote 的在线文档持久化格式(Convex `documents.content`)和本地文件都是 markdown,因此 markdown 是 mnote 的默认和唯一 AI 格式。这是正确的差异化决策。 +**→ mnote 采纳**:CLI Main 用 XML 做默认格式是因为飞书文档本身是 XML 存储。mnote 当前主存储是 local-first `.md` 文件,历史 cloud / compat 内容也必须先投影成 markdown 后再进入 AI 编辑面,因此 markdown 是 mnote 的默认和唯一 AI 格式。这是正确的差异化决策。 #### 2.1.7 Skill CI 校验 @@ -320,8 +327,8 @@ LLM 流式输出 partial JSON |------|------|---------------| | `StreamTool` | 单操作的定义:name + inputSchema + validate + execute | mnote 已有(`mnote.block.*` / `mnote.doc.*`),不需要重构 | | `StreamToolExecutor` | 流式解析 partial JSON → 逐条 enqueue → 按顺序 execute | Phase C 新增:`StreamApplyController` | -| `suggestChanges` | ProseMirror suggestion marks:AI 编辑不直接落盘,先标记为待审阅 | Phase C 新增:在线文档的 `ReviewSession` | -| `RebaseTool` | 协作场景:用户同时在编辑 → AI 操作 rebase 到最新文档状态 | Phase C 考虑(协作场景依赖 Convex 的 revision 乐观锁) | +| `suggestChanges` | ProseMirror suggestion marks:AI 编辑不直接落盘,先标记为待审阅 | Phase C 新增:local-first `ReviewSession` / 临时审阅层 | +| `RebaseTool` | 协作场景:用户同时在编辑 → AI 操作 rebase 到最新文档状态 | Phase C 考虑;local-first 下优先基于 file version / BufferStore 冲突模型 | | `delayAgentStep` | 逐条 apply 之间加 50-200ms 延迟,给用户"AI 正在操作"的可见性 | Phase C 可选改善 | **BlockNote AI 不适合直接搬的原因**:它的所有操作都通过 `id`(blockId)寻址。`add` 需要 `referenceId`、`update` 需要 `id`、`delete` 需要 `id`。这意味着: @@ -354,23 +361,23 @@ LLM 流式输出 partial JSON 「把文章中所有 TODO 改成 DONE」这类跨块请求,当前需要逐一产出 N 个 `{ op: "replace", blockId: "..." }`。文本级 search/replace 只需一条:`{ search: "TODO", replace: "DONE" }`。 -### 3.2 本地文件没有 AI 写入路径 +### 3.2 旧块级工具没有 local-first 默认写入语义 > 参考对照:CLI Main 的 skill 可以操作任何 `doc-token`(包括本地文件和云端文档),因为它用的是文本级 + XML 级工具,不依赖特定存储。 -mnote 当前:Hermes 只知道 workspace/document 模型,不知道本地文件夹/文件模型。本地 `.md` 文件在 AI 视角完全不可见。 +mnote 当前主线:页面已能定位 local folder `.md`,但普通 AI 正文编辑不能回到“必须构造完整 page context 或 block ops”的旧模型。AI 视角应看到受控文件引用、selection 和 allowed roots,而不是一份由前端拼出的第二事实源。 ### 3.3 两套体系互不相认 -| | 在线 Convex 文档 | 本地 .md 文件 | +| | 历史 cloud / compat 文档 | 当前 local-first `.md` 文件 | |---|---|---| -| 存储 | Convex `documents.content` | 文件系统 `*.md` | -| AI 读取 | `mnote.doc.fetch`(block projection) | 无 | -| AI 写入 | `mnote.block.*` / `mnote.doc.apply_block_ops` | 无 | -| 写入粒度 | 块级(需要 blockId) | —(无 AI 路径) | -| 设计覆盖 | 07-ai 全部文档 | 仅在 03-rust-web 讨论解析 | +| 存储 | 历史迁移源 / 显式 cloud compat / sync replica | 文件系统 `*.md` | +| AI 读取 | `mnote.doc.fetch` 兼容投影 | 当前文件引用 / Page Aggregate / local markdown | +| AI 写入 | `mnote.doc.*` 或 `mnote.block.*` 兼容 fallback | agent 原生 patch/diff + watcher 同步 | +| 写入粒度 | 工具代理,不能作为新增默认入口 | 文件级 patch + selection 辅助 | +| 设计定位 | 历史 / compat 边界 | 当前主路径 | -两份体系在设计中互不相认。3-13 写明"不影响 Convex workspace 链路"——这是主动划清界限。**收敛是必须的,markdown 是共同分母。** +旧设计把 compat 文档和本地文件写成两套体系,容易让后续实现继续往旧 cloud / block 工具堆逻辑。当前必须收敛:**local-first `.md` 是默认事实源,markdown 是共同格式,compat 只服务迁移、显式 cloud 或 remote agent 边界。** --- @@ -380,28 +387,28 @@ mnote 当前:Hermes 只知道 workspace/document 模型,不知道本地文 > 参考对照:CLI Main 用 Markdown 做整篇导入/导出和对话引用,用 XML 做精确块编辑。mnote 直接用 Markdown 做所有 AI 操作,因为 mnote 没有 XML 存储层。 -AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 AI 概念。在线文档的持久化格式和本地文件的持久化格式都可以投影为 markdown。 +AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 AI 概念。当前 local-first 持久化格式就是 markdown;历史 cloud / compat 内容若需要进入 AI 编辑,也必须先投影成 markdown。 ### 4.2 两层操作模型(兼容层) | 层 | 工具 | 寻址方式 | 适用场景 | 占比 | |----|------|---------|---------|------| | **文件级(主)** | agent 原生 `diff/apply_patch/文件编辑` | 授权文件引用 / selection | local-first 普通 Markdown 改写 | 80%+ | -| **文本级(兼容)** | `mnote.doc.markdown_edit` | search/replace 文本对 | cloud / remote agent / 兼容旧页面 AI | 次要 | +| **文本级(兼容)** | `mnote.doc.markdown_edit` | search/replace 文本对 | 显式 cloud / remote agent / 兼容旧页面 AI | 次要 | | **块级(辅助)** | `mnote.doc.apply_block_ops` | blockId / matchText | "把第三块拖到第一块后面"、"精确删除引用块" | <20% | -### 4.3 在线和本地的主写入路径 +### 4.3 local-first 主写入路径 ``` 页面 AI / ACP - → resolve_source(documentId) → Convex | LocalFS - → local-first: 传授权文件引用给 agent runtime - → agent 原生 patch/diff 写入文件 - → MNote 做权限 / 审计 / refresh - → cloud / remote fallback: mnote.doc.markdown_edit + → resolve_current_file(documentId, sourceKind=local_folder, rootUri) + → 传授权文件引用 / selection / allowed roots 给 agent runtime + → agent 原生 patch/diff 写入文件 + → MNote 做权限 / 审计 / watcher refresh / BufferStore 冲突 + → 仅在显式 cloud / remote compat 时 fallback 到 mnote.doc.markdown_edit ``` -差异主要在执行层:local-first 直接让 agent 修改授权文件;cloud 或受限 remote runtime 无法直接访问本地文件时,再走 `mnote.doc.markdown_edit` 代理。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致,只是 mnote 进一步把“编辑算法”让渡给 agent runtime。 +当前执行层不再二选一。默认只有 local-first 授权文件路径;cloud 或受限 remote runtime 无法直接访问本地文件时,才显式走 `mnote.doc.markdown_edit` 代理。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致,只是 mnote 进一步把“编辑算法”让渡给 agent runtime。 ### 4.4 Diff 是内部实现细节 @@ -434,17 +441,17 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 ``` 增强点: -- `format: "markdown"` — 新增值。在线文档由 Page Aggregate 产出 PageMarkdown;本地文件直接返回 `.md` 原文。 -- `documentId` — 自动检测 source:Convex workspace 文档 vs 本地文件系统路径。 +- `format: "markdown"` — 兼容值。local-first 文档直接返回 `.md` 原文;历史 cloud / compat 文档必须经 Page Aggregate 投影成 markdown。 +- `documentId` — 当前默认解析为 local-first 文档;只有显式 sourceKind / compat 标记允许进入 cloud adapter。 返回: ```json { "ok": true, - "source": "convex", - "documentId": "tree_xxx", - "revision": 3, + "source": "local_folder", + "documentId": "local-md:README.md", + "fileVersion": "local-md:README.md:...", "format": "markdown", "content": "# 标题\n\n段落内容...\n\n## 子标题\n\n...", "truncated": false, @@ -471,7 +478,7 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 服务端处理流程: ``` -1. resolve_source(documentId) → ConvexAdapter | LocalFSAdapter +1. resolve_source(documentId, sourceKind, rootUri) → LocalFSAdapter | explicit CompatAdapter 2. 读取当前 markdown 全文 3. 逐条 search_replace(精确匹配 → fuzzy fallback) 4. 计算内部 diff(用于 BlockDelta 推送) @@ -484,16 +491,16 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 ```json { "ok": true, - "source": "convex", - "documentId": "tree_xxx", - "revision": { "before": 3, "after": 4 }, + "source": "local_folder", + "documentId": "local-md:README.md", + "fileVersion": { "before": "local-md:README.md:...", "after": "local-md:README.md:..." }, "operationsApplied": 2, "operationsFailed": 0, "failedOperations": [], "changedText": "已将「原文片段」替换为「新文本」\n已将「另一段」替换为「改写后的内容」", "blockDelta": { - "documentId": "tree_xxx", - "revision": 4, + "documentId": "local-md:README.md", + "fileVersion": "local-md:README.md:...", "operations": [...] } } @@ -518,7 +525,7 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 多条 operation 按数组顺序串行执行。前一条的替换结果对后一条可见(和 sed 语义一致)。 -**乐观锁**:执行前用 `revision` 检测。如果写入时 revision 已过期,返回冲突错误,让 AI 重新 fetch + edit。 +**乐观锁**:local-first 下执行前用 file version / conflict detection key 检测。如果写入时文件版本已过期,返回冲突错误,让 AI 重新 fetch + edit;历史 cloud compat 才能使用旧 revision 语义。 --- @@ -534,7 +541,7 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 2. 服务端逐条 apply 并推送 BlockDelta 3. 编辑器逐条渲染修改(带延迟,模拟"AI 正在编辑") 4. 用户可逐条 accept / reject(suggestion 模式) -5. 确认后才最终写入 Convex(review 模式) +5. 确认后才最终写入本地 `.md` 或显式 compat 目标(review 模式) ### 7.2 与 BlockNote AI 的差异 @@ -558,9 +565,9 @@ StreamApplyController(新增) 4. 可选:注入 delay(50-200ms)模拟人类编辑节奏 输出:逐条 BlockDelta -ReviewSession(新增,在线文档) +ReviewSession(新增,local-first 审阅层) 状态:pending → accepted | rejected - 存储:Convex review_session 表或 documents 的 review 字段 + 存储:SQLite control-plane / 本地 transient review state;不依赖 Convex 生命周期: - AI 编辑 → 创建 ReviewSession → 所有修改标记为 pending - 用户逐条操作 → accept/reject → 更新 session 状态 @@ -577,10 +584,10 @@ GhostTextOverlay(新增,编辑器) |------|------|------| | C-1 | `StreamApplyController`:流式 apply + SSE 逐条推送 delta | 7-13 EditorRuntimeActor delta 通道 | | C-2 | `GhostTextOverlay`:编辑器内 diff 展示(红删绿增) | leptos-tiptap 的 decoration 能力 | -| C-3 | `ReviewSession`:在线文档的 accept/reject session | Convex review 表设计 | +| C-3 | `ReviewSession`:local-first accept/reject session | SQLite control-plane / editor transient review state 设计 | | C-4 | `delayAgentStep`:可选的编辑节奏模拟 | C-1 完成 | -**当前不做实施决策**。Phase C 的启动时机以 Phase A/B 完成后,编辑器能力和 Convex review 表设计就绪为前置条件。 +**当前不做实施决策**。Phase C 的启动时机以 Phase A/B 完成后,编辑器 decoration 能力、BufferStore/file version 冲突模型和 SQLite control-plane review state 设计就绪为前置条件。 --- @@ -588,19 +595,19 @@ GhostTextOverlay(新增,编辑器) ### Phase A:文件引用主路径 + `mnote.doc.*` 兼容层 -- [x] `mnote.doc.fetch` 增加 `format: "markdown"`(在线文档 Page Aggregate → PageMarkdown) -- [x] `mnote.doc.fetch` 增加本地文件 source 路由(自动检测 Convex vs 文件系统路径) -- [x] 实现 `resolve_source(documentId)` — 本地文件路径 `local_fs` vs 其余走 Convex +- [x] `mnote.doc.fetch` 增加 `format: "markdown"`(local-first `.md` / Page Aggregate markdown 投影) +- [x] `mnote.doc.fetch` 增加本地文件 source 路由(`local_folder` / `rootUri` / `local-md:*`) +- [x] 实现 `resolve_source(documentId, sourceKind, rootUri)` — 默认 `local_folder`;历史 cloud / compat 必须显式进入 compat adapter - [ ] 页面 AI / ACP 普通正文编辑默认只传当前文件引用、可选 selection 和用户指令,不再默认构造完整 page context - [ ] 本地 agent runtime 在 `allowed_roots / allowed_file_paths` 内执行 patch/diff,并把 changed files / diff 摘要回传 MNote - [x] 实现 `search_replace(text, operations)` — 四级匹配策略(精确→宽松→段落 fuzzy→失败) -- [x] 实现 Convex 写入 adapter(复用 `doc_apply_block_ops` 链路) +- [x] 历史 cloud / compat 写入 adapter 已降级为 fallback,不再作为 local-first 默认入口 - [x] 实现本地文件写入 adapter(`mnote.doc.markdown_edit` 检测到本地文件路径时直接 `fs::write` 写回,不经过 Convex) - [ ] 内部 diff 生成 + BlockDelta 推送(复用 7-13 delta 基础设施,待 Phase C 实现) - [x] Hermes tool manifest 注册 `mnote.doc.markdown_edit` - [x] Hermes `mnote` plugin 更新:`mnote_doc_fetch` schema + `mnote_doc_markdown_edit` 新增 -- [x] 乐观锁:`revision` 冲突检测(`doc_apply_block_ops` 已有) -- [x] 浏览器 smoke(在线文档 `format: "markdown"` + `markdown_edit` 搜索替换) +- [x] 乐观锁:local-first 使用 file version / conflict detection key;compat 路径才保留 revision 语义 +- [x] 历史 browser smoke(cloud compat `format: "markdown"` + `markdown_edit` 搜索替换)仅作兼容证据,不代表当前主路径 - [x] 单元测试(`search_replace` 精确/失败/全文、`blocks_to_markdown` with_ids/heading 共 5 测试通过) - [x] 浏览器 smoke(本地 `.md` 文件读取 `mnote_doc_fetch` + 写入 `mnote_doc_markdown_edit`,`full_content` 创建 + `operations` 搜索替换 + 回读验证全部通过) @@ -662,7 +669,7 @@ GhostTextOverlay(新增,编辑器) - 不强迫 local-first agent 产出 MNote 自定义 diff;Hermes / Reasonix 可使用自身成熟 patch / diff / apply_patch 能力,MNote 负责白名单权限、文件版本冲突和审计。 - 不要求本地文件有稳定的 `blockId`(本地文件没有 block identity)。 - 不在 markdown_edit 内部引入新的 AI 模型调用(diff 是确定性算法)。 -- 不把 Convex `documents:updateContent` 重新提升为 local-first 正文主存储;`markdown_edit` 在 cloud / compat 场景可复用受控保存路径。 +- 不把 Convex / `documents:updateContent` 重新提升为当前正文主存储;`markdown_edit` 只在显式 cloud / compat 场景复用受控保存路径。 - 不把 `mnote.page.save` 重新描述为精确编辑主入口(它仍是兜底工具)。 - **不照搬 BlockNote AI 的纯 blockId 寻址模式**(与 mnote 的 markdown 优先策略冲突)。 @@ -670,12 +677,12 @@ GhostTextOverlay(新增,编辑器) ## 11. 成功标准 -- [x] `mnote.doc.fetch(documentId, format: "markdown")` 对在线文档返回正确 markdown +- [x] `mnote.doc.fetch(documentId, format: "markdown")` 对历史 cloud / compat 文档返回正确 markdown(兼容证据) - [ ] `mnote.doc.fetch(documentId, format: "markdown")` 对本地 `.md` 文件返回正确 markdown(兼容路径) - [x] `mnote.doc.markdown_edit` 的简单搜索替换(1 条 operation)浏览器 smoke 通过 - [ ] `mnote.doc.markdown_edit` 的复杂改写(3+ 条 operations)成功率 > 80% - [ ] 本地 `.md` 文件通过页面 AI 面板可被读取和写入,且默认通过 agent 原生 patch/diff 完成 -- [x] 在线文档的 markdown_edit 不增加 Convex RTT(和当前块操作持平) +- [x] 历史 cloud / compat markdown_edit 不增加额外 RTT(兼容证据,不代表当前主路径) - [x] `direct_block_edit_operations` 已退役(路由跳过,代码保留) - [x] `page_ai_workflow.rs` 的 system prompt 已补全 search/replace schema - [x] Phase C(流式/review)的设计已冻结,不阻塞 A/B 实施 diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 78f12ab1..a79ea865 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -63,6 +63,14 @@ fn capabilities_json(capabilities: &[String]) -> Result<String, ControlPlaneErro serde_json::to_string(capabilities).map_err(ControlPlaneError::from) } +fn file_uri_to_legacy_path(value: &str) -> String { + value + .trim() + .strip_prefix("file://") + .unwrap_or("") + .to_string() +} + fn row_to_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserRecord> { Ok(UserRecord { id: row.get(0)?, @@ -793,13 +801,21 @@ impl ControlPlaneStore for SqliteControlPlaneStore { root_uri: &str, ) -> Result<ResolvedAccess, ControlPlaneError> { let conn = self.conn.lock().unwrap(); + let root_path = file_uri_to_legacy_path(root_uri); let mut stmt = conn.prepare( "SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision FROM directory_grants - WHERE user_id = ?1 AND status = 'active' AND (?2 = root_uri OR (recursive = 1 AND ?2 LIKE root_uri || '%'))", + WHERE user_id = ?1 + AND status = 'active' + AND ( + ?2 = root_uri + OR (recursive = 1 AND ?2 LIKE root_uri || '%') + OR (?3 != '' AND ?3 = root_path) + OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%') + )", )?; let grants = stmt - .query_map(params![actor_id, root_uri], row_to_grant)? + .query_map(params![actor_id, root_uri, root_path], row_to_grant)? .collect::<Result<Vec<_>, _>>()?; let mut permission = "none".to_string(); for grant in &grants { @@ -1800,6 +1816,31 @@ mod tests { assert_eq!(access.permission, "none"); } + #[test] + fn resolve_access_supports_legacy_directory_grants_with_plain_path_root_uri() { + let store = store(); + create_user(&store, "legacy_reader"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "legacy_reader".to_string(), + workspace_id: None, + root_uri: "/tmp/shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "read".to_string(), + recursive: true, + capabilities: vec![], + source: "legacy".to_string(), + created_by: None, + }) + .expect("legacy grant"); + + let access = store + .resolve_access("legacy_reader", "file:///tmp/shared/page.md") + .expect("resolve access"); + assert_eq!(access.permission, "read"); + assert_eq!(access.grant_ids.len(), 1); + } + #[test] fn share_link_token_is_hashed_and_revocable() { let store = store(); diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 001e191c..91225e48 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -3,7 +3,7 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::{ control_plane_db_path_display, create_default_local_workspace_for_actor, - ensure_local_workspace_read_access, is_local_access_policy_admin_context, + ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, load_local_folder_page_tree_snapshot, load_local_trash_entries, }; use crate::routes::snapshot_support::load_sidebar_dataset; @@ -311,7 +311,7 @@ pub async fn root_entry( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; - ensure_local_workspace_read_access(&context, root_uri)?; + ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?; let snapshot = load_local_folder_page_tree_snapshot(root_uri)?; let workspace_id = snapshot .dataset @@ -614,7 +614,7 @@ pub async fn trash_entry( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; - ensure_local_workspace_read_access(&context, root_uri) + ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let workspace_id = crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?; @@ -1780,6 +1780,7 @@ fn handle_sqlite_auth_action( &resolved.user.id, resolved.user.email.as_deref().unwrap_or_default(), &resolved.user.display_name, + &effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role), )) } @@ -1809,18 +1810,20 @@ fn build_sqlite_auth_response( user_id: &str, email: &str, name: &str, + actor_type: &str, ) -> Response { let mut response = axum::Json(json!({ "ok": true, "userId": user_id, "email": email, "name": name, - "authMode": "sqliteSession" + "authMode": "sqliteSession", + "actorType": actor_type })) .into_response(); set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token); set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id); - set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user"); + set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type); if !email.trim().is_empty() { set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email); } @@ -1835,6 +1838,19 @@ fn build_sqlite_auth_response( response } +fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String { + let role = stored_role.trim(); + let fallback_role = if role.is_empty() { "user" } else { role }; + if crate::routes::local_folder_source::is_local_access_policy_admin_actor( + user_id, + fallback_role, + ) { + "admin".to_string() + } else { + fallback_role.to_string() + } +} + fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response { if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { let token_hash = session_token_hash(&raw_token); diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index b74efb3d..922289b9 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -2,7 +2,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::{ - ensure_local_workspace_read_access, load_local_folder_file_tree_snapshot, + ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, }; use crate::routes::query_support::resolve_effective_workspace_id; @@ -85,7 +85,7 @@ async fn project_projection( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; - ensure_local_workspace_read_access(&context, root_uri) + ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let snapshot = if projection == KernelProjectionKind::FileTree { load_local_folder_file_tree_snapshot(root_uri)? diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs index 8aa79eda..8f421343 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -2,7 +2,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::{ - decode_local_id_segment, ensure_local_workspace_read_access, + decode_local_id_segment, ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_workspace_id_from_root_uri, }; @@ -36,8 +36,9 @@ pub async fn local_folder_events( Extension(context): Extension<RequestContext>, Query(query): Query<LocalFolderEventsQuery>, ) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> { - let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri) - .map_err(|error| error.with_context(&context))?; + let canonical_root = + ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) + .map_err(|error| error.with_context(&context))?; let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) { build_tree_live_stream(state, context, canonical_root, query.root_uri).await? diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 9c22ccc1..68534ed8 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::cmp::Ordering; use std::collections::hash_map::DefaultHasher; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; @@ -132,7 +132,7 @@ struct LocalShareGrant { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LocalAccessMode { +pub(crate) enum LocalAccessMode { Read, Write, } @@ -490,7 +490,7 @@ fn ensure_local_workspace_access_for_actor_with_mode( )) } -fn ensure_local_workspace_access_with_state( +pub(crate) fn ensure_local_workspace_access_with_state( state: &AppState, context: &RequestContext, root_uri: &str, @@ -525,18 +525,7 @@ fn ensure_local_workspace_access_with_state( ) } -pub(crate) fn ensure_local_workspace_read_access( - context: &RequestContext, - root_uri: &str, -) -> Result<PathBuf, WebError> { - ensure_local_workspace_read_access_for_actor( - &context.auth.actor_id, - &context.auth.actor_type, - root_uri, - ) -} - -fn ensure_local_workspace_read_access_with_state( +pub(crate) fn ensure_local_workspace_read_access_with_state( state: &AppState, context: &RequestContext, root_uri: &str, @@ -544,6 +533,14 @@ fn ensure_local_workspace_read_access_with_state( ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Read) } +pub(crate) fn ensure_local_workspace_write_access_with_state( + state: &AppState, + context: &RequestContext, + root_uri: &str, +) -> Result<PathBuf, WebError> { + ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write) +} + pub(crate) fn ensure_local_path_read_access( context: &RequestContext, root_uri: &str, @@ -587,6 +584,17 @@ pub(crate) fn ensure_local_path_read_access( Ok(canonical_target) } +pub(crate) fn ensure_local_workspace_read_access( + context: &RequestContext, + root_uri: &str, +) -> Result<PathBuf, WebError> { + ensure_local_workspace_read_access_for_actor( + &context.auth.actor_id, + &context.auth.actor_type, + root_uri, + ) +} + pub(crate) fn ensure_local_workspace_access( context: &RequestContext, root_uri: &str, @@ -598,14 +606,6 @@ pub(crate) fn ensure_local_workspace_access( ) } -fn ensure_local_workspace_write_access_with_state( - state: &AppState, - context: &RequestContext, - root_uri: &str, -) -> Result<PathBuf, WebError> { - ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write) -} - fn local_access_policy_path() -> PathBuf { std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE) .ok() @@ -759,6 +759,10 @@ fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError> pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool { let actor_id = context.auth.actor_id.trim(); let actor_type = context.auth.actor_type.trim(); + is_local_access_policy_admin_actor(actor_id, actor_type) +} + +pub(crate) fn is_local_access_policy_admin_actor(actor_id: &str, actor_type: &str) -> bool { if actor_id.is_empty() || actor_id == "anonymous" || actor_type.is_empty() @@ -1629,6 +1633,7 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value { json!({ "id": grant.id, "userId": grant.user_id, + "workspaceId": grant.workspace_id, "rootUri": grant.root_uri, "rootPath": grant.root_path, "permission": grant.permission, @@ -1643,6 +1648,16 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value { }) } +fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool { + grant.source.trim() == "auto" + && grant.permission.trim() == "write" + && grant.recursive + && grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str()) + && grant.workspace_id.is_some() + && grant.root_uri.starts_with("local://users/") + && grant.root_uri.ends_with("/workspaces/my-space") +} + fn sqlite_access_policy_payload( state: &AppState, context: &RequestContext, @@ -1689,7 +1704,10 @@ fn sqlite_user_access_policy_payload( .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; let grant_values = grants .iter() - .filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id)) + .filter(|grant| { + grant.user_id.trim() == actor_id + || grant.created_by.as_deref().map(str::trim) == Some(actor_id) + }) .map(control_plane_grant_payload) .collect::<Vec<_>>(); Ok(json!({ @@ -1871,6 +1889,13 @@ fn delete_sqlite_user_access_grant_for_context( "目录授权不存在或不属于当前用户", ) })?; + if is_default_workspace_auto_grant(grant) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_access_policy_system_grant_readonly", + "默认空间的系统授权不能撤销", + )); + } if grant.created_by.as_deref().map(str::trim) != Some(actor_id) { return Err(WebError::new( StatusCode::FORBIDDEN, @@ -1933,6 +1958,26 @@ fn delete_sqlite_local_access_grant_for_context( "必须提供 grantId", )); } + let grants = state + .control_plane() + .find_directory_grants(DirectoryGrantLookup { + grant_id: Some(grant_id.to_string()), + user_id: None, + root_uri: None, + include_revoked: false, + }) + .map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?; + if grants + .first() + .map(is_default_workspace_auto_grant) + .unwrap_or(false) + { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_access_policy_system_grant_readonly", + "默认空间的系统授权不能撤销", + )); + } state .control_plane() .revoke_directory_grant(grant_id, None) @@ -2710,7 +2755,16 @@ pub fn resolve_local_markdown_page_aggregate( })?; let parsed = parse_markdown_page(&markdown, &markdown_file.file_name); let title = parsed.title; - let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body); + let attachment_paths = uploaded_asset_markdown_relative_paths_for_document( + &canonical_root, + &metadata, + document_id, + &markdown_file.path, + ); + let content = crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths( + &parsed.body, + &attachment_paths, + ); let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64; let page_subtree = markdown_page_subtree(document_id, &title, &content); let workspace_id = local_workspace_id(&canonical_root); @@ -3122,7 +3176,28 @@ pub async fn read_local_resource( .unwrap_or("资源") .to_string(); let content = if is_markdown_file(&file_name) { - crate::routes::local_markdown_parser::markdown_to_blocks(&text) + let metadata = load_local_folder_metadata(&root)?; + let target_relative_path = normalize_relative_path(&root, &target)?; + let attachment_paths = metadata + .uploaded_assets + .get(&target_relative_path) + .map(|entry| { + uploaded_asset_markdown_relative_paths_for_document( + &root, + &metadata, + &entry.document_id, + &target, + ) + }) + .unwrap_or_default(); + if attachment_paths.is_empty() { + crate::routes::local_markdown_parser::markdown_to_blocks(&text) + } else { + crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths( + &text, + &attachment_paths, + ) + } } else { text_to_editor_blocks(&text, &file_name) }; @@ -6437,6 +6512,26 @@ fn find_markdown_by_page_id( walk(root, root, metadata, document_id) } +fn uploaded_asset_markdown_relative_paths_for_document( + root: &Path, + metadata: &LocalFolderMetadata, + document_id: &str, + markdown_path: &Path, +) -> BTreeSet<String> { + let Some(markdown_dir) = markdown_path.parent() else { + return BTreeSet::new(); + }; + metadata + .uploaded_assets + .values() + .filter(|entry| entry.document_id == document_id) + .filter_map(|entry| { + let path = root.join(&entry.relative_path); + normalize_markdown_relative_asset_path(markdown_dir, &path).ok() + }) + .collect() +} + fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { let mut item = json!({ "rowId": row.row_id, @@ -7125,7 +7220,7 @@ fn editor_blocks_to_markdown_with_rewrite( .and_then(Value::as_str) .unwrap_or("paragraph"); let inline_markdown = block_content_value(&block) - .map(inline_nodes_to_markdown) + .map(|content| inline_nodes_to_markdown(content, local_file_context)) .unwrap_or_default() .trim() .to_string(); @@ -7319,7 +7414,11 @@ fn rewrite_local_open_url_to_markdown_relative( local_file_context: Option<(&Path, &Path)>, ) -> Option<String> { let (root, markdown_path) = local_file_context?; - let parsed = Url::parse(value).ok()?; + let parsed = if value.starts_with('/') { + Url::parse(&format!("http://localhost{}", value)).ok()? + } else { + Url::parse(value).ok()? + }; if parsed.path() != "/api/local-folder/files/open" { return None; } @@ -7605,7 +7704,7 @@ fn editor_block_table_to_markdown(block: &Value) -> String { .map(|cell| { let cell_text = cell .get("content") - .map(inline_nodes_to_markdown) + .map(|content| inline_nodes_to_markdown(content, None)) .unwrap_or_default() .replace('\n', " ") .replace('|', r"\|") @@ -7715,29 +7814,33 @@ fn block_content_value(block: &Value) -> Option<&Value> { // 过渡实现:手写行内节点→Markdown 回写函数。从 editor block 的 content 数组中 // 逐个节点提取 text + styles,按 legacy 样式格式输出为内联 Markdown。 // AST + 中间 IR 迁移 complete 后应统一走 MarkdownInline→Markdown 反向映射。 -fn inline_nodes_to_markdown(value: &Value) -> String { +fn inline_nodes_to_markdown(value: &Value, local_file_context: Option<(&Path, &Path)>) -> String { if let Some(text) = value.as_str() { return escape_markdown_inline_text(text); } if let Some(array) = value.as_array() { return array .iter() - .map(inline_node_to_markdown) + .map(|node| inline_node_to_markdown(node, local_file_context)) .collect::<Vec<_>>() .join(""); } if let Some(object) = value.as_object() { if let Some(text) = object.get("text").and_then(Value::as_str) { - return markdown_text_with_styles(text, &inline_styles_from_object(object)); + return markdown_text_with_styles( + text, + &inline_styles_from_object(object), + local_file_context, + ); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { - return inline_nodes_to_markdown(content); + return inline_nodes_to_markdown(content, local_file_context); } } String::new() } -fn inline_node_to_markdown(node: &Value) -> String { +fn inline_node_to_markdown(node: &Value, local_file_context: Option<(&Path, &Path)>) -> String { if let Some(text) = node.as_str() { return escape_markdown_inline_text(text); } @@ -7747,10 +7850,14 @@ fn inline_node_to_markdown(node: &Value) -> String { .and_then(Value::as_str) .unwrap_or_default(); if !text.is_empty() { - return markdown_text_with_styles(text, &inline_styles_from_object(object)); + return markdown_text_with_styles( + text, + &inline_styles_from_object(object), + local_file_context, + ); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { - return inline_nodes_to_markdown(content); + return inline_nodes_to_markdown(content, local_file_context); } } String::new() @@ -7802,7 +7909,11 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value { Value::Object(styles) } -fn markdown_text_with_styles(text: &str, styles: &Value) -> String { +fn markdown_text_with_styles( + text: &str, + styles: &Value, + local_file_context: Option<(&Path, &Path)>, +) -> String { let mut value = escape_markdown_inline_text(text); let link = styles .get("link") @@ -7838,6 +7949,8 @@ fn markdown_text_with_styles(text: &str, styles: &Value) -> String { value = format!("~~{value}~~"); } if let Some(href) = link { + let href = + rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href); value = format!("[{}]({href})", value.replace(']', r"\]")); } value @@ -7995,14 +8108,14 @@ mod tests { initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id, - open_local_file, record_shared_cache, record_sync_pending_change, + open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, save_local_markdown_page, update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data, write_local_resource, write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, - LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, - LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest, - SyncConflictReportRequest, SyncPendingChangeRequest, + LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest, + LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, + SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest, }; use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; @@ -8010,7 +8123,7 @@ mod tests { use axum::http::{HeaderMap, Method, StatusCode}; use axum::Json; use control_plane::{DirectoryGrantInput, UpsertUserInput}; - use serde_json::Value; + use serde_json::{json, Value}; use std::sync::Mutex; fn env_lock() -> &'static Mutex<()> { @@ -8452,6 +8565,193 @@ fn main() {} assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf"); } + #[test] + fn local_markdown_page_aggregate_preserves_uploaded_markdown_assets_as_media_blocks() { + let root = temp_root("mnote-local-uploaded-md-assets-media"); + init_workspace(&root); + std::fs::create_dir_all(root.join("Page")).expect("create page dir"); + std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); + let root_uri = format!("file://{}", root.display()); + let document_id = "local-md:Page~2FPage.md"; + + let first = write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Notes one\n".to_vec(), + }, + ) + .expect("upload first md asset"); + let second = write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Notes two\n".to_vec(), + }, + ) + .expect("upload second md asset"); + assert_eq!(first["sourcePath"], "notes.md"); + assert_eq!(second["sourcePath"], "notes-1.md"); + + save_local_markdown_page( + &root_uri, + document_id, + None, + &json!([ + {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Page"}]}, + {"type":"media","props":{"name":"notes.md","sourcePath":"notes.md"}}, + {"type":"media","props":{"name":"notes-1.md","sourcePath":"notes-1.md"}} + ]), + ) + .expect("save uploaded md links"); + + let aggregate = + resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate"); + let media_paths = aggregate + .body + .content + .as_array() + .expect("blocks") + .iter() + .filter(|block| block["type"].as_str() == Some("media")) + .map(|block| { + block["props"]["sourcePath"] + .as_str() + .unwrap_or_default() + .to_string() + }) + .collect::<Vec<_>>(); + assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() { + let root = temp_root("mnote-local-uploaded-md-inline-link"); + init_workspace(&root); + std::fs::create_dir_all(root.join("Page")).expect("create page dir"); + std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); + let root_uri = format!("file://{}", root.display()); + let document_id = "local-md:Page~2FPage.md"; + let asset = write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Notes\n".to_vec(), + }, + ) + .expect("upload md asset"); + let href = format!( + "http://127.0.0.1:3000/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md", + root_uri + ); + + save_local_markdown_page( + &root_uri, + document_id, + None, + &json!([ + { + "type": "paragraph", + "content": [{ + "type": "text", + "text": "notes.md", + "marks": [{ + "type": "link", + "attrs": { + "href": href, + "class": "mnote-uploaded-attachment" + } + }] + }] + } + ]), + ) + .expect("save inline attachment link"); + + assert_eq!(asset["sourcePath"], "notes.md"); + let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); + assert!(saved.contains("[notes.md](notes.md)")); + assert!(!saved.contains("/api/local-folder/files/open")); + + let aggregate = + resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate"); + let media = aggregate + .body + .content + .as_array() + .expect("blocks") + .iter() + .find(|block| block["type"].as_str() == Some("media")) + .expect("uploaded md inline link should reload as media block"); + assert_eq!(media["props"]["sourcePath"], "notes.md"); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() { + let root = temp_root("mnote-local-uploaded-md-relative-open-link"); + init_workspace(&root); + std::fs::create_dir_all(root.join("Page")).expect("create page dir"); + std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); + let root_uri = format!("file://{}", root.display()); + let document_id = "local-md:Page~2FPage.md"; + write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Notes\n".to_vec(), + }, + ) + .expect("upload md asset"); + let href = format!( + "/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md", + root_uri + ); + + save_local_markdown_page( + &root_uri, + document_id, + None, + &json!([ + { + "type": "paragraph", + "content": [{ + "type": "text", + "text": "notes.md", + "marks": [{ + "type": "link", + "attrs": { + "href": href, + "class": "mnote-uploaded-attachment-row" + } + }] + }] + } + ]), + ) + .expect("save relative open url"); + + let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); + assert!(saved.contains("[notes.md](notes.md)")); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() { let root = temp_root("mnote-local-markdown-save-basic-blocks"); @@ -9382,6 +9682,16 @@ fn main() {} assert_eq!(listed["grants"][0]["id"], grant_id); assert_eq!(listed["grants"][0]["permission"], "write"); + let (_, Json(target_listed)) = get_user_access_policy( + State(state.clone()), + Extension(request_context("user_target", "user")), + ) + .await + .expect("target can list incoming directory grants"); + assert_eq!(target_listed["controlPlane"], "sqlite"); + assert_eq!(target_listed["grants"][0]["id"], grant_id); + assert_eq!(target_listed["grants"][0]["userId"], "user_target"); + let target_delete_error = delete_user_access_grant( State(state.clone()), Extension(request_context("user_target", "user")), @@ -9426,6 +9736,44 @@ fn main() {} let _ = std::fs::remove_dir_all(&outside_root); } + #[tokio::test] + async fn user_access_policy_cannot_revoke_default_workspace_auto_grant() { + let _guard = env_lock().lock().expect("env lock"); + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("shujuan".into()), + email: Some("shujuan@example.com".into()), + username: "shujuan".into(), + display_name: "shujuan".into(), + role: None, + password_hash: None, + }) + .expect("upsert user"); + state + .control_plane() + .ensure_default_workspace("shujuan") + .expect("default workspace"); + let grant_id = state + .control_plane() + .list_directory_grants() + .expect("list grants") + .into_iter() + .find(|grant| grant.user_id == "shujuan" && grant.source == "auto") + .map(|grant| grant.id) + .expect("default auto grant"); + + let error = delete_user_access_grant( + State(state.clone()), + Extension(request_context("shujuan", "user")), + AxumPath(grant_id), + ) + .await + .expect_err("default grant must be readonly"); + assert_eq!(error.status(), StatusCode::FORBIDDEN); + } + #[tokio::test] async fn admin_share_grant_allows_any_local_folder_with_minimal_fields() { let _guard = env_lock().lock().expect("env lock"); @@ -10012,6 +10360,115 @@ fn main() {} assert!(markdown.contains("资源正文")); } + #[tokio::test] + async fn local_resource_read_preserves_uploaded_markdown_attachment_links() { + let root = temp_root("mnote-local-resource-read-uploaded-md-links"); + init_workspace(&root); + std::fs::create_dir_all(root.join("Page")).expect("create page dir"); + std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); + let root_uri = format!("file://{}", root.display()); + let document_id = "local-md:Page~2FPage.md"; + let resource = write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "resource.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Resource\n".to_vec(), + }, + ) + .expect("upload resource markdown"); + let first = write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Notes one\n".to_vec(), + }, + ) + .expect("upload first linked markdown"); + let second = write_local_markdown_asset( + &root_uri, + document_id, + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Notes two\n".to_vec(), + }, + ) + .expect("upload second linked markdown"); + assert_eq!(resource["sourcePath"], "resource.md"); + assert_eq!(first["sourcePath"], "notes.md"); + assert_eq!(second["sourcePath"], "notes-1.md"); + std::fs::write( + root.join("Page").join("resource.md"), + "# Resource\n\n[notes.md](notes.md)\n\n[notes-1.md](notes-1.md)\n", + ) + .expect("write resource links"); + + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("resource_reader".into()), + email: Some("resource-reader@example.com".into()), + username: "resource_reader".into(), + display_name: "resource_reader".into(), + role: None, + password_hash: None, + }) + .expect("upsert resource reader"); + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: "resource_reader".into(), + workspace_id: None, + root_uri: root_uri.clone(), + root_path: root + .canonicalize() + .expect("canonical root") + .display() + .to_string(), + permission: "read".into(), + recursive: true, + capabilities: vec![], + source: "test".into(), + created_by: None, + }) + .expect("grant read"); + let context = request_context("resource_reader", "user"); + let (_, _, payload) = read_local_resource( + State(state), + Extension(context), + Query(LocalResourceReadQuery { + root_uri: root_uri.clone(), + path: "Page/resource.md".into(), + }), + ) + .await + .expect("read resource markdown"); + + let media_paths = payload["result"]["content"] + .as_array() + .expect("content blocks") + .iter() + .filter(|block| block["type"].as_str() == Some("media")) + .map(|block| { + block["props"]["sourcePath"] + .as_str() + .unwrap_or_default() + .to_string() + }) + .collect::<Vec<_>>(); + assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_tree_command_delete_folder_moves_directory_to_trash() { let root = temp_root("mnote-local-delete-folder"); @@ -10383,6 +10840,64 @@ fn main() {} let _ = std::fs::remove_dir_all(&policy_root); } + #[tokio::test] + async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root"); + let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config"); + let policy_file = policy_root.join("missing-access-policy.json"); + let state = test_state(); + let canonical_root = root.canonicalize().expect("canonical root"); + let canonical_root_path = canonical_root.display().to_string(); + let root_uri = format!("file://{canonical_root_path}"); + std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("legacy_sqlite_reader".into()), + email: Some("legacy-sqlite-reader@example.com".into()), + username: "legacy_sqlite_reader".into(), + display_name: "legacy_sqlite_reader".into(), + role: None, + password_hash: None, + }) + .expect("upsert sqlite reader"); + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: "legacy_sqlite_reader".into(), + workspace_id: None, + root_uri: canonical_root_path.clone(), + root_path: canonical_root_path, + permission: "read".into(), + recursive: true, + capabilities: vec![], + source: "legacy-test".into(), + created_by: None, + }) + .expect("grant legacy sqlite read"); + + let context = request_context("legacy_sqlite_reader", "user"); + let (_, _, bytes) = open_local_file( + State(state), + Extension(context), + Query(LocalFileOpenQuery { + root_uri, + path: "README.txt".into(), + download: None, + }), + ) + .await + .expect("legacy sqlite read grant can open local file"); + assert_eq!(bytes, b"hello legacy sqlite"); + assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy"); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } + #[tokio::test] async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() { let _guard = env_lock().lock().expect("env lock"); diff --git a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs index ebd8c343..8737ac63 100644 --- a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs +++ b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs @@ -1,6 +1,7 @@ use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment}; use comrak::{parse_document, Arena, Options}; use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; #[derive(Debug, Clone)] pub struct ParsedLocalMarkdownPage { @@ -134,7 +135,14 @@ fn find_first_h1(body: &str) -> Option<String> { } pub fn markdown_to_blocks(markdown: &str) -> Value { - markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown)) + markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new()) +} + +pub fn markdown_to_blocks_with_attachment_paths( + markdown: &str, + attachment_paths: &BTreeSet<String>, +) -> Value { + markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths)) } fn markdown_options() -> Options<'static> { @@ -149,6 +157,13 @@ fn markdown_options() -> Options<'static> { } pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { + parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new()) +} + +fn parse_markdown_attachment_link_with_paths( + trimmed: &str, + attachment_paths: &BTreeSet<String>, +) -> Option<(String, String)> { let value = trimmed .strip_prefix('!') .unwrap_or(trimmed) @@ -170,7 +185,9 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> } let target_path = std::path::Path::new(target); let extension = target_path.extension().and_then(|value| value.to_str())?; - if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") { + if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")) + && !attachment_paths.contains(target) + { return None; } let fallback_name = target_path @@ -186,20 +203,27 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> Some((name.to_string(), target.to_string())) } -fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument { +fn parse_markdown_ast_document( + markdown: &str, + attachment_paths: &BTreeSet<String>, +) -> MarkdownAstDocument { let arena = Arena::new(); let options = markdown_options(); let root = parse_document(&arena, markdown, &options); let mut blocks = Vec::new(); for node in root.children() { - append_ast_block(node, &mut blocks); + append_ast_block(node, &mut blocks, attachment_paths); } MarkdownAstDocument { blocks } } -fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) { +fn append_ast_block<'a>( + node: &'a AstNode<'a>, + blocks: &mut Vec<MarkdownBlock>, + attachment_paths: &BTreeSet<String>, +) { match node.data.borrow().value.clone() { - NodeValue::Paragraph => append_ast_paragraph(node, blocks), + NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths), NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading { level: heading.level, content: collect_inline_children(node), @@ -212,7 +236,12 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) }), NodeValue::List(list) => { for item in node.children() { - append_ast_list_item(item, list.list_type == ListType::Ordered, blocks); + append_ast_list_item( + item, + list.list_type == ListType::Ordered, + blocks, + attachment_paths, + ); } } NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)), @@ -233,7 +262,11 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) } } -fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) { +fn append_ast_paragraph<'a>( + node: &'a AstNode<'a>, + blocks: &mut Vec<MarkdownBlock>, + attachment_paths: &BTreeSet<String>, +) { if let Some((alt, source_path)) = paragraph_image(node) { blocks.push(MarkdownBlock::Image { alt, source_path }); return; @@ -242,11 +275,13 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc blocks.push(MarkdownBlock::Mindmap { name, source_path }); return; } - if let Some((name, source_path)) = paragraph_attachment_media(node) { + if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) { blocks.push(MarkdownBlock::Media { name, source_path }); return; } - if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) { + if let Some((name, source_path, remaining)) = + paragraph_leading_attachment_media(node, attachment_paths) + { blocks.push(MarkdownBlock::Media { name, source_path }); let content = merge_adjacent_inline_nodes(remaining); if !content.is_empty() { @@ -257,17 +292,22 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node))); } -fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec<MarkdownBlock>) { +fn append_ast_list_item<'a>( + node: &'a AstNode<'a>, + ordered: bool, + blocks: &mut Vec<MarkdownBlock>, + attachment_paths: &BTreeSet<String>, +) { let (is_task, checked) = match node.data.borrow().value.clone() { NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()), NodeValue::Item(_) => (false, false), - _ => return append_ast_block(node, blocks), + _ => return append_ast_block(node, blocks, attachment_paths), }; let mut content = Vec::new(); for child in node.children() { match child.data.borrow().value.clone() { NodeValue::Paragraph => content.extend(collect_inline_children(child)), - _ => append_ast_block(child, blocks), + _ => append_ast_block(child, blocks, attachment_paths), } } @@ -404,13 +444,16 @@ fn merge_adjacent_inline_nodes(nodes: Vec<MarkdownInline>) -> Vec<MarkdownInline merged } -fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { +fn paragraph_attachment_media<'a>( + node: &'a AstNode<'a>, + attachment_paths: &BTreeSet<String>, +) -> Option<(String, String)> { let mut children = node.children(); let first = children.next()?; if children.next().is_some() { return None; } - link_attachment_media(first) + link_attachment_media(first, attachment_paths) } fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { @@ -437,10 +480,11 @@ fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { fn paragraph_leading_attachment_media<'a>( node: &'a AstNode<'a>, + attachment_paths: &BTreeSet<String>, ) -> Option<(String, String, Vec<MarkdownInline>)> { let mut children = node.children(); let first = children.next()?; - let (name, source_path) = link_attachment_media(first)?; + let (name, source_path) = link_attachment_media(first, attachment_paths)?; let second = children.next()?; if !matches!( second.data.borrow().value, @@ -455,11 +499,17 @@ fn paragraph_leading_attachment_media<'a>( Some((name, source_path, remaining)) } -fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { +fn link_attachment_media<'a>( + node: &'a AstNode<'a>, + attachment_paths: &BTreeSet<String>, +) -> Option<(String, String)> { let NodeValue::Link(link) = &node.data.borrow().value else { return None; }; - parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url)) + parse_markdown_attachment_link_with_paths( + &format!("[{}]({})", collect_plain_text(node), link.url), + attachment_paths, + ) } fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { diff --git a/rust/crates/mnote-web/src/routes/mindmap_api.rs b/rust/crates/mnote-web/src/routes/mindmap_api.rs index a2afc51d..8291d7b7 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_api.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_api.rs @@ -3,8 +3,8 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts; use crate::routes::local_folder_source::{ - ensure_local_workspace_access, ensure_local_workspace_read_access, read_local_mindmap_data, - write_local_mindmap_data, + ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state, + read_local_mindmap_data, write_local_mindmap_data, }; use crate::routes::query_support::{ execute_runtime_query_against_data, execute_runtime_query_via_convex, @@ -151,7 +151,7 @@ pub async fn get_mindmap( } if is_local_folder_source(params.source_kind.as_deref()) { let root_uri = local_root_uri(params.root_uri.as_deref(), None)?; - ensure_local_workspace_read_access(&context, root_uri) + ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let data = read_local_mindmap_data(root_uri, document_id, mindmap_id) .map_err(|error| error.with_context(&context))?; @@ -228,7 +228,7 @@ pub async fn apply_mindmap_command( .or(params.source_kind.as_deref()); if is_local_folder_source(local_source_kind) { let root_uri = local_root_uri(params.root_uri.as_deref(), body.root_uri.as_deref())?; - ensure_local_workspace_access(&context, root_uri) + ensure_local_workspace_write_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; if command_name == Some("mindmap.command.apply") { let current = read_local_mindmap_data(root_uri, document_id, mindmap_id) diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index 868cd8b4..45126871 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -1268,7 +1268,9 @@ mod tests { std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("Page")).expect("create page"); - std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write md"); + let markdown_path = root.join("Page").join("Page.md"); + let original_markdown = "# Page\n\n[map](map.mindmap.json)\n"; + std::fs::write(&markdown_path, original_markdown).expect("write md"); std::fs::write( root.join("Page").join("map.mindmap.json"), r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#, @@ -1313,6 +1315,11 @@ mod tests { .join("trash") .join("map.mindmap.json") .exists()); + assert_eq!( + std::fs::read_to_string(&markdown_path).expect("read markdown after trash"), + original_markdown, + "mindmap archive 只移动资源文件,不应改写正文引用" + ); let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index"); assert!(trash_index.contains("local-file:Page/map.mindmap.json")); @@ -1354,6 +1361,11 @@ mod tests { "tree.resource.restore" ); assert!(root.join("Page").join("map.mindmap.json").exists()); + assert_eq!( + std::fs::read_to_string(&markdown_path).expect("read markdown after restore"), + original_markdown, + "mindmap restore 只恢复资源文件,不应改写正文引用" + ); let delete_again = app() .oneshot( diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index eb22da44..dfff1461 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -179,8 +179,10 @@ pub async fn documents( WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri") .with_context(&context) })?; - let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) - .map_err(|error| error.with_context(&context))?; + let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( + &state, &context, root_uri, + ) + .map_err(|error| error.with_context(&context))?; local_search_index::query_local_search_index( &root_path, root_uri, @@ -227,6 +229,7 @@ pub async fn documents( } pub async fn refresh_local_index( + State(state): State<AppState>, Extension(context): Extension<RequestContext>, Json(body): Json<LocalSearchIndexRefreshRequest>, ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { @@ -241,8 +244,10 @@ pub async fn refresh_local_index( ) .with_context(&context)); } - let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) - .map_err(|error| error.with_context(&context))?; + let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( + &state, &context, root_uri, + ) + .map_err(|error| error.with_context(&context))?; let refreshed = local_search_index::refresh_local_search_index( &root_path, root_uri, @@ -268,6 +273,7 @@ pub async fn refresh_local_index( } pub async fn local_index_backlinks( + State(state): State<AppState>, Extension(context): Extension<RequestContext>, Query(query): Query<LocalSearchIndexQuery>, ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { @@ -294,8 +300,10 @@ pub async fn local_index_backlinks( ) .with_context(&context) })?; - let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) - .map_err(|error| error.with_context(&context))?; + let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( + &state, &context, root_uri, + ) + .map_err(|error| error.with_context(&context))?; let backlinks = local_search_index::query_local_backlinks( &root_path, root_uri, @@ -322,6 +330,7 @@ pub async fn local_index_backlinks( } pub async fn local_index_tags( + State(state): State<AppState>, Extension(context): Extension<RequestContext>, Query(query): Query<LocalSearchIndexQuery>, ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { @@ -336,8 +345,10 @@ pub async fn local_index_tags( ) .with_context(&context)); } - let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri) - .map_err(|error| error.with_context(&context))?; + let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( + &state, &context, root_uri, + ) + .map_err(|error| error.with_context(&context))?; let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); diff --git a/rust/crates/mnote-web/src/routes/session.rs b/rust/crates/mnote-web/src/routes/session.rs index 53288487..a3dd9aff 100644 --- a/rust/crates/mnote-web/src/routes/session.rs +++ b/rust/crates/mnote-web/src/routes/session.rs @@ -78,10 +78,10 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR return SessionResponse { ok: true, owner: "mnote-web", - user_id: resolved.user.id, + user_id: resolved.user.id.clone(), email: resolved.user.email.unwrap_or_default(), name: resolved.user.display_name, - actor_type: "user".to_string(), + actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role), auth_mode: "sqliteSession", request_id: context.trace.request_id, trace_id: context.trace.trace_id, @@ -109,7 +109,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR state.config().dev_user_id.clone() }; let actor_type = if has_forwarded_actor { - context.auth.actor_type + effective_actor_type_for_user(actor_id, &context.auth.actor_type) } else { "devFallback".to_string() }; @@ -139,6 +139,19 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR } } +fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String { + let role = stored_role.trim(); + let fallback_role = if role.is_empty() { "user" } else { role }; + if crate::routes::local_folder_source::is_local_access_policy_admin_actor( + user_id, + fallback_role, + ) { + "admin".to_string() + } else { + fallback_role.to_string() + } +} + fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> { let token = context .auth @@ -376,6 +389,155 @@ mod tests { assert_eq!(payload["authMode"], "sqliteSession"); } + #[tokio::test] + async fn session_returns_admin_for_sqlite_admin_user() { + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("liaibo".into()), + email: Some("liaibo@yeah.net".into()), + username: "liaibo".into(), + display_name: "liaibo".into(), + role: Some("admin".into()), + password_hash: None, + }) + .expect("upsert admin user"); + state + .control_plane() + .create_session(CreateSessionInput { + id: None, + user_id: "liaibo".into(), + token_hash: session_token_hash("raw-admin-session-token"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create admin session"); + + let response = build_app(state) + .oneshot( + Request::builder() + .uri("/api/auth/session") + .header("cookie", "mnote_session=raw-admin-session-token") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["userId"], "liaibo"); + assert_eq!(payload["actorType"], "admin"); + assert_eq!(payload["authMode"], "sqliteSession"); + } + + #[tokio::test] + async fn session_returns_admin_for_local_access_policy_admin() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + let policy_root = std::env::temp_dir().join(format!( + "mnote-session-access-policy-admin-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&policy_root); + std::fs::create_dir_all(&policy_root).expect("create policy root"); + let policy_file = policy_root.join("access-policy.json"); + std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#) + .expect("write access policy"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("liaibo".into()), + email: Some("liaibo@yeah.net".into()), + username: "liaibo".into(), + display_name: "liaibo".into(), + role: None, + password_hash: None, + }) + .expect("upsert policy admin user"); + state + .control_plane() + .create_session(CreateSessionInput { + id: None, + user_id: "liaibo".into(), + token_hash: session_token_hash("raw-policy-admin-session-token"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create policy admin session"); + + let response = build_app(state) + .oneshot( + Request::builder() + .uri("/api/auth/session") + .header("cookie", "mnote_session=raw-policy-admin-session-token") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&policy_root); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["userId"], "liaibo"); + assert_eq!(payload["actorType"], "admin"); + assert_eq!(payload["authMode"], "sqliteSession"); + } + #[tokio::test] async fn legacy_whoami_alias_prefers_forwarded_actor_identity() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 7d247742..e91b73f7 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -6,10 +6,10 @@ use crate::routes::command_support::{ execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, }; use crate::routes::local_folder_source::{ - ensure_local_workspace_access, ensure_local_workspace_read_access, + ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, local_folder_watch_revision, - local_workspace_id_from_root_uri, + local_workspace_id_from_root_uri, LocalAccessMode, }; use crate::routes::query_support::{ fetch_documents_meta_via_convex, resolve_effective_workspace_id, @@ -6084,10 +6084,11 @@ fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<V } pub async fn local_folder_watch( + State(state): State<AppState>, Extension(context): Extension<RequestContext>, Query(query): Query<LocalFolderWatchQuery>, ) -> Result<(StatusCode, Json<Value>), WebError> { - ensure_local_workspace_read_access(&context, &query.root_uri) + ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let revision = local_folder_watch_revision(&query.root_uri)?; Ok(json_response( @@ -6185,7 +6186,7 @@ pub async fn tree_shell( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; - ensure_local_workspace_read_access(&effective_context, root_uri) + ensure_local_workspace_read_access_with_state(&state, &effective_context, root_uri) .map_err(|error| error.with_context(&effective_context))?; ( local_workspace_id_from_root_uri(root_uri)?, @@ -6808,8 +6809,13 @@ pub async fn tree_command( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; - ensure_local_workspace_access(&context, root_uri) - .map_err(|error| error.with_context(&context))?; + ensure_local_workspace_access_with_state( + &state, + &context, + root_uri, + LocalAccessMode::Write, + ) + .map_err(|error| error.with_context(&context))?; let execution = execute_local_tree_command_with_sort( root_uri, action, @@ -6932,6 +6938,7 @@ mod tests { use crate::routes::command_support::build_runtime_command_plan; use axum::body::Body; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; + use control_plane::{DirectoryGrantInput, UpsertUserInput}; use serde_json::Value; use tower::util::ServiceExt; @@ -7223,6 +7230,88 @@ mod tests { assert!(html.contains("data-document-id=\"local-md:README.md\"")); } + #[tokio::test] + async fn tree_shell_local_folder_allows_sqlite_directory_read_grant() { + let root = std::env::temp_dir().join(format!( + "mnote-local-folder-sqlite-read-grant-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create local root"); + std::fs::write(root.join("README.md"), "# Shared\n").expect("write local md"); + let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "owner_user"); + + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: true, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] { + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(user_id.into()), + email: Some(format!("{user_id}@example.com")), + username: user_id.into(), + display_name: user_id.into(), + role: role.map(str::to_string), + password_hash: None, + }) + .expect("upsert grant user"); + } + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: "shujuan".into(), + workspace_id: None, + root_uri: root_uri.clone(), + root_path: root + .canonicalize() + .expect("canonical root") + .display() + .to_string(), + permission: "read".into(), + recursive: true, + capabilities: vec![], + source: "admin".into(), + created_by: Some("liaibo".into()), + }) + .expect("grant sqlite read"); + + let response = build_app(state) + .oneshot( + Request::builder() + .uri(format!( + "/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}" + )) + .header("x-mnote-actor-id", "shujuan") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + let _ = std::fs::remove_dir_all(&root); + assert_eq!(response.status(), StatusCode::OK); + } + #[tokio::test] async fn local_folder_tree_shell_does_not_reload_page_for_refresh() { let root = std::env::temp_dir().join(format!( @@ -7778,6 +7867,89 @@ mod tests { assert_eq!(payload["code"], "local_workspace_access_denied"); } + #[tokio::test] + async fn tree_command_local_folder_allows_sqlite_directory_write_grant() { + let root = std::env::temp_dir().join(format!( + "mnote-local-tree-sqlite-write-grant-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create local root"); + init_local_workspace(&root, "owner_user"); + let root_uri = format!("file://{}", root.display()); + + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: true, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] { + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(user_id.into()), + email: Some(format!("{user_id}@example.com")), + username: user_id.into(), + display_name: user_id.into(), + role: role.map(str::to_string), + password_hash: None, + }) + .expect("upsert grant user"); + } + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: "shujuan".into(), + workspace_id: None, + root_uri: root_uri.clone(), + root_path: root + .canonicalize() + .expect("canonical root") + .display() + .to_string(), + permission: "write".into(), + recursive: true, + capabilities: vec![], + source: "admin".into(), + created_by: Some("liaibo".into()), + }) + .expect("grant sqlite write"); + + let response = build_app(state) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "shujuan") + .header("x-mnote-actor-type", "user") + .body(Body::from(format!( + r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"授权新页面"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + + let _ = std::fs::remove_dir_all(&root); + assert_eq!(response.status(), StatusCode::OK); + } + #[tokio::test] async fn tree_shell_embeds_renderer_input_contract() { let filetree_response = app() diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index b5899dab..31624da0 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -9,7 +9,7 @@ use crate::routes::documents::{ }; use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::local_folder_source::{ - ensure_local_workspace_read_access, is_local_access_policy_admin_context, + ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate, }; @@ -1107,6 +1107,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return decodeLocalIdSegment(segment).replace(/^\/+/, ''); }; + const localMarkdownDocumentIdFromRelativePath = (relativePath) => { + const normalized = String(relativePath || '').trim().replace(/^\/+/, ''); + if (!normalized) return ''; + return 'local-md:' + normalized + .split('/') + .map((part) => part.replace(/ /g, '~20')) + .join('~2F'); + }; + const localMarkdownDirectoryFromDocumentId = (documentId) => { const relativePath = localMarkdownRelativePathFromDocumentId(documentId); const slash = relativePath.lastIndexOf('/'); @@ -1360,6 +1369,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { ? body.file_version : null; + const conflictDetectionKeyBelongsToSession = (session, key) => { + if (!session || session.sourceKind !== 'local_folder') return true; + const value = String(key || '').trim(); + if (!value) return false; + return value.startsWith(`local-md:${session.documentId}:`); + }; + const revisionFromConflictKey = (value) => { const match = String(value || '').match(/:(\d+)$/); return match ? Number(match[1]) : null; @@ -1444,13 +1460,40 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const paneViewRegistry = new Map(); const mindmapPaneViewRegistry = new Map(); const resourceTabRegistry = new Map(); - const resourceTabMru = []; + const resourceTabMru = { primary: [], secondary: [] }; const resourceTabMruMax = 20; const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded'; let nextViewId = 1; const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; const SESSION_RELEASE_DELAY_MS = 1200; + const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1'; + + const ensureLocalFolderSelfChangeSuppressions = () => { + const now = Date.now(); + const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map + ? window.__mnoteLocalFolderSelfChangeSuppressions + : new Map(); + window.__mnoteLocalFolderSelfChangeSuppressions = map; + try { + const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : ''; + const parsed = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed === 'object') { + Object.entries(parsed).forEach(([documentId, expiresAt]) => { + const doc = String(documentId || '').trim(); + const expiry = Number(expiresAt || 0); + if (doc && Number.isFinite(expiry) && expiry > now) { + map.set(doc, expiry); + } else if (doc) { + map.delete(doc); + delete parsed[documentId]; + } + }); + if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed)); + } + } catch (_) {} + return map; + }; const unmountMindmapPane = (paneRole) => { const view = mindmapPaneViewRegistry.get(paneRole); @@ -1484,8 +1527,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const doc = String(documentId || '').trim(); if (!doc) return false; const kind = String(eventKind || ''); - if (!kind.includes('Create') && !kind.includes('Metadata')) return false; - const suppressions = window.__mnoteLocalFolderSelfChangeSuppressions; + const suppressibleSelfWrite = kind.includes('Create') + || kind.includes('Metadata') + || kind.includes('Modify(Data') + || kind.includes('Modify(Any') + || kind.includes('Modify(Name'); + if (!suppressibleSelfWrite) return false; + const suppressions = ensureLocalFolderSelfChangeSuppressions(); if (!suppressions || typeof suppressions.get !== 'function') return false; const expiresAt = Number(suppressions.get(doc) || 0); if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false; @@ -1496,6 +1544,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return true; }; + const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => { + if (!session || session.sourceKind !== 'local_folder') return; + const doc = String(session.documentId || '').trim(); + if (!doc) return; + const suppressions = ensureLocalFolderSelfChangeSuppressions(); + if (!suppressions || typeof suppressions.set !== 'function') return; + const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000); + suppressions.set(doc, expiresAt); + try { + if (!window.sessionStorage) return; + const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const next = parsed && typeof parsed === 'object' ? parsed : {}; + next[doc] = expiresAt; + window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next)); + } catch (_) {} + }; + const normalizeSessionSourceKind = (bootstrap) => { const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; return value || 'convex_workspace'; @@ -1573,6 +1639,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { workspaceId: session.workspaceId, viewCount: session.views.size, status: session.status, + conflictDetectionKey: session.conflictDetectionKey || '', + lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '', lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null, dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), })), @@ -2157,6 +2225,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') { savePayload.conflictDetectionKey = session.conflictDetectionKey; } + markLocalFolderSelfChangeSuppression(session); const response = await fetch(saveEndpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -2341,9 +2410,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (!targetSession || targetSession.views.size === 0) return; const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; const eventKind = String(payload.eventKind || ''); - const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name'); const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); - if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return; + if (documentId && !targetsCurrentDocument) return; if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); @@ -2658,6 +2726,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => { if (node instanceof HTMLElement) node.textContent = title; }); + const pageTab = document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${runtimeDescriptor.paneRole}"]`); + if (pageTab instanceof HTMLElement) { + pageTab.setAttribute('data-document-id', documentId); + pageTab.setAttribute('data-workspace-id', workspaceId); + const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title'); + if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title; + } if (runtimeDescriptor.paneRole === 'primary') { document.body.dataset.documentId = documentId; document.body.dataset.mnoteShell = 'document'; @@ -2665,13 +2740,6 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { document.title = title; const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; - const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); - if (pageTab instanceof HTMLElement) { - pageTab.setAttribute('data-document-id', documentId); - pageTab.setAttribute('data-workspace-id', workspaceId); - const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title'); - if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title; - } window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', { detail: { documentId, workspaceId, title } })); @@ -2728,6 +2796,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { updatePaneChrome(runtimeDescriptor); if (paneRole === 'secondary' && workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'true'); + setSecondaryEditorHostVisible(true); const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; applyStoredSecondaryWidth(); @@ -2789,6 +2858,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { pushUrlState(url); }; + const setSecondaryEditorHostVisible = (visible) => { + const host = document.querySelector('[data-testid="mnote-secondary-editor-tab-host"]'); + if (host instanceof HTMLElement) host.hidden = !visible; + }; + const closeSecondaryPane = (url) => { const view = paneViewRegistry.get('secondary'); if (view) { @@ -2809,10 +2883,26 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { root.removeAttribute('data-mnote-side-target-unsupported'); root.removeAttribute('data-mnote-side-target-asset-id'); } + Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => { + if (normalizePaneRole(entry?.paneRole) !== 'secondary') return; + if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); + if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { + try { + entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); + } catch (error) { + console.warn('mnote secondary mindmap resource tab unmount failed', error); + } + } + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + removeFromResourceTabMru('secondary', key); + }); if (workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'false'); workspace.style.removeProperty('grid-template-columns'); } + setSecondaryEditorHostVisible(false); const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); if (resizerNode instanceof HTMLElement) resizerNode.hidden = true; document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); @@ -2963,7 +3053,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (payload?.meta && typeof payload.meta === 'object') { if (Number.isInteger(payload.meta.revision)) session.revision = payload.meta.revision; if (typeof payload.meta.conflictDetectionKey === 'string' && payload.meta.conflictDetectionKey.trim()) { - session.conflictDetectionKey = payload.meta.conflictDetectionKey.trim(); + const nextMetaConflictKey = payload.meta.conflictDetectionKey.trim(); + if (conflictDetectionKeyBelongsToSession(session, nextMetaConflictKey)) { + session.conflictDetectionKey = nextMetaConflictKey; + } } if (typeof payload.meta.readOnly === 'boolean') { session.readOnly = payload.meta.readOnly; @@ -3050,17 +3143,30 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { && node.offsetParent !== null && node.getAttribute('data-mnote-side-target-unsupported') !== 'true' )); - if (anchorElement instanceof Element) { - const activeRoot = roots.find((root) => root.contains(anchorElement)); - if (activeRoot) return activeRoot; - } + const intendedRoot = window.__mnoteIntendedSlashRoot instanceof HTMLElement + && roots.includes(window.__mnoteIntendedSlashRoot) + ? window.__mnoteIntendedSlashRoot + : null; + if (intendedRoot) return intendedRoot; const focused = document.activeElement instanceof Element ? roots.find((root) => root.contains(document.activeElement)) : null; if (focused) return focused; + if (anchorElement instanceof Element) { + const activeRoot = roots.find((root) => root.contains(anchorElement)); + if (activeRoot) return activeRoot; + } return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null; }; + const markIntendedSlashRoot = (entry) => { + const root = entry?.view?.runtimeDescriptor?.root; + if (!(root instanceof HTMLElement) || !root.isConnected) return; + window.__mnoteIntendedSlashRoot = root; + hideSlashMenusOutsideRoot(root); + scheduleSlashMenuPosition(root); + }; + const slashMenuAnchorFromSelection = (root) => { const currentBlockFromSelection = () => { try { @@ -3124,6 +3230,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (menu.style.display === 'none') menu.style.display = ''; }; + const setSlashMenuStyle = (menu, property, value) => { + if (!(menu instanceof HTMLElement)) return; + if (menu.style[property] !== value) menu.style[property] = value; + }; + + const setSlashMenuAttribute = (menu, name, value) => { + if (!(menu instanceof HTMLElement)) return; + if (menu.getAttribute(name) !== value) menu.setAttribute(name, value); + }; + const hideSlashMenusOutsideRoot = (activeRoot) => { document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => { const root = menu.closest(ROOT_SELECTOR); @@ -3164,17 +3280,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { top = mindmapRect.bottom + gap; } } - menu.style.position = 'fixed'; - menu.style.zIndex = '130'; - menu.style.left = `${Math.round(left)}px`; - menu.style.top = `${Math.round(top)}px`; - menu.style.width = `min(316px, calc(100vw - 16px))`; - menu.style.maxHeight = `min(430px, calc(100vh - 16px))`; - menu.setAttribute('data-mnote-slash-positioned', 'host'); + setSlashMenuStyle(menu, 'position', 'fixed'); + setSlashMenuStyle(menu, 'zIndex', '130'); + setSlashMenuStyle(menu, 'left', `${Math.round(left)}px`); + setSlashMenuStyle(menu, 'top', `${Math.round(top)}px`); + setSlashMenuStyle(menu, 'width', `min(316px, calc(100vw - 16px))`); + setSlashMenuStyle(menu, 'maxHeight', `min(430px, calc(100vh - 16px))`); + setSlashMenuAttribute(menu, 'data-mnote-slash-positioned', 'host'); }; + let pendingSlashMenuPositionFrame = 0; + let pendingSlashMenuPositionRoot = null; const scheduleSlashMenuPosition = (root) => { - window.requestAnimationFrame(() => positionSlashMenuForRoot(root)); + if (root instanceof HTMLElement) pendingSlashMenuPositionRoot = root; + if (pendingSlashMenuPositionFrame) return; + pendingSlashMenuPositionFrame = window.requestAnimationFrame(() => { + const targetRoot = pendingSlashMenuPositionRoot; + pendingSlashMenuPositionFrame = 0; + pendingSlashMenuPositionRoot = null; + positionSlashMenuForRoot(targetRoot); + }); + }; + + const shouldReactToSlashMenuMutation = (records, root) => { + if (!Array.isArray(records) || !(root instanceof HTMLElement)) return false; + return records.some((record) => { + const target = record && record.target instanceof HTMLElement ? record.target : null; + if (!target || !root.contains(target) && target !== root) return false; + if (record.type === 'attributes') { + return record.attributeName !== 'style' && record.attributeName !== 'data-mnote-slash-positioned' && record.attributeName !== 'data-mnote-slash-inactive'; + } + if (record.type === 'childList') { + return Array.from(record.addedNodes || []).some((node) => node instanceof HTMLElement && node.closest('[data-testid="mnote-leptos-tiptap-slash-menu"]')) + || Array.from(record.removedNodes || []).some((node) => node instanceof HTMLElement && node.closest('[data-testid="mnote-leptos-tiptap-slash-menu"]')); + } + return true; + }); }; const scheduleGlobalSlashMenuPosition = () => { @@ -3201,8 +3342,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const observeSlashMenuPosition = (view) => { const root = view.runtimeDescriptor.root; if (!(root instanceof HTMLElement) || typeof MutationObserver !== 'function') return; - const observer = new MutationObserver(() => scheduleSlashMenuPosition(root)); - observer.observe(document.body, { childList: true, subtree: true }); + const observer = new MutationObserver((records) => { + if (!view.disposed && shouldReactToSlashMenuMutation(records, root)) { + scheduleSlashMenuPosition(root); + } + }); + observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'hidden', 'class', 'data-mnote-slash-positioned', 'data-mnote-slash-inactive'] }); view.disconnectSlashObserver = () => observer.disconnect(); }; @@ -3277,13 +3422,21 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return view; }; - const resourceTabHostNodes = () => ({ - strip: document.querySelector('[data-mnote-main-tab-strip]'), - pageTab: document.querySelector('[data-mnote-main-tab="page"]'), - pagePanel: document.querySelector('[data-mnote-page-tab-panel]'), - host: document.querySelector('[data-mnote-resource-tab-host]'), - panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'), - }); + const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary'; + + const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`; + + const resourceTabHostNodes = (paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + return { + paneRole: role, + strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`), + pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`), + pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`), + host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`), + panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`), + }; + }; const resourceTabBadgeKind = (input, kind) => { const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); @@ -3341,13 +3494,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return { editorKind: kind, badgeKind, defaultOpenMode, editable, viewerUrl, openTarget }; }; - const touchResourceTabMru = (key) => { + const touchResourceTabMru = (paneRole, key) => { + const role = normalizePaneRole(paneRole); const id = String(key || '').trim(); if (!id) return; - const index = resourceTabMru.indexOf(id); - if (index >= 0) resourceTabMru.splice(index, 1); - resourceTabMru.unshift(id); - if (resourceTabMru.length > resourceTabMruMax) resourceTabMru.length = resourceTabMruMax; + const list = resourceTabMru[role] || (resourceTabMru[role] = []); + const index = list.indexOf(id); + if (index >= 0) list.splice(index, 1); + list.unshift(id); + if (list.length > resourceTabMruMax) list.length = resourceTabMruMax; }; const setResourceTabLastActive = (key) => { @@ -3355,13 +3510,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (entry?.session) entry.session.lastActiveAt = Date.now(); }; - const removeFromResourceTabMru = (key) => { - const index = resourceTabMru.indexOf(key); - if (index >= 0) resourceTabMru.splice(index, 1); + const removeFromResourceTabMru = (paneRole, key) => { + const list = resourceTabMru[normalizePaneRole(paneRole)] || []; + const index = list.indexOf(key); + if (index >= 0) list.splice(index, 1); }; - const lastActiveResourceTabKey = () => { - for (const key of resourceTabMru) { + const lastActiveResourceTabKey = (paneRole = 'primary') => { + const list = resourceTabMru[normalizePaneRole(paneRole)] || []; + for (const key of list) { if (resourceTabRegistry.has(key)) return key; } return ''; @@ -3414,33 +3571,35 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }); const buildOpenEditorsSnapshot = () => { - const nodes = resourceTabHostNodes(); - const pageTitle = nodes.pageTab instanceof HTMLElement - ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() - : '页面'; - const pageEntry = { - objectIdentity: 'page', - documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || currentWebShellDocumentId() || '').trim(), - workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(), - title: pageTitle || '页面', - kind: 'page', - badgeKind: 'code', - active: nodes.pageTab instanceof HTMLElement - ? nodes.pageTab.getAttribute('aria-selected') === 'true' - : false, - dirtyGuard: '', - }; + const pageEntries = ['primary', 'secondary'].map((paneRole) => { + const nodes = resourceTabHostNodes(paneRole); + const pageTitle = nodes.pageTab instanceof HTMLElement + ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() + : '页面'; + return { + objectIdentity: `page:${paneRole}`, + paneRole, + documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(), + workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(), + title: pageTitle || '页面', + kind: 'page', + badgeKind: 'code', + active: nodes.pageTab instanceof HTMLElement + ? nodes.pageTab.getAttribute('aria-selected') === 'true' + : false, + dirtyGuard: '', + }; + }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]')); const resources = []; resourceTabRegistry.forEach((entry, key) => { resources.push(openEditorsSnapshotEntry(entry, key)); }); + const active = [...pageEntries, ...resources].find((entry) => entry.active); return { schema: 'mnote.open_editors_snapshot.v1', generatedAt: Date.now(), - activeObjectIdentity: pageEntry.active - ? 'page' - : (resources.find((entry) => entry.active)?.objectIdentity || ''), - editors: [pageEntry, ...resources], + activeObjectIdentity: active?.objectIdentity || '', + editors: [...pageEntries, ...resources], resourceEditors: resources, }; }; @@ -3455,7 +3614,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }; const showResourceTabCloseGuardNotice = (entry, reason) => { - const nodes = resourceTabHostNodes(); + const nodes = resourceTabHostNodes(entry?.paneRole || 'primary'); const messages = { dirty: '当前资源有未保存的修改,保存完成后再关闭。', saving: '当前资源正在保存中,请稍后再关闭。', @@ -3522,18 +3681,30 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } }; - const syncActiveResourceUrlState = (activeResource) => { + const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + if (role !== 'primary') return; const url = currentUrl(); - if (activeResource) url.searchParams.set('resourceTab', activeResource); - else url.searchParams.delete('resourceTab'); + if (activeResource) { + const entry = resourceTabRegistry.get(activeResource); + const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim(); + if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`; + url.searchParams.set('resourceTab', activeResource); + } else { + const documentId = currentWebShellDocumentId(); + if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`; + url.searchParams.delete('resourceTab'); + } replaceUrlState(url); }; - const activateMainEditorTab = (objectIdentity) => { - const nodes = resourceTabHostNodes(); + const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => { const activeResource = String(objectIdentity || '').trim(); + const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null; + const role = normalizePaneRole(activeEntry?.paneRole || paneRole); + const nodes = resourceTabHostNodes(role); if (activeResource) { - touchResourceTabMru(activeResource); + touchResourceTabMru(role, activeResource); setResourceTabLastActive(activeResource); } if (nodes.pageTab instanceof HTMLElement) { @@ -3545,6 +3716,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource); if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource; resourceTabRegistry.forEach((entry, key) => { + if (normalizePaneRole(entry.paneRole) !== role) return; const active = key === activeResource; if (entry.tab instanceof HTMLElement) { entry.tab.classList.toggle('is-active', active); @@ -3554,13 +3726,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active; syncResourceTabCloseGuard(entry); }); - syncActiveResourceFileTreeRow(activeResource); - syncActiveResourceUrlState(activeResource); + if (activeEntry) markIntendedSlashRoot(activeEntry); + if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null; + if (role === 'primary') syncActiveResourceFileTreeRow(activeResource); + syncActiveResourceUrlState(activeResource, role); syncOpenEditorsSnapshot(); }; - const bindMainEditorTabStrip = () => { - const nodes = resourceTabHostNodes(); + const bindMainEditorTabStrip = (paneRole = 'primary') => { + const nodes = resourceTabHostNodes(paneRole); if (!(nodes.strip instanceof HTMLElement)) return; if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return; nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true'); @@ -3595,15 +3769,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }); }; - const bindMainEditorPageTab = () => { - const nodes = resourceTabHostNodes(); + const bindMainEditorPageTab = (paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + const nodes = resourceTabHostNodes(role); if (!(nodes.pageTab instanceof HTMLElement)) return; - bindMainEditorTabStrip(); + bindMainEditorTabStrip(role); if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return; nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true'); nodes.pageTab.addEventListener('click', (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return; event.preventDefault(); - activateMainEditorTab(''); + activateMainEditorTab('', role); }); syncOpenEditorsSnapshot(); }; @@ -3619,7 +3796,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { showResourceTabCloseGuardNotice(entry, guardReason); return; } - removeFromResourceTabMru(key); + removeFromResourceTabMru(entry.paneRole || 'primary', key); if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { try { @@ -3631,9 +3808,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (entry.tab instanceof HTMLElement) entry.tab.remove(); if (entry.panel instanceof HTMLElement) entry.panel.remove(); resourceTabRegistry.delete(key); - const nextKey = lastActiveResourceTabKey(); - activateMainEditorTab(nextKey); - const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes().pageTab; + const role = normalizePaneRole(entry.paneRole); + const nextKey = lastActiveResourceTabKey(role); + activateMainEditorTab(nextKey, role); + const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes(role).pageTab; if (nextTab instanceof HTMLElement) nextTab.focus(); }; @@ -3652,16 +3830,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }; const createResourceTabDom = (input) => { - const nodes = resourceTabHostNodes(); + const paneRole = normalizePaneRole(input.paneRole); + const nodes = resourceTabHostNodes(paneRole); if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null; const objectIdentity = String(input.objectIdentity || '').trim(); + const registryKey = resourceTabRegistryKey(paneRole, objectIdentity); const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源'; const kind = normalizeResourceTabKind(input); const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'mnote-main-tab'; tab.setAttribute('role', 'tab'); - tab.setAttribute('data-mnote-main-tab', objectIdentity); + tab.setAttribute('data-mnote-main-tab', registryKey); + tab.setAttribute('data-mnote-object-identity', objectIdentity); + tab.setAttribute('data-pane-role', paneRole); tab.setAttribute('data-mnote-tab-kind', kind); tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind)); tab.setAttribute('tabindex', '-1'); @@ -3673,20 +3855,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) { event.preventDefault(); event.stopPropagation(); - closeResourceTab(objectIdentity); + closeResourceTab(registryKey); return; } - activateMainEditorTab(objectIdentity); + activateMainEditorTab(registryKey, paneRole); }); const panel = document.createElement('section'); panel.className = 'mnote-resource-tab-panel'; - panel.setAttribute('data-mnote-resource-tab-panel', objectIdentity); + panel.setAttribute('data-mnote-resource-tab-panel', registryKey); + panel.setAttribute('data-mnote-object-identity', objectIdentity); + panel.setAttribute('data-pane-role', paneRole); panel.setAttribute('data-resource-kind', kind); panel.hidden = true; nodes.strip.append(tab); nodes.panelRoot.append(panel); return { objectIdentity, + registryKey, + paneRole, title, kind, tab, @@ -3695,9 +3881,29 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { session: null, assetId: String(input.assetId || '').trim(), path: String(input.path || '').trim(), + documentId: String(input.documentId || '').trim(), + ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(), }; }; + const releaseResourceTabEntryRuntime = (entry) => { + if (!entry) return; + if (entry.view) { + unmountEditorViewBinding(entry.view, { releaseSession: true }); + entry.view = null; + entry.session = null; + } + if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { + try { + entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); + } catch (error) { + console.warn('mnote resource tab runtime unmount failed', error); + } + entry.mindmapRuntime = null; + } + if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren(); + }; + const localResourceReadUrl = (rootUri, path) => { const url = new URL('/api/local-folder/resource/read', window.location.origin); url.searchParams.set('rootUri', rootUri || ''); @@ -3706,16 +3912,25 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }; const createResourceSession = (entry, input, readResult) => { - const tiptapDocument = toTiptapDocument(readResult?.content, readResult?.text || ''); + const resourcePath = String(input.path || ''); + const tiptapDocument = localizeTiptapAssetUrls( + toTiptapDocument(readResult?.content, readResult?.text || ''), + { + sourceKind: 'local_folder', + rootUri: String(input.rootUri || ''), + documentId: localMarkdownDocumentIdFromRelativePath(resourcePath), + } + ); const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim(); const session = { key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`, sessionKind: 'resource', documentId: entry.objectIdentity, + ownerDocumentId: String(input.documentId || currentWebShellDocumentId() || '').trim(), workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''), sourceKind: 'local_folder', rootUri: String(input.rootUri || ''), - resourcePath: String(input.path || ''), + resourcePath, saveEndpoint: '/api/local-folder/resource/write', pageAggregateScriptId: '', latestAggregate: null, @@ -3753,7 +3968,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const openTiptapResourceTab = async (entry, input) => { const runtime = await loadRuntime(); - entry.panel.innerHTML = '<main class="document-shell mnote-resource-tab-text-shell" data-editor-host="leptos_tiptap_resource"><div class="mnote-resource-tab-editor-root" data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_resource" data-runtime-editor-status="booting" data-pane-role="resource"></div><div class="sr-only" data-editor-host-observability="rust-web-resource-tab" data-pane-role="resource"></div></main>'; + const paneRole = normalizePaneRole(entry.paneRole); + entry.panel.innerHTML = `<main class="document-shell mnote-resource-tab-text-shell" data-editor-host="leptos_tiptap_resource" data-mnote-editor-kind="resource" data-pane-role="${paneRole}"><div class="mnote-resource-tab-editor-root" data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_resource" data-mnote-editor-kind="resource" data-runtime-editor-status="booting" data-pane-role="${paneRole}"></div><div class="sr-only" data-editor-host-observability="rust-web-resource-tab" data-mnote-editor-kind="resource" data-pane-role="${paneRole}"></div></main>`; const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); const observability = entry.panel.querySelector('[data-editor-host-observability]'); if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing'); @@ -3761,20 +3977,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`); const readResult = payload.result || {}; + const session = createResourceSession(entry, input, readResult); const runtimeDescriptor = { - paneRole: 'resource', + paneRole, root, observability, aggregate: { layout: { pageOptions: {} } }, bootstrap: { - documentId: entry.objectIdentity, + documentId: String(input.documentId || currentWebShellDocumentId() || '').trim(), workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''), sourceKind: 'local_folder', rootUri: String(input.rootUri || ''), saveEndpoint: '/api/local-folder/resource/write', }, }; - const session = createResourceSession(entry, input, readResult); const view = createEditorViewBinding(session, runtime, runtimeDescriptor); const mountId = runtime.mount(root, { documentId: session.documentId, @@ -3790,9 +4006,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { view.mountId = mountId; root.setAttribute('data-runtime-mount-id', String(mountId)); root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource'); + root.setAttribute('data-document-id', session.ownerDocumentId || ''); + root.setAttribute('data-workspace-id', session.workspaceId || ''); + if (entry.panel instanceof HTMLElement) { + const shell = entry.panel.querySelector('.document-shell'); + if (shell instanceof HTMLElement) { + shell.setAttribute('data-document-id', session.ownerDocumentId || ''); + shell.setAttribute('data-workspace-id', session.workspaceId || ''); + } + } setStatus(runtimeDescriptor, 'mounting-editor'); entry.view = view; entry.session = session; + markIntendedSlashRoot(entry); }; const openPassiveResourceTab = (entry, input) => { @@ -3867,6 +4093,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const workspace = document.querySelector('.mnote-document-workspace'); if (workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'true'); + setSecondaryEditorHostVisible(true); applyStoredSecondaryWidth(); } const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); @@ -3904,19 +4131,46 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }; const openResourceInActiveTab = async (input = {}) => { - bindMainEditorPageTab(); + const paneRole = normalizePaneRole(input.paneRole || input.targetPaneRole || 'primary'); + if (paneRole === 'secondary') { + if (workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'true'); + applyStoredSecondaryWidth(); + } + setSecondaryEditorHostVisible(true); + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; + const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); + if (pane instanceof HTMLElement) { + pane.hidden = false; + pane.setAttribute('data-pane-visible', 'true'); + } + } + bindMainEditorPageTab(paneRole); const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim(); if (!objectIdentity) return false; - const existing = resourceTabRegistry.get(objectIdentity); + const registryKey = resourceTabRegistryKey(paneRole, objectIdentity); + if (paneRole === 'secondary') { + resourceTabRegistry.forEach((entry, key) => { + if (normalizePaneRole(entry.paneRole) !== paneRole) return; + if (key === registryKey) return; + releaseResourceTabEntryRuntime(entry); + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + removeFromResourceTabMru(paneRole, key); + }); + } + const existing = resourceTabRegistry.get(registryKey); if (existing) { refreshExistingOfficeResourceTab(existing, input); - activateMainEditorTab(objectIdentity); + activateMainEditorTab(registryKey, paneRole); return true; } - const entry = createResourceTabDom({ ...input, objectIdentity }); + const entry = createResourceTabDom({ ...input, objectIdentity, paneRole }); if (!entry) return false; - resourceTabRegistry.set(objectIdentity, entry); - activateMainEditorTab(objectIdentity); + resourceTabRegistry.set(registryKey, entry); + activateMainEditorTab(registryKey, paneRole); try { if (entry.kind === 'mindmap') { await openMindmapResourceTab(entry, input); @@ -3925,7 +4179,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } else { openPassiveResourceTab(entry, input); } - activateMainEditorTab(objectIdentity); + activateMainEditorTab(registryKey, paneRole); return true; } catch (error) { console.warn('mnote resource tab 打开失败', error); @@ -3972,8 +4226,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (!id) return false; const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); await replacePaneDocument('primary', descriptor); + activateMainEditorTab('', 'primary'); updatePrimaryUrl(descriptor, url instanceof URL ? url : null); - activateMainEditorTab(''); return true; }, openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => { @@ -3993,22 +4247,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (!id) return false; const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri }); await replacePaneDocument('secondary', descriptor); + activateMainEditorTab('', 'secondary'); if (url instanceof URL) replaceUrlState(url); return true; }, resolveResourceOpen: (input) => resolveResourceOpen(input), openResourceInActiveTab: openResourceInActiveTab, - activatePageTab: () => { - bindMainEditorPageTab(); - activateMainEditorTab(''); + activatePageTab: ({ paneRole } = {}) => { + const role = normalizePaneRole(paneRole || 'primary'); + bindMainEditorPageTab(role); + activateMainEditorTab('', role); return true; }, openResourceAsSideTarget: async (input = {}) => { - const resolved = resolveResourceOpen({ ...input, openTarget: 'side' }); - if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') { - return openUnsupportedSideTarget(input); - } - return openUnsupportedSideTarget(input); + return openResourceInActiveTab({ ...input, paneRole: 'secondary', openTarget: 'active-tab' }); }, closeSecondaryDocument: ({ url } = {}) => { closeSecondaryPane(url instanceof URL ? url : currentUrl()); @@ -4034,7 +4286,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }, }; - bindMainEditorPageTab(); + bindMainEditorPageTab('primary'); + bindMainEditorPageTab('secondary'); window.addEventListener('pagehide', () => { Array.from(documentSessionRegistry.values()).forEach((session) => { @@ -4196,7 +4449,7 @@ pub(crate) async fn build_page_aggregate_snapshot( .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; - ensure_local_workspace_read_access(context, root_uri) + ensure_local_workspace_read_access_with_state(state, context, root_uri) .map_err(|error| error.with_context(context))?; return resolve_local_markdown_page_aggregate(root_uri, document_id); } @@ -4505,6 +4758,7 @@ mod tests { use crate::context::RequestContext; use axum::body::{to_bytes, Body}; use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; + use control_plane::{DirectoryGrantInput, UpsertUserInput}; use serde_json::Value; use tower::util::ServiceExt; @@ -4588,6 +4842,54 @@ mod tests { .expect("init local workspace"); } + fn request_context(actor_id: &str, actor_type: &str) -> RequestContext { + let mut headers = HeaderMap::new(); + headers.insert("x-mnote-actor-id", actor_id.parse().unwrap()); + headers.insert("x-mnote-actor-type", actor_type.parse().unwrap()); + RequestContext::from_http_parts( + &Method::GET, + &"/api/page-aggregate/local-md:test.md".parse().expect("uri"), + &headers, + ) + } + + fn grant_local_workspace_read_access( + state: &AppState, + actor_id: &str, + root_uri: &str, + root: &std::path::Path, + ) { + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(actor_id.into()), + email: Some(format!("{actor_id}@example.com")), + username: actor_id.into(), + display_name: actor_id.into(), + role: None, + password_hash: None, + }) + .expect("upsert sqlite reader"); + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: actor_id.into(), + workspace_id: None, + root_uri: root_uri.into(), + root_path: root + .canonicalize() + .expect("canonical root") + .display() + .to_string(), + permission: "read".into(), + recursive: true, + capabilities: vec![], + source: "unit-test".into(), + created_by: None, + }) + .expect("grant sqlite read"); + } + fn app_with_unreachable_convex_without_fixture() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -4703,6 +5005,12 @@ mod tests { assert!(html.contains("bindMainEditorTabStrip")); assert!(html.contains("data-mnote-tab-strip-bound")); assert!(html.contains("currentWebShellDocumentId")); + assert!(html.contains( + "const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();" + )); + assert!(html.contains( + "if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;" + )); assert!(!html .contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()")); assert!(html.contains("runtime.default({ module_or_path: wasmUrl })")); @@ -4748,6 +5056,12 @@ mod tests { assert!(html.contains( "const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);" )); + assert!(html.contains("mnote.localFolder.selfChangeSuppressions.v1")); + assert!(html.contains("ensureLocalFolderSelfChangeSuppressions()")); + assert!(html.contains("markLocalFolderSelfChangeSuppression(session);")); + assert!(html.contains("const suppressibleSelfWrite = kind.includes('Create')")); + assert!(html.contains("|| kind.includes('Modify(Data')")); + assert!(html.contains("|| kind.includes('Modify(Any')")); assert!(!html.contains("mnote-web-document-shell")); // Resource open resolver contract @@ -4756,7 +5070,16 @@ mod tests { assert!(html.contains("resourceTabCloseGuardReason")); assert!(html.contains("closeResourceTab")); assert!(html.contains("resourceTabRegistry.delete(key)")); - assert!(html.contains("const nextKey = lastActiveResourceTabKey();")); + assert!(html.contains("data-testid=\"mnote-secondary-editor-tab-host\"")); + assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\"")); + assert!(html.contains("resourceTabRegistryKey(paneRole, objectIdentity)")); + assert!(html.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'")); + assert!(html.contains("data-mnote-editor-kind=\"resource\"")); + assert!(html.contains("const paneRole = normalizePaneRole(entry.paneRole);")); + assert!(html.contains("paneRole,")); + assert!(html.contains("markIntendedSlashRoot(entry);")); + assert!(html.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);")); + assert!(html.contains("const nextKey = lastActiveResourceTabKey(role);")); assert!(html.contains("if (nextTab instanceof HTMLElement) nextTab.focus();")); assert!(html.contains("resourceTabBadgeKind(input, kind)")); assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")); @@ -4968,6 +5291,65 @@ mod tests { .contains("Local Heading")); } + #[tokio::test] + async fn page_aggregate_endpoint_allows_sqlite_granted_local_folder_read_access() { + let root = std::env::temp_dir().join(format!( + "mnote-local-page-aggregate-grant-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("Grant Root")).expect("create local page bundle"); + std::fs::write( + root.join("Grant Root").join("Grant Root.md"), + "# Grant Heading\n授权正文\n", + ) + .expect("write local md"); + + let root_uri = format!("file://{}", root.display()); + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + init_local_workspace(&root, "owner_user"); + grant_local_workspace_read_access(&state, "user_test", &root_uri, &root); + + let context = request_context("user_test", "user"); + let aggregate = super::build_page_aggregate_snapshot( + &state, + &context, + "local-md:Grant~20Root~2FGrant~20Root.md", + None, + Some("local_folder"), + Some(&root_uri), + ) + .await + .expect("sqlite read grant can open local page aggregate"); + + let _ = std::fs::remove_dir_all(&root); + + assert_eq!( + aggregate.identity.document_id, + "local-md:Grant~20Root~2FGrant~20Root.md" + ); + assert_eq!(aggregate.head.title, "Grant Heading"); + } + #[tokio::test] async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() { let root = diff --git a/rust/crates/mnote-web/src/ssr/pages/admin.rs b/rust/crates/mnote-web/src/ssr/pages/admin.rs index 9e0d5734..6571c7c2 100644 --- a/rust/crates/mnote-web/src/ssr/pages/admin.rs +++ b/rust/crates/mnote-web/src/ssr/pages/admin.rs @@ -111,6 +111,7 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#" var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]'); var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]'); var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]'); + var currentActorId = document.body && document.body.getAttribute ? String(document.body.getAttribute('data-mnote-actor-id') || '').trim() : ''; var pageConfig = (function () { var node = root.querySelector('#__MNOTE_ACCESS_POLICY_PAGE__'); try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; } @@ -147,6 +148,48 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#" return suffix ? base + suffix : base; } + function pathToFileRootUri(value) { + var trimmed = String(value || '').trim(); + if (!trimmed) return ''; + if (/^file:\/\//i.test(trimmed)) return trimmed; + if (trimmed.charAt(0) !== '/') return ''; + return 'file://' + trimmed.split('/').map(function(part, index) { + return index === 0 ? '' : encodeURIComponent(part); + }).join('/'); + } + + function localFolderOpenHref(grant) { + var rootUri = String(grant.rootUri || '').trim(); + if (rootUri && !/^file:\/\//i.test(rootUri)) rootUri = ''; + if (!rootUri) { + var rootPath = String(grant.rootPath || '').trim(); + if (rootPath) rootUri = pathToFileRootUri(rootPath); + } + if (!rootUri) { + rootUri = pathToFileRootUri(String(grant.rootUri || '').trim()); + } + if (!rootUri) return ''; + var url = new URL('/', window.location.origin); + url.searchParams.set('treeView', 'filetree'); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + return url.pathname + url.search; + } + + function isDefaultWorkspaceAutoGrant(grant) { + var source = String(grant && grant.source || '').trim(); + var workspaceId = String(grant && grant.workspaceId || '').trim(); + var rootUri = String(grant && grant.rootUri || '').trim(); + var permission = String(grant && grant.permission || '').trim(); + var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim(); + var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim(); + return source === 'auto' + && workspaceId + && permission === 'write' + && createdBy === targetUser + && /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri); + } + function renderShareGrants(payload) { if (!shareGrantsList) return; var grants = readDirectoryGrants(payload); @@ -156,12 +199,20 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#" } shareGrantsList.innerHTML = grants.map(function(grant) { var active = grant.active === false || grant.status === 'revoked' ? '已撤销' : '有效'; - var revokeButton = grant.active === false ? '' : + var openHref = localFolderOpenHref(grant); + var rootLabel = escapeHtml(grant.rootPath || grant.rootUri || ''); + var createdBy = String(grant.createdBy || grant.ownerUserId || '').trim(); + var systemOwned = isDefaultWorkspaceAutoGrant(grant); + var canRevoke = !systemOwned && (isAdmin || (createdBy && currentActorId && createdBy === currentActorId)); + var rootNode = openHref + ? '<a class="mnote-admin-policy-root-link" data-testid="mnote-admin-open-granted-root" href="' + escapeHtml(openHref) + '">' + rootLabel + '</a>' + : '<strong>' + rootLabel + '</strong>'; + var revokeButton = grant.active === false || !canRevoke ? '' : '<button type="button" data-admin-action="revoke-access-grant" data-grant-id="' + escapeHtml(grant.id || '') + '">撤销</button>'; return '<article class="mnote-admin-policy-row">' + - '<div><strong>' + escapeHtml(grant.rootPath || grant.rootUri || '') + '</strong><span>授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' + + '<div>' + rootNode + '<span>授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' + '<div>' + renderBadge(grant.permission) + renderBadge(active) + '</div>' + - '<div><span>创建者 ' + escapeHtml(grant.createdBy || grant.ownerUserId || '') + '</span><span>授权 ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' + + '<div><span>创建者 ' + escapeHtml(createdBy) + '</span><span>授权 ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' + '</article>'; }).join(''); } @@ -203,7 +254,8 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#" root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) { event.preventDefault(); - var values = shareGrantFormValues(event.currentTarget); + var form = event.currentTarget; + var values = shareGrantFormValues(form); setText(createShareGrantResult, '正在创建...'); requestJson(accessPolicyUrl('/grants'), { method: 'POST', @@ -211,7 +263,7 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#" }).then(function (payload) { setText(createShareGrantResult, payload); setText(shareGrantsMessage, '文件夹授权已创建'); - event.currentTarget.reset(); + form.reset(); return refreshShareGrants(); }).catch(function (error) { setText(createShareGrantResult, { ok: false, error: error.message || '创建失败' }); @@ -292,4 +344,20 @@ mod tests { assert!(!html.contains("capabilities")); assert!(!html.contains("mnote-admin-validate-root-submit")); } + + #[test] + fn admin_policy_script_keeps_form_reference_for_async_reset() { + assert!(ADMIN_POLICY_SCRIPT.contains("var form = event.currentTarget;")); + assert!(ADMIN_POLICY_SCRIPT.contains("form.reset();")); + assert!(!ADMIN_POLICY_SCRIPT.contains("event.currentTarget.reset();")); + } + + #[test] + fn admin_policy_script_links_grants_to_local_folder_entry() { + assert!(ADMIN_POLICY_SCRIPT.contains("function localFolderOpenHref(grant)")); + assert!(ADMIN_POLICY_SCRIPT.contains("sourceKind', 'local_folder'")); + assert!(ADMIN_POLICY_SCRIPT.contains("data-testid=\"mnote-admin-open-granted-root\"")); + assert!(ADMIN_POLICY_SCRIPT.contains("function isDefaultWorkspaceAutoGrant(grant)")); + assert!(ADMIN_POLICY_SCRIPT.contains("var canRevoke = !systemOwned &&")); + } } diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index 926aa6e2..b50f64d9 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -94,17 +94,6 @@ fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView { rows="1" >{model.title.clone()}</textarea> </h1> - <Show when={move || model.pane_role == "secondary"}> - <button - type="button" - class="document-pane-close" - data-mnote-pane-close="secondary" - aria-label="关闭右侧文档" - title="关闭右侧文档" - > - <span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span> - </button> - </Show> </div> <div class="document-shell-meta" aria-label="页面元信息"> <span data-page-title-current="true">{model.title.clone()}</span> @@ -297,13 +286,14 @@ pub fn DocumentPage( data-testid="mnote-document-workspace" data-has-secondary-pane={secondary_visible.to_string()} > - <section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host"> - <div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" role="tablist" aria-label="主编辑区标签页"> + <section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host" data-pane-role="primary"> + <div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="primary" role="tablist" aria-label="主编辑区标签页"> <button type="button" class="mnote-main-tab is-active" data-mnote-main-tab="page" data-mnote-tab-kind="page" + data-pane-role="primary" role="tab" aria-selected="true" tabindex="0" @@ -313,16 +303,17 @@ pub fn DocumentPage( </button> </div> <div class="mnote-main-tab-panels"> - <div data-mnote-page-tab-panel="true"> + <div data-mnote-page-tab-panel="true" data-pane-role="primary"> <DocumentPane model={primary_model} /> </div> <section class="mnote-resource-tab-host" data-mnote-resource-tab-host="true" + data-pane-role="primary" data-testid="mnote-resource-tab-host" hidden=true > - <div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true"></div> + <div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="primary"></div> </section> </div> </section> @@ -333,7 +324,38 @@ pub fn DocumentPage( aria-hidden="true" hidden={!secondary_visible} ></div> - <DocumentPane model={secondary_model} /> + <section class="document-main-editor-group" data-testid="mnote-secondary-editor-tab-host" data-pane-role="secondary" hidden={!secondary_visible}> + <div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="secondary" role="tablist" aria-label="右侧编辑区标签页"> + <button + type="button" + class="mnote-main-tab is-active" + data-mnote-main-tab="page" + data-mnote-tab-kind="page" + data-pane-role="secondary" + role="tab" + aria-selected="true" + tabindex="0" + > + <span class="mnote-main-tab-badge" aria-hidden="true"></span> + <span class="mnote-main-tab-title">{secondary_model.title.clone()}</span> + <span class="mnote-main-tab-close" role="button" data-mnote-pane-close="secondary" aria-label="关闭右侧文档" title="关闭右侧文档">{"×"}</span> + </button> + </div> + <div class="mnote-main-tab-panels"> + <div data-mnote-page-tab-panel="true" data-pane-role="secondary"> + <DocumentPane model={secondary_model} /> + </div> + <section + class="mnote-resource-tab-host" + data-mnote-resource-tab-host="true" + data-pane-role="secondary" + data-testid="mnote-secondary-resource-tab-host" + hidden=true + > + <div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="secondary"></div> + </section> + </div> + </section> </div> <div class="sr-only" data-mnote-workspace-label>{workspace_label}</div> </PageLayout> diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index d9c2a370..9f22de1d 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -97,6 +97,57 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function installWorkspaceSidebarResizer() { + var shell = document.querySelector('.mnote-shell, .wolai-workspace-shell'); + var sidebar = document.querySelector('[data-testid="wolai-sidebar"]'); + var resizer = document.querySelector('[data-mnote-sidebar-resizer="true"]'); + if (!(shell instanceof HTMLElement) || !(sidebar instanceof HTMLElement) || !(resizer instanceof HTMLElement)) return; + if (resizer.getAttribute('data-mnote-sidebar-resizer-bound') === 'true') return; + resizer.setAttribute('data-mnote-sidebar-resizer-bound', 'true'); + var storageKey = 'mnote.workspace.sidebarWidth.v1'; + var minWidth = 220; + var maxWidth = 520; + function clampWidth(value) { + var width = Number(value); + if (!Number.isFinite(width)) return 248; + return Math.max(minWidth, Math.min(maxWidth, width)); + } + function applyWidth(value) { + var width = clampWidth(value); + shell.style.setProperty('--mnote-sidebar-width', width + 'px'); + document.documentElement.setAttribute('data-mnote-sidebar-width', String(width)); + return width; + } + try { + var stored = Number(window.localStorage.getItem(storageKey) || ''); + if (stored) applyWidth(stored); + } catch (_) {} + resizer.addEventListener('pointerdown', function(event) { + if (event.button !== 0) return; + event.preventDefault(); + var startX = event.clientX; + var startWidth = sidebar.getBoundingClientRect().width || clampWidth(0); + resizer.setPointerCapture(event.pointerId); + document.documentElement.setAttribute('data-mnote-sidebar-resizing', 'true'); + function onMove(moveEvent) { + applyWidth(startWidth + moveEvent.clientX - startX); + } + function onEnd(endEvent) { + resizer.removeEventListener('pointermove', onMove); + resizer.removeEventListener('pointerup', onEnd); + resizer.removeEventListener('pointercancel', onEnd); + try { resizer.releasePointerCapture(endEvent.pointerId); } catch (_) {} + document.documentElement.removeAttribute('data-mnote-sidebar-resizing'); + try { + window.localStorage.setItem(storageKey, String(Math.round(sidebar.getBoundingClientRect().width))); + } catch (_) {} + } + resizer.addEventListener('pointermove', onMove); + resizer.addEventListener('pointerup', onEnd); + resizer.addEventListener('pointercancel', onEnd); + }); + } + function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') @@ -124,9 +175,62 @@ const SIDEBAR_TREE_JS: &str = r##" function currentDocumentId() { var match = window.location.pathname.match(/^\/documents\/([^\/]+)/); if (!match) match = window.location.pathname.match(/^\/mindmap\/([^\/]+)\/([^\/]+)/); - return match ? decodeURIComponent(match[1]) : ''; + if (match) return decodeURIComponent(match[1]); + var params = new URLSearchParams(window.location.search); + var fromQuery = (params.get('documentId') || params.get('pageId') || '').trim(); + if (fromQuery) return fromQuery; + var activePane = document.querySelector('.document-pane[data-pane-role="primary"][data-pane-document-id], .document-pane[data-pane-visible="true"][data-pane-document-id]'); + if (activePane instanceof HTMLElement) { + var paneDocumentId = (activePane.getAttribute('data-pane-document-id') || '').trim(); + if (paneDocumentId) return paneDocumentId; + } + var shell = document.querySelector('.document-shell[data-document-id]'); + if (shell instanceof HTMLElement) return (shell.getAttribute('data-document-id') || '').trim(); + return ''; } + function localFolderSelfChangeSuppressions() { + window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map(); + return window.__mnoteLocalFolderSelfChangeSuppressions; + } + + function persistLocalFolderSelfChangeSuppression(documentId, expiresAt) { + var doc = String(documentId || '').trim(); + if (!doc) return; + localFolderSelfChangeSuppressions().set(doc, expiresAt); + try { + var key = 'mnote.localFolder.selfChangeSuppressions.v1'; + var existing = JSON.parse(window.sessionStorage.getItem(key) || '{}'); + existing[doc] = expiresAt; + window.sessionStorage.setItem(key, JSON.stringify(existing)); + } catch (_) {} + } + + var EDITOR_UPLOAD_ROOT_SELECTOR = '[data-editor-host-kind="leptos_tiptap_island"], [data-editor-host-kind="leptos_tiptap_resource"]'; + + function editorUploadRootFromElement(target) { + if (!(target instanceof Element)) return null; + var root = target.closest(EDITOR_UPLOAD_ROOT_SELECTOR); + return root instanceof HTMLElement ? root : null; + } + + function rememberEditorUploadRootFromTarget(target) { + var root = editorUploadRootFromElement(target); + if (root instanceof HTMLElement) { + window.__mnoteLastEditorUploadRoot = root; + return root; + } + return null; + } + + document.addEventListener('pointerdown', function(event) { + rememberEditorUploadRootFromTarget(event.target); + }, true); + + document.addEventListener('focusin', function(event) { + rememberEditorUploadRootFromTarget(event.target); + }, true); + function currentFileTreeActiveRowId() { var explicitRowId = new URL(window.location.href).searchParams.get('restoreFocusRowId') || ''; if (explicitRowId) return explicitRowId; @@ -779,6 +883,34 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function recentLocalRootLabel(rootUri) { + var label = fileRootUriToPathInput(rootUri); + return label || '本地文件夹'; + } + + function normalizeGrantedLocalFolderRootUri(grant) { + var rootUri = String(grant && grant.rootUri || '').trim(); + if (rootUri && /^file:\/\//i.test(rootUri)) return rootUri; + var rootPath = String(grant && grant.rootPath || '').trim(); + if (rootPath) return pathToFileRootUri(rootPath); + if (rootUri) return pathToFileRootUri(rootUri); + return ''; + } + + function isDefaultWorkspaceAutoGrant(grant) { + var source = String(grant && grant.source || '').trim(); + var workspaceId = String(grant && grant.workspaceId || '').trim(); + var rootUri = String(grant && grant.rootUri || '').trim(); + var permission = String(grant && grant.permission || '').trim(); + var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim(); + var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim(); + return source === 'auto' + && workspaceId + && permission === 'write' + && createdBy === targetUser + && /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri); + } + function openLocalFolderRoot(rootUri) { if (currentSourceKind() !== 'local_folder') { rememberCurrentCloudWorkspaceId(); @@ -1066,6 +1198,49 @@ const SIDEBAR_TREE_JS: &str = r##" card.appendChild(title); card.appendChild(status); card.appendChild(input); + var authorizedSection = document.createElement('div'); + authorizedSection.className = 'mnote-local-folder-dialog__authorized'; + authorizedSection.setAttribute('data-testid', 'mnote-local-folder-authorized-roots'); + authorizedSection.hidden = true; + card.appendChild(authorizedSection); + fetch('/api/user/access-policy', { + method: 'GET', + headers: { 'accept': 'application/json' }, + credentials: 'include' + }).then(function(response) { + return response.ok ? response.json() : null; + }).then(function(payload) { + var grants = payload && Array.isArray(payload.grants) ? payload.grants : []; + var activeGrants = grants.filter(function(grant) { + return grant && grant.active !== false && grant.status !== 'revoked'; + }); + if (!activeGrants.length) return; + authorizedSection.hidden = false; + var authorizedTitle = document.createElement('div'); + authorizedTitle.style.fontSize = '12px'; + authorizedTitle.style.color = '#6b7280'; + authorizedTitle.textContent = '已授权文件夹'; + var authorizedList = document.createElement('div'); + authorizedList.className = 'mnote-local-folder-dialog__recent'; + activeGrants.slice(0, 8).forEach(function(grant) { + if (isDefaultWorkspaceAutoGrant(grant)) return; + var rootUri = normalizeGrantedLocalFolderRootUri(grant); + if (!rootUri) return; + var button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-testid', 'mnote-local-folder-authorized-root'); + button.textContent = fileRootUriToPathInput(rootUri); + button.addEventListener('click', function(event) { + event.preventDefault(); + closeLocalFolderDialog(); + openLocalFolderRoot(rootUri); + }); + authorizedList.appendChild(button); + }); + if (!authorizedList.childElementCount) return; + authorizedSection.appendChild(authorizedTitle); + authorizedSection.appendChild(authorizedList); + }).catch(function() {}); if (recent.length) { var recentTitle = document.createElement('div'); recentTitle.style.fontSize = '12px'; @@ -1077,7 +1252,7 @@ const SIDEBAR_TREE_JS: &str = r##" recent.slice(0, 5).forEach(function(rootUri) { var button = document.createElement('button'); button.type = 'button'; - button.textContent = rootUri.replace(/^file:\/\//, ''); + button.textContent = recentLocalRootLabel(rootUri); button.addEventListener('click', function(event) { event.preventDefault(); closeLocalFolderDialog(); @@ -1569,8 +1744,7 @@ const SIDEBAR_TREE_JS: &str = r##" var nextWorkspaceId = result.workspaceId || workspaceId; var nextDocumentId = commandDocumentId(result, ''); if (nextDocumentId) { - window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map(); - window.__mnoteLocalFolderSelfChangeSuppressions.set(nextDocumentId, Date.now() + 5000); + persistLocalFolderSelfChangeSuppression(nextDocumentId, Date.now() + 5000); } if (nextDocumentId && currentSourceKind() === 'local_folder') { await refreshLocalFolderSidebarSnapshot(); @@ -2055,7 +2229,7 @@ const SIDEBAR_TREE_JS: &str = r##" ? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</ul>' : ''; var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"'; - return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>'; + return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>'; }).join(''); } @@ -2104,6 +2278,20 @@ const SIDEBAR_TREE_JS: &str = r##" return { objectKind: objectKind, documentId: documentId, blockId: null, assetId: assetId }; } + function fileOwnerDocumentId(item, fallbackDocumentId, objectIdentity) { + var identity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : fileObjectIdentity(item); + var owner = String(identity && identity.documentId || '').trim(); + if (owner) return owner; + var assetId = fileAssetId(item); + var localPath = localFilePathFromAssetId(assetId); + if (localPath && localPath.indexOf('/') > 0) { + var parts = localPath.split('/'); + var bundleName = parts[0] || ''; + if (bundleName) return 'local-md:' + bundleName + '~2F' + bundleName + '.md'; + } + return String(fallbackDocumentId || '').trim(); + } + function objectIdentityAttr(identity) { try { return JSON.stringify(identity || {}); @@ -2148,6 +2336,7 @@ const SIDEBAR_TREE_JS: &str = r##" var assetId = fileAssetId(item); var relativePath = fileWorkspaceRelativePath(item); var objectIdentity = fileObjectIdentity(item); + var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity); var iconKind = iconKindOf(item); var title = isFileTreeProjectionPageRow(rowKind, assetId) ? fileTreePageTitle(rawTitle) @@ -2166,7 +2355,7 @@ const SIDEBAR_TREE_JS: &str = r##" var childHtml = expandable ? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '</ul>' : ''; - return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>'; + return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>'; }).join(''); } @@ -2401,7 +2590,8 @@ const SIDEBAR_TREE_JS: &str = r##" documentId: detail.documentId || currentDocumentId() || '', workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', href: buildLocalFileOpenUrl(localFilePath, false), - officeUrl: localOfficeUrl + officeUrl: localOfficeUrl, + paneRole: detail.paneRole || 'primary' }); if (!opened) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer'); return true; @@ -2498,7 +2688,8 @@ const SIDEBAR_TREE_JS: &str = r##" href: String(input && input.href || buildLocalFileOpenUrl(relativePath, false) || '').trim(), officeUrl: String(input && input.officeUrl || '').trim(), documentId: String(input && input.documentId || currentDocumentId() || '').trim(), - workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim() + workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(), + paneRole: String(input && input.paneRole || 'primary').trim() || 'primary' }); } @@ -2536,11 +2727,23 @@ const SIDEBAR_TREE_JS: &str = r##" var forceEditMode = openTarget === 'edit-mode'; if (openTarget === 'side') { if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') { + var sideLocalFilePath = localFilePathFromAssetId(assetId); + var sideRootUri = String(detail.rootUri || currentRootUri() || '').trim(); + var sideFileName = String(detail.title || detail.fileName || (sideLocalFilePath ? sideLocalFilePath.split('/').pop() : '') || assetId || '').trim(); + var sideKind = String(detail.iconKind || detail.assetType || fileTreeIconKindForFileName(sideFileName) || 'file').trim(); + var sideHref = sideLocalFilePath ? buildLocalFileOpenUrl(sideLocalFilePath, false) : ''; + var sideOfficeUrl = sideLocalFilePath ? buildLocalOnlyOfficeOpenUrl(sideLocalFilePath, sideFileName, String(detail.documentId || currentDocumentId() || '').trim(), assetId, 'view') : ''; + if (sideOfficeUrl) sideKind = 'office'; void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({ - objectIdentity: String(detail.objectIdentity || detail.assetId || ''), + objectIdentity: sideLocalFilePath && sideRootUri ? 'resource:file:' + sideRootUri + ':' + sideLocalFilePath : String(detail.objectIdentity || detail.assetId || ''), assetId: assetId, - title: String(detail.title || detail.fileName || assetId || ''), - kind: String(detail.iconKind || detail.assetType || 'file'), + title: sideFileName, + fileName: sideFileName, + kind: sideKind, + path: sideLocalFilePath, + rootUri: sideRootUri, + href: sideHref, + officeUrl: sideOfficeUrl, documentId: String(detail.documentId || currentDocumentId() || ''), workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || '') }); @@ -2571,7 +2774,8 @@ const SIDEBAR_TREE_JS: &str = r##" navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim()); return; } - var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceEditMode ? 'edit' : 'view'); + var requestedOfficeMode = forceNewWindow || forceEditMode ? 'edit' : 'view'; + var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, requestedOfficeMode); if (localOfficeUrl) { if (!forceNewWindow && await openLocalResourceInActiveTab({ path: localFilePath, @@ -2587,7 +2791,8 @@ const SIDEBAR_TREE_JS: &str = r##" } var localFileUrl = buildLocalFileOpenUrl(localFilePath, false); if (localFileUrl) { - if (!forceNewWindow && await openLocalResourceInActiveTab({ + var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName); + if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({ path: localFilePath, title: localFileName, kind: fileTreeIconKindForFileName(localFileName), @@ -2932,6 +3137,11 @@ const SIDEBAR_TREE_JS: &str = r##" return ''; } + function shouldOpenLocalResourceInNewWindow(fileName) { + var ext = attachmentExtensionFromFileName(fileName); + return ext === 'pdf'; + } + function isLocalUploadedAsset(asset) { var id = String(asset && asset.id || '').trim(); return String(asset && asset.sourceKind || '').trim() === 'local_folder' || id.indexOf('local:asset:') === 0; @@ -3492,10 +3702,99 @@ const SIDEBAR_TREE_JS: &str = r##" return true; } - async function insertUploadedAssetIntoEditor(asset) { - var editorRoot = document.querySelector('.editor-surface .ProseMirror'); + function resolveEditorUploadContext(detail) { + var root = null; + var selector = detail && detail.editorRootSelector ? String(detail.editorRootSelector) : ''; + if (selector) { + try { + var selected = document.querySelector(selector); + if (selected instanceof HTMLElement) root = selected; + } catch (_) {} + } + if (!root && window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) { + root = window.__mnoteIntendedSlashRoot; + } + if (!root && window.__mnoteLastEditorUploadRoot instanceof HTMLElement && window.__mnoteLastEditorUploadRoot.isConnected) { + root = window.__mnoteLastEditorUploadRoot; + } + if (!root && document.activeElement instanceof Element) { + root = editorUploadRootFromElement(document.activeElement); + } + if (!root) { + var focused = document.querySelector(EDITOR_UPLOAD_ROOT_SELECTOR + ' .ProseMirror:focus-within'); + root = editorUploadRootFromElement(focused); + } + if (!root) { + root = document.querySelector('[data-editor-host-kind="leptos_tiptap_island"][data-pane-role="primary"]'); + } + var pane = root instanceof Element ? root.closest('.document-pane[data-pane-role]') : null; + var shell = root instanceof Element ? root.closest('.document-shell[data-document-id]') : null; + return { + root: root instanceof HTMLElement ? root : null, + documentId: String( + detail && detail.documentId + || (root instanceof HTMLElement && root.getAttribute('data-document-id')) + || (pane instanceof HTMLElement && pane.getAttribute('data-pane-document-id')) + || (shell instanceof HTMLElement && shell.getAttribute('data-document-id')) + || currentDocumentId() + || '' + ).trim(), + workspaceId: String( + detail && detail.workspaceId + || (root instanceof HTMLElement && root.getAttribute('data-workspace-id')) + || (pane instanceof HTMLElement && pane.getAttribute('data-pane-workspace-id')) + || (shell instanceof HTMLElement && shell.getAttribute('data-workspace-id')) + || resolveWorkspaceId(document.body) + || '' + ).trim() + }; + } + + function editorRootFromUploadOptions(options) { + if (options && options.editorRoot instanceof HTMLElement) return options.editorRoot; + if (window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) { + return window.__mnoteIntendedSlashRoot; + } + if (window.__mnoteLastEditorUploadRoot instanceof HTMLElement && window.__mnoteLastEditorUploadRoot.isConnected) { + return window.__mnoteLastEditorUploadRoot; + } + var focused = document.querySelector(EDITOR_UPLOAD_ROOT_SELECTOR + ' .ProseMirror:focus-within'); + return editorUploadRootFromElement(focused); + } + + async function fetchWithTimeout(input, init, timeoutMs, label) { + var controller = typeof AbortController === 'function' ? new AbortController() : null; + var timer = 0; + try { + if (controller) { + timer = window.setTimeout(function() { + controller.abort(); + }, Math.max(1000, Number(timeoutMs) || 15000)); + } + var nextInit = Object.assign({}, init || {}); + if (controller) nextInit.signal = controller.signal; + return await fetch(input, nextInit); + } catch (error) { + if (error && error.name === 'AbortError') { + throw new Error((label || '请求') + '超时'); + } + throw error; + } finally { + if (timer) window.clearTimeout(timer); + } + } + + async function insertUploadedAssetIntoEditor(asset, targetRoot) { + var editorRoot = targetRoot instanceof HTMLElement + ? targetRoot.querySelector('.editor-surface .ProseMirror') + : document.querySelector('.editor-surface .ProseMirror'); + if (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot; var editor = editorRoot && editorRoot.editor; - if (!editor || !editor.chain) return false; + if (!editor || !editor.chain) { + document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false'); + document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'editor_unavailable'); + return false; + } var title = uploadedAssetTitle(asset); var url = localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset); var type = uploadedAssetType(asset); @@ -3525,28 +3824,42 @@ const SIDEBAR_TREE_JS: &str = r##" mode: 'view' })) : href; - var inserted = editor.chain().focus().insertContent({ - type: 'paragraph', - content: [{ - type: 'text', - text: title, - marks: [{ - type: 'link', - attrs: { - href: storedHref, - target: '_blank', - rel: 'noopener noreferrer nofollow', - class: uploadedAttachmentClass(asset) - } + var inserted = editor.chain().focus().insertContent([ + { + type: 'paragraph', + content: [{ + type: 'text', + text: title, + marks: [{ + type: 'link', + attrs: { + href: storedHref, + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: uploadedAttachmentClass(asset) + } + }] }] - }] - }).run() === true; + }, + { type: 'paragraph' } + ]).focus('end').run() === true; + if (inserted) { + document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true'); + document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || ''); + document.documentElement.removeAttribute('data-mnote-last-upload-insert-error'); + } else { + document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false'); + document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed'); + } window.setTimeout(function() { + if (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot; enhanceEditorAttachmentLinks(); var selector = assetId ? '.editor-surface .ProseMirror a[href*="' + cssEscape(assetId) + '"]' : '.editor-surface .ProseMirror a'; - var link = document.querySelector(selector); + var link = targetRoot instanceof HTMLElement + ? targetRoot.querySelector(selector) + : document.querySelector(selector); if (link instanceof HTMLElement) { link.setAttribute('data-mnote-attachment-link', 'true'); if (assetId) link.setAttribute('data-asset-id', assetId); @@ -3563,7 +3876,7 @@ const SIDEBAR_TREE_JS: &str = r##" async function uploadFileToMediaAsset(file, plan, options) { if (currentSourceKind() === 'local_folder') { - var rootUri = (new URLSearchParams(window.location.search).get('rootUri') || '').trim(); + var rootUri = currentRootUri(); var documentId = String(plan && plan.targetDocumentId || currentDocumentId() || '').trim(); var hasFolderTarget = plan && Object.prototype.hasOwnProperty.call(plan, 'targetRelativePath'); if (!rootUri || (!documentId && !hasFolderTarget)) { @@ -3575,17 +3888,17 @@ const SIDEBAR_TREE_JS: &str = r##" if (documentId) localForm.append('documentId', documentId); if (hasFolderTarget) localForm.append('targetRelativePath', String(plan.targetRelativePath || '')); localForm.append('kind', file && String(file.type || '').indexOf('image/') === 0 ? 'image' : 'attachment'); - var localResponse = await fetch('/api/local-folder/assets/upload', { + var localResponse = await fetchWithTimeout('/api/local-folder/assets/upload', { method: 'POST', credentials: 'include', body: localForm - }); + }, 15000, '本地上传'); var localPayload = await localResponse.json().catch(function() { return null; }); if (!localResponse.ok || !localPayload || !localPayload.asset) { throw new Error(localPayload && localPayload.error ? localPayload.error : '上传失败'); } if (options && options.insertIntoEditor) { - await insertUploadedAssetIntoEditor(localPayload.asset); + await insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options)); } void refreshLocalFolderSidebarSnapshot(); window.dispatchEvent(new CustomEvent('wolai:local-assets-changed', { @@ -3598,18 +3911,18 @@ const SIDEBAR_TREE_JS: &str = r##" form.append('workspaceId', plan.workspaceId); form.append('documentId', plan.targetDocumentId); if (plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId); - var response = await fetch('/api/media/upload', { + var response = await fetchWithTimeout('/api/media/upload', { method: 'POST', credentials: 'include', body: form - }); + }, 15000, '上传'); var payload = await response.json().catch(function() { return null; }); if (!response.ok || !payload || !payload.asset) { throw new Error(payload && payload.error ? payload.error : '上传失败'); } appendUploadedAssetRow(payload.asset, plan.targetDocumentId); if (options && options.insertIntoEditor) { - await insertUploadedAssetIntoEditor(payload.asset); + await insertUploadedAssetIntoEditor(payload.asset, editorRootFromUploadOptions(options)); } window.dispatchEvent(new CustomEvent('wolai:assets-changed', { detail: { docId: plan.targetDocumentId, asset: payload.asset, assetIds: [payload.asset.id] } @@ -3642,6 +3955,7 @@ const SIDEBAR_TREE_JS: &str = r##" } function openEditorUploadFilePicker(detail) { + var uploadContext = resolveEditorUploadContext(detail || {}); var input = document.createElement('input'); input.type = 'file'; input.multiple = detail && detail.multiple !== false; @@ -3654,11 +3968,12 @@ const SIDEBAR_TREE_JS: &str = r##" var files = Array.from(input.files || []); input.remove(); void uploadFilesWithResolvedTarget(files, { - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), + workspaceId: uploadContext.workspaceId || resolveWorkspaceId(document.body), + documentId: uploadContext.documentId || currentDocumentId(), targetRowId: null }, { - insertIntoEditor: detail && detail.insertIntoEditor !== false + insertIntoEditor: detail && detail.insertIntoEditor !== false, + editorRoot: uploadContext.root }); }, { once: true }); input.click(); @@ -3695,7 +4010,8 @@ const SIDEBAR_TREE_JS: &str = r##" documentId: currentDocumentId(), targetRowId: null }, { - insertIntoEditor: true + insertIntoEditor: true, + editorRoot: editorUploadRootFromElement(editorTarget) }); }, true); @@ -8361,14 +8677,47 @@ const SIDEBAR_TREE_JS: &str = r##" return Boolean(inferOnlyOfficeFileType(fileName, '')); } + function editorAttachmentPaneContext(link) { + var pane = link && typeof link.closest === 'function' ? link.closest('[data-document-pane="true"]') : null; + var roleHost = link && typeof link.closest === 'function' ? link.closest('[data-pane-role]') : null; + var paneRole = ( + pane instanceof HTMLElement && pane.getAttribute('data-pane-role') === 'secondary' + ) || ( + roleHost instanceof HTMLElement && roleHost.getAttribute('data-pane-role') === 'secondary' + ) ? 'secondary' : 'primary'; + var paneDocumentId = ''; + var paneWorkspaceId = ''; + if (pane instanceof HTMLElement) { + paneDocumentId = (pane.getAttribute('data-pane-document-id') || '').trim(); + paneWorkspaceId = (pane.getAttribute('data-pane-workspace-id') || '').trim(); + var shell = pane.querySelector('.document-shell[data-document-id]'); + if (!paneDocumentId && shell instanceof HTMLElement) paneDocumentId = (shell.getAttribute('data-document-id') || '').trim(); + if (!paneWorkspaceId && shell instanceof HTMLElement) paneWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim(); + } + if (roleHost instanceof HTMLElement) { + if (!paneDocumentId) paneDocumentId = (roleHost.getAttribute('data-document-id') || '').trim(); + if (!paneWorkspaceId) paneWorkspaceId = (roleHost.getAttribute('data-workspace-id') || '').trim(); + var roleHostShell = roleHost.matches('.document-shell') ? roleHost : roleHost.querySelector?.('.document-shell[data-document-id]'); + if (!paneDocumentId && roleHostShell instanceof HTMLElement) paneDocumentId = (roleHostShell.getAttribute('data-document-id') || '').trim(); + if (!paneWorkspaceId && roleHostShell instanceof HTMLElement) paneWorkspaceId = (roleHostShell.getAttribute('data-workspace-id') || '').trim(); + } + return { + paneRole: paneRole, + documentId: paneDocumentId || currentDocumentId() || '', + workspaceId: paneWorkspaceId || resolveWorkspaceId(document.body) || '' + }; + } + function detailFromEditorAttachmentLink(link) { var rawHref = link instanceof HTMLAnchorElement ? link.href : ''; var params = attachmentQueryParams(rawHref); + var paneContext = editorAttachmentPaneContext(link); var localFilePath = localFileOpenPathFromHref(rawHref); var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件'; var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, ''); var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || (localFilePath ? 'local-file:' + localFilePath : ''); var fileUrl = params.get('fileUrl') || ''; + var documentId = params.get('documentId') || paneContext.documentId || ''; var href = rawHref; if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) { fileUrl = rawHref; @@ -8377,7 +8726,7 @@ const SIDEBAR_TREE_JS: &str = r##" fileName: fileName, fileType: fileType, assetId: assetId, - documentId: currentDocumentId() || '', + documentId: documentId, userId: '', mode: 'view' }); @@ -8391,8 +8740,9 @@ const SIDEBAR_TREE_JS: &str = r##" title: fileName, fileType: fileType, assetId: assetId, - documentId: params.get('documentId') || currentDocumentId() || '', - workspaceId: resolveWorkspaceId(document.body), + documentId: documentId, + workspaceId: paneContext.workspaceId, + paneRole: paneContext.paneRole, fileSize: (link ? link.getAttribute('data-file-size') : '') || '' }; } @@ -8402,13 +8752,17 @@ const SIDEBAR_TREE_JS: &str = r##" var href = link.getAttribute('href') || ''; var params = attachmentQueryParams(href); var localFilePath = localFileOpenPathFromHref(href); + var paneContext = editorAttachmentPaneContext(link); var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || link.textContent || ''; var className = link.getAttribute('class') || ''; var shouldEnhance = isOnlyOfficeAttachmentHref(href) || className.indexOf('mnote-uploaded-attachment-row') >= 0 || isOfficeFileName(fileName) - || Boolean(localFilePath && (isPdfAttachmentFileName(fileName) || isCodeAttachmentFileName(fileName))); + || Boolean(localFilePath); if (!shouldEnhance) return; + if (localFilePath && !isOnlyOfficeAttachmentHref(href)) { + return; + } link.setAttribute('data-mnote-attachment-link', 'true'); var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : ''); if (assetId) link.setAttribute('data-asset-id', assetId); @@ -8418,7 +8772,7 @@ const SIDEBAR_TREE_JS: &str = r##" fileName: fileName || '未命名附件', fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''), assetId: assetId, - documentId: params.get('documentId') || currentDocumentId() || '', + documentId: params.get('documentId') || paneContext.documentId || '', userId: params.get('userId') || '', mode: params.get('mode') || 'view' })); @@ -8435,6 +8789,10 @@ const SIDEBAR_TREE_JS: &str = r##" document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink); void healLegacyOfficeAttachmentParagraphs(); } + window.__mnoteEnhanceEditorAttachmentLinks = function() { + observeEditorAttachmentRoots(); + enhanceEditorAttachmentLinks(); + }; function ensureAttachmentActions() { var existing = document.querySelector('[data-testid="mnote-attachment-actions"]'); @@ -8486,7 +8844,8 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: detail.assetId, documentId: detail.documentId || currentDocumentId() || '', workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', - href: buildLocalFileOpenUrl(localFilePath, false) + href: buildLocalFileOpenUrl(localFilePath, false), + paneRole: detail.paneRole || 'primary' }).then(function(opened) { if (!opened) window.open(buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer'); }); @@ -8510,7 +8869,8 @@ const SIDEBAR_TREE_JS: &str = r##" kind: 'office', officeUrl: detail.href, documentId: detail.documentId || '', - workspaceId: detail.workspaceId || '' + workspaceId: detail.workspaceId || '', + paneRole: detail.paneRole || 'primary' }); return; } @@ -8582,7 +8942,8 @@ const SIDEBAR_TREE_JS: &str = r##" kind: 'office', officeUrl: href, documentId: detail.documentId || '', - workspaceId: detail.workspaceId || '' + workspaceId: detail.workspaceId || '', + paneRole: detail.paneRole || 'primary' }); if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer'); return didOpenEditTab; @@ -8737,11 +9098,51 @@ const SIDEBAR_TREE_JS: &str = r##" } enhanceEditorAttachmentLinks(); - var attachmentObserver = new MutationObserver(function() { enhanceEditorAttachmentLinks(); }); - attachmentObserver.observe(document.documentElement, { childList: true, subtree: true }); + var attachmentEnhanceFrame = 0; + var attachmentEditorObserver = null; + var attachmentObservedEditors = typeof WeakSet === 'function' ? new WeakSet() : null; + function scheduleEditorAttachmentEnhance() { + if (attachmentEnhanceFrame) return; + attachmentEnhanceFrame = window.requestAnimationFrame(function() { + attachmentEnhanceFrame = 0; + enhanceEditorAttachmentLinks(); + observeEditorAttachmentRoots(); + }); + } + function addedNodeMayContainEditorAttachmentLink(node) { + return node instanceof HTMLElement && ( + node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div') + || node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href]') + ); + } + function observeEditorAttachmentRoots() { + if (!attachmentEditorObserver) return; + document.querySelectorAll('.editor-surface .ProseMirror').forEach(function(editor) { + if (!(editor instanceof HTMLElement)) return; + if (attachmentObservedEditors && attachmentObservedEditors.has(editor)) return; + if (attachmentObservedEditors) attachmentObservedEditors.add(editor); + attachmentEditorObserver.observe(editor, { childList: true, subtree: true }); + }); + } + attachmentEditorObserver = new MutationObserver(function(records) { + var shouldEnhance = Array.isArray(records) && records.some(function(record) { + if (!record || record.type !== 'childList') return false; + return Array.from(record.addedNodes || []).some(function(node) { + return addedNodeMayContainEditorAttachmentLink(node); + }); + }); + if (!shouldEnhance) return; + scheduleEditorAttachmentEnhance(); + }); + attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true }); + observeEditorAttachmentRoots(); + window.addEventListener('mnote:editor-attachment-links-changed', function() { + window.__mnoteEnhanceEditorAttachmentLinks(); + scheduleEditorAttachmentEnhance(); + }); function interceptEditorAttachmentLink(event) { - var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return; event.preventDefault(); event.stopPropagation(); @@ -8750,7 +9151,7 @@ const SIDEBAR_TREE_JS: &str = r##" } function suppressEditorAttachmentLinkDefault(event) { - var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return; event.preventDefault(); event.stopPropagation(); @@ -8761,7 +9162,7 @@ const SIDEBAR_TREE_JS: &str = r##" window.addEventListener('click', interceptEditorAttachmentLink, true); document.addEventListener('mouseover', function(event) { - var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (!(link instanceof HTMLAnchorElement)) return; if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer); enhanceEditorAttachmentLink(link); @@ -8769,7 +9170,7 @@ const SIDEBAR_TREE_JS: &str = r##" }); document.addEventListener('mouseout', function(event) { - var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (!(link instanceof HTMLAnchorElement)) return; var next = event.relatedTarget; var actions = document.querySelector('[data-testid="mnote-attachment-actions"]'); @@ -8807,7 +9208,7 @@ const SIDEBAR_TREE_JS: &str = r##" return; } - var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (editorAttachmentLink instanceof HTMLAnchorElement) { e.preventDefault(); openEditorAttachmentLink(editorAttachmentLink); @@ -9170,6 +9571,7 @@ const SIDEBAR_TREE_JS: &str = r##" var rowId = fileRow.getAttribute('data-row-id') || ''; var rowKind = fileRow.getAttribute('data-row-kind') || ''; var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || ''; + var ownerDocumentId = fileRow.getAttribute('data-owner-document-id') || documentId; var assetId = fileRow.getAttribute('data-asset-id') || ''; var assetType = ''; var kindBadge = fileRow.querySelector('.tree-kind-badge'); @@ -9205,14 +9607,14 @@ const SIDEBAR_TREE_JS: &str = r##" e.preventDefault(); selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey }); var objectIdentity = readFileTreeObjectIdentity(fileRow); - dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) }); + dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) }); if (e.shiftKey || e.ctrlKey || e.metaKey) { return; } if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) { navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' }); } else if (assetId) { - dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) }); + dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow), openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab' }); } return; } @@ -9264,7 +9666,7 @@ const SIDEBAR_TREE_JS: &str = r##" }); document.addEventListener('contextmenu', function(event) { - var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); + var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (editorAttachmentLink instanceof HTMLAnchorElement) { event.preventDefault(); event.stopPropagation(); @@ -9447,6 +9849,7 @@ const SIDEBAR_TREE_JS: &str = r##" updatePageSettingsTriggerState(); updatePageAiTriggerState(); ensureHistorySnapshotsSeeded(); + installWorkspaceSidebarResizer(); } window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot; @@ -10143,6 +10546,7 @@ pub fn PageLayout( <script inner_html={SIDEBAR_TREE_JS.to_string()}></script> <script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script> </aside> + <div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div> <div class="mnote-main"> <header class="wolai-topbar" data-testid="wolai-topbar"> <div class="wolai-topbar-left"> @@ -10180,6 +10584,27 @@ pub fn PageLayout( mod tests { use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS}; + fn js_function_body(source: &str, name: &str) -> String { + let marker = format!("function {name}("); + let start = source.find(&marker).expect("js function exists"); + let rest = &source[start..]; + let brace = rest.find('{').expect("js function body starts"); + let mut depth = 0usize; + let mut end = None; + for (offset, ch) in rest[brace..].char_indices() { + if ch == '{' { + depth += 1; + } else if ch == '}' { + depth -= 1; + if depth == 0 { + end = Some(brace + offset + 1); + break; + } + } + } + rest[..end.expect("js function body ends")].to_string() + } + #[test] fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() { assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight")); @@ -10255,6 +10680,10 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace")); assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace")); assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root")); + assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-roots")); + assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-root")); + assert!(SIDEBAR_TREE_JS.contains("已授权文件夹")); + assert!(SIDEBAR_TREE_JS.contains("fetch('/api/user/access-policy'")); } #[test] @@ -10412,8 +10841,23 @@ mod tests { #[test] fn sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder() { + let current_document_function = js_function_body(SIDEBAR_TREE_JS, "currentDocumentId"); + let upload_function = js_function_body(SIDEBAR_TREE_JS, "uploadFileToMediaAsset"); assert!(SIDEBAR_TREE_JS.contains("currentSourceKind() === 'local_folder'")); assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/assets/upload")); + assert!( + upload_function.contains("var rootUri = currentRootUri();"), + "本地上传必须复用 currentRootUri(),否则 / 页面由 body dataset 提供 rootUri 时 .md 上传会失败" + ); + assert!( + current_document_function.contains("params.get('pageId')"), + "SQLite/local-first 根入口可能通过 pageId 或 DOM 暴露当前页面,不能只解析 /documents/:id" + ); + assert!( + current_document_function.contains("data-pane-document-id") + && current_document_function.contains("data-document-id"), + "主编辑器上传应能从当前文档 DOM 回退解析 documentId" + ); assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)")); assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)")); assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)")); @@ -10422,6 +10866,11 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("fileTreeIconKindForFileName(title)")); assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot")); assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed")); + assert!(SIDEBAR_TREE_JS.contains("function resolveEditorUploadContext")); + assert!(SIDEBAR_TREE_JS.contains("__mnoteLastEditorUploadRoot")); + assert!(SIDEBAR_TREE_JS.contains("data-pane-role=\"primary\"")); + assert!(SIDEBAR_TREE_JS.contains("insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options))")); + assert!(SIDEBAR_TREE_JS.contains("editorRoot: uploadContext.root")); } #[test] @@ -10444,6 +10893,47 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("isLocalAsset ? onlyOfficeUrl")); } + #[test] + fn sidebar_local_folder_authorized_root_normalizes_plain_path_root_uri() { + assert!(SIDEBAR_TREE_JS.contains("function normalizeGrantedLocalFolderRootUri(grant)")); + assert!(SIDEBAR_TREE_JS.contains("if (rootPath) return pathToFileRootUri(rootPath);")); + assert!(SIDEBAR_TREE_JS.contains("if (rootUri) return pathToFileRootUri(rootUri);")); + assert!( + SIDEBAR_TREE_JS.contains("var rootUri = normalizeGrantedLocalFolderRootUri(grant);") + ); + assert!(SIDEBAR_TREE_JS.contains("function isDefaultWorkspaceAutoGrant(grant)")); + assert!(SIDEBAR_TREE_JS.contains("if (isDefaultWorkspaceAutoGrant(grant)) return;")); + assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootLabel(rootUri)")); + assert!(SIDEBAR_TREE_JS.contains("button.textContent = recentLocalRootLabel(rootUri);")); + assert!( + !SIDEBAR_TREE_JS.contains("button.textContent = rootUri.replace(/^file:\\/\\//, '')") + ); + } + + #[test] + fn sidebar_runtime_supports_resizable_filetree_and_hover_titles() { + assert!(SIDEBAR_TREE_JS.contains("function installWorkspaceSidebarResizer()")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-resizer")); + assert!(SIDEBAR_TREE_JS.contains("mnote.workspace.sidebarWidth.v1")); + assert!(SIDEBAR_TREE_JS.contains("shell.style.setProperty('--mnote-sidebar-width'")); + assert!(SIDEBAR_TREE_JS.contains("installWorkspaceSidebarResizer();")); + assert!(SIDEBAR_TREE_JS.contains(r#"" title="' + escapeHtml(title) + '""#)); + assert!(SIDEBAR_TREE_JS + .contains(r#"class="tree-link-title" title="' + escapeHtml(title) + '""#)); + } + + #[test] + fn sidebar_runtime_opens_local_pdf_assets_in_browser_tab_by_default() { + assert!(SIDEBAR_TREE_JS.contains("function shouldOpenLocalResourceInNewWindow(fileName)")); + assert!(SIDEBAR_TREE_JS.contains("return ext === 'pdf';")); + assert!(SIDEBAR_TREE_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);")); + assert!(SIDEBAR_TREE_JS + .contains("if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({")); + assert!(SIDEBAR_TREE_JS.contains( + "openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab'" + )); + } + #[test] fn sidebar_tree_runtime_contains_dev_hot_reload_client() { assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload")); @@ -10628,6 +11118,29 @@ mod tests { assert!(table_loop.contains("/api/tables/")); } + #[test] + fn sidebar_filetree_asset_open_uses_owner_document_id() { + assert!( + SIDEBAR_TREE_JS.contains( + "var ownerDocumentId = fileRow.getAttribute('data-owner-document-id') || documentId;" + ), + "打开资源行时必须优先使用资源 owner document,不能用当前页面 documentId" + ); + assert!( + SIDEBAR_TREE_JS.contains("documentId: ownerDocumentId || null"), + "tree.asset.open detail 应携带资源 owner document" + ); + assert!( + SIDEBAR_TREE_JS.contains("data-owner-document-id=\"' + escapeHtml(ownerDocumentId)"), + "SSR filetree 行应输出 data-owner-document-id" + ); + assert!( + SIDEBAR_TREE_JS + .contains("return 'local-md:' + bundleName + '~2F' + bundleName + '.md';"), + "local_folder bundle 资源应从 local-file 路径推导 owner markdown document" + ); + } + #[test] fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() { assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType")); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 68dd916a..1f842ca1 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -232,7 +232,8 @@ a:hover { } .mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar, -.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar { +.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar, +.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar-resizer { display: none; } @@ -240,13 +241,38 @@ a:hover { --wolai-bg-sidebar: #F7F7F6; --wolai-bg-selected: #F8E6E7; --wolai-accent-red: #E0525B; + --mnote-sidebar-width: 288px; } .wolai-sidebar { - width: 288px; + width: var(--mnote-sidebar-width); background: var(--wolai-bg-sidebar); } +.mnote-sidebar-resizer { + display: block; + width: 6px; + min-height: 100vh; + cursor: col-resize; + position: relative; + flex: 0 0 auto; +} + +.mnote-sidebar-resizer::before { + content: ""; + position: absolute; + left: 2px; + top: 0; + bottom: 0; + width: 2px; + background: rgba(27, 28, 28, 0.08); +} + +.mnote-sidebar-resizer:hover::before, +html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before { + background: rgba(37, 99, 235, 0.42); +} + .wolai-sidebar-header { height: 52px; gap: 10px; @@ -345,6 +371,33 @@ a:hover { color: #1D4ED8; } +.mnote-local-folder-dialog__recent { + display: grid; + gap: 6px; + min-width: 0; +} + +.mnote-local-folder-dialog__recent button { + min-width: 0; + max-width: 100%; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 6px; + background: #fff; + padding: 7px 9px; + color: var(--wolai-text-primary); + font-size: 13px; + line-height: 1.4; + text-align: left; + cursor: pointer; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.mnote-local-folder-dialog__recent button:hover { + background: var(--wolai-bg-hover); +} + .mnote-account-menu{inset-inline-start:auto;right:8px;top:calc(100% - 10px);width:194px}.mnote-account-menu__item{display:flex;align-items:center;gap:10px}.mnote-account-menu__logout{color:#C2410C}.mnote-account-menu__logout:hover{background:#FFF7ED}.mnote-account-menu__error{padding:6px 8px 2px;color:#B91C1C;font-size:12px}.mnote-profile-dialog{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-profile-dialog__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-profile-dialog__panel{position:relative;width:min(440px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-profile-dialog__header,.mnote-profile-dialog__identity,.mnote-profile-dialog__list>div,.mnote-profile-dialog__list dd{display:flex;align-items:center}.mnote-profile-dialog__header{justify-content:space-between;margin-bottom:18px}.mnote-profile-dialog__eyebrow,.mnote-profile-dialog__identity span,.mnote-profile-dialog__list dt{color:var(--wolai-text-secondary);font-size:13px}.mnote-profile-dialog__header h2{font-size:20px}.mnote-profile-dialog__close,.mnote-profile-dialog__list button{border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-profile-dialog__close{width:32px;height:32px}.mnote-profile-dialog__identity{gap:12px;padding:12px;border-radius:8px;background:var(--wolai-bg-sidebar)}.mnote-profile-dialog__avatar{width:36px;height:36px;display:grid;place-items:center;border-radius:6px;background:#D6545D;color:#fff;font-weight:650}.mnote-profile-dialog__list{margin-top:16px}.mnote-profile-dialog__list>div{justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--wolai-border)}.mnote-profile-dialog__list dd{min-width:0;gap:8px;max-width:280px;font-size:13px;text-align:right;overflow-wrap:anywhere}.mnote-profile-dialog__list code{font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px;white-space:normal} .wolai-quick-actions { @@ -792,10 +845,15 @@ a:hover { .mnote-content { flex: 1; + min-height: 0; overflow-y: auto; padding: 0; } +.mnote-content:has(.document-workspace) { + overflow: hidden; +} + /* ===== 首页 ===== */ .mnote-home { max-width: 720px; @@ -1311,7 +1369,7 @@ body { .mnote-sidebar, .wolai-sidebar { - width: 248px; + width: var(--mnote-sidebar-width, 248px); background: var(--atelier-sidebar); border-right: 0; box-shadow: none; @@ -1798,7 +1856,8 @@ body { font-size: 18px; } -.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row { +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, +.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"] { display: inline-flex !important; align-items: center !important; gap: 7px !important; @@ -1812,7 +1871,8 @@ body { text-decoration: none !important; } -.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before { +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before, +.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before { content: "" !important; display: inline-block !important; width: 18px !important; @@ -1900,7 +1960,7 @@ body { background: var(--atelier-document); } -.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row strong,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}} +.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row>div{min-width:0}.mnote-admin-policy-row strong,.mnote-admin-policy-root-link{overflow-wrap:anywhere}.mnote-admin-policy-row strong,.mnote-admin-policy-root-link,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-root-link{color:var(--wolai-text-primary);font-weight:650;text-decoration:none}.mnote-admin-policy-root-link:hover{text-decoration:underline}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}} .mnote-trash-workbench { width: min(860px, calc(100vw - 64px)); @@ -2217,9 +2277,12 @@ body { --mnote-secondary-pane-resizer-width: 6px; display: grid; grid-template-columns: minmax(0, 1fr); - align-items: start; + align-items: stretch; width: 100%; + height: 100%; min-width: 0; + min-height: 0; + overflow: hidden; } .document-workspace[data-has-secondary-pane="true"] { @@ -2228,6 +2291,28 @@ body { .document-main-editor-group { min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; + overscroll-behavior: contain; +} + +.mnote-main-tab-panels { + min-width: 0; + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.mnote-main-tab-panels > [data-mnote-page-tab-panel] { + min-width: 0; + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; } .mnote-main-tab-strip { @@ -2376,8 +2461,20 @@ body { display: none !important; } +.mnote-resource-tab-host { + flex: 1 1 auto; + min-height: 0; +} + +.mnote-resource-tab-panel-root { + min-height: 0; + height: 100%; +} + .mnote-resource-tab-panel { min-height: calc(100vh - 76px); + height: 100%; + min-width: 0; } .mnote-resource-tab-frame, @@ -2439,6 +2536,39 @@ body { color: var(--color-basic-600, #9B9A97); } +.mnote-resource-tab-mindmap-shell { + width: 100%; + max-width: none; + height: 100%; + min-height: calc(100vh - 80px); + margin: 0; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.mnote-resource-tab-mindmap-root { + width: 100%; + height: 100%; + min-height: calc(100vh - 80px); + overflow: hidden; + flex: 1 1 auto; +} + +.document-pane[data-pane-role="secondary"] .mnote-resource-tab-mindmap-shell, +[data-testid="mnote-secondary-editor-tab-host"] .mnote-resource-tab-mindmap-shell { + width: 100%; + padding: 0; +} + +.mnote-resource-tab-mindmap-shell [data-testid="mnote-mindmap-editor-root"] { + width: 100%; + height: 100%; + min-height: calc(100vh - 80px); + overflow: hidden; +} + .mnote-resource-tab-editor-root { min-height: calc(100vh - 112px); } @@ -2689,6 +2819,10 @@ body { min-height: 320px; } +[data-editor-host-kind="leptos_tiptap_island"] { + min-height: 320px; +} + #mnote-leptos-tiptap-island-editor-root .editor-surface { border: 0 !important; box-shadow: none !important; @@ -2696,6 +2830,13 @@ body { padding: 0 !important; } +[data-editor-host-kind="leptos_tiptap_island"] .editor-surface { + border: 0 !important; + box-shadow: none !important; + background: transparent !important; + padding: 0 !important; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror, .ProseMirror { color: var(--atelier-text); @@ -2711,6 +2852,13 @@ body { padding: 0 !important; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror { + width: 100% !important; + min-height: 280px; + margin: 0 !important; + padding: 0 !important; +} + #mnote-leptos-tiptap-island-editor-root .block-handle-shell { left: -32px !important; z-index: 80; @@ -2733,17 +2881,32 @@ body { margin: 0 0 8px; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p { + margin: 0 0 8px; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror ul, #mnote-leptos-tiptap-island-editor-root .ProseMirror ol { margin: 4px 0 8px; padding-left: 1.5em; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul, +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol { + margin: 4px 0 8px; + padding-left: 1.5em; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror li { margin: 2px 0; padding-left: 2px; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror li { + margin: 2px 0; + padding-left: 2px; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror blockquote { margin: 8px 0; padding: 2px 0 2px 14px; @@ -2751,6 +2914,13 @@ body { color: #5F5B56; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote { + margin: 8px 0; + padding: 2px 0 2px 14px; + border-left: 3px solid #D9D6D0; + color: #5F5B56; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror input[type="checkbox"] { width: 16px; height: 16px; @@ -2759,6 +2929,14 @@ body { accent-color: #2EA44F; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0 8px 0 0; + vertical-align: -3px; + accent-color: #2EA44F; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror h1, #mnote-leptos-tiptap-island-editor-root .ProseMirror h2, #mnote-leptos-tiptap-island-editor-root .ProseMirror h3 { @@ -2766,6 +2944,13 @@ body { letter-spacing: 0; } +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h1, +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h2, +[data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h3 { + line-height: 1.2; + letter-spacing: 0; +} + .mnote-workspace-active-state { padding-top: 10px; } @@ -2846,6 +3031,13 @@ body { line-height: 22px; } +.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror, +.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p, +.document-shell[data-page-small-text="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror li { + font-size: 15px; + line-height: 22px; +} + .document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p, .document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul, .document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol, @@ -2853,6 +3045,13 @@ body { margin-bottom: 4px; } +.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p, +.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul, +.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol, +.document-shell[data-layout-density="compact"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote { + margin-bottom: 4px; +} + .document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p, .document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul, .document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol, @@ -2860,20 +3059,39 @@ body { margin-bottom: 14px; } +.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror p, +.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ul, +.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror ol, +.document-shell[data-layout-density="spacious"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror blockquote { + margin-bottom: 14px; +} + .document-shell[data-page-font="song"] .document-title-input, .document-shell[data-page-font="song"] #mnote-leptos-tiptap-island-editor-root .ProseMirror { font-family: "Noto Serif SC", "Songti SC", serif; } +.document-shell[data-page-font="song"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror { + font-family: "Noto Serif SC", "Songti SC", serif; +} + .document-shell[data-page-font="kai"] .document-title-input, .document-shell[data-page-font="kai"] #mnote-leptos-tiptap-island-editor-root .ProseMirror { font-family: "STKaiti", "KaiTi", serif; } +.document-shell[data-page-font="kai"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror { + font-family: "STKaiti", "KaiTi", serif; +} + .document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror { counter-reset: mnote-heading; } +.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror { + counter-reset: mnote-heading; +} + .document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h1::before, .document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h2::before, .document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h3::before { @@ -2883,6 +3101,15 @@ body { font-weight: 500; } +.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h1::before, +.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h2::before, +.document-shell[data-page-show-heading-numbers="true"] [data-editor-host-kind="leptos_tiptap_island"] .ProseMirror h3::before { + counter-increment: mnote-heading; + content: counter(mnote-heading) ". "; + color: #8B8782; + font-weight: 500; +} + .wolai-page-settings-popover { position: fixed; top: 52px; @@ -4025,6 +4252,10 @@ button.wolai-page-ai-message-text { width: 220px; } + .mnote-sidebar-resizer { + display: none; + } + .wolai-topbar { padding: 0 12px; } @@ -4091,6 +4322,10 @@ button.wolai-page-ai-message-text { box-shadow: none; } + .mnote-sidebar-resizer { + display: none; + } + .document-shell { padding: 36px 20px 112px; } @@ -4141,6 +4376,9 @@ mod tests { assert!(MNOTE_CSS.contains("#mnote-editor-island")); assert!(MNOTE_CSS.contains("#mnote-search-island")); assert!(MNOTE_CSS.contains("#mnote-mindmap-island")); + assert!(MNOTE_CSS.contains("--mnote-sidebar-width")); + assert!(MNOTE_CSS.contains(".mnote-sidebar-resizer")); + assert!(MNOTE_CSS.contains(".mnote-local-folder-dialog__recent button")); } #[test] @@ -4176,6 +4414,15 @@ mod tests { assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb")); } + #[test] + fn admin_policy_rows_wrap_long_grant_values() { + assert!(MNOTE_CSS.contains( + ".mnote-admin-policy-row strong,.mnote-admin-policy-root-link{overflow-wrap:anywhere" + )); + assert!(MNOTE_CSS.contains(".mnote-admin-policy-row>div{min-width:0")); + assert!(MNOTE_CSS.contains(".mnote-admin-policy-root-link:hover")); + } + #[test] fn mnote_css_contains_prosemirror_styles() { assert!(MNOTE_CSS.contains(".ProseMirror")); diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs index 6d1eedd6..5eb231f6 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -90,7 +90,7 @@ fn render_filetree_row( }; let owner_document_id = row.document_id.as_deref().unwrap_or_default(); html.push_str(&format!( - r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#, + r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#, node_id = escape_html(&row.node_id), aria_level = row.depth + 1, expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, @@ -217,6 +217,8 @@ mod tests { assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap"")); assert!(html.contains("tree-children")); assert!(html.contains("首页 <安全>")); + assert!(html.contains("title=\"首页 <安全>\"")); + assert!(html.contains("title=\"思维导图.json\"")); assert!(html.contains("data-selected=\"true\"")); } } diff --git a/scripts/task165-rust-web-dual-pane-smoke.js b/scripts/task165-rust-web-dual-pane-smoke.js index 7971d137..48028f50 100644 --- a/scripts/task165-rust-web-dual-pane-smoke.js +++ b/scripts/task165-rust-web-dual-pane-smoke.js @@ -115,7 +115,7 @@ function buildFixtureEnv(port) { function startGateway(port) { return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { - cwd: "/mnt/Data1T/mnote/rust", + cwd: path.resolve(__dirname, "..", "rust"), env: buildFixtureEnv(port), stdio: ["ignore", "pipe", "pipe"], }); @@ -123,6 +123,21 @@ function startGateway(port) { function createLocalFolderFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-")); + const mnoteDir = path.join(root, ".mnote"); + fs.mkdirSync(mnoteDir, { recursive: true }); + fs.writeFileSync( + path.join(mnoteDir, "workspace.json"), + JSON.stringify( + { + workspaceId: "local-ws:dual-pane-smoke", + ownerId: "user_real", + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, + null, + 2, + ), + "utf8", + ); const readmePath = path.join(root, "README.md"); const sidePath = path.join(root, "side.md"); const thirdPath = path.join(root, "third.md"); @@ -184,6 +199,10 @@ function paneSelector(role) { return `.document-pane[data-pane-role="${role}"]`; } +function paneScrollHostSelector(role) { + return `.document-main-editor-group[data-pane-role="${role}"]`; +} + async function waitForDualPaneReady(page) { await page.waitForFunction( ({ primarySelector, secondarySelector, primaryRootSelector, secondaryRootSelector }) => { @@ -294,6 +313,31 @@ async function waitForPaneStatus(page, role, status) { ); } +async function waitForPaneStatusWithSnapshot(page, role, status, label) { + try { + await waitForPaneStatus(page, role, status); + } catch (error) { + const snapshot = await readDocumentSessionSnapshot(page).catch((snapshotError) => ({ + error: snapshotError && snapshotError.stack ? snapshotError.stack : String(snapshotError), + })); + const paneState = await page.evaluate(({ rootSelector, paneSelector }) => { + const root = document.querySelector(rootSelector); + const pane = document.querySelector(paneSelector); + return { + status: root?.getAttribute("data-runtime-editor-status") || "", + error: root?.getAttribute("data-runtime-editor-error") || "", + panelText: pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "", + }; + }, { + rootSelector: paneRootSelector(role), + paneSelector: paneSelector(role), + }).catch((stateError) => ({ + error: stateError && stateError.stack ? stateError.stack : String(stateError), + })); + throw new Error(`${label} 等待 ${role}=${status} 失败: pane=${JSON.stringify(paneState)} sessions=${JSON.stringify(snapshot)}`, { cause: error }); + } +} + async function readPaneText(page, role) { return await page.evaluate((selector) => { const editor = document.querySelector(selector); @@ -309,20 +353,24 @@ async function readActivePaneRole(page) { } async function readPaneScrollState(page, role) { - return await page.evaluate(({ paneSelector, editorSelector }) => { - const pane = document.querySelector(paneSelector); + return await page.evaluate(({ scrollHostSelector, editorSelector }) => { + const scrollHost = document.querySelector(scrollHostSelector); const editor = document.querySelector(editorSelector); - const findScrollable = (start) => { + const findScrollable = (start, boundary) => { let current = start; while (current instanceof HTMLElement) { - if (current.scrollHeight > current.clientHeight + 8) { + const overflowY = window.getComputedStyle(current).overflowY; + if (current.scrollHeight > current.clientHeight + 8 && /auto|scroll|overlay/.test(overflowY)) { return current; } + if (current === boundary) { + break; + } current = current.parentElement; } return null; }; - const target = findScrollable(editor) || findScrollable(pane); + const target = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null; if (!(target instanceof HTMLElement)) { return null; } @@ -332,26 +380,30 @@ async function readPaneScrollState(page, role) { clientHeight: target.clientHeight, }; }, { - paneSelector: paneSelector(role), + scrollHostSelector: paneScrollHostSelector(role), editorSelector: paneEditorSelector(role), }); } async function setPaneScrollTop(page, role, top) { - return await page.evaluate(({ paneSelector, editorSelector, topValue }) => { - const pane = document.querySelector(paneSelector); + return await page.evaluate(({ scrollHostSelector, editorSelector, topValue }) => { + const scrollHost = document.querySelector(scrollHostSelector); const editor = document.querySelector(editorSelector); - const findScrollable = (start) => { + const findScrollable = (start, boundary) => { let current = start; while (current instanceof HTMLElement) { - if (current.scrollHeight > current.clientHeight + 8) { + const overflowY = window.getComputedStyle(current).overflowY; + if (current.scrollHeight > current.clientHeight + 8 && /auto|scroll|overlay/.test(overflowY)) { return current; } + if (current === boundary) { + break; + } current = current.parentElement; } return null; }; - const target = findScrollable(editor) || findScrollable(pane); + const target = scrollHost instanceof HTMLElement ? (findScrollable(editor, scrollHost) || findScrollable(scrollHost, scrollHost)) : null; if (!(target instanceof HTMLElement)) { return null; } @@ -362,7 +414,7 @@ async function setPaneScrollTop(page, role, top) { clientHeight: target.clientHeight, }; }, { - paneSelector: paneSelector(role), + scrollHostSelector: paneScrollHostSelector(role), editorSelector: paneEditorSelector(role), topValue: top, }); @@ -379,6 +431,11 @@ async function setViewportScroll(page, top) { }, top); } +async function clickSidebarRowOpen(page, text) { + const row = page.getByTestId("wolai-sidebar-row").filter({ hasText: text }).first(); + await row.locator(".tree-link").first().click({ timeout: UI_TIMEOUT_MS }); +} + async function waitForRequestCount(requests, startIndex, predicate, expected, label) { const deadline = Date.now() + UI_TIMEOUT_MS; while (Date.now() < deadline) { @@ -401,6 +458,100 @@ async function readDocumentSessionSnapshot(page) { }); } +async function waitForTreeLiveConnected(page, label) { + await page.waitForFunction( + () => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", + {}, + { timeout: UI_TIMEOUT_MS }, + ); + const snapshot = await readTreeLiveSnapshot(page); + assert(snapshot.transport, `${label} 应暴露 tree live transport`); + assert(snapshot.sourceKind, `${label} 应持有 tree live source`); + return snapshot; +} + +async function readTreeLiveSnapshot(page) { + return await page.evaluate(() => { + const source = window.__mnoteTreeLiveEventSource || null; + if (!window.__mnoteSmokeTreeLiveSourceIds) { + window.__mnoteSmokeTreeLiveSourceIds = new WeakMap(); + window.__mnoteSmokeTreeLiveNextSourceId = 1; + } + let sourceId = ""; + if (source && typeof source === "object") { + if (!window.__mnoteSmokeTreeLiveSourceIds.has(source)) { + window.__mnoteSmokeTreeLiveSourceIds.set(source, window.__mnoteSmokeTreeLiveNextSourceId++); + } + sourceId = String(window.__mnoteSmokeTreeLiveSourceIds.get(source) || ""); + } + return { + status: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + transport: document.documentElement.getAttribute("data-mnote-tree-live-transport") || "", + sourceKind: source + ? (typeof WebSocket !== "undefined" && source instanceof WebSocket + ? "websocket" + : (typeof EventSource !== "undefined" && source instanceof EventSource ? "eventsource" : "unknown")) + : "", + sourceId, + url: source && typeof source.url === "string" ? source.url : "", + readyState: source && typeof source.readyState === "number" ? source.readyState : null, + }; + }); +} + +function requestUrl(record) { + try { + return new URL(record.url); + } catch { + return null; + } +} + +function summarizeLocalFolderEventRequests(requests, startIndex) { + const summary = { + total: 0, + documentChannel: 0, + treeLive: 0, + byRootUri: {}, + }; + for (const record of requests.slice(startIndex)) { + if (record.method !== "GET" || !record.url.includes("/api/local-folder/events")) continue; + const url = requestUrl(record); + const rootUri = url?.searchParams.get("rootUri") || ""; + const phase = url?.searchParams.get("treeLive") === "true" ? "treeLive" : "documentChannel"; + summary.total += 1; + summary[phase] += 1; + if (!summary.byRootUri[rootUri]) { + summary.byRootUri[rootUri] = { total: 0, documentChannel: 0, treeLive: 0 }; + } + summary.byRootUri[rootUri].total += 1; + summary.byRootUri[rootUri][phase] += 1; + } + return summary; +} + +function recordLocalFolderEventDiagnostics(diagnostics, label, requests, startIndex, snapshot) { + diagnostics.localFolderEventPhases.push({ + label, + requests: summarizeLocalFolderEventRequests(requests, startIndex), + snapshot, + }); +} + +async function waitForLocalFolderChannelCount(page, expected, label) { + const deadline = Date.now() + UI_TIMEOUT_MS; + let snapshot = null; + while (Date.now() < deadline) { + snapshot = await readDocumentSessionSnapshot(page); + if (snapshot.localFolderChannelCount === expected) { + return snapshot; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.equal(snapshot?.localFolderChannelCount, expected, label); + return snapshot; +} + async function readNoReloadProbe(page) { return await page.evaluate(({ primaryRootSelector, secondaryRootSelector }) => ({ pagehideCount: Number(window.sessionStorage?.getItem("__mnoteSmokePagehideCount") || "0"), @@ -425,25 +576,31 @@ function countRequests(requests, startIndex, predicate) { } function isSaveRequest(record) { - return record.method === "POST" && record.url.includes("/api/documents/save"); + return record.method === "POST" + && (record.url.includes("/api/documents/save") || record.url.includes("/api/page-body/write")); } function isTreeEventRequest(record) { - return record.method === "GET" && record.url.includes("/api/tree/events"); + return (record.method === "GET" && record.url.includes("/api/tree/events")) + || (record.method === "WS" && record.url.includes("/api/realtime/ws")); } function isLocalFolderEventRequest(record) { - return record.method === "GET" && record.url.includes("/api/local-folder/events"); + if (record.method !== "GET" || !record.url.includes("/api/local-folder/events")) return false; + try { + const url = new URL(record.url); + return url.searchParams.get("treeLive") !== "true"; + } catch { + return !record.url.includes("treeLive=true"); + } } async function runFixturePhase(page, baseUrl, requests) { const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`; - const openIndex = requests.length; await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); - await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连"); + await waitForTreeLiveConnected(page, "fixture tree live 建连"); await page.waitForTimeout(800); - assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource"); const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first(); await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); @@ -453,8 +610,8 @@ async function runFixturePhase(page, baseUrl, requests) { await typePaneText(page, "primary", primaryToSecondary); await waitForPaneText(page, "primary", primaryToSecondary); await waitForPaneText(page, "secondary", primaryToSecondary); - await waitForPaneStatus(page, "primary", "saved"); - await waitForPaneStatus(page, "secondary", "saved"); + await waitForPaneStatusWithSnapshot(page, "primary", "saved", "cross-doc primary save"); + await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "cross-doc secondary save"); await page.waitForTimeout(800); assert.equal(countRequests(requests, primarySaveIndex, isSaveRequest), 1, "同 session 双 view 主 pane 输入后应只触发一次保存请求"); assert.equal(await readActivePaneRole(page), "primary", "primary 输入后焦点不应被同步到 secondary"); @@ -464,26 +621,24 @@ async function runFixturePhase(page, baseUrl, requests) { await typePaneText(page, "secondary", secondaryToPrimary); await waitForPaneText(page, "primary", secondaryToPrimary); await waitForPaneText(page, "secondary", secondaryToPrimary); - await waitForPaneStatus(page, "primary", "saved"); - await waitForPaneStatus(page, "secondary", "saved"); + await waitForPaneStatusWithSnapshot(page, "primary", "saved", "different-doc primary save"); + await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "different-doc secondary save"); await page.waitForTimeout(800); assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求"); assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary"); - const reloadIndex = requests.length; await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); - await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连"); + await waitForTreeLiveConnected(page, "reload 后 tree live 建连"); await page.waitForTimeout(800); - assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource"); const fixtureSnapshot = await readDocumentSessionSnapshot(page); assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session"); assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view"); - const navigationIndex = requests.length; const beforeFixtureNavigation = await readNoReloadProbe(page); + const beforeTreeLiveNavigation = await readTreeLiveSnapshot(page); assert(beforeFixtureNavigation.secondaryMountId, "fixture 导航前应已挂载 secondary editor"); - await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS }); + await clickSidebarRowOpen(page, "Fixture Other"); await waitForDocumentPath(page, "doc_other"); await waitForDualPaneReady(page); await page.waitForTimeout(800); @@ -498,7 +653,12 @@ async function runFixturePhase(page, baseUrl, requests) { beforeFixtureNavigation.secondaryMountId, "fixture sidebar 导航不应重挂 secondary editor", ); - assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 0, "fixture sidebar 导航不应重建 tree EventSource"); + const afterTreeLiveNavigation = await readTreeLiveSnapshot(page); + assert.equal( + afterTreeLiveNavigation.sourceId, + beforeTreeLiveNavigation.sourceId, + "fixture sidebar 导航不应重建 tree live source", + ); const navigatedFixtureUrl = new URL(page.url()); assert.equal( navigatedFixtureUrl.searchParams.get("secondaryDocumentId"), @@ -507,15 +667,18 @@ async function runFixturePhase(page, baseUrl, requests) { ); } -async function runLocalFolderPhase(page, baseUrl, requests, fixture) { +async function runLocalFolderPhase(page, baseUrl, requests, fixture, diagnostics) { const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`; const differentDocIndex = requests.length; await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); - await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连"); await page.waitForTimeout(800); - assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource"); - const differentDocSnapshot = await readDocumentSessionSnapshot(page); + const differentDocSnapshot = await waitForLocalFolderChannelCount( + page, + 1, + "同 rootUri 不同文档双开时应只复用一个 local-folder channel", + ); + recordLocalFolderEventDiagnostics(diagnostics, "different-doc-open", requests, differentDocIndex, differentDocSnapshot); assert.equal(differentDocSnapshot.sessionCount, 2, "不同文档双开时应建立两个 session"); assert.equal(differentDocSnapshot.localFolderChannelCount, 1, "同 rootUri 不同文档双开时应只复用一个 local-folder channel"); assert.deepEqual( @@ -523,7 +686,49 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) { [fixture.documentId, fixture.sideDocumentId].sort(), "不同文档双开时 session 应分别归属到两个 documentId", ); - await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS }); + + const crossDocPrimaryText = `cross-doc-primary-${Date.now().toString().slice(-6)}`; + const crossDocSecondaryText = `cross-doc-secondary-${(Date.now() + 1).toString().slice(-6)}`; + const crossDocPrimarySaveIndex = requests.length; + await typePaneText(page, "primary", crossDocPrimaryText); + await waitForPaneText(page, "primary", crossDocPrimaryText); + await waitForPaneStatusWithSnapshot(page, "primary", "saved", "same-doc primary save"); + await page.waitForTimeout(800); + assert.equal( + countRequests(requests, crossDocPrimarySaveIndex, isSaveRequest), + 1, + "同 rootUri 不同文档时 primary pane 输入后应只触发一次保存请求", + ); + const afterPrimaryCrossDocSnapshot = await readDocumentSessionSnapshot(page); + const afterPrimarySecondarySession = afterPrimaryCrossDocSnapshot.sessions.find((item) => item.documentId === fixture.sideDocumentId); + assert(afterPrimarySecondarySession, "primary save 后 secondary session 应存在"); + assert.equal( + afterPrimarySecondarySession.status, + "saved", + `primary save 后 secondary session 不应被误判冲突: ${JSON.stringify(afterPrimarySecondarySession)}`, + ); + + const crossDocSecondarySaveIndex = requests.length; + await typePaneText(page, "secondary", crossDocSecondaryText); + await waitForPaneText(page, "secondary", crossDocSecondaryText); + await waitForPaneStatusWithSnapshot(page, "secondary", "saved", "same-doc secondary save"); + await page.waitForTimeout(800); + assert.equal( + countRequests(requests, crossDocSecondarySaveIndex, isSaveRequest), + 1, + "同 rootUri 不同文档时 secondary pane 输入后应只触发一次保存请求", + ); + + const crossDocSnapshot = await readDocumentSessionSnapshot(page); + const primarySession = crossDocSnapshot.sessions.find((item) => item.documentId === fixture.documentId); + const secondarySession = crossDocSnapshot.sessions.find((item) => item.documentId === fixture.sideDocumentId); + assert(primarySession, "cross-doc primary session 应存在"); + assert(secondarySession, "cross-doc secondary session 应存在"); + assert.equal(primarySession.status, "saved", `cross-doc primary session 不应进入冲突态: ${JSON.stringify(primarySession)}`); + assert.notEqual(secondarySession.status, "external-change-conflict", `cross-doc secondary session 不应进入冲突态: ${JSON.stringify(secondarySession)}`); + assert.notEqual(secondarySession.dirtyState, "ExternalModified", `cross-doc secondary session 不应被误判为外部修改: ${JSON.stringify(secondarySession)}`); + + await clickSidebarRowOpen(page, "Local Third"); await waitForDocumentPath(page, fixture.thirdDocumentId); await waitForDualPaneReady(page); const navigatedUrl = new URL(page.url()); @@ -561,23 +766,33 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) { const openIndex = requests.length; await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); - await waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连"); await page.waitForTimeout(800); - assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource"); + const sameDocOpenSnapshot = await waitForLocalFolderChannelCount(page, 1, "同 rootUri 双 pane 应只保留一个 local-folder channel"); + recordLocalFolderEventDiagnostics(diagnostics, "same-doc-open", requests, openIndex, sameDocOpenSnapshot); assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource"); const reloadIndex = requests.length; await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForDualPaneReady(page); - await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连"); await page.waitForTimeout(800); - assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource"); - const sameDocSnapshot = await readDocumentSessionSnapshot(page); + const sameDocSnapshot = await waitForLocalFolderChannelCount(page, 1, "reload 后同 rootUri 双 pane 仍应只保留一个 local-folder channel"); + recordLocalFolderEventDiagnostics(diagnostics, "same-doc-reload", requests, reloadIndex, sameDocSnapshot); assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session"); assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view"); - const viewportBefore = await setViewportScroll(page, 260); - assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置"); + const primaryScrollBefore = await readPaneScrollState(page, "primary"); + const secondaryScrollBefore = await readPaneScrollState(page, "secondary"); + assert(primaryScrollBefore && primaryScrollBefore.scrollHeight > primaryScrollBefore.clientHeight, `primary pane 应有独立滚动容器: ${JSON.stringify(primaryScrollBefore)}`); + assert(secondaryScrollBefore && secondaryScrollBefore.scrollHeight > secondaryScrollBefore.clientHeight, `secondary pane 应有独立滚动容器: ${JSON.stringify(secondaryScrollBefore)}`); + const primaryScrollAfter = await setPaneScrollTop(page, "primary", 260); + const secondaryScrollAfter = await setPaneScrollTop(page, "secondary", 40); + assert((primaryScrollAfter?.scrollTop || 0) > 120, `primary pane 内滚动应生效: ${JSON.stringify(primaryScrollAfter)}`); + assert((secondaryScrollAfter?.scrollTop || 0) < 120, `secondary pane 内滚动应独立于 primary: ${JSON.stringify(secondaryScrollAfter)}`); + const primaryScrollFinal = await readPaneScrollState(page, "primary"); + assert((primaryScrollFinal?.scrollTop || 0) > 120, `secondary pane 滚动不应重置 primary pane: ${JSON.stringify(primaryScrollFinal)}`); + const primaryScrollBeforeSync = primaryScrollFinal; + const secondaryScrollBeforeSync = await readPaneScrollState(page, "secondary"); + const externalText = `external-sync-${Date.now().toString().slice(-6)}`; fs.writeFileSync( fixture.readmePath, @@ -597,10 +812,15 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) { await waitForPaneStatus(page, "primary", "synced-external-change"); await waitForPaneStatus(page, "secondary", "synced-external-change"); await page.waitForTimeout(800); - const viewportAfter = await readViewportScroll(page); + const primaryScrollAfterSync = await readPaneScrollState(page, "primary"); + const secondaryScrollAfterSync = await readPaneScrollState(page, "secondary"); assert( - Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40, - "远端 replaceContent 后共享页面滚动不应被重置", + Math.abs((primaryScrollAfterSync?.scrollTop || 0) - (primaryScrollBeforeSync?.scrollTop || 0)) < 40, + `远端 replaceContent 后 primary pane 滚动不应被重置: before=${JSON.stringify(primaryScrollBeforeSync)} after=${JSON.stringify(primaryScrollAfterSync)}`, + ); + assert( + Math.abs((secondaryScrollAfterSync?.scrollTop || 0) - (secondaryScrollBeforeSync?.scrollTop || 0)) < 40, + `远端 replaceContent 后 secondary pane 滚动不应被重置: before=${JSON.stringify(secondaryScrollBeforeSync)} after=${JSON.stringify(secondaryScrollAfterSync)}`, ); const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8"); @@ -681,6 +901,7 @@ async function main() { const gateway = useExistingServer ? null : startGateway(port); const localFixture = createLocalFolderFixture(); const requests = []; + const diagnostics = { localFolderEventPhases: [] }; let stderr = ""; let stdout = ""; @@ -694,7 +915,14 @@ async function main() { await waitForGateway(baseUrl); const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + locale: "zh-CN", + extraHTTPHeaders: { + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + }); await context.addInitScript(() => { const key = "__mnoteSmokePagehideCount"; window.addEventListener("pagehide", () => { @@ -718,12 +946,20 @@ async function main() { }); }); + page.on("websocket", (socket) => { + requests.push({ + url: socket.url(), + method: "WS", + payload: null, + }); + }); + let caughtError = null; try { if (!useExistingServer) { await runFixturePhase(page, baseUrl, requests); } - await runLocalFolderPhase(page, baseUrl, requests, localFixture); + await runLocalFolderPhase(page, baseUrl, requests, localFixture, diagnostics); await runSinglePaneTitlePhase(page, baseUrl, localFixture); console.log( JSON.stringify( @@ -737,6 +973,7 @@ async function main() { treeEventRequests: requests.filter(isTreeEventRequest).length, localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length, }, + diagnostics, }, null, 2, diff --git a/scripts/task443-filetree-mindmap-click-active-row-smoke.js b/scripts/task443-filetree-mindmap-click-active-row-smoke.js index 57f93678..babb0050 100644 --- a/scripts/task443-filetree-mindmap-click-active-row-smoke.js +++ b/scripts/task443-filetree-mindmap-click-active-row-smoke.js @@ -1,41 +1,105 @@ #!/usr/bin/env node "use strict"; -const fs = require("node:fs/promises"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const os = require("node:os"); const path = require("node:path"); const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - assert, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - openDocument, - openFilesystemView, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); const TASK = "task443-filetree-mindmap-click-active-row-smoke"; const OUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUT_DIR, "result.json"); +const ACTOR_ID = "user_real"; -async function writeResult(result) { - await fs.mkdir(OUT_DIR, { recursive: true }); - await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +function fileUrl(localPath) { + return `file://${localPath}`; } -async function createMindmap(request, workspaceId, documentId, mindmapId, title) { - return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { - method: "POST", - data: { - commandName: "mindmaps.put", - workspaceId, - createOnly: true, - data: { root: { data: { text: title }, children: [] } }, - }, - }); +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root) { + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".mnote", "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ACTOR_ID}:task443`, + ownerId: ACTOR_ID, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +function writeMindmapFixture(root, stamp) { + const pageDir = path.join(root, "Task443"); + fs.mkdirSync(pageDir, { recursive: true }); + fs.writeFileSync( + path.join(pageDir, "Task443.md"), + [ + "---", + `title: TEST-443-mindmap-active-${stamp}`, + "---", + "", + "# TEST 443", + "", + `[TEST-443-mind-${stamp}](map-${stamp}.mindmap.json)`, + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(pageDir, `map-${stamp}.mindmap.json`), + `${JSON.stringify({ data: { uid: "root", text: `TEST-443-mind-${stamp}` }, children: [] }, null, 2)}\n`, + "utf8", + ); + return { + relativePath: "Task443/Task443.md", + documentId: localMdDocumentId("Task443/Task443.md"), + mindmapFileName: `map-${stamp}.mindmap.json`, + }; +} + +async function writeResult(result) { + await fsp.mkdir(OUT_DIR, { recursive: true }); + await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function waitForMindmapRow(page, mindmapFileName) { + await page.waitForFunction((fileName) => { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) + .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); + }, mindmapFileName, { timeout: UI_TIMEOUT_MS }); +} + +async function clickMindmapRow(page, mindmapFileName) { + await page.evaluate((fileName) => { + const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) + .find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName)); + const link = row?.querySelector(".tree-link"); + if (!(link instanceof HTMLElement)) { + throw new Error(`找不到 mindmap 文件行: ${fileName}`); + } + link.click(); + }, mindmapFileName); } async function readSelectedFileTreeRows(page) { @@ -44,6 +108,7 @@ async function readSelectedFileTreeRows(page) { rowId: row.getAttribute("data-row-id") || "", rowKind: row.getAttribute("data-row-kind") || "", docId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "", + ownerDocumentId: row.getAttribute("data-owner-document-id") || "", assetId: row.getAttribute("data-asset-id") || "", objectIdentity: row.getAttribute("data-object-identity") || "", title: (row.textContent || "").trim().slice(0, 160), @@ -52,9 +117,23 @@ async function readSelectedFileTreeRows(page) { } (async () => { - await fs.mkdir(OUT_DIR, { recursive: true }); - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await fsp.mkdir(OUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task443-local-mindmap-")); + const stamp = Date.now().toString().slice(-8); + writeWorkspaceManifest(root); + const fixture = writeMindmapFixture(root, stamp); + + const browser = await chromium.launch({ + headless: true, + ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "user", + }, + }); const page = await context.newPage(); const navEvents = []; const network = []; @@ -68,10 +147,9 @@ async function readSelectedFileTreeRows(page) { } }); - let doc = null; const result = { baseUrl: BASE_URL, - fixture: {}, + fixture: { root, ...fixture }, beforeSelectedRows: [], afterSelectedRows: [], navEvents, @@ -80,35 +158,29 @@ async function readSelectedFileTreeRows(page) { }; try { - await ensureAuthenticated(page, context.request); - doc = await createTempDocument(context.request, null); - const stamp = Date.now().toString().slice(-8); - const title = `TEST-443-mindmap-active-${stamp}`; - await renameDocument(context.request, doc.workspaceId, doc.documentId, title); - const mindmapId = `mindmap_443_active_${stamp}`; - await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-443-mind-${stamp}`); - result.fixture = { ...doc, title, mindmapId }; - - await openDocument(page, doc.workspaceId, doc.documentId); - await openFilesystemView(page); - await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS }); - result.beforeSelectedRows = await readSelectedFileTreeRows(page); - - await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - ({ documentId, mindmapId }) => { - const url = new URL(window.location.href); - if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false; - const rt = url.searchParams.get("resourceTab") || ""; - if (!rt) return false; - return decodeURIComponent(rt).includes(`resource:mindmap:${documentId}:${mindmapId}`); - }, - { documentId: doc.documentId, mindmapId }, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, { + await page.goto(documentUrl(root, fixture.relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", timeout: UI_TIMEOUT_MS, }); + await waitForMindmapRow(page, fixture.mindmapFileName); + result.beforeSelectedRows = await readSelectedFileTreeRows(page); + + await clickMindmapRow(page, fixture.mindmapFileName); + await page.waitForFunction( + ({ documentId, fileName }) => { + const url = new URL(window.location.href); + if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false; + const rt = decodeURIComponent(url.searchParams.get("resourceTab") || ""); + return rt.includes("resource:mindmap:") && rt.includes(documentId) && rt.includes(fileName); + }, + { documentId: fixture.documentId, fileName: fixture.mindmapFileName }, + { timeout: UI_TIMEOUT_MS }, + ); + await page.waitForFunction((fileName) => { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) + .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); + }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS }); result.afterUrl = page.url(); result.afterSelectedRows = await readSelectedFileTreeRows(page); const screenshotPath = path.join(OUT_DIR, "after-mindmap-click.png"); @@ -116,11 +188,11 @@ async function readSelectedFileTreeRows(page) { result.screenshots.push(screenshotPath); assert( - result.afterSelectedRows.some((row) => row.rowId === `asset:${mindmapId}` && row.assetId === mindmapId), + result.afterSelectedRows.some((row) => row.assetId.includes(fixture.mindmapFileName)), `点击 mindmap 文件行后应保持 asset row 选中: ${JSON.stringify(result.afterSelectedRows)}`, ); assert( - !result.afterSelectedRows.some((row) => row.rowId === `doc:${doc.documentId}`), + !result.afterSelectedRows.some((row) => row.rowId === `doc:${fixture.documentId}`), `点击 mindmap 文件行后不应闪回父页面行选中: ${JSON.stringify(result.afterSelectedRows)}`, ); @@ -135,8 +207,8 @@ async function readSelectedFileTreeRows(page) { }); throw error; } finally { - if (doc) await cleanupDocuments(context.request, [doc.documentId]).catch(() => null); await browser.close().catch(() => null); + fs.rmSync(root, { recursive: true, force: true }); } })().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); diff --git a/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js b/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js index 23e3ad16..cee71327 100644 --- a/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js +++ b/scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js @@ -1,49 +1,154 @@ #!/usr/bin/env node "use strict"; -const fs = require("node:fs/promises"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const os = require("node:os"); const path = require("node:path"); const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - assert, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - openDocument, - openFilesystemView, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); const TASK = "task445-filetree-mindmap-switch-no-flicker-smoke"; const OUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUT_DIR, "result.json"); +const ACTOR_ID = "user_real"; + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root) { + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".mnote", "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ACTOR_ID}:task445`, + ownerId: ACTOR_ID, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +function writePage(root, relativePath, title, bodyLines) { + const fullPath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync( + fullPath, + ["---", `title: ${title}`, "---", "", ...bodyLines, ""].join("\n"), + "utf8", + ); +} + +function writeMindmapFixture(root, stamp) { + const rootRelativePath = "Task445Root/Task445Root.md"; + const otherRelativePath = "Task445Other.md"; + const mindmapFileName = `map-${stamp}.mindmap.json`; + writePage(root, rootRelativePath, `TEST-445-root-${stamp}`, [ + "# TEST 445 Root", + "", + `[TEST-445-mind-${stamp}](${mindmapFileName})`, + ]); + writePage(root, otherRelativePath, `TEST-445-other-${stamp}`, [ + "# TEST 445 Other", + "", + "用于验证从另一个页面点击资源行时仍回到资源 owner document。", + ]); + fs.writeFileSync( + path.join(root, "Task445Root", mindmapFileName), + `${JSON.stringify({ data: { uid: "root", text: `TEST-445-mind-${stamp}` }, children: [] }, null, 2)}\n`, + "utf8", + ); + return { + rootRelativePath, + otherRelativePath, + documentId: localMdDocumentId(rootRelativePath), + otherDocumentId: localMdDocumentId(otherRelativePath), + mindmapFileName, + }; +} async function writeResult(result) { - await fs.mkdir(OUT_DIR, { recursive: true }); - await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); + await fsp.mkdir(OUT_DIR, { recursive: true }); + await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); } -async function createMindmap(request, workspaceId, documentId, mindmapId, title) { - return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { - method: "POST", - data: { - commandName: "mindmaps.put", - workspaceId, - createOnly: true, - data: { root: { data: { text: title }, children: [] } }, - }, - }); +async function waitForMindmapRow(page, mindmapFileName) { + await page.waitForFunction((fileName) => { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) + .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); + }, mindmapFileName, { timeout: UI_TIMEOUT_MS }); } -async function readFileTreeState(page, documentId, mindmapId) { +async function clickMindmapRow(page, mindmapFileName) { + await page.evaluate((fileName) => { + const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) + .find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName)); + const link = row?.querySelector(".tree-link"); + if (!(link instanceof HTMLElement)) { + throw new Error(`找不到 mindmap 文件行: ${fileName}`); + } + link.click(); + }, mindmapFileName); +} + +async function clickDocumentRow(page, documentId) { + await page.evaluate((docId) => { + const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) + .find((candidate) => ( + (candidate.getAttribute("data-document-id") || candidate.getAttribute("data-doc-id") || "") === docId + )); + const link = row?.querySelector(".tree-link"); + if (!(link instanceof HTMLElement)) { + throw new Error(`找不到文档行: ${docId}`); + } + link.click(); + }, documentId); +} + +async function readAllFileTreeRows(page) { + return await page.evaluate(() => + Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map((row) => ({ + rowId: row.getAttribute("data-row-id") || "", + rowKind: row.getAttribute("data-row-kind") || "", + nodeId: row.getAttribute("data-node-id") || "", + documentId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "", + ownerDocumentId: row.getAttribute("data-owner-document-id") || "", + assetId: row.getAttribute("data-asset-id") || "", + title: (row.textContent || "").trim().slice(0, 160), + selected: row.getAttribute("data-selected") || "", + })), + ); +} + +async function readFileTreeState(page, documentId, mindmapFileName) { return await page.evaluate( - ({ documentId: docId, mindmapId: mapId }) => { + ({ documentId: docId, mindmapFileName: fileName }) => { const fileRoot = document.getElementById("sidebar-file-tree-root"); - const pageRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`); - const mindmapRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(mapId)}"]`); + const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')); + const pageRow = rows.find((row) => ( + (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId + )); + const mindmapRow = rows.find((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); const titleNode = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title"); return { fileRootStable: Boolean(fileRoot && fileRoot === window.__task445FileRoot), @@ -52,20 +157,81 @@ async function readFileTreeState(page, documentId, mindmapId) { pageSelected: pageRow instanceof HTMLElement ? pageRow.getAttribute("data-selected") || "" : "", mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "", mindmapTitle: titleNode instanceof HTMLElement ? (titleNode.textContent || "").trim() : "", + mindmapAssetId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-asset-id") || "" : "", + mindmapOwnerDocumentId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-owner-document-id") || "" : "", objectEditor: document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") || document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-mnote-object-editor") || "", }; }, - { documentId, mindmapId }, + { documentId, mindmapFileName }, ); } +async function waitForMindmapResourceTab(page, documentId, mindmapFileName) { + await page.waitForFunction( + ({ docId, fileName }) => { + const url = new URL(window.location.href); + if (!url.pathname.includes(`/documents/${encodeURIComponent(docId)}`)) return false; + const rt = decodeURIComponent(url.searchParams.get("resourceTab") || ""); + return rt.includes("resource:mindmap:") && rt.includes(docId) && rt.includes(fileName); + }, + { docId: documentId, fileName: mindmapFileName }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readMindmapTabLayout(page) { + return await page.evaluate(() => { + const host = document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]'); + const panel = document.querySelector('.mnote-resource-tab-panel[data-pane-role="primary"][data-resource-kind="mindmap"]:not([hidden])'); + const shell = panel?.querySelector('.mnote-resource-tab-mindmap-shell'); + const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + const rectOf = (node) => { + if (!(node instanceof HTMLElement)) return null; + const rect = node.getBoundingClientRect(); + return { + width: Math.round(rect.width), + height: Math.round(rect.height), + left: Math.round(rect.left), + right: Math.round(rect.right), + scrollWidth: node.scrollWidth, + clientWidth: node.clientWidth, + scrollHeight: node.scrollHeight, + clientHeight: node.clientHeight, + }; + }; + return { + hostHidden: host instanceof HTMLElement ? host.hidden : true, + host: rectOf(host), + panel: rectOf(panel), + shell: rectOf(shell), + root: rectOf(root), + shellOverflow: shell instanceof HTMLElement ? getComputedStyle(shell).overflow : "", + rootOverflow: root instanceof HTMLElement ? getComputedStyle(root).overflow : "", + }; + }); +} + (async () => { - await fs.mkdir(OUT_DIR, { recursive: true }); - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await fsp.mkdir(OUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task445-local-mindmap-")); + const stamp = Date.now().toString().slice(-8); + writeWorkspaceManifest(root); + const fixture = writeMindmapFixture(root, stamp); + + const browser = await chromium.launch({ + headless: true, + ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "user", + }, + }); const page = await context.newPage(); const navEvents = []; const consoleMessages = []; @@ -81,10 +247,9 @@ async function readFileTreeState(page, documentId, mindmapId) { consoleMessages.push({ type: "pageerror", text: error instanceof Error ? error.stack || error.message : String(error) }); }); - const createdDocuments = []; const result = { baseUrl: BASE_URL, - fixture: {}, + fixture: { root, ...fixture }, before: null, afterMindmap: null, afterPage: null, @@ -99,80 +264,76 @@ async function readFileTreeState(page, documentId, mindmapId) { }; try { - await ensureAuthenticated(page, context.request); - const doc = await createTempDocument(context.request, null); - createdDocuments.push(doc.documentId); - const otherDoc = await createTempDocument(context.request, null); - createdDocuments.push(otherDoc.documentId); - const stamp = Date.now().toString().slice(-8); - await renameDocument(context.request, doc.workspaceId, doc.documentId, `TEST-445-root-${stamp}`); - await renameDocument(context.request, otherDoc.workspaceId, otherDoc.documentId, `TEST-445-other-${stamp}`); - const mindmapId = `mindmap_445_${stamp}`; - await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-445-mind-${stamp}`); - result.fixture = { ...doc, otherDocumentId: otherDoc.documentId, mindmapId }; - - await openDocument(page, doc.workspaceId, doc.documentId); - await openFilesystemView(page); - await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS }); + await page.goto(documentUrl(root, fixture.rootRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await waitForMindmapRow(page, fixture.mindmapFileName); await page.evaluate(() => { window.__task445FileRoot = document.getElementById("sidebar-file-tree-root"); }); - result.before = await readFileTreeState(page, doc.documentId, mindmapId); + result.before = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName); if (/^mindmap-mindmap[_-]/i.test(result.before.mindmapTitle)) { recordFailure("mindmap_title_double_technical_prefix", result.before.mindmapTitle); } - if (result.before.mindmapTitle.length > 24) { + if (result.before.mindmapTitle.length > 32) { recordFailure("mindmap_title_too_long", result.before.mindmapTitle); } - await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - ({ documentId, mindmapId }) => { - const url = new URL(window.location.href); - if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) return false; - const rt = url.searchParams.get("resourceTab") || ""; - if (!rt) return false; - return decodeURIComponent(rt).includes(`resource:mindmap:${documentId}:${mindmapId}`); - }, - { documentId: doc.documentId, mindmapId }, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, { - timeout: UI_TIMEOUT_MS, - }); - result.afterMindmap = await readFileTreeState(page, doc.documentId, mindmapId); + await clickMindmapRow(page, fixture.mindmapFileName); + await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName); + await page.waitForFunction((fileName) => { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) + .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); + }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS }); + result.afterMindmap = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName); if (!result.afterMindmap.fileRootStable) recordFailure("mindmap_click_replaced_sidebar_root", result.afterMindmap); + result.rowsBeforeOtherClick = await readAllFileTreeRows(page); - await page.click(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(otherDoc.documentId)}`), { timeout: UI_TIMEOUT_MS }); - await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"][data-selected="true"]`, { - timeout: UI_TIMEOUT_MS, - }); - result.afterPage = await readFileTreeState(page, otherDoc.documentId, mindmapId); + await clickDocumentRow(page, fixture.otherDocumentId); + await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(fixture.otherDocumentId)}`), { timeout: UI_TIMEOUT_MS }); + await page.waitForFunction((docId) => { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) + .some((row) => (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId); + }, fixture.otherDocumentId, { timeout: UI_TIMEOUT_MS }); + result.afterPage = await readFileTreeState(page, fixture.otherDocumentId, fixture.mindmapFileName); if (!result.afterPage.fileRootStable) recordFailure("page_click_after_mindmap_replaced_sidebar_root", result.afterPage); - await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - ({ mindmapId, docId }) => { - const url = new URL(window.location.href); - if (!url.pathname.startsWith("/documents/")) return false; - const rt = url.searchParams.get("resourceTab") || ""; - if (!rt) return false; - return decodeURIComponent(rt).includes(`resource:mindmap:${docId}:${mindmapId}`); - }, - { mindmapId, docId: doc.documentId }, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, { - timeout: UI_TIMEOUT_MS, - }); - result.afterMindmapAgain = await readFileTreeState(page, doc.documentId, mindmapId); + await clickMindmapRow(page, fixture.mindmapFileName); + await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName); + await page.waitForFunction((fileName) => { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) + .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); + }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS }); + result.afterMindmapAgain = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName); + result.mindmapLayout = await readMindmapTabLayout(page); if (!result.afterMindmapAgain.fileRootStable) recordFailure("mindmap_click_again_replaced_sidebar_root", result.afterMindmapAgain); + if (result.mindmapLayout.hostHidden) recordFailure("mindmap_resource_host_hidden", result.mindmapLayout); + if (!result.mindmapLayout.shell || !result.mindmapLayout.root) recordFailure("mindmap_resource_shell_or_root_missing", result.mindmapLayout); + if ((result.mindmapLayout.shell?.width || 0) < 320 || (result.mindmapLayout.root?.width || 0) < 320) { + recordFailure("mindmap_resource_width_too_small", result.mindmapLayout); + } + if ((result.mindmapLayout.shell?.clientWidth || 0) + 4 < (result.mindmapLayout.shell?.scrollWidth || 0)) { + recordFailure("mindmap_resource_horizontal_overflow", result.mindmapLayout); + } + if ((result.mindmapLayout.root?.height || 0) < 600) { + recordFailure("mindmap_resource_height_too_small", result.mindmapLayout); + } + if (!/hidden/.test(result.mindmapLayout.shellOverflow) || !/hidden/.test(result.mindmapLayout.rootOverflow)) { + recordFailure("mindmap_resource_overflow_not_clipped", result.mindmapLayout); + } + if (!page.url().includes(`/documents/${encodeURIComponent(fixture.documentId)}`)) { + recordFailure("mindmap_click_again_used_wrong_owner_document", { + expectedDocumentId: fixture.documentId, + actualUrl: page.url(), + }); + } const screenshotPath = path.join(OUT_DIR, "after-mindmap-again.png"); await page.screenshot({ path: screenshotPath, fullPage: true }); result.screenshots.push(screenshotPath); - assert(result.failures.length === 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`); + assert.equal(result.failures.length, 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`); await writeResult({ ...result, ok: true, finalUrl: page.url() }); console.log(`ok ${TASK} ${RESULT_PATH}`); } catch (error) { @@ -184,8 +345,8 @@ async function readFileTreeState(page, documentId, mindmapId) { }); throw error; } finally { - if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null); await browser.close().catch(() => null); + fs.rmSync(root, { recursive: true, force: true }); } })().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); diff --git a/scripts/task453-local-folder-page-ai-changed-files-smoke.js b/scripts/task453-local-folder-page-ai-changed-files-smoke.js index 416e17b9..a4989e0f 100644 --- a/scripts/task453-local-folder-page-ai-changed-files-smoke.js +++ b/scripts/task453-local-folder-page-ai-changed-files-smoke.js @@ -11,6 +11,9 @@ const { UI_TIMEOUT_MS, } = require("./tree-shell-smoke-helpers"); +const TASK = "task453-local-folder-page-ai-changed-files-smoke"; +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); @@ -19,6 +22,10 @@ function fileUrl(localPath) { return `file://${localPath}`; } +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + function writeWorkspaceManifest(root, ownerId, workspaceId) { fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.writeFileSync( @@ -33,16 +40,76 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) { ); } +async function fetchPageAggregate(page, documentId, rootUri) { + return await page.evaluate(async ({ id, uri }) => { + const url = new URL(`/api/page-aggregate/${encodeURIComponent(id)}`, window.location.origin); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", uri); + const response = await fetch(url.toString(), { headers: { accept: "application/json" } }); + return { + ok: response.ok, + status: response.status, + payload: await response.json().catch(() => null), + }; + }, { id: documentId, uri: rootUri }); +} + +async function waitForEditorText(page, expected) { + await page.waitForFunction( + (text) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + return (editor?.textContent || "").includes(text); + }, + expected, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function typeDirtyText(page, text) { + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.type(text, { delay: 8 }); + await waitForEditorText(page, text.trim()); +} + +async function waitForEditorStatus(page, status) { + await page.waitForFunction( + (expected) => { + const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + return root?.getAttribute("data-runtime-editor-status") === expected; + }, + status, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function conflictEnvelope(page, documentId) { + return await page.evaluate((docId) => { + const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.(); + if (!snapshot) return null; + const session = snapshot.sessions.find((item) => item.documentId === docId); + return session?.lastExternalConflictEnvelope || null; + }, documentId); +} + async function main() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const suffix = Date.now().toString(36); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-ai-changed-files-")); - const documentId = "local-md:README.md"; + const documentId = localMdDocumentId("README.md"); + const dirtyDocumentId = localMdDocumentId("Dirty.md"); const actorId = "user_real"; const sessionId = `mnote_local_ai_changed_${suffix}`; const runId = `run_local_ai_changed_${suffix}`; + const dirtySessionId = `mnote_local_ai_dirty_${suffix}`; + const dirtyRunId = `run_local_ai_dirty_${suffix}`; const marker = `LOCAL-AI-CHANGED-FILES-${suffix}`; + const dirtyMarker = `LOCAL-AI-DIRTY-FILES-${suffix}`; + const dirtyLocalToken = `LOCAL-UNSAVED-DIRTY-${suffix}`; const readmePath = path.join(root, "README.md"); + const dirtyPath = path.join(root, "Dirty.md"); const captured = []; + let currentScenario = "clean"; const browser = await chromium.launch({ headless: true, @@ -61,11 +128,34 @@ async function main() { const workspaceId = `local-ws:${actorId}:task453`; writeWorkspaceManifest(root, actorId, workspaceId); fs.writeFileSync(readmePath, `# Local AI Changed Files\n初始内容 ${suffix}\n`, "utf8"); + fs.writeFileSync(dirtyPath, `# Dirty AI Changed Files\n初始 dirty 内容 ${suffix}\n`, "utf8"); const rootUri = fileUrl(root); + const scenarioConfig = () => currentScenario === "dirty" + ? { + sessionId: dirtySessionId, + runId: dirtyRunId, + documentId: dirtyDocumentId, + filePath: dirtyPath, + relativePath: "Dirty.md", + marker: dirtyMarker, + message: "已修改本地 Dirty。", + } + : { + sessionId, + runId, + documentId, + filePath: readmePath, + relativePath: "README.md", + marker, + message: "已修改本地 README。", + }; await page.route("**/api/ai-agent/run", async (route) => { throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); }); + await page.route("**/api/documents/save", async (route) => { + throw new Error(`local-first AI smoke 不应请求 compat /api/documents/save: ${route.request().url()}`); + }); await page.route("**/api/hermes/client/gateway/health**", async (route) => { await route.fulfill({ status: 200, @@ -97,13 +187,14 @@ async function main() { }); }); await page.route("**/api/hermes/client/sessions", async (route) => { + const scenario = scenarioConfig(); captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" }); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, - sessionId, + sessionId: scenario.sessionId, title: "本地 changed files", traceId: `trace_local_changed_${suffix}`, persistence: "local_ai_session_jsonl", @@ -111,35 +202,37 @@ async function main() { }), }); }); - await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { + await page.route("**/api/hermes/client/sessions/*/resume", async (route) => { + const scenario = scenarioConfig(); captured.push({ kind: "session-resume", method: route.request().method(), body: "" }); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, - sessionId, - session: { sessionId, messages: [] }, + sessionId: scenario.sessionId, + session: { sessionId: scenario.sessionId, messages: [] }, runtime: { - sessionId, - runId, + sessionId: scenario.sessionId, + runId: scenario.runId, status: "completed", profile: "reasonix", - documentId, + documentId: scenario.documentId, traceId: `trace_local_changed_resume_${suffix}`, }, }), }); }); await page.route("**/api/hermes/client/runs", async (route) => { + const scenario = scenarioConfig(); captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" }); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, - sessionId, - runId, + sessionId: scenario.sessionId, + runId: scenario.runId, events: [], traceId: `trace_local_changed_run_${suffix}`, persistence: "local_ai_session_jsonl", @@ -147,28 +240,29 @@ async function main() { }), }); }); - await page.route(`**/api/hermes/client/events/${runId}`, async (route) => { + await page.route("**/api/hermes/client/events/*", async (route) => { + const scenario = scenarioConfig(); captured.push({ kind: "events", method: route.request().method(), body: "" }); - fs.appendFileSync(readmePath, `\nAI 写入标记:${marker}\n`, "utf8"); + fs.appendFileSync(scenario.filePath, `\nAI 写入标记:${scenario.marker}\n`, "utf8"); await route.fulfill({ status: 200, headers: { "content-type": "text/event-stream; charset=utf-8" }, body: - `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "已修改本地 README。" })}\n\n` + + `data: ${JSON.stringify({ event: "message.delta", run_id: scenario.runId, session_id: scenario.sessionId, delta: scenario.message })}\n\n` + `data: ${JSON.stringify({ event: "run.completed", - run_id: runId, - session_id: sessionId, - output: "已修改本地 README。", + run_id: scenario.runId, + session_id: scenario.sessionId, + output: scenario.message, agentAudit: { - eventId: `audit_local_changed_${suffix}`, + eventId: `audit_local_changed_${currentScenario}_${suffix}`, rootUri, diffSummary: "1 changed file(s)", changedFiles: [ { - path: "README.md", + path: scenario.relativePath, changeType: "modified", - summary: `追加 ${marker}`, + summary: `追加 ${scenario.marker}`, }, ], }, @@ -217,19 +311,112 @@ async function main() { ), `本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`, ); - assert(fs.readFileSync(readmePath, "utf8").includes(marker), "本地 README.md 未写入 smoke 标记"); - assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求"); - console.log( - JSON.stringify({ - ok: true, - root, - documentId, - sessionId, - runId, - marker, - capturedKinds: captured.map((entry) => entry.kind), - }, null, 2), + const diskText = fs.readFileSync(readmePath, "utf8"); + assert(diskText.includes(marker), "本地 README.md 未写入 smoke 标记"); + const aggregate = await fetchPageAggregate(page, documentId, rootUri); + assert.equal(aggregate.ok, true, `Page Aggregate 应能读取 local_folder 文档: ${JSON.stringify(aggregate)}`); + assert( + JSON.stringify(aggregate.payload || {}).includes(marker), + `Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(aggregate)}`, ); + await waitForEditorText(page, marker); + const editorState = await page.evaluate(() => { + const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]'); + return { + status: root?.getAttribute("data-runtime-editor-status") || "", + text: editor?.textContent || "", + conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0), + }; + }); + assert.notEqual(editorState.status, "external-change-conflict", `clean AI 写入不应触发冲突态: ${JSON.stringify(editorState)}`); + assert.equal(editorState.conflictVisible, false, `clean AI 写入不应显示冲突面板: ${JSON.stringify(editorState)}`); + assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求"); + const runBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}"); + assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`); + assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`); + assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`); + const cleanCapturedKinds = captured.map((entry) => entry.kind); + + currentScenario = "dirty"; + captured.length = 0; + const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`); + dirtyUrl.searchParams.set("sourceKind", "local_folder"); + dirtyUrl.searchParams.set("rootUri", rootUri); + await page.goto(dirtyUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForEditorText(page, "Dirty AI Changed Files"); + await typeDirtyText(page, ` ${dirtyLocalToken}`); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-input]").fill(`请修改 Dirty 并记录 changed files ${dirtyMarker}`, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => { + const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; + return drawerText.includes("agent.changed_files") && drawerText.includes("Dirty.md"); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); + await waitForEditorStatus(page, "external-change-conflict"); + await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + (expected) => { + const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]'); + return (panel?.textContent || "").includes(expected); + }, + `agent run ${dirtyRunId}`, + { timeout: UI_TIMEOUT_MS }, + ); + const envelope = await conflictEnvelope(page, dirtyDocumentId); + assert(envelope, "dirty AI 写入应生成冲突信封"); + assert("externalActor" in envelope, `冲突信封应包含 externalActor: ${JSON.stringify(envelope)}`); + assert("dirtyState" in envelope, `冲突信封应包含 dirtyState: ${JSON.stringify(envelope)}`); + assert("bufferFileVersion" in envelope, `冲突信封应包含 bufferFileVersion: ${JSON.stringify(envelope)}`); + await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + ({ localToken, aiToken }) => { + const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]'); + const text = panel?.textContent || ""; + return text.includes(localToken) && text.includes(aiToken); + }, + { localToken: dirtyLocalToken, aiToken: dirtyMarker }, + { timeout: UI_TIMEOUT_MS }, + ); + const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS }); + assert(diffText.includes(dirtyLocalToken), `dirty diff 应包含本地未保存内容: ${diffText}`); + assert(diffText.includes(dirtyMarker), `dirty diff 应包含 AI 写盘内容: ${diffText}`); + const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri); + assert( + JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker), + `dirty Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`, + ); + const dirtyRunBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}"); + assert.equal(dirtyRunBody.documentId, dirtyDocumentId, `dirty Hermes run 应携带 local documentId: ${JSON.stringify(dirtyRunBody)}`); + assert.equal(dirtyRunBody.sourceKind, "local_folder", `dirty Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(dirtyRunBody)}`); + assert.equal(dirtyRunBody.rootUri, rootUri, `dirty Hermes run 应携带 rootUri: ${JSON.stringify(dirtyRunBody)}`); + + const result = { + ok: true, + root, + documentId, + sessionId, + runId, + marker, + aggregateRevision: aggregate.payload?.result?.body?.revision ?? aggregate.payload?.body?.revision ?? null, + editorStatus: editorState.status, + dirtyDocumentId, + dirtyRunId, + dirtyMarker, + dirtyConflictStatus: "external-change-conflict", + dirtyEnvelope: envelope, + capturedKinds: cleanCapturedKinds, + dirtyCapturedKinds: captured.map((entry) => entry.kind), + resultPath: RESULT_PATH, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(result, null, 2)); } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); diff --git a/scripts/task459-local-markdown-attachment-tab-smoke.js b/scripts/task459-local-markdown-attachment-tab-smoke.js index 247ba32d..391465b2 100644 --- a/scripts/task459-local-markdown-attachment-tab-smoke.js +++ b/scripts/task459-local-markdown-attachment-tab-smoke.js @@ -53,6 +53,62 @@ async function quickLogin(page) { } } +async function activatePrimaryPageTab(page) { + const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first(); + if (await pageTab.count()) { + await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined); + } + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function uploadAttachmentViaPrimarySlash(page, fileName, markdown) { + await activatePrimaryPageTab(page); + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("End").catch(() => undefined); + await page.keyboard.type("/"); + const item = page + .locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]') + .first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), + item.click({ timeout: UI_TIMEOUT_MS }), + ]); + await fileChooser.setFiles({ + name: fileName, + mimeType: "text/markdown", + buffer: Buffer.from(markdown, "utf8"), + }); + await page.waitForFunction( + (name) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + return editor instanceof HTMLElement && (editor.textContent || "").includes(name); + }, + fileName, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function clickPrimaryAttachmentAndExpectTab(page, fileName) { + await activatePrimaryPageTab(page); + const link = page + .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', { + hasText: fileName, + }) + .first(); + await link.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.mnote-main-tab.is-active[data-pane-role="primary"][data-mnote-tab-kind="markdown"]', { + hasText: fileName, + }).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task459-md-attachment-")); const relativePath = "README.md"; @@ -100,7 +156,8 @@ async function main() { await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => { const node = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"][href*="README%2Fresource-note.md"]'); - return node && node.classList.contains("mnote-uploaded-attachment-row") && node.getAttribute("data-mnote-attachment-link") === "true"; + return node instanceof HTMLAnchorElement + && (node.classList.contains("mnote-uploaded-attachment-row") || getComputedStyle(node).display === "inline-flex"); }, null, { timeout: UI_TIMEOUT_MS }); await link.click({ timeout: UI_TIMEOUT_MS }); @@ -142,6 +199,13 @@ async function main() { }); await resourceEditor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n"); + await clickPrimaryAttachmentAndExpectTab(page, "uploaded-one.md"); + await uploadAttachmentViaPrimarySlash(page, "uploaded-two.md", "# Uploaded Two\n\n第二个上传附件\n"); + await clickPrimaryAttachmentAndExpectTab(page, "uploaded-one.md"); + await clickPrimaryAttachmentAndExpectTab(page, "uploaded-two.md"); + assert.equal(popups.length, 0, `连续上传两个 MD 附件后点击不应打开浏览器新窗口,实际 popup=${popups.length}`); + console.log(JSON.stringify({ ok: true, root, documentId, popups: popups.length }, null, 2)); } finally { await context.close().catch(() => undefined); diff --git a/scripts/task472-side-target-secondary-pane-smoke.js b/scripts/task472-side-target-secondary-pane-smoke.js index d948f3f0..921a4ce7 100644 --- a/scripts/task472-side-target-secondary-pane-smoke.js +++ b/scripts/task472-side-target-secondary-pane-smoke.js @@ -77,6 +77,47 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte ); } +async function uploadAttachmentViaSecondarySlash(page, fileName, markdown, action) { + const editor = page + .locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror') + .first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("End").catch(() => undefined); + await page.keyboard.type("/"); + const item = page + .locator('.mnote-resource-tab-panel[data-pane-role="secondary"] [data-testid="slash-item-upload-attachment"]') + .first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), + item.click({ timeout: UI_TIMEOUT_MS }), + ]); + await fileChooser.setFiles({ + name: fileName, + mimeType: "text/markdown", + buffer: Buffer.from(markdown, "utf8"), + }); + await page.waitForFunction( + ({ name }) => { + const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror'); + return (editor instanceof HTMLElement && (editor.textContent || "").includes(name)) + || document.documentElement.getAttribute("data-mnote-last-upload-inserted") === "false"; + }, + { name: fileName }, + { timeout: UI_TIMEOUT_MS }, + ); + const inserted = await page.evaluate((name) => { + const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror'); + return { + hasText: editor instanceof HTMLElement && (editor.textContent || "").includes(name), + inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "", + error: document.documentElement.getAttribute("data-mnote-last-upload-insert-error") || "", + text: editor?.textContent || "", + }; + }, fileName); + assert(inserted.hasText, `${action} 上传后未插入 secondary 编辑器:${JSON.stringify(inserted)}`); +} + async function waitForPrimaryReady(page) { await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({ state: "visible", @@ -104,7 +145,7 @@ async function readSideTargetState(page) { return await page.evaluate(() => { const url = new URL(window.location.href); const pane = document.querySelector('.document-pane[data-pane-role="secondary"]'); - const activeTab = document.querySelector(".mnote-main-tab.is-active"); + const activeTab = document.querySelector('.mnote-main-tab.is-active[data-pane-role="primary"]'); const placeholder = document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"); return { resourceTab: url.searchParams.get("resourceTab") || "", @@ -116,6 +157,13 @@ async function readSideTargetState(page) { secondarySideTarget: pane?.getAttribute("data-mnote-side-target") || "", activeTabKind: activeTab?.getAttribute("data-mnote-tab-kind") || "", activeTabText: activeTab?.textContent || "", + secondaryActiveTabKind: document.querySelector('.mnote-main-tab.is-active[data-pane-role="secondary"]')?.getAttribute("data-mnote-tab-kind") || "", + secondaryActiveTabText: document.querySelector('.mnote-main-tab.is-active[data-pane-role="secondary"]')?.textContent || "", + secondaryResourceText: document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror')?.textContent || "", + primaryVisibleEditorText: document.querySelector('.document-pane[data-pane-role="primary"] .mnote-resource-tab-panel:not([hidden]) .ProseMirror, .document-pane[data-pane-role="primary"] [data-mnote-page-tab-panel]:not([hidden]) .ProseMirror')?.textContent || "", + secondaryOfficeFrameSrc: document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame')?.getAttribute("src") || "", + primarySlashVisible: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] [data-testid="mnote-leptos-tiptap-slash-menu"]')).some((node) => node instanceof HTMLElement && getComputedStyle(node).display !== "none"), + secondarySlashVisible: Array.from(document.querySelectorAll('.document-pane[data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]')).some((node) => node instanceof HTMLElement && getComputedStyle(node).display !== "none"), placeholderText: placeholder?.textContent || "", unsupportedFlag: document.documentElement.getAttribute("data-mnote-side-target-unsupported") || "", popupCount: window.__mnoteSideTargetPopupCount || 0, @@ -123,6 +171,53 @@ async function readSideTargetState(page) { }); } +async function waitForDiskTextContains(filePath, expectedNames) { + const deadline = Date.now() + UI_TIMEOUT_MS; + while (Date.now() < deadline) { + const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; + if (expectedNames.every((name) => text.includes(name))) return text; + await new Promise((resolve) => setTimeout(resolve, 150)); + } + const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; + throw new Error(`等待资源文件保存超时: ${filePath} text=${JSON.stringify(text)}`); +} + +async function waitForSecondaryAttachmentLinks(page, expectedNames) { + await page.waitForFunction( + ({ names }) => { + const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror'); + if (!(editor instanceof HTMLElement)) return false; + return names.every((name) => { + const link = Array.from(editor.querySelectorAll("a[href]")) + .find((node) => (node.textContent || "").includes(name)); + if (!(link instanceof HTMLAnchorElement)) return false; + const href = link.getAttribute("href") || ""; + const className = link.getAttribute("class") || ""; + const styledAsAttachment = getComputedStyle(link).display === "inline-flex"; + const enhancedAsAttachment = link.getAttribute("data-mnote-attachment-link") === "true" + || className.includes("mnote-uploaded-attachment-row"); + return href.includes("/api/local-folder/files/open") + && (styledAsAttachment || enhancedAsAttachment); + }); + }, + { names: expectedNames }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function clickSecondaryAttachment(page, fileName) { + await page.evaluate((name) => { + const editor = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror'); + const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"], a[data-mnote-attachment-link="true"]') || []) + .find((node) => (node.textContent || "").includes(name)); + if (!(link instanceof HTMLAnchorElement)) { + throw new Error(`secondary_attachment_link_missing:${name}`); + } + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window })); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); + }, fileName); +} + async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-side-target-")); const relativePath = "README.md"; @@ -156,7 +251,6 @@ async function main() { }; }); const page = await context.newPage(); - try { await quickLogin(page); await page.goto(documentUrl(root, relativePath, firstSideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); @@ -172,6 +266,15 @@ async function main() { Buffer.from("# Resource\n\n资源正文\n", "utf8"), "attachment", ); + const officeAsset = await uploadLocalAsset( + page, + root, + documentId, + "side-target-office.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Buffer.from("task472 secondary office probe", "utf8"), + "attachment", + ); await page.evaluate(({ asset, documentId }) => { window.dispatchEvent(new CustomEvent("tree.asset.open", { @@ -227,16 +330,196 @@ async function main() { }, })); }, { asset, documentId }); - await page.waitForFunction(() => document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"), {}, { timeout: UI_TIMEOUT_MS }); + await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); const afterResourceSideOpen = await readSideTargetState(page); - assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side placeholder 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`); - assert.equal(afterResourceSideOpen.secondarySideTarget, "unsupported-resource", `资源 openTarget=side 应标记 unsupported side target: ${JSON.stringify(afterResourceSideOpen)}`); - assert.match(afterResourceSideOpen.placeholderText, /暂不支持在侧栏打开此资源/, `资源 side placeholder 应可观测: ${JSON.stringify(afterResourceSideOpen)}`); - assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side placeholder 不应清空 active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`); - assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side placeholder 不应切走 active resource tab: ${JSON.stringify(afterResourceSideOpen)}`); + assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`); + assert.equal(afterResourceSideOpen.secondaryActiveTabKind, "markdown", `资源 openTarget=side 应在 secondary 资源标签打开 markdown: ${JSON.stringify(afterResourceSideOpen)}`); + assert(afterResourceSideOpen.secondaryActiveTabText.includes("side-target-resource.md"), `secondary 资源标签标题应可观测: ${JSON.stringify(afterResourceSideOpen)}`); + assert(afterResourceSideOpen.secondaryResourceText.includes("资源正文"), `secondary 资源标签应渲染附件内容: ${JSON.stringify(afterResourceSideOpen)}`); + assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side open 不应清空 primary active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`); + assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side open 不应切走 primary active resource tab: ${JSON.stringify(afterResourceSideOpen)}`); assert.equal(afterResourceSideOpen.popupCount, 0, `资源 openTarget=side 不应误开新窗口: ${JSON.stringify(afterResourceSideOpen)}`); + const secondaryResourceEditor = page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first(); + await secondaryResourceEditor.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForTimeout(150); + const afterSecondaryFirstClick = await readSideTargetState(page); + assert.equal(afterSecondaryFirstClick.primarySlashVisible, false, `secondary 首次点击资源正文不应让 primary 菜单闪现: ${JSON.stringify(afterSecondaryFirstClick)}`); - console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2)); + await uploadAttachmentViaSecondarySlash( + page, + "secondary-real-upload-1.md", + "# Upload One\n\n第一个真实上传\n", + "secondary 第一个真实 md", + ); + await page.waitForTimeout(250); + const afterFirstRealSecondaryUpload = await readSideTargetState(page); + if (!afterFirstRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-1.md")) { + const debug = await page.evaluate(() => { + const root = document.querySelector('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editor = root?.querySelector(".editor-surface .ProseMirror"); + return { + rootKind: root instanceof HTMLElement ? root.getAttribute("data-editor-host-kind") : "", + rootDocumentId: root instanceof HTMLElement ? root.getAttribute("data-document-id") : "", + rootWorkspaceId: root instanceof HTMLElement ? root.getAttribute("data-workspace-id") : "", + rootStatus: root instanceof HTMLElement ? root.getAttribute("data-runtime-editor-status") : "", + hasEditorHandle: Boolean(editor?.editor?.chain), + lastRootKind: window.__mnoteLastEditorUploadRoot instanceof HTMLElement ? window.__mnoteLastEditorUploadRoot.getAttribute("data-editor-host-kind") : "", + lastRootPane: window.__mnoteLastEditorUploadRoot instanceof HTMLElement ? window.__mnoteLastEditorUploadRoot.getAttribute("data-pane-role") : "", + visibleResourceEditors: Array.from(document.querySelectorAll('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror')).map((node) => ({ + text: node.textContent, + hasEditorHandle: Boolean(node.editor?.chain), + })), + }; + }); + throw new Error(`secondary 真实上传第一个 md 未插入 secondary 资源编辑器,debug=${JSON.stringify(debug)} state=${JSON.stringify(afterFirstRealSecondaryUpload)}`); + } + assert(afterFirstRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-1.md"), `secondary 真实上传第一个 md 应插入 secondary 资源编辑器: ${JSON.stringify(afterFirstRealSecondaryUpload)}`); + assert(!afterFirstRealSecondaryUpload.primaryVisibleEditorText.includes("secondary-real-upload-1.md"), `secondary 真实上传第一个 md 不应插入 primary 编辑器: ${JSON.stringify(afterFirstRealSecondaryUpload)}`); + assert.equal(afterFirstRealSecondaryUpload.primarySlashVisible, false, `secondary 真实上传第一个 md 后 primary 菜单不应可见: ${JSON.stringify(afterFirstRealSecondaryUpload)}`); + + await uploadAttachmentViaSecondarySlash( + page, + "secondary-real-upload-2.md", + "# Upload Two\n\n第二个真实上传\n", + "secondary 第二个真实 md", + ); + await page.waitForTimeout(250); + const afterSecondRealSecondaryUpload = await readSideTargetState(page); + assert(afterSecondRealSecondaryUpload.secondaryResourceText.includes("secondary-real-upload-2.md"), `secondary 真实上传第二个 md 应插入 secondary 资源编辑器: ${JSON.stringify(afterSecondRealSecondaryUpload)}`); + assert(!afterSecondRealSecondaryUpload.primaryVisibleEditorText.includes("secondary-real-upload-2.md"), `secondary 真实上传第二个 md 不应插入 primary 编辑器: ${JSON.stringify(afterSecondRealSecondaryUpload)}`); + assert.equal(afterSecondRealSecondaryUpload.primarySlashVisible, false, `secondary 真实上传第二个 md 后 primary 菜单不应可见: ${JSON.stringify(afterSecondRealSecondaryUpload)}`); + + const sideResourceDiskPath = path.join(root, "README", "side-target-resource.md"); + await waitForDiskTextContains(sideResourceDiskPath, [ + "secondary-real-upload-1.md", + "secondary-real-upload-2.md", + ]); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForPrimaryReady(page); + await page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "side-target-resource.md", + assetType: asset.asset_type || "attachment", + openTarget: "side", + }, + })); + }, { asset, documentId }); + await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await waitForSecondaryAttachmentLinks(page, [ + "secondary-real-upload-1.md", + "secondary-real-upload-2.md", + ]); + await clickSecondaryAttachment(page, "secondary-real-upload-1.md"); + await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const afterReloadFirstAttachmentClick = await readSideTargetState(page); + assert(afterReloadFirstAttachmentClick.secondaryActiveTabText.includes("secondary-real-upload-1.md"), `刷新后第一个 md 附件应在 secondary tab 打开: ${JSON.stringify(afterReloadFirstAttachmentClick)}`); + assert.equal(afterReloadFirstAttachmentClick.popupCount, 0, `刷新后第一个 md 附件不应新开窗口: ${JSON.stringify(afterReloadFirstAttachmentClick)}`); + await page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "side-target-resource.md", + assetType: asset.asset_type || "attachment", + openTarget: "side", + }, + })); + }, { asset, documentId }); + await waitForSecondaryAttachmentLinks(page, [ + "secondary-real-upload-1.md", + "secondary-real-upload-2.md", + ]); + await clickSecondaryAttachment(page, "secondary-real-upload-2.md"); + await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const afterReloadSecondAttachmentClick = await readSideTargetState(page); + assert(afterReloadSecondAttachmentClick.secondaryActiveTabText.includes("secondary-real-upload-2.md"), `刷新后第二个 md 附件应在 secondary tab 打开: ${JSON.stringify(afterReloadSecondAttachmentClick)}`); + assert.equal(afterReloadSecondAttachmentClick.popupCount, 0, `刷新后第二个 md 附件不应新开窗口: ${JSON.stringify(afterReloadSecondAttachmentClick)}`); + + fs.writeFileSync(path.join(root, "Second-resource.md"), "# Second Resource\n\n第二个资源正文\n", "utf8"); + const secondAsset = await uploadLocalAsset( + page, + root, + documentId, + "Second-resource.md", + "text/markdown", + Buffer.from("# Second Resource\n\n第二个资源正文\n", "utf8"), + "attachment", + ); + await page.evaluate(({ secondAsset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: secondAsset.id, + documentId, + title: secondAsset.file_name || "Second-resource.md", + assetType: secondAsset.asset_type || "attachment", + openTarget: "side", + }, + })); + }, { secondAsset, documentId }); + await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const afterSecondResourceSideOpen = await readSideTargetState(page); + assert(afterSecondResourceSideOpen.secondaryActiveTabText.includes("Second-resource.md"), `secondary 第二个 md 资源应切到新标签: ${JSON.stringify(afterSecondResourceSideOpen)}`); + assert(afterSecondResourceSideOpen.secondaryResourceText.includes("第二个资源正文"), `secondary 第二个 md 资源应渲染新正文: ${JSON.stringify(afterSecondResourceSideOpen)}`); + assert.equal(afterSecondResourceSideOpen.secondaryActiveTabKind, "markdown", `secondary 第二个 md 资源不应回到 primary: ${JSON.stringify(afterSecondResourceSideOpen)}`); + assert.equal(afterSecondResourceSideOpen.popupCount, 0, `secondary 第二个 md 资源不应误开新窗口: ${JSON.stringify(afterSecondResourceSideOpen)}`); + + await page.evaluate(({ officeAsset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: officeAsset.id, + documentId, + title: officeAsset.file_name || "side-target-office.docx", + assetType: officeAsset.asset_type || "attachment", + openTarget: "side", + }, + })); + }, { officeAsset, documentId }); + await page.locator('.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="office"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const afterOfficeSideOpen = await readSideTargetState(page); + assert.equal(afterOfficeSideOpen.secondaryDocumentId, null, `Office openTarget=side 不应恢复 secondaryDocumentId: ${JSON.stringify(afterOfficeSideOpen)}`); + assert.equal(afterOfficeSideOpen.secondaryActiveTabKind, "office", `Office openTarget=side 应在 secondary 资源标签打开 office: ${JSON.stringify(afterOfficeSideOpen)}`); + assert(afterOfficeSideOpen.secondaryActiveTabText.includes("side-target-office.docx"), `secondary Office 标签标题应可观测: ${JSON.stringify(afterOfficeSideOpen)}`); + assert(afterOfficeSideOpen.secondaryOfficeFrameSrc, `secondary Office 应创建 iframe: ${JSON.stringify(afterOfficeSideOpen)}`); + const secondaryOfficeFrameUrl = new URL(afterOfficeSideOpen.secondaryOfficeFrameSrc, BASE_URL); + assert.equal(secondaryOfficeFrameUrl.pathname, "/onlyoffice", `secondary Office iframe 应指向 /onlyoffice: ${JSON.stringify(afterOfficeSideOpen)}`); + assert.equal(secondaryOfficeFrameUrl.searchParams.get("assetId"), officeAsset.id, `secondary Office iframe 应携带 assetId: ${JSON.stringify(afterOfficeSideOpen)}`); + assert.equal(secondaryOfficeFrameUrl.searchParams.get("mode"), "view", `secondary Office iframe 应使用 view 模式: ${JSON.stringify(afterOfficeSideOpen)}`); + assert.equal(afterOfficeSideOpen.popupCount, 0, `Office openTarget=side 不应误开新窗口: ${JSON.stringify(afterOfficeSideOpen)}`); + + console.log(JSON.stringify({ ok: true, root, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2)); } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined);