From 3ae33cc21da7ed933c729846cd8447fbfb993b46 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Wed, 27 May 2026 11:31:12 +0800 Subject: [PATCH] Improve local filetree view state and sidebar performance --- ...05-27-local-search-query-rebuilds-index.md | 70 ++ ...filetree-create-page-bundle-auto-expand.md | 29 + ...tarred-scoped-file-open-reloads-sidebar.md | 35 + ...-local-file-operation-event-contract-v1.md | 238 +++++ ...letree-viewstate-generation-followup-v1.md | 212 ++++ ...red-shortcuts-sqlite-scoped-explorer-v1.md | 242 +++++ ...ttings-sqlite-preference-convergence-v1.md | 380 +++++++ ...iletree-lazy-loading-sidex-alignment-v1.md | 363 +++++++ .../migrations/003-sidebar-shortcuts.sql | 22 + .../migrations/004-user-ui-preferences.sql | 21 + .../005-sidebar-shortcut-root-uri.sql | 8 + rust/crates/control-plane/src/migrations.rs | 14 + rust/crates/control-plane/src/model.rs | 66 ++ rust/crates/control-plane/src/sqlite.rs | 450 +++++++- rust/crates/control-plane/src/store.rs | 42 +- .../document-editor-adapter-runtime.js | 9 +- .../mnote-web/browser/filetree-runtime.js | 2 +- .../sidebar-attachment-open-runtime.js | 6 + .../sidebar-filetree-command-runtime.js | 336 ++++-- .../browser/sidebar-page-settings-runtime.js | 32 +- .../browser/sidebar-page-tree-runtime.js | 2 + .../sidebar-tree-live-apply-runtime.js | 556 +++++++++- .../mnote-web/browser/sidebar-tree-runtime.js | 479 ++++++++- .../browser/sidebar-workspace-runtime.js | 2 +- .../mnote-web/browser/tree-live-controller.js | 12 + .../mnote-web/src/document_buffer_store.rs | 117 +++ .../crates/mnote-web/src/hermes_tools/page.rs | 53 +- rust/crates/mnote-web/src/routes/documents.rs | 43 +- rust/crates/mnote-web/src/routes/gateway.rs | 95 +- .../mnote-web/src/routes/hermes_tools.rs | 83 ++ rust/crates/mnote-web/src/routes/kernel.rs | 131 ++- .../src/routes/local_folder_events.rs | 190 +++- .../src/routes/local_folder_source.rs | 986 ++++++++++++++++-- .../src/routes/local_search_index.rs | 89 +- .../mnote-web/src/routes/mindmap_shell.rs | 6 +- rust/crates/mnote-web/src/routes/mod.rs | 20 +- .../mnote-web/src/routes/sidebar_shortcuts.rs | 285 +++++ rust/crates/mnote-web/src/routes/tree.rs | 113 +- .../mnote-web/src/routes/ui_preferences.rs | 489 +++++++++ rust/crates/mnote-web/src/routes/web_shell.rs | 445 +++++++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 254 ++++- rust/crates/mnote-web/src/ssr/styles.rs | 45 + .../src/tree_shell/filetree_renderer.rs | 35 +- .../src/tree_shell/picker_renderer.rs | 2 + rust/crates/mnote-web/src/workspace_shell.rs | 417 +++++++- .../task-page-aggregate-options-sync-smoke.js | 6 +- ...age-aggregate-refresh-persistence-smoke.js | 6 +- ...local-folder-unified-tree-browser-smoke.js | 15 +- ...down-title-body-options-no-convex-smoke.js | 6 +- ...-local-folder-bulk-resource-trash-smoke.js | 38 +- ...older-markdown-resource-lifecycle-smoke.js | 40 +- ...task492-sidebar-starred-shortcuts-smoke.js | 323 ++++++ ...-page-settings-sqlite-preferences-smoke.js | 216 ++++ ...sk494-filetree-lazy-loading-dedup-smoke.js | 544 ++++++++++ ...95-filetree-copy-id-absolute-path-smoke.js | 151 +++ ...r-open-parallel-runtime-aggregate-smoke.js | 204 ++++ 56 files changed, 8614 insertions(+), 461 deletions(-) create mode 100644 bugs/04-tree-domain/done/2026-05-27-local-search-query-rebuilds-index.md create mode 100644 bugs/05-editor-mainline/done/2026-05-27-filetree-create-page-bundle-auto-expand.md create mode 100644 bugs/05-editor-mainline/done/2026-05-27-starred-scoped-file-open-reloads-sidebar.md create mode 100644 design/04-tree-domain/done/4-49-local-file-operation-event-contract-v1.md create mode 100644 design/05-editor-mainline/done/5-33-filetree-viewstate-generation-followup-v1.md create mode 100644 design/05-editor-mainline/process/5-30-sidebar-starred-shortcuts-sqlite-scoped-explorer-v1.md create mode 100644 design/05-editor-mainline/process/5-31-page-settings-sqlite-preference-convergence-v1.md create mode 100644 design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md create mode 100644 rust/crates/control-plane/migrations/003-sidebar-shortcuts.sql create mode 100644 rust/crates/control-plane/migrations/004-user-ui-preferences.sql create mode 100644 rust/crates/control-plane/migrations/005-sidebar-shortcut-root-uri.sql create mode 100644 rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs create mode 100644 rust/crates/mnote-web/src/routes/ui_preferences.rs create mode 100644 scripts/task492-sidebar-starred-shortcuts-smoke.js create mode 100644 scripts/task493-page-settings-sqlite-preferences-smoke.js create mode 100644 scripts/task494-filetree-lazy-loading-dedup-smoke.js create mode 100644 scripts/task495-filetree-copy-id-absolute-path-smoke.js create mode 100644 scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js diff --git a/bugs/04-tree-domain/done/2026-05-27-local-search-query-rebuilds-index.md b/bugs/04-tree-domain/done/2026-05-27-local-search-query-rebuilds-index.md new file mode 100644 index 00000000..5b443164 --- /dev/null +++ b/bugs/04-tree-domain/done/2026-05-27-local-search-query-rebuilds-index.md @@ -0,0 +1,70 @@ +# local search query 每次重建索引导致大工作区搜索卡顿 + +> 状态:done +> Owner:04-tree-domain / 03-rust-web +> 发现日期:2026-05-27 +> 来源:Sidex 全项目 review / 本地搜索性能审查 + +## 现象 + +在 local-first 工作区中,侧边栏搜索输入虽然有前端 debounce,但后端每次 query 都会重建并写入整个 local search index。大工作区下,连续输入会触发多次递归扫描、读取 Markdown、写 `.mnote/index/search-index.json`,导致搜索和主界面卡顿。 + +## 证据 + +- `rust/crates/mnote-web/src/routes/local_search_index.rs` + - `query_local_search_index()` 第一步调用 `rebuild_local_search_index(...)`。 + - `rebuild_local_search_index()` 递归 `collect_markdown_documents(...)` 后写入 index。 +- 前端 `sidebar-tree-runtime.js` 的搜索渲染 debounce 不能避免后端每次 query 全盘 rebuild。 +- 同文件已经存在 `refresh_local_search_index_for_path(...)` 增量更新入口,说明 watcher/单文件刷新方向已有基础,但 query path 没有复用它。 + +## Sidex / VSCode 对照 + +Sidex / VSCode 搜索路径不会把每次 query 等价为全盘同步 rebuild: + +- quick access/search 使用 throttle、cache state 和 cancellation token。 +- Rust search crate 有 max_results、ignore-aware、binary skip 和并行扫描。 +- watcher 变化应驱动增量缓存更新,query 只消费当前 cache 或启动后台 refresh。 + +MNote 不需要照搬 VSCode 搜索 UI,但应采用同一原则:query path 不做强制全量重建。 + +## 期望行为 + +- query 优先读取已有 index。 +- index 缺失、版本不匹配、rootUri/workspaceId 不匹配时,才同步 rebuild。 +- index 过期但仍可读时,query 返回旧 index 结果,并后台触发 refresh。 +- watcher/path event 继续调用 `refresh_local_search_index_for_path(...)` 做增量更新。 +- 连续输入时,旧 query 可以被取消或自然过期,不能排队多次全盘扫描。 + +## 建议修复切片 + +1. 新增 `load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)`: + - 先 `read_local_search_index(root_path)`。 + - 若 version/rootUri/workspaceId 匹配,直接返回 index。 + - 不匹配或缺失时才调用 `rebuild_local_search_index(...)`。 +2. 将 `query_local_search_index()` 改为调用 `load_or_rebuild_local_search_index(...)`。 +3. 保留显式 refresh API 调用 `refresh_local_search_index(...)`。 +4. 加单测: + - 首次 query 会创建 index。 + - 第二次 query 不改写 index `built_at`。 + - refresh 后 query 能读到新内容。 + - 单文件 `refresh_local_search_index_for_path(...)` 后 query 命中更新内容。 +5. 后续中期再做后台 refresh、取消令牌和搜索 worker。 + +## 验收 + +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_index -- --nocapture` 通过。 +- 连续两次相同 query 时,第二次不会调用 full rebuild,不更新 `built_at`。 +- 侧边栏连续输入 5 个字符时,不出现 5 次全盘 rebuild。 +- 大目录 smoke 中搜索响应不阻塞 FileTree 展开。 + +## 修复记录 + +- 2026-05-27:新增 `load_or_rebuild_local_search_index(...)`,`query_local_search_index(...)` 优先读取已有匹配 version/rootUri/workspaceId 的索引;只有索引缺失、无效或 root/workspace 不匹配时才 full rebuild。 +- 2026-05-27:新增回归测试 `local_search_query_reads_existing_index_without_rebuilding`,验证 query 不会看到未 refresh 的磁盘新内容,增量 refresh 后才命中新内容。 +- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_index -- --nocapture`,4 passed。 + +## 非目标 + +- 不在本缺陷单实现全文搜索排序重构。 +- 不引入新的搜索 UI。 +- 不实现全局后台 index worker;这是后续性能设计项。 diff --git a/bugs/05-editor-mainline/done/2026-05-27-filetree-create-page-bundle-auto-expand.md b/bugs/05-editor-mainline/done/2026-05-27-filetree-create-page-bundle-auto-expand.md new file mode 100644 index 00000000..e16e7ef8 --- /dev/null +++ b/bugs/05-editor-mainline/done/2026-05-27-filetree-create-page-bundle-auto-expand.md @@ -0,0 +1,29 @@ +# FileTree 连续新建页面会展开上一个页面包目录 + +> 状态:done +> Owner:05-editor-mainline / 04-tree-domain +> 发现日期:2026-05-27 +> 来源:用户反馈 / Sidex Explorer 行为对照 + +## 现象 + +在 local folder 的“我的空间”中连续点击新建页面时,FileTree 会先短暂显示内部 `.md` 行,然后折叠;再次新建页面后,上一个页面包目录会被自动展开,当前页面包目录保持折叠,视觉上像是展开状态串到了旧页面。 + +## 根因 + +本地页面是 `页面名/页面名.md` 的同名目录包。新建完成后,前端 `selectSidebarFileTreeDocument(...)` 找不到折叠目录内部的 `.md` 行,会调用 `revealFileTreeResource(...)` 展开目录包并持久化展开状态。后端 `load_local_folder_file_tree_snapshot_with_reveal(...)` 也会把同名页面包目录当作普通祖先展开,导致下一次刷新时旧页面包恢复展开。 + +Sidex / VSCode Explorer 的对齐原则是:刷新 collapsed 节点只标记 stale;reveal 只展开必要祖先,不应把当前资源自身的内部实现目录当作用户展开状态持久化。 + +## 修复 + +- `local-md:` 路径解码改为完整 `~XX -> %XX -> decodeURIComponent`,避免中文路径无法匹配可见目录包行。 +- 前端选择 local markdown 时,若目标是同名页面包 `目录/目录.md` 且目录包行已可见,则直接选中/激活目录包行,不展开内部 `.md`。 +- 后端 FileTree reveal 对同名页面包只展开到页面包父级,不扫描并展开包内部 `.md`。 +- `task494-filetree-lazy-loading-dedup-smoke.js` 增加连续新建页面回归:上一个页面包不自动展开,当前页面包不展开内部 md,当前页面包行保持 active。 + +## 验证 + +- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 先失败于上一个页面包 `expanded=true`,修复后通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --nocapture --test-threads=1`,46 passed。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_file_tree -- --nocapture --test-threads=1`,4 passed。 diff --git a/bugs/05-editor-mainline/done/2026-05-27-starred-scoped-file-open-reloads-sidebar.md b/bugs/05-editor-mainline/done/2026-05-27-starred-scoped-file-open-reloads-sidebar.md new file mode 100644 index 00000000..1b571f69 --- /dev/null +++ b/bugs/05-editor-mainline/done/2026-05-27-starred-scoped-file-open-reloads-sidebar.md @@ -0,0 +1,35 @@ +# 星标 scoped 文件夹打开 Markdown 会重载并重建 FileTree + +> 状态:done +> Owner:05-editor-mainline / 03-rust-web +> 发现日期:2026-05-27 +> 来源:用户反馈 / Sidex Explorer 行为对照 + +## 现象 + +从“星标置顶”点击本地文件夹 `design` 后,继续在 scoped FileTree 中打开任意 `.md` 文件,页面会明显卡顿,表现为侧栏重新构建、文件树重新加载。用户在 localhost 下也能感到卡顿,远程延迟时更明显。 + +## 根因 + +这次实际有三条慢链路叠在一起: + +1. `navigateToDocument(...)` 在 `fileTreeScope` 存在时绕过 `window.__mnoteDocumentPaneRuntime.openPrimaryDocument(...)`,直接走 `window.location.assign(...)`。因此 scoped 文件树中打开 Markdown 不是 pane 内导航,而是整页 SSR reload。 +2. scoped 模式收到 root live snapshot 时,`renderLiveSidebarSnapshot(...)` 会调用 `refreshLocalFolderSidebarSnapshot()`,兜底重拉 scope 根 projection。 +3. tree live disabled / fallback polling 模式下,coarse `/api/tree/local-folder-watch` revision 变化会整刷 scoped FileTree;普通 Markdown mount 时,旧 `healLegacyOfficeAttachmentParagraphs()` 也会为了 legacy office 附件兼容拉 workspace root projection。 + +Sidex / VSCode Explorer 的对齐原则是:打开文件只切换 editor/input 和 selection,不重建 Explorer 数据源;Explorer 的 scoped/root view state 与文件打开解耦。 + +## 修复 + +- 允许 scoped FileTree 打开 Markdown 时继续走 `openPrimaryDocument(...)` pane 内导航。 +- URL 仍保留 `fileTreeScope`,因此刷新页面时仍能恢复 scoped Explorer。 +- scoped 模式收到非当前 scope 的 root snapshot 时只标记忽略,不再兜底重拉 scope 根。 +- fallback polling 发现 coarse root revision 变化时,对 scoped FileTree 只标记 `scope_stale`,不做即时 scope refresh。 +- legacy office 附件兼容逻辑先检查 DOM 中是否存在“单段 office 文件名”候选;普通 Markdown 不再触发 workspace root projection。 +- `task494-filetree-lazy-loading-dedup-smoke.js` 增加回归:点击 scoped 中已可见 Markdown 后,页面 JS marker 必须保留,且不应再次请求 scope 根 projection;root snapshot / coarse watch revision 也不得重拉 scope 根。 + +## 验证 + +- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 先失败于 marker 被整页 reload 清空,修复后通过。 +- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 继续失败于 scoped root snapshot 和 coarse watch revision 重拉 scope 根,修复后通过。 +- 真实 `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md` 链路验证通过:`scopeRootRequests=0`、`workspaceRootRequests=0`、`childrenRequests=2`,请求只剩 `design/05-editor-mainline` 与 `design/05-editor-mainline/process` 两个必要 children projection。 diff --git a/design/04-tree-domain/done/4-49-local-file-operation-event-contract-v1.md b/design/04-tree-domain/done/4-49-local-file-operation-event-contract-v1.md new file mode 100644 index 00000000..4a132ab5 --- /dev/null +++ b/design/04-tree-domain/done/4-49-local-file-operation-event-contract-v1.md @@ -0,0 +1,238 @@ +# 4-49 Local File Operation 与 Watch Event 合同 v1 + +> 状态:process +> Owner:04-tree-domain / 03-rust-web +> 背景:Sidex / VSCode Explorer 对照显示,MNote local_folder 的 tree command、watch event、refresh/reveal 和 opened resource 生命周期之间仍缺少稳定合同。当前前端常靠 `relativePath` / `previousRelativePath` 反推刷新父级,watch event 又容易退回全量 resync,导致大目录、批量操作和已打开资源场景下出现重复刷新、状态漂移和错误恢复困难。 + +## 1. 结论 + +local_folder 命令结果必须从“执行成功 + 少量路径字段”升级为“文件操作事件合同”: + +1. 后端声明 `affectedParents`,前端不再猜 old/new/current parent。 +2. 命令结果携带 `revealTarget` / `selectTarget`,刷新后可稳定落焦。 +3. watch event 按 root 合并批次,输出 `changedPaths`、`affectedParents`、`eventKinds`。 +4. opened editor/resource 通过 participant 参与 rename/move/delete,处理 buffer rekey、stale、close 或 redirect。 +5. 批量 paste/delete/move 用 batch id 聚合执行结果和刷新点。 + +## 2. Sidex / VSCode 对照 + +### 2.1 可借鉴的行为 + +- ExplorerService 对 create/copy/move/delete 明确计算 old parent、new parent、nested parent,并刷新对应 item。 +- file changes 先进入延迟队列,批量判断是否影响已解析模型,再决定是否 refresh。 +- WorkingCopyFileService 在 move/delete 前后处理 dirty working copy,失败时发结构化 fail event。 +- BulkFileEdits 按批次执行,收集 per-item result 和 undo 操作。 + +### 2.2 不照搬的内容 + +- 不引入 VSCode undo/redo service 全模型。 +- 不引入完整 working copy service container。 +- 不改变 MNote 当前 local-first 文件系统事实源。 +- 不把 Convex command 分支重新变成本地默认路径。 + +## 3. 当前缺口 + +### 3.1 command result 刷新边界不足 + +当前 `local_folder` command 走 `execute_local_tree_command_with_sort` 后返回 `execution`,前端再用 `relativePath`、`previousRelativePath`、`parentRelativePath` 和 request body 推断刷新父级。这个推断对 rename、move、trash、restore、bulk paste 都容易遗漏 old parent 或 new parent。 + +### 3.2 批量操作没有一致提交点 + +paste / bulk delete 当前逐项 dispatch command,单项成功后可能立即 patch DOM。多个结果交错时,UI 会经历多次 refresh,失败项也缺少结构化状态。 + +### 3.3 watch event 粒度偏粗 + +watcher 可以观察 path 事件,但 live event route 仍容易 rebuild full snapshot / resync。前端 fallback 有 debounce,服务端 SSE/resync 路径缺同等批处理语义。 + +### 3.4 opened resource 生命周期缺 participant + +rename/move/delete 对已打开 Markdown buffer、mindmap、office resource tab 的影响仍分散: + +- Markdown buffer key 绑定 relative path。 +- resource tab identity 由前端字符串拼接。 +- delete/archive 后是否 close、mark stale、redirect,没有统一参与层。 + +## 4. 目标 + +- 所有 local_folder tree command 成功结果都能说明影响哪些 parent。 +- 前端 refresh scheduler 只消费 `affectedParents`,不解析 action-specific 路径字段。 +- 批量操作只在 batch 完成后统一 refresh/apply。 +- watcher event 按 root 合并,优先刷新 visible/loaded affected parents。 +- opened resource 在文件操作前后收到 participant 通知,能处理 dirty、rekey、stale、close。 +- 所有失败路径发结构化 error,包含 phase/rootUri/action/path/batchId。 + +## 5. 非目标 + +- 不实现全局 undo/redo。 +- 不重写 trash 物理存储。 +- 不改变现有 tree command URL。 +- 不把 resource tab group persistence 放进本设计;那属于 editor-mainline 后续。 + +## 6. 协议草案 + +### 6.1 Command result + +建议 local command result 至少包含: + +```json +{ + "schema": "mnote.local_file_operation_result.v1", + "operationId": "uuid-or-short-id", + "batchId": "", + "action": "rename", + "rootUri": "file:///mnt/Data1T/mnote", + "resource": { + "documentId": "local-md:design/foo.md", + "relativePath": "design/foo.md", + "objectIdentity": "resource:file:file:///mnt/Data1T/mnote:design/foo.md" + }, + "previousResource": { + "documentId": "local-md:design/old.md", + "relativePath": "design/old.md", + "objectIdentity": "resource:file:file:///mnt/Data1T/mnote:design/old.md" + }, + "affectedParents": [ + { "relativePath": "design", "reason": "old-parent" }, + { "relativePath": "design/new", "reason": "new-parent" } + ], + "revealTarget": { + "relativePath": "design/foo.md", + "rowId": "local:file:design/foo.md" + }, + "selectTarget": { + "relativePath": "design/foo.md", + "rowId": "local:file:design/foo.md" + } +} +``` + +约束: + +- rename same-parent 也必须返回 affected parent。 +- move cross-parent 必须返回 old parent 和 new parent。 +- delete/archive 必须返回 old parent 和 next focus hint。 +- restore 必须返回 restored parent 和 reveal target。 +- `objectIdentity` 必须来自后端 canonical identity,不由前端拼接。 + +### 6.2 Batch result + +批量操作返回: + +```json +{ + "schema": "mnote.local_file_operation_batch_result.v1", + "batchId": "batch-20260527-001", + "action": "paste", + "succeeded": 3, + "failed": 1, + "results": [], + "affectedParents": [], + "primaryRevealTarget": {} +} +``` + +前端规则: + +- batch 未完成前不做全量 refresh。 +- per-item 可做乐观占位,但最终以 batch result 的 affected parents 为准。 +- 部分失败时展示结构化失败列表,不用 `alert` 拼字符串作为唯一反馈。 + +### 6.3 Watch event batch + +服务端按 root 聚合短窗口: + +```json +{ + "schema": "mnote.local_folder_watch_batch.v1", + "rootUri": "file:///mnt/Data1T/mnote", + "watchRevision": {}, + "changedPaths": [ + { "relativePath": "design/foo.md", "kind": "modified" } + ], + "affectedParents": [ + { "relativePath": "design", "reason": "child-modified" } + ], + "eventKinds": ["modified"], + "fallbackResync": false +} +``` + +前端规则: + +- visible/loaded affected parent:局部 refresh。 +- collapsed/loaded parent:标记 stale。 +- 未 loaded parent:只记录 dirty,不加载。 +- `fallbackResync=true`:刷新当前 scope parent,保留 expanded cache。 + +### 6.4 FileOperationParticipant + +后端或 runtime 层新增参与点: + +```text +beforeLocalFileOperation +afterLocalFileOperation +onLocalFileOperationFailed +``` + +参与者: + +- `DocumentBufferStore`:rename/move 后 rekey,delete/archive 后 mark stale 或 close。 +- `ResourceTabRuntime`:resource identity 改变时更新 tab key,目标被删除时显示 stale/closed state。 +- `SearchIndex`:按 changed path 增量刷新,避免 query 时 rebuild。 +- `FileTreeRefreshScheduler`:收集 affected parents。 + +## 7. Checklist + +- [x] 审查 `execute_local_tree_command_with_sort` 每个 action 当前返回字段。 +- [x] 定义 Rust `LocalFileOperationResult` / `AffectedParent` / `RevealTarget` 类型。 +- [x] 让 create/rename/move/archive/restore/purge 返回统一 `affectedParents`。 +- [x] 增加过渡期 `affectedParents` 合成器,根据后端已有 `previousRelativePath` / `relativePath` / `parentRelativePath` 声明 old/new/current parent。 +- [x] 修改前端 `fileTreeRefreshParentsForCommand`,优先消费 `result.affectedParents`。 +- [x] 为 legacy result 保留兼容 fallback,但加日志标记。 +- [x] 给 bulk paste/delete 增加 batch id,完成后统一 refresh。 +- [x] watch registry 或 event route 增加 root 级 debounce batch。 +- [x] `tree:resync` payload 缺失或 rebuild 失败时发结构化 error event。 +- [x] 加 `DocumentBufferStore` participant 设计切片:rename/move rekey,delete/archive mark stale。 +- [x] 补 smoke:批量删除、跨父级移动、rename opened markdown、watch batch 不全量折叠。 + +## 8. 验收 + +- rename same-parent 只刷新该 parent,目标 row 保持 selected/focused。 +- move cross-parent 刷新 old parent 和 new parent,不刷新 workspace root。 +- bulk delete 10 个文件只触发一次 batch refresh。 +- 外部连续创建/删除多个文件时,watch event 合并成一个 batch。 +- 已打开 Markdown rename 后,buffer key 和 tab identity 更新,不出现旧路径保存覆盖。 +- 已打开 resource 被 archive/delete 后,tab 进入 stale/closed state,不继续写旧路径。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_command -- --test-threads=1` 通过。 +- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 通过。 +- 新增批量/watch smoke 通过。 + +## 9. 风险 + +- 如果 command result 同时保留新旧字段,前端可能继续依赖旧推断。迁移期需要明确优先级:`affectedParents` 优先,旧字段只作 fallback。 +- participant 不能阻塞文件系统操作太久;dirty/conflict 处理需要有明确 timeout 或失败策略。 +- watch batch 不能吞掉不可恢复错误;需要把 watcher unhealthy / fallback polling 状态暴露给前端。 + +## 10. 执行记录 + +- 2026-05-27:在 `execute_local_tree_command_with_sort(...)` 出口新增 `add_local_tree_command_affected_parents(...)`,基于现有 command result 字段生成 `affectedParents`。 +- 2026-05-27:新增 `local_tree_command_move_returns_affected_parents`,验证跨父级 move 返回旧父级 root 和新父级 `docs`。 +- 2026-05-27:前端 `fileTreeRefreshParentsForCommand(...)` 新增 `addAffectedParentsFromCommandResult(...)`,优先消费 `result.affectedParents`;旧字段推断保留为 fallback。 +- 2026-05-27:新增 `sidebar_filetree_runtime_prefers_command_affected_parents` 契约测试。 +- 2026-05-27:完成 `execute_local_tree_command_with_sort(...)` action 返回字段审查;发现 delete/archive/purge、目录 move、legacy wrapper 是主要缺口。 +- 2026-05-27:新增 Rust `LocalFileOperationResult` / `AffectedParent` / `RevealTarget` / `LocalFileOperationResource` 类型,并在 local_folder 命令出口统一补 `schema= mnote.local_file_operation_result.v1`、`operationId`、`rootUri`、`resource/previousResource`、`affectedParents`、`revealTarget/selectTarget`。 +- 2026-05-27:补齐 delete/archive/purge 的 `originalRelativePath`、目录 move 的 `previousRelativePath`;前端 affectedParents fallback 支持 `result.execution.affectedParents`,legacy fallback 标记 `data-mnote-filetree-command-refresh-fallback`。 +- 2026-05-27:bulk paste/delete 增加 `batchId`,`tree:local-command` 按 batch 暂存 affected parents,`tree:local-command-batch-complete` 后统一 refresh;修复 bulk delete local asset 缺少 `removeFileTreeAssetRow` 依赖导致命令成功但 UI 记录失败的问题。 +- 2026-05-27:local folder tree live SSE 从逐事件 full resync 改为 120ms root 级 `watch_batch`,payload 输出 `changedPaths/affectedParents/eventKinds/fallbackResync=false`;重建失败/缺 payload 输出 `mnote.tree_live_error.v1` 结构化 error。 +- 2026-05-27:`DocumentBufferStore` 增加 local file operation participant:rename/move rekey opened Markdown buffer,delete/archive/purge mark deleted;tree command local_folder 分支执行后调用 participant。 +- 2026-05-27:扩展 smoke:`task494` 覆盖 watch batch 不折叠、opened Markdown rename buffer rekey、未加载深层 reveal;`task471` 覆盖 bulk delete batchId 与 batch 完成后统一 refresh。 +- 验证: + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_move_returns_affected_parents -- --nocapture`,通过。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture`,5 passed。 + - `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_ -- --nocapture --test-threads=1`,11 passed。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_events -- --nocapture --test-threads=1`,7 passed。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_ -- --nocapture --test-threads=1`,13 passed。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture --test-threads=1`,10 passed。 + - `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,覆盖 lazy dedup、scope generation、watch batch、reveal、opened rename buffer rekey。 + - `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:38291 node scripts/task471-local-folder-bulk-resource-trash-smoke.js`,通过,覆盖 bulk delete batchId 与 batch refresh。 diff --git a/design/05-editor-mainline/done/5-33-filetree-viewstate-generation-followup-v1.md b/design/05-editor-mainline/done/5-33-filetree-viewstate-generation-followup-v1.md new file mode 100644 index 00000000..31b47143 --- /dev/null +++ b/design/05-editor-mainline/done/5-33-filetree-viewstate-generation-followup-v1.md @@ -0,0 +1,212 @@ +# 5-33 FileTree ViewState 与请求代际收口 v1 + +> 状态:process +> Owner:05-editor-mainline / 03-rust-web +> 前置:`design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md` +> 背景:5-32 已把 FileTree lazy children、cache 和局部 refresh 推到可用层,但 Sidex / VSCode 对照显示,当前仍缺少统一 view-state、request generation、collapsed stale 和 reveal command。继续只做“展开恢复”会掩盖慢请求、重复请求和过期结果覆盖的问题。 + +## 1. 结论 + +FileTree 下一步不应继续围绕 DOM 还原做补丁,而应收口为一个显式状态机: + +1. `FileTreeViewState` 负责展开、选择、焦点、滚动、loaded/stale parent。 +2. `FileTreeRequestTracker` 负责同一 parent 的 in-flight 去重、request generation 和过期结果丢弃。 +3. `FileTreeRevealCommand` 负责从 active resource / command result 精确展开父链、选中目标、聚焦目标。 + +这三层只管理浏览器 view model,不改变 local-first 文件系统事实源,也不新增第二套树真相。数据真相仍来自 Rust projection / kernel command result。 + +## 2. Sidex / VSCode 对照 + +### 2.1 可借鉴的行为 + +- `AsyncDataTree.refreshNode` 会复用相交 subtree refresh promise,避免同一节点重复 refresh。 +- collapsed node 被刷新时只标记 `stale`,不立即解析 children。 +- `doGetChildren` 对同一 node 的 children promise 去重。 +- 慢请求超过阈值后进入 slow/loading state,完成后清理。 +- Explorer `selectResource` 负责展开 parent chain、reveal、focus、selection,而不是依赖 DOM 模糊匹配。 + +### 2.2 不照搬的内容 + +- 不引入 VSCode workbench service container。 +- 不照搬完整 AsyncDataTree 实现。 +- 不把前端 view state 升级成系统事实源。 +- 不把 PageTree / ResourceTree 强行塞进同一个前端树组件。 + +## 3. MNote 当前缺口 + +### 3.1 状态分散 + +当前 FileTree 展开状态至少分散在: + +- DOM `aria-expanded` / `data-filetree-children-loaded` +- `fileTreeExpandedRelativePaths` +- `sessionStorage` +- `fileTreeState.loadedParents/loadingParents/dirtyParents` +- 后端 projection 的 expanded/default scope + +这会导致 refresh 替换 DOM 后,逻辑展开状态与实际 children DOM 不一致。 + +### 3.2 缺少 request generation + +当前已能用 `loadingParents` 复用 in-flight promise,但缺少 generation: + +- A 请求慢,B 请求快时,A 完成后仍可能 patch 旧 rows。 +- scope/rootUri 切换后,旧请求返回仍可能误写当前 DOM。 +- watcher refresh 与用户展开请求并发时,无法判定谁是最新。 + +### 3.3 collapsed stale 语义不清 + +当前未展开且未 loaded 的 parent 在局部 patch 时直接跳过,但没有显式 `stale` 标记。结果是: + +- 用户展开时不知道是否应该强制拉最新 children。 +- watch/command 只知道 parent dirty,不知道 dirty 是“已展开需后台更新”还是“折叠后下次展开更新”。 + +### 3.4 reveal/focus 仍靠 DOM 匹配 + +resource tab 激活、命令执行后,FileTree 的 active row/reveal 仍依赖 DOM 查询、path includes 或当前可见节点。lazy children 下,目标父链可能还没加载,必须由 tree runtime command 负责展开父链。 + +## 4. 目标 + +- 同一 `rootUri + scope + parentRelativePath` 在任一时刻最多一个 active children 请求。 +- 任一 fetch/render 结果必须带 generation;过期结果不得 patch DOM 或覆盖 cache。 +- collapsed parent 收到 refresh 时只标记 stale,不立即加载 children。 +- expanded parent 收到 refresh 时保留当前 children,后台刷新并 patch。 +- active resource / command result 可通过 canonical row id 或 relative path 精确 reveal。 +- render/patch 后 selection/focus 基于 logical id 复投影,不依赖“当前 DOM 还在”。 + +## 5. 非目标 + +- 不实现虚拟滚动;大目录 viewport virtualization 另行设计。 +- 不重写 Rust projection 协议。 +- 不改变 FileTree 排序、DND、右键命令语义。 +- 不修搜索索引 rebuild 问题;该问题独立记录在 `bugs/04-tree-domain/process/2026-05-27-local-search-query-rebuilds-index.md`。 + +## 6. 设计 + +### 6.1 FileTreeViewState + +建议在 `sidebar-tree-live-apply-runtime.js` 中把 FileTree 状态收敛为: + +```js +const fileTreeViewState = { + rootUri: '', + scope: '', + expandedParents: new Set(), + selectedRowIds: new Set(), + focusedRowId: '', + activeRowId: '', + scrollTop: 0, + loadedParents: new Set(), + staleParents: new Set(), + dirtyParents: new Set(), + rowsByParent: new Map(), + revisionByParent: new Map(), +}; +``` + +约束: + +- key 必须包含 `rootUri`,避免多个 workspace/local root 串状态。 +- `selectedRowIds/focusedRowId` 存 logical row id,不存 DOM element。 +- `expandedParents` 只表达用户视图意图;children 是否已加载由 `loadedParents/staleParents` 表达。 +- scope/rootUri 变化时必须 reset 或 hydrate 对应 namespace 的状态。 + +### 6.2 FileTreeRequestTracker + +建议新增轻量 request tracker: + +```js +const fileTreeRequests = { + generation: 0, + loadingByParent: new Map(), + latestGenerationByParent: new Map(), +}; +``` + +请求流程: + +1. `beginFileTreeRequest(parent)` 递增 generation,并记录 parent 最新 generation。 +2. 如果 parent 已有 in-flight promise,直接返回该 promise。 +3. fetch 结果回来后检查 `isLatestFileTreeRequest(parent, generation)`。 +4. 若不是最新,返回 `{ stale: true }`,不得写 cache,不得 patch DOM。 +5. scope/rootUri 切换时递增全局 generation,旧请求全部自然失效。 + +### 6.3 stale parent 规则 + +- collapsed + dirty:`staleParents.add(parent)`,不拉 children。 +- expanded + dirty:保留旧 rows,后台 refresh parent。 +- expand parent: + - 若 loaded 且不 stale,直接渲染 cache。 + - 若 stale 或未 loaded,进入 loading state 并 fetch children。 +- fetch 失败: + - 不清旧 rows。 + - parent 标记 `stale`。 + - row 展示可恢复错误状态,允许重试。 + +### 6.4 reveal command + +新增内部 command: + +```js +revealFileTreeResource({ + rootUri, + relativePath, + rowId, + select: true, + focus: true, + scroll: true, +}) +``` + +行为: + +1. 校验 rootUri 是否当前 root。 +2. 计算 parent chain,例如 `design/03-rust-web/process`。 +3. 逐层调用 `getFileTreeChildren(parent)`,确保父链已加载。 +4. 渲染每层 children 后设置 expanded。 +5. 使用 rowId 或 relativePath 精确选中目标。 +6. 同步 `selectedRowIds/focusedRowId/activeRowId`。 + +## 7. Checklist + +- [x] 审查 `sidebar-tree-live-apply-runtime.js` 中所有读写展开、选择、focus 的入口,列出迁移点。 +- [x] 新增 `fileTreeViewState`,把 `fileTreeExpandedRelativePaths` 迁入 view-state namespace。 +- [x] 新增 request generation,覆盖 `getFileTreeChildren` 的 children 请求结果写入。 +- [x] 让 scope/rootUri 切换使旧 generation 失效。 +- [x] 把 collapsed + dirty 改成 stale parent,不立即加载 children。 +- [x] render/patch 后按 logical state 复投影 selection/focus。 +- [x] 新增 `revealFileTreeResource` 内部 command,替代 path includes DOM 匹配。 +- [x] 补 JS/Rust 字符串契约测试,检查 generation/stale 关键函数存在。 +- [x] 扩展 `scripts/task494-filetree-lazy-loading-dedup-smoke.js`,覆盖慢请求后切 scope、快速展开/收起、watch refresh 后不折叠。 + +## 8. 验收 + +- 快速连续点击同一目录 5 次,只产生 1 个 in-flight children 请求。 +- 人为延迟第一个 children 请求,再触发 refresh,旧请求返回后不会覆盖新 rows。 +- collapsed parent 收到 watcher/command dirty 后不发 children 请求;下一次展开才请求。 +- active resource tab 能 reveal 未加载的深层 filetree row。 +- 创建/重命名/删除后 selection/focus 不因局部 patch 丢失。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --test-threads=1` 通过。 +- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过。 + +## 9. 风险 + +- 如果把 view-state 写成新的事实源,会和 Rust projection 冲突。必须限定为浏览器视图状态。 +- 如果 generation 只绑定 parent,不绑定 rootUri/scope,仍会出现跨 workspace 旧请求污染。 +- 如果 reveal command 隐式加载过多 parent chain,深层大目录仍可能慢;需要只加载 path chain,不加载 siblings 的 children。 + +## 10. 执行记录 + +- 2026-05-27:在 `sidebar-tree-live-apply-runtime.js` 中新增 `staleParents`、`requestGeneration`、`latestGenerationByParent`、`beginFileTreeRequest(...)`、`isLatestFileTreeRequest(...)`。 +- 2026-05-27:`getFileTreeChildren(...)` 写入 cache/DOM 前检查 generation;scope/rootUri 切换时递增 generation 并清理 latest generation map,使旧请求自然失效。 +- 2026-05-27:`refreshFileTreeParent(...)` 对 collapsed non-root parent 只标记 stale,不立即加载 children;下一次展开绕过旧 cache 拉取最新 children。 +- 2026-05-27:新增 `sidebar_filetree_runtime_discards_stale_generation_results` 契约测试。 +- 2026-05-27:审查 FileTree 展开/selection/focus 入口后,确认 `fileTreeViewState` 已承接 expanded/loaded/stale/dirty/selection/focus/active;`fileTreeExpandedRelativePaths` 保留为 `fileTreeViewState.expandedParents` 的兼容 alias。 +- 2026-05-27:`renderFileProjection(...)`、`patchFileTreeParentChildren(...)` 以及 lazy children render 后按 logical view-state 复投影 selection/focus/active,避免局部 patch 后丢失选中态。 +- 2026-05-27:`revealFileTreeResource(...)` 接入 `selectSidebarFileTreeDocument(...)` 的 DOM miss fallback;active document 变更可按 local markdown documentId 解出 relative path,逐层加载未展开父链并选中目标 row。 +- 2026-05-27:扩展 `task494-filetree-lazy-loading-dedup-smoke.js`,覆盖 watch batch 局部刷新不折叠、未加载深层 resource reveal、opened Markdown rename 后 BufferStore rekey。 +- 验证: + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture`,5 passed。 + - `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,`targetChildrenRequests=1`,`cachedExpandMs=33`。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture --test-threads=1`,10 passed。 + - `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=32`。 diff --git a/design/05-editor-mainline/process/5-30-sidebar-starred-shortcuts-sqlite-scoped-explorer-v1.md b/design/05-editor-mainline/process/5-30-sidebar-starred-shortcuts-sqlite-scoped-explorer-v1.md new file mode 100644 index 00000000..9c739d3c --- /dev/null +++ b/design/05-editor-mainline/process/5-30-sidebar-starred-shortcuts-sqlite-scoped-explorer-v1.md @@ -0,0 +1,242 @@ +# 5-30 Sidebar 星标置顶快捷入口与 Scoped Explorer 设计 v1 + +> 状态:process +> Owner:05-editor-mainline / 03-rust-web / control-plane +> 背景:星标置顶需要对齐 Wolai 的 Sidebar 顶部快速访问体验,但 MNote 还需要支持把高频本地文件夹固定成 Explorer scoped root,避免每次进入大 workspace 时加载完整根目录。 + +## 目标 + +星标置顶不是文件或文件夹自身的属性,而是用户在 Sidebar 固定的一组快捷入口。 + +- 当前页面 `.md` 可以加入星标置顶,点击后直接打开该页面。 +- FileTree 文件夹右键可以加入星标置顶,点击后切到 Explorer,并把该文件夹作为临时 scoped root 显示。 +- 星标置顶按登录用户持久化到 SQLite control-plane;同一用户在不同设备登录时应看到同一组快捷入口。 +- 顶栏“全网公开”不能默认误导显示;没有真实公开分享状态时隐藏。 +- scoped Explorer 必须避免扫描完整大 workspace,例如 `/mnt/Data1T/mnote` 下只打开 `design/` 时只加载 `design/` 子树。 + +## 非目标 + +- 不把星标写入 Markdown frontmatter。 +- 不写入 `.mnote/starred-pins.json` 作为长期真相。 +- 不把文件夹变成 Resource Tree 的业务属性。 +- 不在前端 `localStorage` 中持久化最终星标真相;前端最多做加载态/乐观态缓存。 +- 不在本阶段实现跨设备文件内容同步;本设计只定义用户快捷入口在 control-plane 的一致性。 + +## 用户模型 + +星标置顶等价于“我的 Sidebar 快捷入口”: + +- 页面快捷入口:`kind=page`,目标是某个 workspace 下的页面 identity。 +- 文件夹快捷入口:`kind=folder`,目标是某个 workspace 下的相对路径 scope。 +- 快捷入口属于用户,不属于文件夹,不影响其他用户。 +- 如果某台设备没有对应 workspace 或没有该 folder 相对路径,星标项保留但显示失效态,允许移除或重新绑定。 + +## SQLite 数据模型 + +新增 control-plane 表:`sidebar_shortcuts`。 + +建议字段: + +```sql +CREATE TABLE sidebar_shortcuts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + root_uri TEXT, + kind TEXT NOT NULL, -- page | folder + source_kind TEXT NOT NULL DEFAULT 'local_folder', + target_id TEXT NOT NULL, + relative_path TEXT, + document_id TEXT, + title TEXT NOT NULL, + icon TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(user_id, workspace_id, kind, target_id) +); +``` + +`target_id` 规范: + +- 页面:优先 `document_id`,例如 `local-md:design%2FREADME.md`。 +- 文件夹:`local-dir:` 或稳定的 `local:folder:` 归一化值。 + +`relative_path` 用于 scoped Explorer 与跨设备路径映射,必须保存 workspace 内相对路径,不保存绝对本机路径作为主键。 + +`root_uri` 必须作为显式字段保存和返回,而不是只塞在 `metadata_json` 中。原因: + +- 星标文件夹点击必须直接消费保存时的 workspace root 上下文,不能从 `workspace_id` 字符串反推本机路径。 +- local workspace id 是派生标识,不是路径编码协议;用它反推 `file:///...` 会把 UI fallback 变成事实源。 +- `metadata_json.rootUri` 只作为兼容冗余字段,不能作为唯一来源。 + +长期跨设备映射仍以 `workspace_id + relative_path` 为主;当前设备打开本地 workspace 时必须把实际 `root_uri` 一起写入 shortcut,缺失时应显示失效/需重绑定状态,而不是自动猜测路径。 + +## API / 命令面 + +新增最小 API: + +- `GET /api/sidebar/shortcuts?workspaceId=...` + - 返回当前用户当前 workspace 的星标快捷入口。 +- `POST /api/sidebar/shortcuts` + - upsert 快捷入口。 + - body 包含 `workspaceId/rootUri/kind/sourceKind/targetId/relativePath/documentId/title/icon`。 +- `DELETE /api/sidebar/shortcuts/:id` + - 移除当前用户自己的快捷入口。 + +实现边界: + +- API 只操作 control-plane 用户快捷入口表。 +- API 不写本地文件,不改 Markdown,不改 `.mnote` 元数据。 +- 写操作要求真实登录用户;dev fallback 不应静默创建跨用户快捷入口。 +- 每次写入追加 audit:`sidebar.shortcut.created` / `sidebar.shortcut.removed`。 +- 后续需要实时同步时,通过 `outbox_events` 或现有 WS delta 广播 `sidebar.shortcut.*`。 + +## Projection 与 Sidebar 渲染 + +`WorkspaceShellProjection.starred_items` 需要从 `documents[].is_starred` 迁移为 control-plane `sidebar_shortcuts` 投影。 + +投影项扩展: + +- `id` +- `title` +- `kind: page | folder` +- `href` +- `workspaceId` +- `sourceKind` +- `targetId` +- `relativePath` +- `rootUri` +- `documentId` +- `active` +- `unavailable` + +页面快捷入口: + +- `href` 指向当前页面打开 URL。 +- 点击后走现有 document open 主链。 + +文件夹快捷入口: + +- `href` 可为当前页面 URL 加 query,例如 `?treeView=filetree&filetreeScope=design`。 +- 更推荐 runtime 拦截点击,调用 `openScopedFileTree(relativePath)`,避免不必要的页面跳转。 +- 点击后切换到 Explorer tab,并显示 scoped Explorer。 + +## Scoped Explorer 行为 + +文件夹星标点击后: + +1. Sidebar 切到 Explorer tab。 +2. Explorer 标题显示 scoped 状态,例如 `Explorer / design`。 +3. FileTree root 只渲染该文件夹下的 children。 +4. 子目录继续按需加载。 +5. 提供“返回工作区根”入口;点击后恢复 root Explorer。 + +数据读取: + +- scoped root 为 `relativePath=design` 时,调用现有 `load_local_folder_file_tree_children_snapshot(rootUri, "design")` 或对应 API。 +- 不调用完整 root snapshot。 +- 不预加载 scoped root 之外的兄弟目录。 + +URL / 状态: + +- scoped 状态可以写入 URL query:`treeView=filetree&filetreeScope=design`。 +- 刷新页面后恢复 scoped Explorer。 +- 切回“我的页面”不清除 scope;点击“返回工作区根”才清除 scope。 + +## 顶栏星标与公开状态 + +顶栏星标按钮: + +- 未星标:空心星;tooltip 对齐 Wolai:“点击 加入星标置顶,可在左侧边栏顶部快速访问”。 +- 已星标:高亮星;tooltip “取消星标置顶”。 +- 点击当前页面星标只操作 `sidebar_shortcuts`。 + +顶栏公开状态: + +- `wolai-public-pill` 只有当当前页面存在 active share link / public permission 时显示。 +- 没有公开状态时隐藏,不显示“全网公开”。 +- 公开分享弹窗仍属于 share link / permission 领域,不与星标快捷入口混用。 + +## FileTree 右键菜单 + +文件夹行增加: + +- 未星标:`加入星标置顶` +- 已星标:`取消星标置顶` + +启用条件: + +- `sourceKind == local_folder` +- `rowKind == folder | directory | index` +- 当前用户对 workspace 有至少 read access;写入快捷入口只需要用户偏好写权限,不要求文件系统写权限。 + +不支持项: + +- 普通附件文件暂不加入星标置顶。 +- 多选批量星标暂不做。 + +## 失效与重命名处理 + +MVP 处理: + +- 文件夹被删除:快捷入口保留,显示失效态;点击提示“文件夹不存在”,允许移除。 +- 文件夹重命名:如果重命名由 MNote 发起,可同步更新对应 `relative_path/title/target_id`;如果外部重命名,先显示失效态。 +- 页面被删除:页面快捷入口显示失效态,允许移除。 + +长期可选: + +- 引入 local object stable id 后,星标目标可由 stable object id 跟随重命名。 + +## 验收标准 + +- 无公开分享状态时,顶栏不显示“全网公开”。 +- 当前页面点击星标后,Sidebar 星标置顶出现该页面;再次点击移除。 +- 右键 `design/` 文件夹可加入星标置顶。 +- 点击 `design/` 星标后,切到 Explorer scoped root,只加载并显示 `design/` 下内容,不显示 workspace root 的其他大目录。 +- 刷新页面后,`filetreeScope=design` 能恢复 scoped Explorer。 +- 同一用户重新登录后,星标快捷入口仍存在。 +- 不同用户登录同一 workspace 时,星标列表互不污染。 +- 文件夹不存在时,星标项显示失效态并可移除。 + +## 测试计划 + +Rust/control-plane: + +- migration 创建 `sidebar_shortcuts`。 +- store 可 upsert/list/delete 当前用户快捷入口。 +- unique 约束防止同一用户同一 workspace 重复固定同一目标。 +- 不同用户同一目标互不影响。 + +mnote-web: + +- API 鉴权:只能读写当前用户自己的快捷入口。 +- `WorkspaceShellProjection` 从 shortcuts 投影 starred rows。 +- local folder scoped snapshot 不扫描完整 root。 + +浏览器 smoke: + +- 顶栏公开 pill 隐藏断言。 +- 顶栏星标页面 add/remove。 +- FileTree 文件夹右键 add/remove。 +- 点击星标文件夹进入 scoped Explorer,并断言 root siblings 不渲染。 +- 刷新恢复 scoped Explorer。 + +## 实施顺序 + +1. control-plane migration / model / store:新增 `sidebar_shortcuts`。 +2. mnote-web API:list/upsert/delete shortcuts。 +3. workspace shell projection:星标区改读 shortcuts,并保留历史 `is_starred` fixture 兼容测试。 +4. 顶栏:星标按钮接 API;公开 pill 改为条件显示。 +5. FileTree 右键菜单:文件夹加入/取消星标置顶。 +6. Scoped Explorer runtime:支持 `filetreeScope`,只加载 scoped children,并提供返回 root。 +7. smoke 与 Rust 测试补齐。 + +## 决策记录 + +- 2026-05-26:星标置顶确定为用户级 Sidebar 快捷入口,不是文件夹/页面自身属性。 +- 2026-05-26:星标快捷入口必须写入 SQLite control-plane,满足同一用户跨设备一致显示。 +- 2026-05-26:文件夹星标点击进入 scoped Explorer,避免大 workspace root 扫描。 diff --git a/design/05-editor-mainline/process/5-31-page-settings-sqlite-preference-convergence-v1.md b/design/05-editor-mainline/process/5-31-page-settings-sqlite-preference-convergence-v1.md new file mode 100644 index 00000000..fb236483 --- /dev/null +++ b/design/05-editor-mainline/process/5-31-page-settings-sqlite-preference-convergence-v1.md @@ -0,0 +1,380 @@ +# 5-31 页面设置 SQLite 状态收口设计 v1 + +> 状态:process +> +> Owner:05-editor-mainline / control-plane / mnote-web +> +> 更新时间:2026-05-26 +> +> 背景:`隐藏本地 Markdown 文件标题` 在普通 local folder 中暴露出多状态覆盖问题。进一步复核后,问题不只属于该字段:当前页面设置同时散落在 `.mnote/page-options.json`、Page Aggregate runtime cache、浏览器 `localStorage` 和前端临时状态里,导致同一用户不同浏览器之间不能稳定同步,也让 local folder watcher / stale aggregate 更容易把 UI 状态恢复成旧值。 + +## 1. 目标 + +把页面设置与阅读偏好统一收口到 Rust SQLite control-plane,形成跨浏览器一致的状态来源。 + +- 用户在一个浏览器中修改页面显示设置后,另一个浏览器刷新或重新打开同一用户空间时能读到同一状态。 +- 普通 local folder 不再把页面设置长期写入项目目录下的 `.mnote/page-options.json`。 +- `hideTitleHeader` 不再按单个 Markdown 文件保存,改为按用户和 source family 保存: + - `my_space`:我的空间 / 托管 local workspace + - `external_local_folder`:其它本地文件夹 +- Page Aggregate 继续作为前端读取页面运行态的统一入口,但其 `layout.pageOptions` 应是 SQLite 偏好合并后的有效值,而不是前端本地缓存或 sidecar 文件的直接回声。 +- 前端页面设置 runtime 不再直接管理多个长期状态来源,只负责乐观更新、失败回滚和重新拉取。 + +## 2. 非目标 + +- 本阶段不改变 Markdown 正文真相;本地 `.md` 仍是正文事实源。 +- 不把 UI 偏好写入 Markdown frontmatter。 +- 不用 `.mnote/page-options.json` 作为长期写入目标;它只保留 legacy read fallback。 +- 不把所有字段无脑变成全局开关。SQLite 是存储层,字段仍需按语义选择作用域。 +- 不在本阶段实现多人协作共享页面设置。当前目标是“同一用户跨浏览器同步”。 + +## 3. 当前状态盘点 + +### 3.1 前端页面设置字段 + +`sidebar-page-settings-runtime.js` 当前默认字段: + +| 字段 | 当前用途 | 当前长期状态来源 | 问题 | +| --- | --- | --- | --- | +| `wideLayout` | 主内容列宽 | Page Aggregate / `.mnote/page-options.json` / compat command | local folder 写入项目目录;跨浏览器不稳定 | +| `smallText` | 正文小字体 | Page Aggregate / `.mnote/page-options.json` | 同上 | +| `layoutDensity` | 段落密度 | Page Aggregate / `.mnote/page-options.json` | 同上 | +| `pageFont` | 页面字体 | Page Aggregate runtime 字段 | 当前更像用户阅读偏好,不应是文件属性 | +| `showHeadingNumbers` | 标题编号 | `localStorage` 强制覆盖 | 只能单浏览器生效 | +| `hideTitleHeader` | 本地 Markdown 页头标题显隐 | Page Aggregate / `.mnote/page-options.json` / 前端竞态保护 | 不应按文件保存,容易被 stale aggregate 覆盖 | +| `showToc` | 目录面板 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 | +| `showStructure` | 结构显示 | PageOptions 字段,当前未完整接线 | 语义更像用户视图偏好 | +| `showWordCount` | 字数统计 | PageOptions 字段 | 语义更像用户视图偏好 | +| `collapseBacklinks` | 折叠反链 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 | +| `hideChildPages` | 隐藏子页面 | PageOptions 字段,当前待接线 | 语义可能是用户视图偏好 | +| `showBlockRefCount` | 块引用数字 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 | +| `protectEditing` | 编辑保护 | PageOptions 字段,当前待接线 | 语义可能是页面策略,不应直接归入用户偏好 | +| `embedDefaultBlockId` | 嵌入默认块 | PageOptions 字段 | 语义更像页面/嵌入属性,不是纯 UI 偏好 | + +### 3.2 后端与协议现状 + +- `core-protocol::PageOptions` 当前把页面布局、阅读偏好、编辑策略和嵌入属性都放在一个结构里。 +- `mnote-web` 的 local folder 写链是 `POST /api/documents/options -> update_local_page_options -> .mnote/page-options.json`。 +- local folder aggregate 构建会读取 `.mnote/page-options.json`,没有值时用“托管工作区显示标题、普通本地文件夹隐藏标题”的默认规则。 +- 非 local folder 仍走 `page.layout.updateOptions` compat command。 +- `showHeadingNumbers` 已经被前端单独放进 `localStorage`,说明当前系统事实上承认存在“用户级 UI 偏好”,但没有 SQLite-backed 实现。 + +## 4. 核心判断 + +页面设置需要拆成两类概念: + +1. **用户视图偏好**:同一用户在不同浏览器应该一致,但不应改变文件、页面正文或其他用户视图。 +2. **页面共享属性 / 策略**:属于页面或工作区事实,可能影响所有用户,必须与权限、分享和协作边界一起设计。 + +当前暴露问题的字段基本属于第 1 类。它们应进入 SQLite control-plane 的用户偏好层,而不是继续写 local folder sidecar。 + +## 5. 推荐方案 + +### 5.1 新增 SQLite 用户偏好表 + +新增 control-plane migration,例如 `004-user-ui-preferences.sql`: + +```sql +CREATE TABLE user_ui_preferences ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + source_kind TEXT, + scope_kind TEXT NOT NULL, + scope_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(user_id, workspace_id, source_kind, scope_kind, scope_id, key) +); + +CREATE INDEX idx_user_ui_preferences_lookup + ON user_ui_preferences(user_id, workspace_id, source_kind, scope_kind, scope_id); +``` + +`scope_kind` 建议取值: + +| scope_kind | scope_id 示例 | 用途 | +| --- | --- | --- | +| `global` | `default` | 同用户全局偏好 | +| `source_family` | `my_space` / `external_local_folder` | 同一类数据源的默认显示策略 | +| `workspace` | `ws_xxx` 或 `local:_mnt_Data1T_mnote` | 单工作区偏好 | +| `document` | `local-md:README.md` | 保留能力,MVP 不用于 `hideTitleHeader` | + +### 5.2 字段归属 + +MVP 先迁移当前已接通且影响 UI 的字段: + +| 字段 | SQLite scope | 理由 | +| --- | --- | --- | +| `hideTitleHeader` | `source_family` | 用户明确希望只区分“我的空间”和“其它本地文件夹”,不按文件保存 | +| `showHeadingNumbers` | `global` 或 `workspace` | 当前 localStorage 字段,迁 SQLite 后才能跨浏览器同步 | +| `wideLayout` | `workspace`,可 fallback 到 `global` | 阅读布局偏好,通常用户对某类工作区有稳定习惯 | +| `smallText` | `workspace`,可 fallback 到 `global` | 同上 | +| `layoutDensity` | `workspace`,可 fallback 到 `global` | 同上 | +| `pageFont` | `workspace`,可 fallback 到 `global` | 字体是用户阅读偏好,不是 Markdown 文件属性 | +| `showToc` | `workspace`,可 fallback 到 `global` | 目录面板属于用户视图 | +| `collapseBacklinks` | `workspace`,可 fallback 到 `global` | 反链面板展示偏好 | +| `showWordCount` | `global` | 统计展示偏好 | +| `hideChildPages` | `workspace` | 页面树/子页面展示偏好 | +| `showBlockRefCount` | `workspace` | 块引用计数展示偏好 | + +暂不迁入用户偏好的字段: + +| 字段 | 处理 | +| --- | --- | +| `protectEditing` | 暂停作为可写 UI 偏好;后续进入页面策略 / 权限设计 | +| `embedDefaultBlockId` | 保留在 Page Aggregate / 页面属性合同中;不作为用户偏好 | + +### 5.3 默认值与合并顺序 + +页面打开时,后端合并出 effective options: + +```text +协议默认值 + -> 用户 global 偏好 + -> 用户 source_family 偏好 + -> 用户 workspace 偏好 + -> 用户 document 偏好(MVP 只保留能力,不主动使用) + -> legacy .mnote/page-options.json fallback(只在 SQLite 完全没有对应值时生效) +``` + +关键规则: + +- SQLite 值永远优先于 `.mnote/page-options.json`。 +- `.mnote/page-options.json` 只读,不再由页面设置写链更新。 +- `hideTitleHeader` 的默认值仍保持当前体验: + - `my_space` 默认 `false` + - `external_local_folder` 默认 `true` +- 一旦用户修改 `hideTitleHeader`,写入对应 `source_family` scope,之后所有同类页面使用该值。 + +## 6. API 设计 + +新增最小 API: + +### 6.1 读取有效页面偏好 + +`GET /api/ui/preferences/effective` + +Query: + +- `workspaceId` +- `sourceKind` +- `rootUri` +- `documentId` + +返回: + +```json +{ + "ok": true, + "owner": "mnote-web", + "result": { + "scope": { + "sourceFamily": "external_local_folder", + "workspaceId": "local:_mnt_Data1T_mnote", + "documentId": "local-md:README.md" + }, + "pageOptions": { + "wideLayout": false, + "smallText": false, + "layoutDensity": "normal", + "pageFont": "default", + "showHeadingNumbers": false, + "hideTitleHeader": true + }, + "sources": { + "hideTitleHeader": "source_family", + "showHeadingNumbers": "global" + } + } +} +``` + +### 6.2 写入偏好 + +`PUT /api/ui/preferences` + +Body: + +```json +{ + "workspaceId": "local:_mnt_Data1T_mnote", + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/mnote", + "documentId": "local-md:README.md", + "updates": { + "hideTitleHeader": false, + "layoutDensity": "compact" + } +} +``` + +后端根据字段决定 scope,不信任前端直接指定 scope: + +- `hideTitleHeader` 写入 `source_family` +- `showHeadingNumbers` 写入 `global` +- `wideLayout/smallText/layoutDensity/pageFont/showToc/...` 写入 `workspace` + +返回同一份 effective preferences,前端用返回值刷新当前 shell。 + +## 7. Page Aggregate 合同调整 + +Page Aggregate 继续输出: + +- `layout.pageOptions` +- `layout_options` + +但含义调整为: + +> `layout.pageOptions` 是后端合并后的 effective UI options。 + +为避免后续再混淆,建议追加调试字段: + +```json +"layout": { + "pageOptions": {}, + "pageOptionsSource": { + "wideLayout": "workspace", + "hideTitleHeader": "source_family", + "showHeadingNumbers": "global" + } +} +``` + +协议层可分两步: + +1. MVP:保持 `PageOptions` 结构不变,只改变读取与写入来源。 +2. 后续:把 `PageOptions` 拆为 `effectiveUiOptions` 与 `pagePolicyOptions`,再处理 `protectEditing/embedDefaultBlockId`。 + +## 8. 前端 runtime 调整 + +`sidebar-page-settings-runtime.js` 的长期职责应简化为: + +- 从 `__MNOTE_PAGE_AGGREGATE__` 读取 effective options 初始化 UI。 +- 用户修改设置时调用 `PUT /api/ui/preferences`。 +- 用返回的 effective options 更新当前 shell 和 aggregate script。 +- 不再写 `localStorage` 保存 `showHeadingNumbers`。 +- 不再把 local folder options 写入 `/api/documents/options`。 +- 不再需要 `data-mnote-page-options-local-write-at` 这类 stale aggregate 竞态补丁作为长期机制。 + +`/api/documents/options` 的定位调整: + +- 保留为 cloud/compat 或未来页面策略命令入口。 +- local folder 的 UI 偏好写入不再走这里。 +- 如果 body 中包含 `protectEditing/embedDefaultBlockId` 这类非用户偏好字段,再进入页面策略链路;MVP 可以保持 disabled。 + +## 9. local folder sidecar 兼容策略 + +`.mnote/page-options.json` 保留读兼容: + +- 读取时只作为 fallback。 +- 写入时不再更新。 +- 如果同一字段在 SQLite 已存在,以 SQLite 为准。 +- 不自动删除用户已有 sidecar 文件,避免破坏历史证据。 + +建议后续提供显式迁移工具: + +```bash +node scripts/migrate-local-page-options-to-sqlite.js --root /path/to/root --actor mnote-e2e +``` + +迁移工具只在用户明确执行时运行。 + +## 10. 实施切片 + +### Phase A:SQLite 偏好底座 + +- 新增 `user_ui_preferences` migration。 +- control-plane model/store/sqlite 增加 upsert/list/effective merge 方法。 +- 增加单测覆盖 scope 优先级与 user 隔离。 + +验收: + +- 同一用户同一 key 在不同 scope 下可合并。 +- 不同用户互不影响。 +- migration idempotent。 + +### Phase B:mnote-web API 与 Page Aggregate 合并 + +- 新增 `/api/ui/preferences/effective` 与 `PUT /api/ui/preferences`。 +- local folder aggregate 构建时合并 SQLite preferences。 +- `hideTitleHeader` 默认策略改为 source_family 默认值。 +- `.mnote/page-options.json` 只作为 fallback。 + +验收: + +- 普通 local folder 默认隐藏标题。 +- 我的空间默认显示标题。 +- SQLite 写入后优先于 sidecar。 + +### Phase C:前端页面设置写链迁移 + +- `sidebar-page-settings-runtime.js` 改写设置保存 API。 +- 删除 `showHeadingNumbers` 的 localStorage 长期真相。 +- `persistPageOptionsPatch` 用后端返回 effective options 更新 UI。 +- 保留失败回滚。 + +验收: + +- 一个浏览器修改设置,另一个浏览器刷新后同步。 +- 切换文档、local folder watcher refresh 后不恢复旧状态。 +- `hideTitleHeader` 对同类 source 生效,不按单个文件漂移。 + +### Phase D:兼容与清理 + +- local folder `/api/documents/options` 不再写 `.mnote/page-options.json`,或只保留 legacy endpoint 并标记 deprecated。 +- 移除上一轮针对 per-file sidecar 竞态的长期补丁,只保留必要的 aggregate script 同步事件。 +- 更新 `hermes_tools::page` 中页面 options 写入语义,避免 AI tool 写 UI 偏好到 sidecar。 + +验收: + +- 不再产生新的 `.mnote/page-options.json` 写入。 +- 旧 sidecar 存在时不破坏读取。 +- CodeGraph 可清楚定位 UI preference API、control-plane store、Page Aggregate merge 三个边界。 + +## 11. Smoke / 测试计划 + +新增或更新 smoke: + +| Smoke | 断言 | +| --- | --- | +| `task493-page-settings-sqlite-preferences-smoke.js` | 页面设置写入 SQLite,刷新后保持 | +| `task494-page-settings-cross-browser-sync-smoke.js` | 同一用户两个 browser context 同步页面设置 | +| `task495-local-title-source-family-smoke.js` | `my_space` 与 `external_local_folder` 的 `hideTitleHeader` 独立 | +| `task496-page-options-sidecar-readonly-compat-smoke.js` | 旧 `.mnote/page-options.json` 只读 fallback,不再被写入 | + +Rust 单测: + +- control-plane migration / store merge +- mnote-web preference API route +- local folder aggregate merge preference +- Page Aggregate effective options source 标记 + +当前实施记录(2026-05-26): + +- 已完成 Phase A/B/C 最小闭环:`user_ui_preferences` migration/store、`/api/ui/preferences`、Page Aggregate SQLite 合并、前端页面设置写链迁移。 +- 已完成 Phase D 的旧写链收口:local folder `/api/documents/options` 与 `mnote.page.update_options` 均不再写 `.mnote/page-options.json`。 +- 已新增 `task493-page-settings-sqlite-preferences-smoke.js`,覆盖两个 browser context 的同用户同步、不同用户隔离和不生成 sidecar。 +- 已更新旧 smoke 的页面设置写链断言,从 `/api/documents/options` UI 主链改为 `/api/ui/preferences`;`/api/documents/options` 仅保留兼容入口。 + +## 12. 风险与边界 + +- 如果把所有字段都做成 user preference,未来协作页面的共享设置会缺位。因此必须保留“用户偏好”和“页面策略”两类边界。 +- 如果继续让 Page Aggregate 暴露字段但不标注来源,后续仍可能误以为它们是页面事实源。建议尽快追加 `pageOptionsSource`。 +- 对 local folder 的旧 sidecar 只能读兼容,不能自动删除。删除或批量迁移需要用户显式确认。 +- 当前工作区已有 sidebar shortcuts / control-plane 未提交改动;实施时需要先确认是否基于那些改动继续,避免 migration 编号冲突。 + +## 13. 推荐下一步 + +先做 Phase A + B 的最小闭环: + +1. 在当前 control-plane migration 链上新增 `user_ui_preferences`。 +2. 增加 effective preference merge API。 +3. 只迁移 `hideTitleHeader` 与 `showHeadingNumbers` 两个问题最明确的字段。 +4. 通过跨浏览器 smoke 验证后,再迁移 `wideLayout/smallText/layoutDensity/pageFont`。 + +这样可以先解决“状态恢复原样”和“不同浏览器不同步”两个根问题,同时控制改动范围。 diff --git a/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md b/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md new file mode 100644 index 00000000..8cdd47ec --- /dev/null +++ b/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md @@ -0,0 +1,363 @@ +# 5-32 FileTree 大目录懒加载与展开状态设计 v1 + +> 状态:process +> Owner:05-editor-mainline / 03-rust-web +> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。 + +## 结论 + +当前问题不能只用“加载后恢复展开”解决。正确模型应对齐 VS Code/Sidex Explorer 与 MUI X lazy tree 的三个原则: + +1. 同一个 `rootUri + parentRelativePath` 的 children 请求必须有 in-flight 去重和结果缓存。 +2. refresh/watch/local command 只刷新受影响 parent,不整棵替换已展开子树。 +3. 后端 projection 应一次扫描生成 children、`childrenCount/expandable` 和 revision,避免为了 child_count/watch revision 重复读目录。 + +## 2026-05-27 页面树 / 文件树打开性能纠偏方案 + +### 根因判断 + +本轮纠偏不再把“隐藏页面树”作为性能策略。页面树必须继续可见,且星标 scoped folder 打开后必须用本地 Markdown 页面树投影替换旧“我的空间”页面树。 + +当前卡顿的核心是:`页面树可见` 被实现成 `打开文件树 / 星标文件夹 / 文档前同步完成全量页面树 projection`。即使在 localhost,这也会把全盘 Markdown 扫描、页面树 DOM 替换和 editor mount 串到同一条打开链路上。 + +已确认的高风险点: + +- `gateway.rs` 本地 root 入口已经读取一次 `load_local_folder_page_tree_snapshot(root_uri)`,后续 `render_local_sidebar_tree_html(...)` 仍可能再次触发同一 root 的页面树扫描。 +- `sidebar-tree-runtime.js` 星标文件夹打开必须并行请求 `/api/tree/projections/sidebar` 与 scoped `/api/tree/projections/file`;scoped FileTree 应先可交互,慢页面树只负责替换旧页面树。 +- `sidebar-tree-live-apply-runtime.js` 不应把 `renderSidebarSnapshot(...)` 写成 page tree 与 file tree 同步整块应用;二者需要独立 generation / stale guard。 +- Page Aggregate / editor runtime 已经与 runtime asset 并行,但如果 workspace shell 先同步重建页面树,editor mount 仍会被前置 shell 构建拖慢。 + +### Sidex / Context7 对齐原则 + +- Sidex / VSCode Explorer 使用 `AsyncDataTree` 按需 resolve children,慢 children 进入 slow/loading state,不把整个树同步塞进 DOM。 +- Explorer refresh 按受影响 parent、view state 和 request promise 管理,而不是 root projection 返回后整棵替换。 +- 浏览器侧参考 MDN:用 `PerformanceObserver` 记录 long task / long animation frame;用 `AbortController` 或 request generation 丢弃过期 fetch;避免大段同步 DOM insertion 阻塞主线程。 +- Tokio 侧 `spawn_blocking` 只能避免阻塞 async worker,不能消除全盘扫描成本;本地 projection 仍需要减少触发次数、限并发、缓存和局部化。 + +### 新策略 + +1. 页面树保留,但从打开关键路径降级为“可见的异步 projection”: + - SSR 可以输出已有/轻量页面树壳。 + - scoped filetree 先应用并可点击。 + - page tree projection 完成后只替换 `#sidebar-tree-root`。 + - 旧“我的空间”页面树必须在本地 rootUri 确认后标记 stale/loading,不能继续被当成当前页面树。 + +2. FileTree / PageTree projection 拆开应用: + - `applyFileProjection(...)` 和 `applyPageProjection(...)` 独立 generation。 + - scope/rootUri 切换时旧请求自然失效。 + - 页面树慢请求返回后,不得覆盖新的 scoped filetree state。 + +3. gateway SSR 去重: + - 同一请求内复用已加载的 `page_tree_snapshot`。 + - 禁止 `load_local_folder_page_tree_snapshot` 和 `render_local_sidebar_tree_html` 对同一 root 重复扫描。 + - Page Aggregate/editor mount 不等待 sidebar root 重建。 + +4. 本地 PageTree 进入 lazy / cached projection: + - 短期:按 `rootUri + watchRevision` 做 page tree snapshot cache / stale-while-revalidate。 + - 中期:PageTree 改为顶层 + active/reveal path projection,不再每次全量递归 Markdown。 + +5. 观测与验收必须落到真实浏览器: + - 记录 projection request start/end、filetree interactive time、Page Aggregate TTFB、editor ready、longtask count。 + - localhost 打开目标 Markdown 时,不能因页面树 projection 重建而阻塞 editor surface。 + +### 待执行 Checklist + +- [ ] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。 +- [ ] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`,记录 sidebar/filetree/page aggregate/editor ready 时序和 long task。 +- [ ] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。 +- [ ] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file projection 可以先应用,sidebar projection 后补页面树。 +- [ ] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。 +- [ ] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。 +- [ ] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。 +- [ ] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。 +- [ ] 验证:`node scripts/task492-sidebar-starred-shortcuts-smoke.js`、`node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、新增 task497、相关 Rust filters、`git diff --check`、`codegraph sync/status`。 + +## Context7 参考结论 + +Context7 查询结果: + +- VS Code `DataTree` / `AsyncDataTree` 面向“数据懒发现”的树,例如文件浏览器。数据源用 `hasChildren(element)` 和 `getChildren(element)` 异步解析 children,tree 组件负责并发 refresh 管理和 slow loading 状态。 +- MUI X `RichTreeViewPro` lazy loading 要求 `dataSource.getChildrenCount()` 与 `dataSource.getTreeItems()`,并建议通过 `dataSourceCache` 缓存 children 数据;大数据场景还需要 request throttle,避免用户快速操作时打爆服务端。 + +映射到 MNote: + +- `parentRelativePath` 就是 tree item identity。 +- `child_count/expandable` 就是 `getChildrenCount` 的最小版本。 +- `fileTreeLazyChildrenCache` 不能只是渲染后的临时兜底,应该成为 FileTreeDataSource cache。 +- `refreshLocalFolderSidebarSnapshot()` 不能绕过 cache 直接 `tree.innerHTML = ...`。 + +## CodeGraph / Sidex 对照 + +已用 CodeGraph 索引确认: + +- Sidex 项目索引存在:`reference-code/sidex-main`,约 2904 files / 124438 nodes。 +- MNote 项目索引存在:`/mnt/Data1T/mnote`,约 382 files / 9520 nodes。 + +Sidex 关键链路: + +- `reference-code/sidex-main/src/vs/base/browser/ui/tree/asyncDataTree.ts` + - `refreshNode` 会检查 `subTreeRefreshPromises`,相交 refresh 会复用已有 promise。 + - collapsed node refresh 时只标记 `stale` 并清 children,不立即解析子树。 + - `doGetChildren` 用 `refreshPromises` 对同一 node 的 `getChildren` 去重。 + - 异步 children 超过 800ms 会进入 slow state,由 UI 显示 loading。 +- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/views/explorerView.ts` + - `refresh(recursive, item)` 刷新指定 item,默认不是整棵 DOM 替换。 + - `setTreeInput` 读取/保存 view state,不把展开状态绑在一次 DOM 结果上。 + - `selectResource` 显式展开 parent chain,用于 reveal,而不是预加载整棵树。 +- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/explorerService.ts` + - 文件 create/copy/move 事件刷新 parent item。 + - parent 未 resolved 时先 resolve parent,再 refresh parent。 + +MNote 当前链路: + +- `rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js` + - `renderFileProjection()` 会直接替换 `#sidebar-file-tree-root.innerHTML`,已展开子树 DOM 会丢失。 + - `refreshLocalFolderSidebarSnapshot()` 同时拉 sidebar projection 和 file projection,随后整树渲染。 + - `loadFileTreeChildren()` 只用 row 上的 `data-filetree-children-loading` 做加载中状态;如果 refresh 替换 DOM,loading 标记会丢。 + - `fileTreeLazyChildrenCache` 只在请求成功后写入,缺少 in-flight promise 去重。 +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` + - `load_local_folder_file_tree_scope_snapshot()` 调 `scan_directory()`。 + - `scan_directory()` 读取当前目录后,对每个子目录调用 `has_visible_child()` 再读一次该子目录。 + - 之后又调用 `local_folder_watch_revision_for_directory()` 再读一次当前目录生成 revision。 +- `rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs` + - SSR filetree row 目前缺少 `data-local-relative-path`,JS renderer 有该属性;这导致首屏 SSR 与后续 JS patch 数据属性不一致。 + +## 实测证据 + +在当前 3000 登录态下,直接访问: + +```text +/?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&rootUri=file:///mnt/Data1T/mnote&treeView=filetree&fileTreeScope=design +``` + +结果: + +- SSR 首屏无 `/api/tree/projections/file` 网络请求。 +- `#sidebar-file-tree-root` 显示 `design/` 子项 17 行。 +- `documentElement` 未设置 `data-mnote-filetree-scope`,但 URL 有 `fileTreeScope=design`。 +- 首屏 row 的 `data-local-relative-path` 为空,rowId 为 `local:folder:design/03-rust-web`。 + +展开 `design/03-rust-web`: + +```text +GET /api/tree/projections/file/children?...&parentRelativePath=design/03-rust-web +status=200 +time=14ms +size=3119 bytes +children=done/process/reference +``` + +这说明本机此子目录单次后端请求并不慢。用户感知慢更可能来自: + +- 某些更大的目录触发多次 children 请求或 watch refresh。 +- refresh 整树替换导致已加载 children 丢失,看起来像“加载完又折叠”。 +- 首屏 SSR / JS renderer 属性不一致,导致 lazy loader 依赖 rowId fallback。 +- watcher/local command refresh 与用户展开请求并发,造成同一路径重复请求或请求完成后 DOM 被替换。 + +## 目标 + +- 打开 scoped folder 后只显示该 scope 的直接 children,不加载 workspace root siblings。 +- 展开某个目录时,同一 `rootUri + parentRelativePath` 在 in-flight 期间最多一个网络请求。 +- 请求完成后不因 watch refresh / command refresh 折叠已展开子树。 +- 第一次展开慢目录时显示 loading 状态;第二次展开命中 cache,不发网络请求。 +- 创建/重命名/删除只刷新受影响 parent,不重刷整棵 filetree。 +- SSR 与 JS 渲染输出同一套 row data attributes。 + +## 非目标 + +- 不在本阶段实现虚拟滚动。 +- 不引入 React/MUI TreeView;只吸收 lazy tree 的数据源/cache模型。 +- 不重写 Rust kernel 树协议。 +- 不改变 local-first 文件系统事实源。 + +## 新 FileTreeDataSource 模型 + +在 `sidebar-tree-live-apply-runtime.js` 中收口为浏览器端 FileTreeDataSource: + +```js +const fileTreeState = { + rootUri: '', + scope: '', + rowsByParent: new Map(), // key: parentRelativePath -> rows[] + loadedParents: new Set(), // loaded parentRelativePath + loadingParents: new Map(), // parentRelativePath -> Promise + expandedParents: new Set(), // parentRelativePath + dirtyParents: new Set(), // parentRelativePath + revisionByParent: new Map(), // parentRelativePath -> watchRevision +}; +``` + +Key 规则: + +```js +function fileTreeParentKey(rootUri, parentRelativePath) { + return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim(); +} +``` + +请求规则: + +```js +async function getFileTreeChildren(parentRelativePath) { + const key = fileTreeParentKey(currentRootUri(), parentRelativePath); + if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key)) { + return fileTreeState.rowsByParent.get(key) || []; + } + if (fileTreeState.loadingParents.has(key)) { + return fileTreeState.loadingParents.get(key); + } + const promise = fetchFileTreeChildren(parentRelativePath) + .then(rows => { + fileTreeState.rowsByParent.set(key, rows); + fileTreeState.loadedParents.add(key); + fileTreeState.dirtyParents.delete(key); + return rows; + }) + .finally(() => fileTreeState.loadingParents.delete(key)); + fileTreeState.loadingParents.set(key, promise); + return promise; +} +``` + +渲染规则: + +- `renderFileProjection()` 不再无条件替换整棵 `innerHTML`。 +- 新 projection 只更新当前 scope/root parent 的 `rowsByParent`。 +- 对每个 expanded parent,渲染时优先使用 `rowsByParent` 已缓存 children。 +- 如果 parent 被标记 dirty,保留展开 UI 和旧 children,后台刷新该 parent;刷新完成后 patch 该 parent children。 +- collapsed parent 不加载 children,只保留 `expandable`。 + +## 后端 projection 优化 + +当前后端一次 children projection 至少可能读: + +1. 当前目录 `read_sorted_entries()`。 +2. 每个子目录一次 `has_visible_child()`。 +3. 当前目录一次 `local_folder_watch_revision_for_directory()`。 + +改为单 pass helper: + +```rust +struct LocalFolderScanResult { + rows: Vec, + watch_revision: LocalFolderWatchRevision, +} + +fn scan_directory_shallow_with_revision( + root: &Path, + directory: &Path, + parent_node_id: Option, + depth: u32, + root_source_uri: &str, + workspace_id: &str, + metadata: &LocalFolderMetadata, +) -> Result +``` + +要求: + +- 只读取当前目录一次。 +- 对子目录只做 `has_visible_child_fast()`,且最多读到第一个可见 child 后停止。 +- 生成 `watchRevision` 时复用当前目录 entries,不再二次 `read_sorted_entries()`。 +- 返回 projection 时明确 `parentRelativePath`、`watchRevision.entryCount`、`items.length`。 + +## Watch / refresh 策略 + +替代当前“watch root 变更后刷新 sidebar + filetree root/scope”的粗刷新: + +- `local-folder-watch` 仍可作为 fallback,但 filetree 侧只标记 dirty,不立即整树替换。 +- 如果当前 scope root 变更,刷新 scope parent。 +- 如果已展开 parent 变更,刷新对应 parent。 +- 如果无法定位 parent,刷新当前 scope parent,但保留 expanded parent cache。 +- local command 成功后用 command result 的 `parentRelativePath` 定位刷新 parent;没有 parent 信息时才降级刷新 scope parent。 + +## 测试与验收 + +### Rust 单测 + +- `local_folder_file_tree_children_snapshot_scans_parent_once` + - 构造含多个目录的 temp root。 + - 调用 `load_local_folder_file_tree_children_snapshot(rootUri, "design")`。 + - 断言 `projection.parentRelativePath == "design"`。 + - 断言 `items` 只包含直接 children。 + - 断言 `watchRevision.entryCount == items.len()`。 +- `filetree_ssr_rows_include_local_relative_path` + - 构造 `FileTreeRenderRow`。 + - 断言 HTML 包含 `data-local-relative-path="design/03-rust-web"`。 + +### JS runtime 字符串/契约测试 + +在 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中增加断言: + +- 包含 `loadingParents` / `loadedParents` / `rowsByParent`。 +- `loadFileTreeChildren` 在 fetch 前检查 `loadingParents.has(key)`。 +- `renderFileProjection` 不包含无条件 `tree.innerHTML =`。 +- `refreshLocalFolderSidebarSnapshot` 不直接绕过 FileTreeDataSource。 + +### 浏览器 smoke + +新增脚本: + +```text +scripts/task494-filetree-lazy-loading-dedup-smoke.js +``` + +断言: + +- 登录测试账号。 +- 访问 `fileTreeScope=design`。 +- 展开 `design/03-rust-web`。 +- 记录 `/api/tree/projections/file/children?...parentRelativePath=design/03-rust-web` 请求数为 1。 +- 收起再展开同一目录,children 请求数仍为 1。 +- 触发一次 `tree:resync` 或等待 watcher tick 后,该 row 仍 `aria-expanded=true`,children 仍存在。 +- 第二次展开耗时低于 50ms 或无网络请求。 +- 从星标 scoped 文件夹打开已可见 Markdown 时,必须走 pane 内导航;页面 JS marker 不丢失,且不重新请求 scope 根 projection。 +- scoped 模式收到 root snapshot 或 coarse local-folder-watch revision 时,不得兜底重拉 scope 根 projection。 +- 普通 Markdown mount 时,不得为了 legacy office 附件兼容重拉 workspace root projection。 + +### 2026-05-27 scoped 文件打开补充验收 + +- RED:`task494-filetree-lazy-loading-dedup-smoke.js` 在 scoped `design` 文件树点击 `design/Overview.md` 后失败于 JS marker 被清空,确认当前实现发生整页 reload。 +- RED:同一 smoke 继续失败于 root live snapshot / coarse local-folder-watch revision 触发 scope 根 projection。 +- GREEN:`navigateToDocument(...)` 允许 `fileTreeScope` 场景继续使用 `openPrimaryDocument(...)`;scoped root snapshot 改为忽略非 scope projection;fallback polling 只标记 scoped stale;legacy office 兼容先检查候选段落再取索引。 +- 真实路径验证:进入 `fileTreeScope=design`,展开 `design/05-editor-mainline` 与 `design/05-editor-mainline/process`,打开 `5-32-filetree-lazy-loading-sidex-alignment-v1.md` 后页面 marker 保留,`scopeRootRequests=0`、`workspaceRootRequests=0`、`childrenRequests=2`。 + +### 性能目标 + +- localhost 下 `design/03-rust-web` children 请求保持单次 `<100ms`。 +- 1000 个 direct children 的目录 projection 目标 `<300ms`,不得因每个子目录重复深扫导致线性倍增到秒级。 +- 同一路径 5 次快速点击最多 1 个 in-flight 请求。 + +## 实施 Checklist + +- [x] 修改 `FileTreeRenderRow` 增加 `relative_path` 字段,并在 SSR renderer 输出 `data-local-relative-path`。 +- [x] `collect_filetree_render_rows` 从 projection item 读取 `relativePath`。 +- [x] 把 `fileTreeLazyChildrenCache` 升级为 `fileTreeState.rowsByParent/loadingParents/loadedParents/dirtyParents`。 +- [x] `loadFileTreeChildren` 改为 `getFileTreeChildren(parentRelativePath)`,复用 in-flight promise。 +- [x] `renderFileProjection` 改为更新 datasource state,再按 visible state patch DOM,不整树替换 expanded subtree。 +- [x] `refreshLocalFolderSidebarSnapshot` 只刷新当前 scope parent,且不清除 expanded children。 +- [x] local command 成功后按 parent 刷新;无 parent 才刷新 scope。 +- [x] 后端新增 `scan_directory_shallow_with_revision`,减少 children projection 重复读目录。 +- [x] 新增 Rust 单测和 browser smoke。 +- [x] 重跑 `cargo test -p mnote-web --manifest-path rust/Cargo.toml`、`cargo test -p control-plane --manifest-path rust/Cargo.toml`、`cargo fmt --check --all --manifest-path rust/Cargo.toml`。 +- [x] 跑 `codegraph sync .` 并确认 status;已有无关 pending 只记录不清理。 + +## 验证记录 + +- 2026-05-26:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,收起后再次展开无 children 重复请求,cache 展开耗时 `33ms`。 +- 2026-05-26:`cargo test -p mnote-web --manifest-path rust/Cargo.toml` 通过,`567 passed`。 +- 2026-05-26:`cargo test -p control-plane --manifest-path rust/Cargo.toml` 通过,`23 passed`。 +- 2026-05-26:`cargo fmt --check --all --manifest-path rust/Cargo.toml` 通过。 +- 2026-05-26:`codegraph sync .` 已执行;`codegraph status .` 仍报告 `Pending Changes: Added: 5 files`,属于当前工作树既有未提交/新增文件状态,本轮不清理。 + +## 与 5-30 的关系 + +`5-30` 解决“星标 shortcut 是什么、保存在哪里、点击进入哪个 scope”。本设计解决“进入 scope 后 FileTree 如何懒加载、缓存、刷新、避免重复请求”。 + +`5-30` 中的 `rootUri` 必须成为 shortcut 显式字段;缺失时星标文件夹应失效提示,不允许从 `workspaceId` 反推路径。 + +## 决策记录 + +- 2026-05-26:`展开恢复`只能作为兼容保护,不是性能修复。 +- 2026-05-26:FileTree 懒加载主模型按 DataSource/cache/in-flight 去重设计,不按 DOM 替换后再恢复。 +- 2026-05-26:后端 children projection 需要单 pass shallow scan,并把 watch revision 与 rows 同时产出。 diff --git a/rust/crates/control-plane/migrations/003-sidebar-shortcuts.sql b/rust/crates/control-plane/migrations/003-sidebar-shortcuts.sql new file mode 100644 index 00000000..d6f7a27b --- /dev/null +++ b/rust/crates/control-plane/migrations/003-sidebar-shortcuts.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS sidebar_shortcuts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL, + kind TEXT NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'local_folder', + target_id TEXT NOT NULL, + relative_path TEXT, + document_id TEXT, + title TEXT NOT NULL, + icon TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(user_id, workspace_id, kind, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_sidebar_shortcuts_user_workspace + ON sidebar_shortcuts(user_id, workspace_id, status, sort_order); diff --git a/rust/crates/control-plane/migrations/004-user-ui-preferences.sql b/rust/crates/control-plane/migrations/004-user-ui-preferences.sql new file mode 100644 index 00000000..a846afc4 --- /dev/null +++ b/rust/crates/control-plane/migrations/004-user-ui-preferences.sql @@ -0,0 +1,21 @@ +-- 004-user-ui-preferences.sql +-- User-scoped UI preferences for cross-browser page setting sync. + +CREATE TABLE IF NOT EXISTS user_ui_preferences ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL DEFAULT '', + source_kind TEXT NOT NULL DEFAULT '', + scope_kind TEXT NOT NULL, + scope_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(user_id, workspace_id, source_kind, scope_kind, scope_id, key) +); + +CREATE INDEX IF NOT EXISTS idx_user_ui_preferences_lookup + ON user_ui_preferences(user_id, workspace_id, source_kind, scope_kind, scope_id); diff --git a/rust/crates/control-plane/migrations/005-sidebar-shortcut-root-uri.sql b/rust/crates/control-plane/migrations/005-sidebar-shortcut-root-uri.sql new file mode 100644 index 00000000..f54e96ff --- /dev/null +++ b/rust/crates/control-plane/migrations/005-sidebar-shortcut-root-uri.sql @@ -0,0 +1,8 @@ +ALTER TABLE sidebar_shortcuts ADD COLUMN root_uri TEXT; + +UPDATE sidebar_shortcuts +SET root_uri = COALESCE( + NULLIF(json_extract(metadata_json, '$.rootUri'), ''), + NULLIF(json_extract(metadata_json, '$.root_uri'), '') +) +WHERE root_uri IS NULL OR root_uri = ''; diff --git a/rust/crates/control-plane/src/migrations.rs b/rust/crates/control-plane/src/migrations.rs index ee2f42ed..8fa347c2 100644 --- a/rust/crates/control-plane/src/migrations.rs +++ b/rust/crates/control-plane/src/migrations.rs @@ -14,6 +14,18 @@ const MIGRATIONS: &[(&str, &str)] = &[ "v2-ai-runtime-store", include_str!("../migrations/002-ai-runtime-store.sql"), ), + ( + "v3-sidebar-shortcuts", + include_str!("../migrations/003-sidebar-shortcuts.sql"), + ), + ( + "v4-user-ui-preferences", + include_str!("../migrations/004-user-ui-preferences.sql"), + ), + ( + "v5-sidebar-shortcut-root-uri", + include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"), + ), ]; /// Create the `_migrations` meta-table if it does not exist. @@ -94,6 +106,8 @@ mod tests { "legacy_id_map", "ai_runtime_runs", "ai_runtime_events", + "sidebar_shortcuts", + "user_ui_preferences", ] { assert!(table_exists(&conn, table), "table {table} should exist"); } diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs index f9a04f42..04978d8f 100644 --- a/rust/crates/control-plane/src/model.rs +++ b/rust/crates/control-plane/src/model.rs @@ -177,6 +177,72 @@ pub struct OutboxEventInput { pub payload_json: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SidebarShortcutRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: EntityId, + pub root_uri: Option, + pub kind: String, + pub source_kind: String, + pub target_id: String, + pub relative_path: Option, + pub document_id: Option, + pub title: String, + pub icon: Option, + pub sort_order: i64, + pub status: String, + pub metadata_json: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertSidebarShortcutInput { + pub id: Option, + pub user_id: EntityId, + pub workspace_id: EntityId, + pub root_uri: Option, + pub kind: String, + pub source_kind: String, + pub target_id: String, + pub relative_path: Option, + pub document_id: Option, + pub title: String, + pub icon: Option, + pub sort_order: i64, + pub metadata_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserUiPreferenceRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: Option, + pub source_kind: Option, + pub scope_kind: String, + pub scope_id: String, + pub key: String, + pub value_json: String, + pub status: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertUserUiPreferenceInput { + pub id: Option, + pub user_id: EntityId, + pub workspace_id: Option, + pub source_kind: Option, + pub scope_kind: String, + pub scope_id: String, + pub key: String, + pub value_json: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AiPolicyRecord { pub id: EntityId, diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index a79ea865..71db5278 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -12,8 +12,9 @@ use crate::model::{ AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, - ShareLinkRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, - UpsertSyncStateInput, UpsertUserInput, UserRecord, WorkspaceRecord, + ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, + UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, }; use crate::store::ControlPlaneStore; @@ -175,6 +176,60 @@ fn row_to_audit_log(row: &rusqlite::Row<'_>) -> rusqlite::Result }) } +fn row_to_sidebar_shortcut(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SidebarShortcutRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + root_uri: row.get(3)?, + kind: row.get(4)?, + source_kind: row.get(5)?, + target_id: row.get(6)?, + relative_path: row.get(7)?, + document_id: row.get(8)?, + title: row.get(9)?, + icon: row.get(10)?, + sort_order: row.get(11)?, + status: row.get(12)?, + metadata_json: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + revision: row.get(16)?, + }) +} + +fn empty_string_to_option(value: String) -> Option { + if value.trim().is_empty() { + None + } else { + Some(value) + } +} + +fn option_to_stored_text(value: Option) -> String { + value + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .unwrap_or_default() +} + +fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UserUiPreferenceRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: empty_string_to_option(row.get(2)?), + source_kind: empty_string_to_option(row.get(3)?), + scope_kind: row.get(4)?, + scope_id: row.get(5)?, + key: row.get(6)?, + value_json: row.get(7)?, + status: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + revision: row.get(11)?, + }) +} + fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(AiPolicyRecord { id: row.get(0)?, @@ -1065,6 +1120,268 @@ impl ControlPlaneStore for SqliteControlPlaneStore { Ok(()) } + fn upsert_sidebar_shortcut( + &self, + input: UpsertSidebarShortcutInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = input.workspace_id.trim().to_string(); + let root_uri = input + .root_uri + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let kind = input.kind.trim().to_string(); + let source_kind = input.source_kind.trim().to_string(); + let target_id = input.target_id.trim().to_string(); + let title = input.title.trim().to_string(); + if user_id.is_empty() + || workspace_id.is_empty() + || kind.is_empty() + || source_kind.is_empty() + || target_id.is_empty() + || title.is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "sidebar shortcut user/workspace/kind/target/title 不能为空".to_string(), + )); + } + if kind != "page" && kind != "folder" { + return Err(ControlPlaneError::InvalidInput( + "sidebar shortcut kind 只能是 page 或 folder".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let now = now_text(); + conn.execute( + "INSERT INTO sidebar_shortcuts ( + id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path, + document_id, title, icon, sort_order, status, metadata_json, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'active', ?13, ?14, ?15, 1) + ON CONFLICT(user_id, workspace_id, kind, target_id) + DO UPDATE SET + root_uri = excluded.root_uri, + relative_path = excluded.relative_path, + document_id = excluded.document_id, + title = excluded.title, + icon = excluded.icon, + sort_order = excluded.sort_order, + status = 'active', + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at, + revision = sidebar_shortcuts.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("shortcut")), + user_id, + workspace_id, + root_uri, + kind, + source_kind, + target_id, + input.relative_path, + input.document_id, + title, + input.icon, + input.sort_order, + input.metadata_json, + now, + now + ], + )?; + let record = conn.query_row( + "SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path, + document_id, title, icon, sort_order, status, metadata_json, created_at, + updated_at, revision + FROM sidebar_shortcuts + WHERE user_id = ?1 AND workspace_id = ?2 AND kind = ?3 AND target_id = ?4 + LIMIT 1", + params![user_id, workspace_id, kind, target_id], + row_to_sidebar_shortcut, + )?; + Ok(record) + } + + fn list_sidebar_shortcuts( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let workspace_id = workspace_id.trim(); + if user_id.is_empty() || workspace_id.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path, + document_id, title, icon, sort_order, status, metadata_json, created_at, + updated_at, revision + FROM sidebar_shortcuts + WHERE user_id = ?1 AND workspace_id = ?2 AND status = 'active' + ORDER BY sort_order ASC, created_at ASC", + )?; + let rows = stmt + .query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn list_sidebar_shortcuts_with_global_local( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let workspace_id = workspace_id.trim(); + if user_id.is_empty() || workspace_id.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path, + document_id, title, icon, sort_order, status, metadata_json, created_at, + updated_at, revision + FROM sidebar_shortcuts + WHERE user_id = ?1 + AND status = 'active' + AND (workspace_id = ?2 OR source_kind = 'local_folder') + ORDER BY sort_order ASC, created_at ASC", + )?; + let rows = stmt + .query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn delete_sidebar_shortcut( + &self, + user_id: &str, + shortcut_id: &str, + ) -> Result<(), ControlPlaneError> { + let user_id = user_id.trim(); + let shortcut_id = shortcut_id.trim(); + if user_id.is_empty() || shortcut_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "sidebar shortcut user_id/shortcut_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let changed = conn.execute( + "UPDATE sidebar_shortcuts + SET status = 'removed', updated_at = ?1, revision = revision + 1 + WHERE id = ?2 AND user_id = ?3 AND status = 'active'", + params![now_text(), shortcut_id, user_id], + )?; + if changed == 0 { + return Err(ControlPlaneError::NotFound(format!( + "sidebar shortcut not found: {shortcut_id}" + ))); + } + Ok(()) + } + + fn upsert_user_ui_preference( + &self, + input: UpsertUserUiPreferenceInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = option_to_stored_text(input.workspace_id); + let source_kind = option_to_stored_text(input.source_kind); + let scope_kind = input.scope_kind.trim().to_string(); + let scope_id = input.scope_id.trim().to_string(); + let key = input.key.trim().to_string(); + if user_id.is_empty() || scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "user UI preference user/scope/key 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.value_json)?; + let conn = self.conn.lock().unwrap(); + let now = now_text(); + conn.execute( + "INSERT INTO user_ui_preferences ( + id, user_id, workspace_id, source_kind, scope_kind, scope_id, key, + value_json, status, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, 1) + ON CONFLICT(user_id, workspace_id, source_kind, scope_kind, scope_id, key) + DO UPDATE SET + value_json = excluded.value_json, + status = 'active', + updated_at = excluded.updated_at, + revision = user_ui_preferences.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("ui_pref")), + user_id, + workspace_id, + source_kind, + scope_kind, + scope_id, + key, + input.value_json, + now, + now + ], + )?; + let record = conn.query_row( + "SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key, + value_json, status, created_at, updated_at, revision + FROM user_ui_preferences + WHERE user_id = ?1 + AND workspace_id = ?2 + AND source_kind = ?3 + AND scope_kind = ?4 + AND scope_id = ?5 + AND key = ?6 + LIMIT 1", + params![ + user_id, + workspace_id, + source_kind, + scope_kind, + scope_id, + key + ], + row_to_user_ui_preference, + )?; + Ok(record) + } + + fn list_user_ui_preferences( + &self, + user_id: &str, + workspace_id: Option<&str>, + source_kind: Option<&str>, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(Vec::new()); + } + let workspace_id = workspace_id.map(str::trim).unwrap_or_default(); + let source_kind = source_kind.map(str::trim).unwrap_or_default(); + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key, + value_json, status, created_at, updated_at, revision + FROM user_ui_preferences + WHERE user_id = ?1 + AND status = 'active' + AND (workspace_id = '' OR workspace_id = ?2) + AND (source_kind = '' OR source_kind = ?3) + ORDER BY created_at ASC", + )?; + let rows = stmt + .query_map( + params![user_id, workspace_id, source_kind], + row_to_user_ui_preference, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + fn upsert_ai_policy( &self, input: UpsertAiPolicyInput, @@ -1668,7 +1985,7 @@ mod tests { use crate::model::{ password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, - UpsertSyncStateInput, + UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput, }; fn store() -> SqliteControlPlaneStore { @@ -1960,6 +2277,133 @@ mod tests { assert_eq!(pending[0].id, second.id); } + #[test] + fn sidebar_shortcuts_are_user_scoped_and_upserted_by_target() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + let workspace = store.ensure_default_workspace("alice").expect("workspace"); + + let first = store + .upsert_sidebar_shortcut(UpsertSidebarShortcutInput { + id: None, + user_id: "alice".to_string(), + workspace_id: workspace.id.clone(), + root_uri: Some("file:///tmp/mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + target_id: "local-dir:design".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "design".to_string(), + icon: Some("folder_open".to_string()), + sort_order: 0, + metadata_json: "{}".to_string(), + }) + .expect("insert shortcut"); + + let updated = store + .upsert_sidebar_shortcut(UpsertSidebarShortcutInput { + id: None, + user_id: "alice".to_string(), + workspace_id: workspace.id.clone(), + root_uri: Some("file:///tmp/mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + target_id: "local-dir:design".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "Design".to_string(), + icon: Some("folder_open".to_string()), + sort_order: 4, + metadata_json: "{\"scope\":\"filetree\"}".to_string(), + }) + .expect("upsert shortcut"); + + assert_eq!(updated.id, first.id); + assert_eq!(updated.title, "Design"); + assert_eq!(updated.root_uri.as_deref(), Some("file:///tmp/mnote")); + assert_eq!(updated.sort_order, 4); + assert_eq!(updated.revision, first.revision + 1); + + let alice_shortcuts = store + .list_sidebar_shortcuts("alice", &workspace.id) + .expect("list alice shortcuts"); + assert_eq!(alice_shortcuts.len(), 1); + assert_eq!(alice_shortcuts[0].target_id, "local-dir:design"); + let alice_global_local_shortcuts = store + .list_sidebar_shortcuts_with_global_local("alice", "cloud-workspace") + .expect("list alice global local shortcuts"); + assert_eq!(alice_global_local_shortcuts.len(), 1); + assert_eq!(alice_global_local_shortcuts[0].workspace_id, workspace.id); + + let bob_shortcuts = store + .list_sidebar_shortcuts("bob", &workspace.id) + .expect("list bob shortcuts"); + assert!(bob_shortcuts.is_empty()); + + store + .delete_sidebar_shortcut("alice", &updated.id) + .expect("delete shortcut"); + let after_delete = store + .list_sidebar_shortcuts("alice", &workspace.id) + .expect("list deleted shortcuts"); + assert!(after_delete.is_empty()); + } + + #[test] + fn user_ui_preferences_are_scoped_upserted_and_user_isolated() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + + let first = store + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_mnt_Data1T_mnote".to_string()), + source_kind: Some("local_folder".to_string()), + scope_kind: "source_family".to_string(), + scope_id: "external_local_folder".to_string(), + key: "hideTitleHeader".to_string(), + value_json: "false".to_string(), + }) + .expect("insert preference"); + + let updated = store + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_mnt_Data1T_mnote".to_string()), + source_kind: Some("local_folder".to_string()), + scope_kind: "source_family".to_string(), + scope_id: "external_local_folder".to_string(), + key: "hideTitleHeader".to_string(), + value_json: "true".to_string(), + }) + .expect("upsert preference"); + + assert_eq!(updated.id, first.id); + assert_eq!(updated.value_json, "true"); + assert_eq!(updated.revision, first.revision + 1); + + let alice_preferences = store + .list_user_ui_preferences( + "alice", + Some("local:_mnt_Data1T_mnote"), + Some("local_folder"), + ) + .expect("list alice preferences"); + assert_eq!(alice_preferences.len(), 1); + assert_eq!(alice_preferences[0].scope_kind, "source_family"); + assert_eq!(alice_preferences[0].scope_id, "external_local_folder"); + + let bob_preferences = store + .list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder")) + .expect("list bob preferences"); + assert!(bob_preferences.is_empty()); + } + #[test] fn ai_policy_upsert_returns_workspace_policy_before_user_policy() { let store = store(); diff --git a/rust/crates/control-plane/src/store.rs b/rust/crates/control-plane/src/store.rs index d1a4220b..36f9f849 100644 --- a/rust/crates/control-plane/src/store.rs +++ b/rust/crates/control-plane/src/store.rs @@ -6,9 +6,10 @@ use crate::model::{ AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput, - OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SyncStateRecord, - UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSyncStateInput, UpsertUserInput, - UserRecord, WorkspaceRecord, + OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, + SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, + UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord, + UserUiPreferenceRecord, WorkspaceRecord, }; pub trait ControlPlaneStore: Send + Sync { @@ -100,6 +101,41 @@ pub trait ControlPlaneStore: Send + Sync { fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError>; + fn upsert_sidebar_shortcut( + &self, + input: UpsertSidebarShortcutInput, + ) -> Result; + + fn list_sidebar_shortcuts( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError>; + + fn list_sidebar_shortcuts_with_global_local( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError>; + + fn delete_sidebar_shortcut( + &self, + user_id: &str, + shortcut_id: &str, + ) -> Result<(), ControlPlaneError>; + + fn upsert_user_ui_preference( + &self, + input: UpsertUserUiPreferenceInput, + ) -> Result; + + fn list_user_ui_preferences( + &self, + user_id: &str, + workspace_id: Option<&str>, + source_kind: Option<&str>, + ) -> Result, ControlPlaneError>; + fn upsert_ai_policy( &self, input: UpsertAiPolicyInput, diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index e6d88f92..bf5e0e6f 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -66,7 +66,7 @@ import { return window.__mnoteLeptosTiptapRuntimePromise; } window.__mnoteLeptosTiptapRuntimePromise = (async () => { - const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' }); + const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json'); if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`); const manifest = await manifestResponse.json(); if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath'); @@ -368,16 +368,19 @@ import { const replacePaneDocument = async (paneRole, descriptor, options = {}) => { mindmapHost.unmountMindmapPane(paneRole); - const runtime = await loadRuntime(); const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`); if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`); + const runtimePromise = loadRuntime(); + const aggregatePromise = options.aggregate + ? Promise.resolve(options.aggregate) + : fetchPageAggregateForPane(descriptor); const previousView = paneViewRegistry.get(paneRole); + const [runtime, aggregate] = await Promise.all([runtimePromise, aggregatePromise]); if (previousView) { unmountEditorViewBinding(previousView); paneViewRegistry.delete(paneRole); } - const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor); const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole); syncPageAggregateScript({ pageAggregateScriptId: bootstrap.pageAggregateScriptId }, aggregate); const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap }; diff --git a/rust/crates/mnote-web/browser/filetree-runtime.js b/rust/crates/mnote-web/browser/filetree-runtime.js index d66274d9..59f01f6e 100644 --- a/rust/crates/mnote-web/browser/filetree-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-runtime.js @@ -24,7 +24,7 @@ function fileTreeRowAssetId(row, deps) { } function decodeLocalEncodedPath(value) { - var path = String(value || '').trim().replace(/~2F/g, '/'); + var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1'); if (!path) return ''; try { return decodeURIComponent(path); diff --git a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js index cd0901cb..4cbb6614 100644 --- a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js @@ -5,6 +5,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { buildLocalOnlyOfficeOpenUrl, buildOnlyOfficeOpenPath, buildOnlyOfficeOpenUrl, + closestAction: injectedClosestAction, currentDocumentId, currentRootUri, currentWorkspaceSourcePayload, @@ -23,6 +24,11 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { uploadedFileSize, } = dependencies; + const closestAction = typeof injectedClosestAction === 'function' ? injectedClosestAction : function(target, selector) { + var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target; + return node && typeof node.closest === 'function' ? node.closest(selector) : null; + }; + var activeEditorAttachmentLink = null; var attachmentActionsHideTimer = 0; diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index f4b48718..1df3ac66 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -5,6 +5,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { createPage, cssEscape, currentDocumentId, + currentRootUri, currentSourceKind, deleteSingleFileTreeAsset, dispatchSidebarEvent, @@ -23,6 +24,8 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { openEditorAttachmentEditTab, openEditorAttachmentNewWindow, refreshLocalFolderSidebarSnapshot, + removeFileTreeAssetRow, + revealFileTreeResource, resolveWorkspaceId, runtimeState, selectedSidebarFileTreeSelection, @@ -30,6 +33,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { validateFileTreeRename, } = dependencies; const sidebarFileTreeSelection = selectedSidebarFileTreeSelection; + var fileTreeOperationBatchCounter = 0; + + function nextFileTreeOperationBatchId(action) { + fileTreeOperationBatchCounter += 1; + return 'filetree-' + String(action || 'operation') + '-' + Date.now() + '-' + fileTreeOperationBatchCounter; + } function rowTitle(row) { var title = row ? row.querySelector(':scope > .tree-link > .tree-link-title') : null; @@ -59,8 +68,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var rowKind = row.getAttribute('data-row-kind') || ''; var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || ''; var assetId = row.getAttribute('data-asset-id') || ''; - if (!documentId && !assetId) return false; - if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId) return false; + var rowId = row.getAttribute('data-row-id') || ''; + var localFolderSource = currentSourceKind() === 'local_folder'; + var localFolderDirectory = localFolderSource && (rowKind === 'folder' || rowKind === 'directory'); + var commandTargetId = documentId || (localFolderSource ? (assetId || rowId) : ''); + if (!commandTargetId && !assetId) return false; + if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId && !localFolderDirectory) return false; var link = row.querySelector(':scope > .tree-link'); var title = rowTitle(row); if (!(link instanceof HTMLElement)) return false; @@ -68,7 +81,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var input = document.createElement('input'); input.type = 'text'; input.className = 'tree-rename-input'; - input.setAttribute('data-rename-id', row.getAttribute('data-row-id') || ''); + input.setAttribute('data-rename-id', rowId); input.value = title; input.style.minWidth = '0'; input.style.flex = '1 1 auto'; @@ -117,7 +130,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var commandTitle = isFileTreePageRow(row) ? normalizeFileTreePageRenameTitle(nextTitle) : nextTitle; committing = true; input.disabled = true; - var work = assetId + var work = assetId && !localFolderSource ? Promise.resolve().then(function(){ document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-asset-id="' + cssEscape(assetId) + '"] .tree-link-title').forEach(function(titleNode) { titleNode.textContent = commandTitle; @@ -128,9 +141,11 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { : dispatchTreeCommand(row, { action: 'rename', workspaceId: resolveWorkspaceId(row), - documentId: documentId, + documentId: commandTargetId, title: commandTitle - }).then(function(){ updateTitleEverywhere(documentId, commandTitle); }); + }).then(function(){ + if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle); + }); void work.then(close).catch(function(error) { committing = false; input.disabled = false; @@ -293,6 +308,51 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return detail.title || ''; } + function localRootPathFromRootUri(rootUri) { + var value = String(rootUri || '').trim(); + if (!value) return ''; + if (!/^file:\/\//i.test(value)) return value.charAt(0) === '/' ? value : ''; + var pathPart = value.replace(/^file:\/\//i, ''); + if (pathPart.indexOf('localhost/') === 0) pathPart = pathPart.slice('localhost'.length); + if (pathPart.charAt(0) !== '/') pathPart = '/' + pathPart; + try { + return decodeURIComponent(pathPart); + } catch (_) { + return pathPart; + } + } + + function joinLocalAbsolutePath(rootUri, relativePath) { + var rootPath = localRootPathFromRootUri(rootUri); + var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, ''); + if (!rootPath || !normalized) return rootPath || normalized; + return rootPath.replace(/\/+$/g, '') + '/' + normalized; + } + + function fileTreeCopyLocalAbsolutePath(detail, trigger) { + var rootUri = typeof currentRootUri === 'function' ? currentRootUri() : ''; + if (!rootUri && trigger && typeof trigger.closest === 'function') { + var row = trigger.closest('.tree-row[data-shell-mode="filetree"]'); + if (row instanceof HTMLElement) rootUri = row.getAttribute('data-root-uri') || ''; + } + if (!rootUri && document.body) rootUri = document.body.getAttribute('data-mnote-root-uri') || ''; + var relativePath = String(detail && detail.localRelativePath || '').trim(); + if (!relativePath && detail && detail.assetId) relativePath = localFilePathFromAssetId(detail.assetId); + if (!relativePath && detail && detail.documentId) { + relativePath = String(detail.documentId || '') + .replace(/^local-md:/, '') + .replace(/^local-dir:/, ''); + } + if (!relativePath && detail && detail.rowId) { + relativePath = String(detail.rowId || '') + .replace(/^local:asset:/, '') + .replace(/^local:markdown:/, '') + .replace(/^local:folder:/, '') + .replace(/^local:node:/, ''); + } + return joinLocalAbsolutePath(rootUri, relativePath); + } + function fileTreeMenuTargetParentId(detail, trigger) { var rowKind = String(detail && detail.rowKind || '').trim(); var rowId = String(detail && detail.rowId || '').trim(); @@ -361,6 +421,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body); var title = detail.title || '无标题'; var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index'; + if (detail.contextKind === 'filetree' && action === 'toggle-sidebar-folder-shortcut') { + dispatchSidebarEvent('tree.sidebarShortcut.toggleFolder', detail); + return; + } if (detail.contextKind === 'filetree' && action === 'download') { downloadSelectedFileTreeAssetRows(detail, trigger); return; @@ -408,7 +472,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return; } if (action === 'copy-id') { - void copyTreeContextValue(documentId || detail.assetId || detail.rowId || '', 'copy-id'); + var copyIdValue = currentSourceKind() === 'local_folder' + ? fileTreeCopyLocalAbsolutePath(detail, trigger) + : ''; + void copyTreeContextValue(copyIdValue || documentId || detail.assetId || detail.rowId || '', 'copy-id'); return; } if (action === 'delete-trash' && detail.contextKind === 'filetree') { @@ -776,6 +843,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { { separator: true }, { action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' }, { action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' }, + { action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' }, { action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' }, { action: 'refresh', icon: 'refresh', label: 'Refresh' }, { action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' }, @@ -915,22 +983,31 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]') || document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]') || document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]'); - if (!(row instanceof HTMLElement)) return false; - var rowId = row.getAttribute('data-row-id') || ''; - if (!rowId) return false; - selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false }); - document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) { - if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false'); - }); - row.setAttribute('data-active', 'true'); - if (options && options.scrollIntoView !== false) { - try { - row.scrollIntoView({ block: 'nearest' }); - } catch (_) { - row.scrollIntoView(); + if (!(row instanceof HTMLElement)) { + var relativePath = options && options.relativePath + ? String(options.relativePath || '').trim() + : id.indexOf('local-md:') === 0 + ? decodeLocalEncodedPath(id.slice('local-md:'.length)) + : ''; + var bundleParentPath = localMarkdownBundleParentPath(relativePath); + var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null; + if (bundleRow instanceof HTMLElement) { + return activateSidebarFileTreeRow(bundleRow, options); } + if (relativePath && typeof revealFileTreeResource === 'function') { + void revealFileTreeResource({ + rootUri: options && options.rootUri, + relativePath: relativePath, + rowId: options && options.rowId, + select: true, + focus: true, + scroll: options ? options.scrollIntoView !== false : true + }); + return true; + } + return false; } - return true; + return activateSidebarFileTreeRow(row, options); } function selectSidebarFileTreeRowById(rowId, options) { @@ -1063,7 +1140,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { function decodeLocalEncodedPath(value) { var runtimeFn = fileTreeRuntimeFunction('decodeLocalEncodedPath'); if (runtimeFn) return runtimeFn(value); - var path = String(value || '').trim().replace(/~2F/g, '/'); + var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1'); if (!path) return ''; try { return decodeURIComponent(path); @@ -1072,6 +1149,62 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { } } + function normalizeFileTreeRelativePath(value) { + var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, ''); + return normalized === '.' ? '' : normalized; + } + + function parentRelativePathForFileTreePath(relativePath) { + var normalized = normalizeFileTreeRelativePath(relativePath); + if (!normalized) return ''; + var index = normalized.lastIndexOf('/'); + return index > 0 ? normalized.slice(0, index) : ''; + } + + function fileNameStem(value) { + var name = String(value || '').trim(); + var dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(0, dot) : name; + } + + function localMarkdownBundleParentPath(relativePath) { + var normalized = normalizeFileTreeRelativePath(relativePath); + if (!/\.md$/i.test(normalized)) return ''; + var parent = parentRelativePathForFileTreePath(normalized); + if (!parent) return ''; + var fileName = normalized.slice(normalized.lastIndexOf('/') + 1); + var parentName = parent.slice(parent.lastIndexOf('/') + 1); + return fileNameStem(fileName) === parentName ? parent : ''; + } + + function visibleFileTreeRowByRelativePath(relativePath) { + var normalized = normalizeFileTreeRelativePath(relativePath); + if (!normalized) return null; + var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]'); + if (!(row instanceof HTMLElement)) return null; + if (row.closest('.tree-children--collapsed')) return null; + return row; + } + + function activateSidebarFileTreeRow(row, options) { + if (!(row instanceof HTMLElement)) return false; + var rowId = row.getAttribute('data-row-id') || ''; + if (!rowId) return false; + selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false }); + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) { + if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false'); + }); + row.setAttribute('data-active', 'true'); + if (options && options.scrollIntoView !== false) { + try { + row.scrollIntoView({ block: 'nearest' }); + } catch (_) { + row.scrollIntoView(); + } + } + return true; + } + function fileTreeRowLocalRelativePath(row) { var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath'); if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); @@ -1239,7 +1372,8 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var assetRows = []; rows.forEach(function(row) { var kind = fileTreeRowKind(row); - if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown') && fileTreeRowDocumentId(row)) { + var documentId = fileTreeRowDocumentId(row); + if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || documentId.indexOf('local-md:') === 0) && documentId) { docRows.push(row); return; } @@ -1338,69 +1472,101 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return children.length; } + function fileTreeMoveTargetParentId(targetRow) { + if (!(targetRow instanceof HTMLElement)) return null; + return fileTreeMenuTargetParentId({ + rowKind: fileTreeRowKind(targetRow), + rowId: targetRow.getAttribute('data-row-id') || '', + documentId: fileTreeRowDocumentId(targetRow) + }, targetRow) || null; + } + + function fileTreeMoveSourceId(row) { + if (!(row instanceof HTMLElement)) return ''; + return fileTreeRowDocumentId(row) + || (currentSourceKind() === 'local_folder' + ? (fileTreeRowAssetId(row) || row.getAttribute('data-row-id') || '') + : fileTreeRowAssetId(row)); + } + + async function moveSidebarFileTreeRows(rowIds, targetRow, options) { + var rows = fileTreeRowsByRowIds(rowIds || []); + if (rows.length === 0) return false; + var copy = Boolean(options && options.copy); + var targetParentId = fileTreeMoveTargetParentId(targetRow); + var workspaceId = resolveWorkspaceId(targetRow || document.body); + var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy); + if (!writable) return false; + var batchId = nextFileTreeOperationBatchId(copy ? 'copy' : 'move'); + var plan = buildSidebarFileTreeDeletePlan(rows); + var failures = []; + if (copy) { + dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: rowIds || [], targetDocumentId: targetParentId }); + recordFileTreeActionStatus('copy-requested', { documentId: targetParentId, batchId: batchId }); + return true; + } + var movableRows = plan.docRows.concat(plan.folderRows); + if (currentSourceKind() === 'local_folder') { + movableRows = movableRows.concat(plan.fileAssetRows, plan.mindmapRows, plan.tableRows); + } + for (var i = 0; i < movableRows.length; i += 1) { + var sourceRow = movableRows[i]; + var sourceId = fileTreeMoveSourceId(sourceRow); + if (!sourceId || sourceId === targetParentId) continue; + try { + await dispatchTreeCommand(targetRow || sourceRow, { + action: 'move', + batchId: batchId, + workspaceId: workspaceId, + documentId: sourceId, + parentId: targetParentId, + sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i + }); + } catch (error) { + failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败')); + } + } + if (currentSourceKind() !== 'local_folder') { + var assetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean); + if (assetIds.length > 0) { + try { + await postSidebarFileTreeJson('/api/media/batch', { + action: 'move', + assetIds: assetIds, + targetDocumentId: targetParentId + }); + } catch (error) { + failures.push(assetIds.join(',') + ': ' + (error && error.message ? error.message : '移动附件失败')); + } + } + } + window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'move', failed: failures.length, count: movableRows.length } })); + if (failures.length > 0) { + recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId }); + window.alert('部分对象移动失败:' + failures.join(';')); + return false; + } + recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId }); + return true; + } + async function pasteSidebarFileTreeClipboard(trigger) { if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false; var targetRow = trigger instanceof HTMLElement ? trigger : null; if (!targetRow && sidebarFileTreeSelection.focusedRowId) { targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]'); } - var targetDocumentId = fileTreeRowDocumentId(targetRow) || currentDocumentId(); - if (!targetDocumentId) return false; + var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId(); var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy'; - var rows = fileTreeRowsByRowIds(runtimeState.sidebarFileTreeClipboard.rowIds); - if (rows.length === 0) return false; - var writable = await ensureFileTreeWritableTarget('paste', targetRow, runtimeState.sidebarFileTreeClipboard.rowIds, action === 'copy'); - if (!writable) return false; recordFileTreeAction('paste', { rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '', documentId: targetDocumentId, sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds, clipboardAction: runtimeState.sidebarFileTreeClipboard.action }); - var plan = buildSidebarFileTreeDeletePlan(rows); - var workspaceId = resolveWorkspaceId(targetRow || document.body); - var failures = []; - if (action === 'copy') { - dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: runtimeState.sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId }); - recordFileTreeActionStatus('copy-requested', { documentId: targetDocumentId }); - return true; - } - for (var i = 0; i < plan.docRows.length; i += 1) { - var docRow = plan.docRows[i]; - var documentId = fileTreeRowDocumentId(docRow); - if (!documentId || documentId === targetDocumentId) continue; - try { - await dispatchTreeCommand(targetRow || docRow, { - action: 'move', - workspaceId: workspaceId, - documentId: documentId, - parentId: targetDocumentId, - sortOrder: fileTreeChildCount(targetDocumentId) + i - }); - } catch (error) { - failures.push(documentId + ': ' + (error && error.message ? error.message : '移动页面失败')); - } - } - var assetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean); - if (assetIds.length > 0) { - try { - await postSidebarFileTreeJson('/api/media/batch', { - action: 'move', - assetIds: assetIds, - targetDocumentId: targetDocumentId - }); - } catch (error) { - failures.push(assetIds.join(',') + ': ' + (error && error.message ? error.message : '移动附件失败')); - } - } - if (failures.length > 0) { - recordFileTreeActionStatus('failed', { documentId: targetDocumentId, fallback: 'alert' }); - window.alert('部分对象移动失败:' + failures.join(';')); - return false; - } - runtimeState.sidebarFileTreeClipboard = null; - recordFileTreeActionStatus('applied', { documentId: targetDocumentId }); - return true; + var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' }); + if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null; + return ok; } async function deleteSelectedSidebarFileTreeRows(trigger) { @@ -1409,8 +1575,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length; if (total === 0) return false; if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false; - recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total }); - recordFileTreeActionStatus('pending', { count: total }); + var batchId = nextFileTreeOperationBatchId('bulk-delete'); + document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-batch-id', batchId); + recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total, batchId: batchId }); + recordFileTreeActionStatus('pending', { count: total, batchId: batchId }); var failures = []; for (var i = 0; i < plan.docRows.length; i += 1) { var docRow = plan.docRows[i]; @@ -1418,6 +1586,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { try { await dispatchTreeCommand(trigger || docRow, { action: 'archive', + batchId: batchId, workspaceId: resolveWorkspaceId(docRow), documentId: documentId }); @@ -1432,6 +1601,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { try { await dispatchTreeCommand(trigger || folderRow, { action: 'archive', + batchId: batchId, workspaceId: resolveWorkspaceId(folderRow), documentId: folderId }); @@ -1446,6 +1616,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { for (var lf = 0; lf < fileAssetIds.length; lf += 1) { await dispatchTreeCommand(trigger || plan.fileAssetRows[lf], { action: 'archive', + batchId: batchId, workspaceId: resolveWorkspaceId(plan.fileAssetRows[lf]), documentId: fileAssetIds[lf] }); @@ -1455,7 +1626,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { } fileAssetIds.forEach(removeFileTreeAssetRow); } catch (error) { - failures = failures.concat(fileAssetIds); + failures = failures.concat(fileAssetIds.map(function(assetId) { + return assetId + ': ' + (error && error.message ? error.message : '删除附件失败'); + })); } } for (var m = 0; m < plan.mindmapRows.length; m += 1) { @@ -1465,6 +1638,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { if (currentSourceKind() === 'local_folder') { await dispatchTreeCommand(trigger || mindmapRow, { action: 'archive', + batchId: batchId, workspaceId: resolveWorkspaceId(mindmapRow), documentId: mindmapId }); @@ -1485,6 +1659,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { if (currentSourceKind() === 'local_folder') { await dispatchTreeCommand(trigger || tableRow, { action: 'archive', + batchId: batchId, workspaceId: resolveWorkspaceId(tableRow), documentId: tableId }); @@ -1502,13 +1677,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { sidebarFileTreeSelection.focusedRowId = null; syncSidebarFileTreeSelection(); if (failures.length > 0) { - recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert' }); + window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: failures.length, count: total } })); + recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert', batchId: batchId }); window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : '')); return false; } document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true'); - recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal' }); - if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); + window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: 0, count: total } })); + recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal', batchId: batchId }); + if (currentSourceKind() !== 'local_folder') void refreshLocalFolderSidebarSnapshot(); return true; } @@ -1565,6 +1742,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { postSidebarFileTreeJson, fileTreeRowsByRowIds, fileTreeChildCount, + moveSidebarFileTreeRows, pasteSidebarFileTreeClipboard, deleteSelectedSidebarFileTreeRows, }; diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js index 98491d20..5f363a1f 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -45,25 +45,15 @@ export function createSidebarPageSettingsRuntime(context) { } function readGlobalShowHeadingNumbers() { - try { - var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : ''; - return raw === 'true' || raw === '1'; - } catch (_) { - return false; - } + return Boolean(currentPageOptions().showHeadingNumbers); } function writeGlobalShowHeadingNumbers(value) { - try { - if (window.localStorage) { - window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false'); - } - } catch (_) {} - document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value))); + void persistPageOptionsPatch({ showHeadingNumbers: Boolean(value) }); } function effectiveShowHeadingNumbers(options) { - return readGlobalShowHeadingNumbers(); + return Boolean(options && options.showHeadingNumbers); } function pageOptionIsSupported(name) { @@ -161,7 +151,7 @@ export function createSidebarPageSettingsRuntime(context) { function applyPageOptionsToShell() { var options = currentPageOptions(); - var globalShowHeadingNumbers = readGlobalShowHeadingNumbers(); + var globalShowHeadingNumbers = Boolean(options.showHeadingNumbers); var showHeadingNumbers = effectiveShowHeadingNumbers(options); var shell = document.querySelector('.document-shell'); var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); @@ -623,21 +613,27 @@ export function createSidebarPageSettingsRuntime(context) { applyPageOptionsToShell(); renderPageSettingsPopover(); try { - var response = await fetch('/api/documents/options', { - method: 'POST', + var response = await fetch('/api/ui/preferences', { + method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentId: currentDocumentId(), workspaceId: resolveWorkspaceId(document.body), ...currentWorkspaceSourcePayload(), - options: nextOptions, - commandName: 'page.layout.updateOptions' + updates: nextOptions }) }); var payload = await response.json().catch(function(){ return null; }); if (!response.ok || !payload || payload.ok !== true) { throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_settings_save_failed_' + response.status); } + var savedOptions = payload && payload.result && payload.result.pageOptions && typeof payload.result.pageOptions === 'object' + ? Object.assign(defaultPageOptions(), payload.result.pageOptions) + : nextOptions; + pageUiState.pageOptions = Object.assign({}, savedOptions); + nextOptions = Object.assign({}, pageUiState.pageOptions); + applyPageOptionsToShell(); + renderPageSettingsPopover(); var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__'); if (script) { try { diff --git a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js index cffc9163..053ac233 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js @@ -54,6 +54,8 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => { var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin); if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId); if (treeView === 'filetree') targetUrl.searchParams.set('treeView', 'filetree'); + var fileTreeScope = String(options && options.fileTreeScope || '').trim(); + if (fileTreeScope) targetUrl.searchParams.set('fileTreeScope', fileTreeScope); copyWorkspaceSourceParams(targetUrl); var url = targetUrl.pathname + targetUrl.search; if (mnoteNavigationInFlight === url) return; diff --git a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js index 393837a7..44ce5e91 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js @@ -18,11 +18,29 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { shortMindmapFileName, syncSidebarFileTreeSelection, } = dependencies; - var fileTreeLazyChildrenCache = new Map(); - var fileTreeExpandedRelativePaths = new Set(); + var fileTreeViewState = { + rootUri: '', + scope: '', + expandedParents: new Set(), + selectedRowIds: new Set(), + focusedRowId: '', + activeRowId: '', + scrollTop: 0, + rowsByParent: new Map(), + loadedParents: new Set(), + loadingParents: new Map(), + dirtyParents: new Set(), + staleParents: new Set(), + revisionByParent: new Map(), + requestGeneration: 0, + latestGenerationByParent: new Map() + }; + var fileTreeState = fileTreeViewState; + var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents; var fileTreeLazyCacheRootUri = ''; var fileTreeExpansionStorageRootUri = ''; var fileTreeExpansionRestoreTimer = 0; + var fileTreeCommandBatchRefreshParents = new Map(); var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1'; function updateTitleEverywhere(documentId, title) { @@ -70,6 +88,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function isFileTreePageRow(row) { if (!(row instanceof HTMLElement)) return false; if (row.getAttribute('data-asset-id')) return false; + var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || ''; + if (documentId.indexOf('local-md:') === 0) return true; var rowKind = row.getAttribute('data-row-kind') || ''; return rowKind === 'document' || rowKind === 'doc' || rowKind === 'index' || rowKind === 'markdown'; } @@ -295,6 +315,134 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return false; } + function parentRelativePathForPath(relativePath) { + var normalized = String(relativePath || '').trim().replace(/^\/+|\/+$/g, ''); + if (!normalized || normalized === '.') return ''; + var index = normalized.lastIndexOf('/'); + return index > 0 ? normalized.slice(0, index) : ''; + } + + function addCommandRefreshParent(parents, value) { + var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, ''); + if (normalized === '.') normalized = ''; + parents.add(normalized); + } + + function addAffectedParentsFromCommandResult(parents, result) { + var affectedParents = Array.isArray(result && result.affectedParents) + ? result.affectedParents + : Array.isArray(result && result.execution && result.execution.affectedParents) + ? result.execution.affectedParents + : null; + if (!affectedParents) return false; + var before = parents.size; + affectedParents.forEach(function(parent) { + addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path)); + }); + return parents.size > before; + } + + function collectFileTreeRefreshParentsForCommand(result, body) { + var parents = new Set(); + if (!addAffectedParentsFromCommandResult(parents, result)) { + document.documentElement.setAttribute('data-mnote-filetree-command-refresh-fallback', 'legacy-path-fields'); + var values = [ + result && (result.parentRelativePath || result.parent_relative_path), + result && result.execution && (result.execution.parentRelativePath || result.execution.parent_relative_path), + result && (result.relativePath || result.relative_path), + result && result.execution && (result.execution.relativePath || result.execution.relative_path), + result && (result.previousRelativePath || result.previous_relative_path), + result && result.execution && (result.execution.previousRelativePath || result.execution.previous_relative_path), + body && (body.parentRelativePath || body.parent_relative_path) + ]; + values.forEach(function(value) { + var normalized = String(value || '').trim(); + if (!normalized) return; + if (normalized.indexOf('/') >= 0 || /\.[^/]+$/.test(normalized)) addCommandRefreshParent(parents, parentRelativePathForPath(normalized)); + else addCommandRefreshParent(parents, normalized); + }); + } + if (!parents.size) addCommandRefreshParent(parents, currentFileTreeScope()); + return parents; + } + + function fileTreeRefreshParentsForCommand(result, body) { + var parents = collectFileTreeRefreshParentsForCommand(result, body); + return Promise.all(Array.from(parents).map(function(parentRelativePath) { + return refreshFileTreeParent(parentRelativePath).catch(function(error) { + setTreeLiveApplyError(error && error.message ? error.message : '文件树局部刷新失败'); + return false; + }); + })); + } + + function queueFileTreeBatchRefresh(batchId, result, body) { + var normalizedBatchId = String(batchId || '').trim(); + if (!normalizedBatchId) return false; + var batchParents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId); + if (!batchParents) { + batchParents = new Set(); + fileTreeCommandBatchRefreshParents.set(normalizedBatchId, batchParents); + } + collectFileTreeRefreshParentsForCommand(result, body).forEach(function(parent) { + batchParents.add(parent); + }); + document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-pending', normalizedBatchId); + return true; + } + + function flushFileTreeBatchRefresh(batchId) { + var normalizedBatchId = String(batchId || '').trim(); + if (!normalizedBatchId) return false; + var parents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId); + fileTreeCommandBatchRefreshParents.delete(normalizedBatchId); + if (!parents || !parents.size) return false; + document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-applied', normalizedBatchId); + void Promise.all(Array.from(parents).map(function(parentRelativePath) { + return refreshFileTreeParent(parentRelativePath).catch(function(error) { + setTreeLiveApplyError(error && error.message ? error.message : '文件树批量刷新失败'); + return false; + }); + })); + return true; + } + + function applyLocalFolderWatchBatch(payload) { + var batch = payload && (payload.payload || payload); + var affectedParents = Array.isArray(batch && batch.affectedParents) + ? batch.affectedParents + : Array.isArray(batch && batch.affected_parents) + ? batch.affected_parents + : []; + if (!affectedParents.length) { + setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents'); + return false; + } + var parents = new Set(); + affectedParents.forEach(function(parent) { + addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path)); + }); + document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true')); + void Promise.all(Array.from(parents).map(function(parentRelativePath) { + return refreshFileTreeParent(parentRelativePath).catch(function(error) { + setTreeLiveApplyError(error && error.message ? error.message : '文件树 watch batch 刷新失败'); + return false; + }); + })).then(function() { + markLocalFolderWatchApplied('watch_batch'); + }); + return true; + } + + function refreshLocalFolderAfterCommand(action, result, body) { + if (!localCommandNeedsProjectionRefresh(action, result)) return; + if (body && body.batchId) { + queueFileTreeBatchRefresh(body.batchId, result, body); + return; + } + void fileTreeRefreshParentsForCommand(result, body); + } + function sortOrderFromDelta(data) { var raw = data && (data.sortOrder ?? data.sort_order); var value = Number(raw); @@ -440,6 +588,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return String(item && (item.relativePath || item.rootRelativePath) || '').trim(); } + function normalizeFileTreeRelativePath(value) { + var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, ''); + return normalized === '.' ? '' : normalized; + } + + function fileTreeRowByRelativePath(relativePath) { + var normalized = normalizeFileTreeRelativePath(relativePath); + if (!normalized) return null; + return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]'); + } + function groupRowsByParent(rows) { var runtimeFn = fileTreeRuntimeFunction('groupRowsByParent'); if (runtimeFn) return runtimeFn(rows); @@ -454,6 +613,53 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return grouped; } + function fileTreeParentKey(rootUri, parentRelativePath) { + return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim(); + } + + function currentFileTreeParentKey(parentRelativePath) { + return fileTreeParentKey(currentRootUri(), parentRelativePath); + } + + function projectionParentRelativePath(projection) { + var resolved = readProjection(projection); + return String(resolved && (resolved.parentRelativePath || resolved.parent_relative_path) || '').trim(); + } + + function isFileTreeRootProjectionParent(parentRelativePath) { + return normalizeFileTreeRelativePath(parentRelativePath) === normalizeFileTreeRelativePath(currentFileTreeScope()); + } + + function rememberFileTreeProjection(parentRelativePath, rows, projection) { + var key = currentFileTreeParentKey(parentRelativePath); + fileTreeState.rowsByParent.set(key, rows); + fileTreeState.loadedParents.add(key); + fileTreeState.dirtyParents.delete(key); + fileTreeState.staleParents.delete(key); + var resolved = readProjection(projection); + var watchRevision = resolved && (resolved.watchRevision || resolved.watch_revision); + if (watchRevision) fileTreeState.revisionByParent.set(key, watchRevision); + } + + function cachedFileTreeRows(parentRelativePath) { + return fileTreeState.rowsByParent.get(currentFileTreeParentKey(parentRelativePath)) || []; + } + + function beginFileTreeRequest(key) { + fileTreeState.requestGeneration += 1; + fileTreeState.latestGenerationByParent.set(key, fileTreeState.requestGeneration); + return fileTreeState.requestGeneration; + } + + function isLatestFileTreeRequest(key, generation) { + return fileTreeState.latestGenerationByParent.get(key) === generation; + } + + function markFileTreeParentStale(key) { + fileTreeState.staleParents.add(key); + fileTreeState.dirtyParents.add(key); + } + function pageTreeChevronSvg() { return ''; } @@ -586,7 +792,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var objectIdentity = fileObjectIdentity(item); var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity); var iconKind = iconKindOf(item); - var cachedChildren = relativePath ? (fileTreeLazyChildrenCache.get(relativePath) || []) : []; + var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : []; var title = isFileTreeProjectionPageRow(rowKind, assetId) ? fileTreePageTitle(rawTitle) : normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity); @@ -615,9 +821,19 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function ensureFileTreeLazyCacheScope() { var rootUri = currentRootUri(); - if (rootUri === fileTreeLazyCacheRootUri) return; + var scope = currentFileTreeScope(); + if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return; fileTreeLazyCacheRootUri = rootUri; - fileTreeLazyChildrenCache.clear(); + fileTreeState.rootUri = rootUri; + fileTreeState.scope = scope; + fileTreeState.rowsByParent.clear(); + fileTreeState.loadedParents.clear(); + fileTreeState.loadingParents.clear(); + fileTreeState.dirtyParents.clear(); + fileTreeState.staleParents.clear(); + fileTreeState.revisionByParent.clear(); + fileTreeState.latestGenerationByParent.clear(); + fileTreeState.requestGeneration += 1; fileTreeExpandedRelativePaths.clear(); loadStoredFileTreeExpansionState(rootUri); } @@ -683,15 +899,80 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { if (changed) persistFileTreeExpansionState(); } + function rememberFileTreeSelectionState() { + fileTreeViewState.selectedRowIds.clear(); + fileTreeViewState.focusedRowId = ''; + fileTreeViewState.activeRowId = ''; + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + var rowId = String(row.getAttribute('data-row-id') || '').trim(); + if (!rowId) return; + if (row.getAttribute('data-selected') === 'true') fileTreeViewState.selectedRowIds.add(rowId); + if (row.getAttribute('data-focused') === 'true') fileTreeViewState.focusedRowId = rowId; + if (row.getAttribute('data-active') === 'true') fileTreeViewState.activeRowId = rowId; + }); + } + + function reprojectFileTreeSelectionState() { + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + var rowId = String(row.getAttribute('data-row-id') || '').trim(); + row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId))); + row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId)); + if (fileTreeViewState.activeRowId) { + row.setAttribute('data-active', String(rowId === fileTreeViewState.activeRowId)); + } + }); + } + function renderFileProjection(projection) { var tree = document.getElementById('sidebar-file-tree-root'); if (!tree) return false; ensureFileTreeLazyCacheScope(); rememberFileTreeExpansionState(); + rememberFileTreeSelectionState(); var rows = projectionItems(projection); + var parentRelativePath = projectionParentRelativePath(projection) || currentFileTreeScope(); + rememberFileTreeProjection(parentRelativePath, rows, projection); + if (!isFileTreeRootProjectionParent(parentRelativePath)) { + return patchFileTreeParentChildren(parentRelativePath, rows); + } var activeId = currentDocumentId(); var activeRowId = currentFileTreeActiveRowId(); - tree.innerHTML = '
    ' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '
'; + var template = document.createElement('template'); + template.innerHTML = '
    ' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '
'; + tree.replaceChildren(template.content.cloneNode(true)); + reprojectFileTreeSelectionState(); + scheduleRestorePersistedFileTreeExpansionState(); + return true; + } + + function patchFileTreeParentChildren(parentRelativePath, rows) { + var row = fileTreeRowByRelativePath(parentRelativePath); + if (!(row instanceof HTMLElement)) return true; + var node = row.closest('.tree-node'); + if (!node) return true; + rememberFileTreeSelectionState(); + var button = row.querySelector('[data-rust-action="toggle"]'); + var wasExpanded = row.getAttribute('aria-expanded') === 'true'; + var children = node.querySelector(':scope > .tree-children'); + if (!children && !wasExpanded && row.getAttribute('data-filetree-children-loaded') !== 'true') { + return true; + } + if (!children) { + children = document.createElement('ul'); + children.className = 'tree-children'; + node.appendChild(children); + } + var template = document.createElement('template'); + template.innerHTML = renderFileRows('', groupRowsByParent(rows), currentDocumentId(), currentFileTreeActiveRowId()); + children.replaceChildren(template.content.cloneNode(true)); + children.classList.toggle('tree-children--collapsed', !wasExpanded); + row.setAttribute('data-filetree-children-loaded', 'true'); + row.removeAttribute('data-filetree-children-loading'); + setTreeRowExpanded(row, button, wasExpanded); + syncSidebarFileTreeSelection(); + reprojectFileTreeSelectionState(); return true; } @@ -702,6 +983,23 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return renderedPage || renderedFile; } + function renderLiveSidebarSnapshot(payload) { + var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false; + var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection'); + var hasFileProjection = fileProjection && hasProjectionItems(fileProjection); + if (currentFileTreeScope()) { + if (!hasFileProjection) return renderedPage; + var projectionParent = projectionParentRelativePath(fileProjection); + if (isFileTreeRootProjectionParent(projectionParent)) { + return renderFileProjection(fileProjection) || renderedPage; + } + document.documentElement.setAttribute('data-mnote-filetree-scoped-live-snapshot-ignored', projectionParent || 'root'); + return true; + } + var renderedFile = hasFileProjection ? renderFileProjection(fileProjection) : false; + return renderedPage || renderedFile; + } + function replaceSidebarTreeFromDocument(nextDocument, rootId) { var runtimeFn = fileTreeRuntimeFunction('replaceSidebarTreeFromDocument'); if (runtimeFn) return runtimeFn(nextDocument, rootId); @@ -739,10 +1037,61 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return true; } + function currentFileTreeScope() { + var params = new URLSearchParams(window.location.search); + var fromUrl = String(params.get('fileTreeScope') || '').trim(); + if (fromUrl) return fromUrl; + var root = document.getElementById('sidebar-file-tree-root'); + return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : ''; + } + + async function fetchFileTreeProjection(parentRelativePath, options) { + options = options || {}; + var rootUri = currentRootUri(); + if (!rootUri) return null; + var url = new URL(options.childrenOnly ? '/api/tree/projections/file/children' : '/api/tree/projections/file', window.location.origin); + var workspaceId = currentWorkspaceId(); + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + if (parentRelativePath) url.searchParams.set('parentRelativePath', parentRelativePath); + var currentId = currentDocumentId(); + if (!options.childrenOnly && currentId) url.searchParams.set('rootNodeId', currentId); + var response = await fetch(url.toString(), { headers: { accept: 'application/json' } }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_projection_failed_' + response.status); + return readProjection(payload && (payload.result || payload)); + } + + async function refreshFileTreeParent(parentRelativePath) { + ensureFileTreeLazyCacheScope(); + var key = currentFileTreeParentKey(parentRelativePath); + fileTreeState.dirtyParents.add(key); + if (!isFileTreeRootProjectionParent(parentRelativePath)) { + var row = fileTreeRowByRelativePath(parentRelativePath); + if (row instanceof HTMLElement && row.getAttribute('aria-expanded') !== 'true') { + markFileTreeParentStale(key); + return true; + } + } + if (fileTreeState.loadingParents.has(key)) { + return fileTreeState.loadingParents.get(key).then(function(rows) { + fileTreeState.dirtyParents.delete(key); + return isFileTreeRootProjectionParent(parentRelativePath) + ? renderFileProjection({ parentRelativePath: parentRelativePath, items: rows }) + : patchFileTreeParentChildren(parentRelativePath, rows); + }); + } + var projection = await fetchFileTreeProjection(parentRelativePath, { childrenOnly: false }); + if (!projection) return false; + return renderFileProjection(projection); + } + async function refreshLocalFolderSidebarSnapshot() { var workspaceId = currentWorkspaceId(); var rootUri = currentRootUri(); var currentId = currentDocumentId(); + var fileTreeScope = currentFileTreeScope(); if (!workspaceId || !rootUri) return false; var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin); @@ -750,22 +1099,24 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { sidebarUrl.searchParams.set('sourceKind', 'local_folder'); sidebarUrl.searchParams.set('rootUri', rootUri); if (currentId) sidebarUrl.searchParams.set('rootNodeId', currentId); - var fileUrl = new URL('/api/tree/projections/file', window.location.origin); - fileUrl.searchParams.set('workspaceId', workspaceId); - fileUrl.searchParams.set('sourceKind', 'local_folder'); - fileUrl.searchParams.set('rootUri', rootUri); - if (currentId) fileUrl.searchParams.set('rootNodeId', currentId); - - var responses = await Promise.all([ + var responses = await Promise.allSettled([ fetch(sidebarUrl.toString(), { headers: { accept: 'application/json' } }), - fetch(fileUrl.toString(), { headers: { accept: 'application/json' } }) + refreshFileTreeParent(fileTreeScope) ]); - if (!responses[0].ok && !responses[1].ok) return false; + var sidebarResponse = responses[0].status === 'fulfilled' ? responses[0].value : null; + var renderedFile = responses[1].status === 'fulfilled' ? responses[1].value : false; + if ((!sidebarResponse || !sidebarResponse.ok) && !renderedFile) return false; - var sidebarPayload = responses[0].ok ? await responses[0].json().catch(function() { return null; }) : null; - var filePayload = responses[1].ok ? await responses[1].json().catch(function() { return null; }) : null; - var renderedPage = sidebarPayload ? renderSidebarSnapshot(sidebarPayload.result || sidebarPayload) : false; - var renderedFile = filePayload ? renderFileProjection(filePayload.result || filePayload) : false; + var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null; + var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null; + var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload) + ? renderPageProjection(resolvedSidebarPayload) + : false; + if (renderedFile && fileTreeScope) { + var fileRoot = document.getElementById('sidebar-file-tree-root'); + if (fileRoot instanceof HTMLElement) fileRoot.setAttribute('data-mnote-filetree-scope', fileTreeScope); + document.documentElement.setAttribute('data-mnote-filetree-scope', fileTreeScope); + } if (!renderedPage && !renderedFile) return false; syncSidebarFileTreeSelection(); schedulePendingLocalFolderRestoreFocus(); @@ -783,22 +1134,35 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { scheduleRestorePersistedFileTreeExpansionState(); var revision = ''; var refreshTimer = 0; + var treeLiveEventsActive = function() { + var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || ''; + return treeTransport === 'local-folder-events'; + }; var scheduleRefresh = function() { + if (treeLiveEventsActive()) return; if (refreshTimer) return; refreshTimer = window.setTimeout(function() { refreshTimer = 0; + if (treeLiveEventsActive()) return; + var fileTreeScope = currentFileTreeScope(); + if (fileTreeScope) { + markFileTreeParentStale(currentFileTreeParentKey(fileTreeScope)); + markLocalFolderWatchApplied('scope_stale'); + document.documentElement.setAttribute('data-mnote-filetree-scope-watch-stale', fileTreeScope); + return; + } void refreshLocalFolderSidebarSnapshot(); }, 180); }; var poll = async function() { if (document.hidden) return; // If tree live SSE transport is active for local_folder, skip polling (fallback) - var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || ''; - if (treeTransport === 'local-folder-events') return; + if (treeLiveEventsActive()) return; var url = new URL('/api/tree/local-folder-watch', window.location.origin); url.searchParams.set('rootUri', rootUri); var response = await fetch(url.toString(), { headers: { accept: 'application/json' } }); if (!response.ok) return; + if (treeLiveEventsActive()) return; var payload = await response.json(); var nextRevision = payload && payload.result && typeof payload.result.revision === 'string' ? payload.result.revision @@ -881,8 +1245,23 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { persistFileTreeExpansionState(); } + function markExistingFileTreeChildrenLoaded(row, button) { + if (!(row instanceof HTMLElement)) return false; + var node = row.closest('.tree-node'); + if (!node) return false; + var children = node.querySelector(':scope > .tree-children'); + if (!(children instanceof HTMLElement)) return false; + row.setAttribute('data-filetree-children-loaded', 'true'); + children.classList.remove('tree-children--collapsed'); + setTreeRowExpanded(row, button, true); + syncSidebarFileTreeSelection(); + return true; + } + function renderCachedFileTreeChildren(row, button, relativePath) { - var cachedRows = fileTreeLazyChildrenCache.get(relativePath) || []; + var key = currentFileTreeParentKey(relativePath); + if (fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key)) return false; + var cachedRows = cachedFileTreeRows(relativePath); if (!cachedRows.length) return false; var node = row.closest('.tree-node'); if (!node) return false; @@ -892,7 +1271,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { children.className = 'tree-children'; node.appendChild(children); } - children.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId()); + var template = document.createElement('template'); + template.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId()); + children.replaceChildren(template.content.cloneNode(true)); children.classList.remove('tree-children--collapsed'); row.setAttribute('data-filetree-children-loaded', 'true'); setTreeRowExpanded(row, button, true); @@ -900,31 +1281,47 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return true; } + async function getFileTreeChildren(parentRelativePath) { + ensureFileTreeLazyCacheScope(); + var key = currentFileTreeParentKey(parentRelativePath); + if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key) && !fileTreeState.staleParents.has(key)) { + return fileTreeState.rowsByParent.get(key) || []; + } + if (fileTreeState.loadingParents.has(key)) { + return fileTreeState.loadingParents.get(key); + } + var generation = beginFileTreeRequest(key); + var promise = fetchFileTreeProjection(parentRelativePath, { childrenOnly: true }).then(function(projection) { + if (!isLatestFileTreeRequest(key, generation)) { + return fileTreeState.rowsByParent.get(key) || []; + } + var rows = projectionItems(projection); + rememberFileTreeProjection(parentRelativePath, rows, projection); + return rows; + }).finally(function() { + fileTreeState.loadingParents.delete(key); + }); + fileTreeState.loadingParents.set(key, promise); + return promise; + } + async function loadFileTreeChildren(row, button) { if (!(row instanceof HTMLElement)) return false; if (row.getAttribute('data-shell-mode') !== 'filetree') return false; if (currentSourceKind() !== 'local_folder') return false; ensureFileTreeLazyCacheScope(); var relativePath = localFileTreeRelativePathFromRow(row); - if (relativePath && renderCachedFileTreeChildren(row, button, relativePath)) return true; + var key = currentFileTreeParentKey(relativePath); + var stale = fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key); + if (!stale && markExistingFileTreeChildrenLoaded(row, button)) return true; + if (!stale && relativePath && renderCachedFileTreeChildren(row, button, relativePath)) return true; if (row.getAttribute('data-filetree-children-loaded') === 'true') return false; - if (row.getAttribute('data-filetree-children-loading') === 'true') return true; var rootUri = currentRootUri(); if (!rootUri || !relativePath) return false; + setTreeRowExpanded(row, button, true); row.setAttribute('data-filetree-children-loading', 'true'); try { - var url = new URL('/api/tree/projections/file/children', window.location.origin); - var workspaceId = currentWorkspaceId(); - if (workspaceId) url.searchParams.set('workspaceId', workspaceId); - url.searchParams.set('sourceKind', 'local_folder'); - url.searchParams.set('rootUri', rootUri); - url.searchParams.set('parentRelativePath', relativePath); - var response = await fetch(url.toString(), { headers: { accept: 'application/json' } }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_children_failed_' + response.status); - var projection = readProjection(payload && (payload.result || payload)); - var rows = projectionItems(projection); - fileTreeLazyChildrenCache.set(relativePath, rows); + var rows = await getFileTreeChildren(relativePath); if (!rows.length) { row.setAttribute('data-filetree-children-loaded', 'true'); setTreeRowExpanded(row, button, true); @@ -939,6 +1336,53 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } } + function fileTreeParentChainForRelativePath(relativePath) { + var normalized = normalizeFileTreeRelativePath(relativePath); + if (!normalized) return []; + var parts = normalized.split('/').filter(Boolean); + parts.pop(); + var chain = []; + for (var index = 0; index < parts.length; index += 1) { + chain.push(parts.slice(0, index + 1).join('/')); + } + return chain; + } + + async function revealFileTreeResource(input) { + input = input || {}; + var rootUri = String(input.rootUri || currentRootUri() || '').trim(); + if (!rootUri || rootUri !== currentRootUri()) return false; + ensureFileTreeLazyCacheScope(); + var relativePath = normalizeFileTreeRelativePath(input.relativePath || input.relative_path || ''); + var rowId = String(input.rowId || input.row_id || '').trim(); + var parentChain = fileTreeParentChainForRelativePath(relativePath); + for (var index = 0; index < parentChain.length; index += 1) { + var parentRelativePath = parentChain[index]; + var parentRow = fileTreeRowByRelativePath(parentRelativePath); + if (!(parentRow instanceof HTMLElement)) continue; + var button = parentRow.querySelector('[data-rust-action="toggle"]'); + await getFileTreeChildren(parentRelativePath); + renderCachedFileTreeChildren(parentRow, button, parentRelativePath); + setTreeRowExpanded(parentRow, button, true); + } + var targetRow = rowId + ? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]') + : null; + if (!(targetRow instanceof HTMLElement) && relativePath) { + targetRow = fileTreeRowByRelativePath(relativePath); + } + if (!(targetRow instanceof HTMLElement)) return false; + var targetRowId = String(targetRow.getAttribute('data-row-id') || rowId || '').trim(); + if (targetRowId) { + fileTreeViewState.selectedRowIds = new Set([targetRowId]); + fileTreeViewState.focusedRowId = targetRowId; + fileTreeViewState.activeRowId = targetRowId; + } + reprojectFileTreeSelectionState(); + try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {} + return true; + } + function toggleChildren(row, button) { var li = row && row.parentElement; var children = li ? li.querySelector(':scope > .tree-children') : null; @@ -962,6 +1406,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var relativePath = localFileTreeRelativePathFromRow(row); if (!relativePath || !fileTreeExpandedRelativePaths.has(relativePath)) return; var button = row.querySelector('[data-rust-action="toggle"]'); + if (markExistingFileTreeChildrenLoaded(row, button)) { + restored = true; + return; + } if (row.getAttribute('data-filetree-children-loaded') === 'true') { setTreeRowExpanded(row, button, true); restored = true; @@ -998,7 +1446,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var result = detail.result || {}; if (action === 'create') { applyCreatedDocumentLocally(result, body.parentId || null, body.title || '新页面'); - if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot(); + refreshLocalFolderAfterCommand(action, result, body); return; } if (action === 'rename' && body.documentId && body.title) { @@ -1012,7 +1460,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } else { updateTitleEverywhere(body.documentId, body.title); } - if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot(); + refreshLocalFolderAfterCommand(action, result, body); document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'rename'); return; } @@ -1020,20 +1468,25 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) { document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move'); } - if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot(); + refreshLocalFolderAfterCommand(action, result, body); return; } if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) { if (applyRemoveDocumentDelta({ documentId: body.documentId })) { document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove'); } - if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot(); + refreshLocalFolderAfterCommand(action, result, body); } }); + window.addEventListener('tree:local-command-batch-complete', function(event) { + var detail = event.detail || {}; + flushFileTreeBatchRefresh(detail.batchId || detail.batch_id || ''); + }); + window.addEventListener('tree:snapshot', function(event) { var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; - if (renderSidebarSnapshot(payload)) { + if (renderLiveSidebarSnapshot(payload)) { document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot'); return; } @@ -1061,7 +1514,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题'); }); } - if (renderSidebarSnapshot(payload)) { + if (renderLiveSidebarSnapshot(payload)) { document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta'); return; } @@ -1073,7 +1526,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { window.addEventListener('tree:resync', function(event) { var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; - if (renderSidebarSnapshot(payload)) { + if (renderLiveSidebarSnapshot(payload)) { refreshEditorLocalAttachmentExistence(); document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync'); return; @@ -1081,6 +1534,18 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync'); setTreeLiveApplyError('tree_resync_missing_projection_payload'); }); + + window.addEventListener('tree:local-folder-watch-batch', function(event) { + var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; + applyLocalFolderWatchBatch(payload); + }); + + window.addEventListener('tree:error', function(event) { + var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; + var code = payload && (payload.code || payload.error || payload.message); + document.documentElement.setAttribute('data-mnote-tree-live-error-schema', String(payload && payload.schema || '')); + setTreeLiveApplyError(code || 'tree_live_error'); + }); } return { @@ -1091,14 +1556,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { commandDocumentTitle, deltaNeedsProjectionRefresh, fileTreePageTitle, + flushFileTreeBatchRefresh, installTreeLiveApplyEventListeners, isFileTreePageRow, + applyLocalFolderWatchBatch, localCommandNeedsProjectionRefresh, normalizeFileTreePageRenameTitle, objectIdentityAttr, refreshLocalFolderSidebarSnapshot, removeDocumentRowForMode, renderSidebarSnapshot, + revealFileTreeResource, restorePersistedFileTreeExpansionState, setTreeLiveApplyError, startLocalFolderSidebarWatch, diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 6aee85a8..f6172fac 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -301,13 +301,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim if (currentSourceKind() !== 'local_folder') return false; var workspaceId = resolveWorkspaceId(trigger || document.body); var effectiveParentId = String(parentId || '').trim(); - var title = window.prompt('新建文件夹', '新建文件夹'); - if (!title || !title.trim()) return false; var result = await dispatchTreeCommand(trigger || document.body, { action: 'create_folder', workspaceId: workspaceId, parentId: effectiveParentId || null, - title: title.trim() + title: '新建文件夹' }); document.documentElement.setAttribute('data-mnote-filetree-folder-created', 'true'); document.documentElement.setAttribute('data-mnote-filetree-folder-created-id', commandDocumentId(result, result.id || '')); @@ -357,6 +355,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim copyWorkspaceSourceParams, cssEscape, currentDocumentId, + currentRootUri, currentSourceKind, dispatchTreeCommand, normalizeSidebarTreeMode, @@ -387,12 +386,385 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim const startLocalFolderSidebarWatch = (...args) => sidebarTreeLiveApply.startLocalFolderSidebarWatch(...args); const deltaNeedsProjectionRefresh = (...args) => sidebarTreeLiveApply.deltaNeedsProjectionRefresh(...args); const toggleChildren = (...args) => sidebarTreeLiveApply.toggleChildren(...args); + const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args); function dispatchSidebarEvent(name, detail) { document.documentElement.setAttribute('data-mnote-last-tree-action', name); window.dispatchEvent(new CustomEvent(name, { detail: detail })); } + function sidebarShortcutWorkspaceId() { + return currentWorkspaceId() + || (document.getElementById('sidebar-file-tree-root') && document.getElementById('sidebar-file-tree-root').getAttribute('data-workspace-id') || '') + || (document.getElementById('sidebar-tree-root') && document.getElementById('sidebar-tree-root').getAttribute('data-workspace-id') || ''); + } + + function sidebarShortcutSourceKind() { + return currentSourceKind() || 'workspace'; + } + + function currentFileTreeScope() { + var params = new URLSearchParams(window.location.search); + var fromUrl = String(params.get('fileTreeScope') || '').trim(); + if (fromUrl) return fromUrl; + var root = document.getElementById('sidebar-file-tree-root'); + return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : ''; + } + + function currentTopbarTitle() { + var title = document.querySelector('[data-page-title-current="true"]'); + return title && title.textContent ? title.textContent.trim() : '无标题'; + } + + function fileTreeRowTitleForShortcut(row, fallback) { + var title = row && row.querySelector ? row.querySelector(':scope > .tree-link > .tree-link-title') : null; + return title && title.textContent ? title.textContent.trim() : (fallback || '文件夹'); + } + + function sidebarShortcutRows() { + return Array.from(document.querySelectorAll('.wolai-starred-section [data-mnote-shortcut-kind]')); + } + + function shortcutMatches(row, kind, targetId, relativePath, documentId) { + if (!(row instanceof HTMLElement)) return false; + if ((row.getAttribute('data-mnote-shortcut-kind') || '') !== kind) return false; + if (documentId && (row.getAttribute('data-mnote-shortcut-document-id') || row.getAttribute('data-document-id') || '') === documentId) return true; + if (relativePath && (row.getAttribute('data-mnote-shortcut-relative-path') || '') === relativePath) return true; + return Boolean(targetId && (row.getAttribute('data-mnote-shortcut-target-id') || row.getAttribute('data-node-id') || '') === targetId); + } + + async function listSidebarShortcuts(workspaceId) { + var url = new URL('/api/sidebar/shortcuts', window.location.origin); + url.searchParams.set('workspaceId', workspaceId); + var response = await fetch(url.toString(), { + method: 'GET', + credentials: 'include', + headers: { accept: 'application/json' }, + cache: 'no-store' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'sidebar_shortcuts_list_failed_' + response.status); + return payload && Array.isArray(payload.shortcuts) ? payload.shortcuts : []; + } + + function shortcutRecordMatches(shortcut, kind, targetId, relativePath, documentId) { + if (!shortcut || shortcut.kind !== kind) return false; + var shortcutMetadata = shortcut && shortcut.metadata && typeof shortcut.metadata === 'object' ? shortcut.metadata : {}; + var shortcutRootUri = String(shortcut.rootUri || shortcut.root_uri || shortcutMetadata.rootUri || shortcutMetadata.root_uri || '').trim(); + var currentShortcutRootUri = currentRootUri(); + if (shortcutRootUri && currentShortcutRootUri && shortcutRootUri !== currentShortcutRootUri) return false; + if (documentId && String(shortcut.documentId || shortcut.document_id || '') === documentId) return true; + if (relativePath && String(shortcut.relativePath || shortcut.relative_path || '') === relativePath) return true; + return Boolean(targetId && String(shortcut.targetId || shortcut.target_id || '') === targetId); + } + + async function findSidebarShortcut(payload) { + var workspaceId = payload.workspaceId || sidebarShortcutWorkspaceId(); + if (!workspaceId) return null; + var shortcuts = await listSidebarShortcuts(workspaceId); + return shortcuts.find(function(shortcut) { + return shortcutRecordMatches(shortcut, payload.kind, payload.targetId, payload.relativePath, payload.documentId); + }) || null; + } + + async function upsertSidebarShortcut(payload) { + var response = await fetch('/api/sidebar/shortcuts', { + method: 'POST', + credentials: 'include', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(payload) + }); + var result = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(result && (result.error || result.message) || 'sidebar_shortcut_upsert_failed_' + response.status); + return result && result.shortcut ? result.shortcut : null; + } + + async function deleteSidebarShortcut(shortcutId) { + if (!shortcutId) return false; + var response = await fetch('/api/sidebar/shortcuts/' + encodeURIComponent(shortcutId), { + method: 'DELETE', + credentials: 'include', + headers: { accept: 'application/json' } + }); + var result = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(result && (result.error || result.message) || 'sidebar_shortcut_delete_failed_' + response.status); + return true; + } + + function closeSidebarShortcutMenu() { + var existing = document.querySelector('[data-testid="mnote-sidebar-shortcut-menu"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + document.querySelectorAll('[data-mnote-shortcut-action="menu"][aria-expanded="true"]').forEach(function(button) { + button.setAttribute('aria-expanded', 'false'); + }); + } + + async function removeSidebarShortcutByRow(row) { + if (!(row instanceof HTMLElement)) return false; + var shortcutId = String(row.getAttribute('data-mnote-shortcut-id') || '').trim(); + if (!shortcutId) return false; + row.setAttribute('data-mnote-shortcut-pending', 'true'); + try { + await deleteSidebarShortcut(shortcutId); + row.remove(); + closeSidebarShortcutMenu(); + document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'remove'); + return true; + } finally { + if (row.isConnected) row.removeAttribute('data-mnote-shortcut-pending'); + } + } + + function openSidebarShortcutMenu(row, trigger) { + if (!(row instanceof HTMLElement)) return; + closeSidebarShortcutMenu(); + if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'true'); + var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : row.getBoundingClientRect(); + var menu = document.createElement('div'); + menu.className = 'mnote-tree-context-menu'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('data-testid', 'mnote-sidebar-shortcut-menu'); + menu.innerHTML = ''; + menu.__mnoteShortcutRow = row; + document.body.appendChild(menu); + var width = menu.offsetWidth || 220; + var left = Math.min(Math.max(8, rect.right - width), Math.max(8, window.innerWidth - width - 8)); + var top = Math.min(Math.max(8, rect.bottom + 4), Math.max(8, window.innerHeight - (menu.offsetHeight || 120) - 8)); + menu.style.left = left + 'px'; + menu.style.top = top + 'px'; + } + + function removeSidebarShortcutRow(shortcut) { + sidebarShortcutRows().forEach(function(row) { + if (shortcutMatches( + row, + shortcut.kind, + String(shortcut.targetId || shortcut.target_id || ''), + String(shortcut.relativePath || shortcut.relative_path || ''), + String(shortcut.documentId || shortcut.document_id || '') + )) { + row.remove(); + } + }); + } + + function renderSidebarShortcutRow(shortcut) { + if (!shortcut) return; + var section = document.querySelector('.wolai-starred-section'); + if (!(section instanceof HTMLElement)) return; + removeSidebarShortcutRow(shortcut); + var kind = String(shortcut.kind || '').trim(); + var shortcutId = String(shortcut.id || shortcut.targetId || shortcut.target_id || '').trim(); + var targetId = String(shortcut.targetId || shortcut.target_id || '').trim(); + var relativePath = String(shortcut.relativePath || shortcut.relative_path || '').trim(); + var documentId = String(shortcut.documentId || shortcut.document_id || '').trim(); + var sourceKind = String(shortcut.sourceKind || shortcut.source_kind || '').trim(); + var metadata = shortcut.metadata && typeof shortcut.metadata === 'object' ? shortcut.metadata : {}; + var rootUri = String(shortcut.rootUri || shortcut.root_uri || metadata.rootUri || metadata.root_uri || '').trim(); + var workspaceId = String(shortcut.workspaceId || shortcut.workspace_id || sidebarShortcutWorkspaceId() || '').trim(); + var title = String(shortcut.title || (kind === 'folder' ? '文件夹' : '无标题')).trim(); + var href = ''; + if (documentId) { + var targetUrl = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin); + if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId); + if (sourceKind) targetUrl.searchParams.set('sourceKind', sourceKind); + if (rootUri) targetUrl.searchParams.set('rootUri', rootUri); + href = targetUrl.pathname + targetUrl.search; + } + var row = document.createElement(href ? 'a' : 'div'); + row.className = 'wolai-page-row'; + if (href) row.setAttribute('href', href); + else { + row.setAttribute('role', 'button'); + row.setAttribute('tabindex', '0'); + } + row.setAttribute('data-testid', 'wolai-sidebar-row'); + row.setAttribute('data-node-id', shortcutId || targetId); + row.setAttribute('data-document-id', documentId || shortcutId || targetId); + if (shortcutId) row.setAttribute('data-mnote-shortcut-id', shortcutId); + if (workspaceId) row.setAttribute('data-workspace-id', workspaceId); + row.setAttribute('data-mnote-shortcut-kind', kind); + if (sourceKind) row.setAttribute('data-mnote-shortcut-source-kind', sourceKind); + row.setAttribute('data-mnote-shortcut-target-id', targetId); + if (relativePath) row.setAttribute('data-mnote-shortcut-relative-path', relativePath); + if (rootUri) row.setAttribute('data-mnote-shortcut-root-uri', rootUri); + if (documentId) row.setAttribute('data-mnote-shortcut-document-id', documentId); + row.setAttribute('data-depth', '0'); + row.setAttribute('data-active', String(documentId && documentId === currentDocumentId())); + row.innerHTML = '' + escapeHtml(title) + ''; + section.appendChild(row); + } + + async function toggleSidebarShortcut(payload) { + var workspaceId = payload.workspaceId || sidebarShortcutWorkspaceId(); + if (!workspaceId) return false; + var normalized = Object.assign({}, payload, { workspaceId: workspaceId }); + var existing = await findSidebarShortcut(normalized); + if (existing && existing.id) { + await deleteSidebarShortcut(existing.id); + removeSidebarShortcutRow(existing); + document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'remove'); + return true; + } + var shortcut = await upsertSidebarShortcut(normalized); + renderSidebarShortcutRow(shortcut); + document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'upsert'); + return true; + } + + function currentPageShortcutPayload() { + var documentId = currentDocumentId(); + var workspaceId = sidebarShortcutWorkspaceId(); + if (!documentId || !workspaceId) return null; + return { + workspaceId: workspaceId, + kind: 'page', + sourceKind: sidebarShortcutSourceKind(), + targetId: documentId, + documentId: documentId, + title: currentTopbarTitle(), + icon: 'star', + rootUri: currentRootUri(), + metadataJson: JSON.stringify({ rootUri: currentRootUri() }) + }; + } + + function folderShortcutPayload(detail, trigger) { + detail = detail || {}; + var row = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger; + var rowKind = String(detail.rowKind || (row && row.getAttribute('data-row-kind')) || '').trim(); + if (rowKind !== 'folder' && rowKind !== 'directory') return null; + var relativePath = String(detail.localRelativePath || (row && row.getAttribute('data-local-relative-path')) || '').trim(); + if (!relativePath) return null; + var workspaceId = String(detail.workspaceId || sidebarShortcutWorkspaceId() || '').trim(); + if (!workspaceId) return null; + var rowId = String(detail.rowId || (row && row.getAttribute('data-row-id')) || '').trim(); + return { + workspaceId: workspaceId, + kind: 'folder', + sourceKind: 'local_folder', + targetId: rowId || ('folder:' + relativePath), + relativePath: relativePath, + title: detail.title || fileTreeRowTitleForShortcut(row, relativePath), + icon: 'folder_open', + rootUri: currentRootUri(), + metadataJson: JSON.stringify({ rootUri: currentRootUri() }) + }; + } + + async function toggleCurrentPageSidebarShortcut(trigger) { + var payload = currentPageShortcutPayload(); + if (!payload) return; + if (trigger instanceof HTMLElement) trigger.setAttribute('data-mnote-shortcut-pending', 'true'); + try { + await toggleSidebarShortcut(payload); + } finally { + if (trigger instanceof HTMLElement) trigger.removeAttribute('data-mnote-shortcut-pending'); + } + } + + async function toggleFolderSidebarShortcut(detail, trigger) { + var payload = folderShortcutPayload(detail, trigger); + if (!payload) return false; + await toggleSidebarShortcut(payload); + return true; + } + + function ensureStarredFolderFileTreeHost(workspaceId) { + var root = document.getElementById('sidebar-file-tree-root'); + if (root instanceof HTMLElement) return root; + var panel = document.getElementById('wolai-sidebar-file-tree-panel'); + if (!(panel instanceof HTMLElement)) return null; + var section = document.createElement('div'); + section.className = 'sidebar-tree-section sidebar-file-tree-section'; + root = document.createElement('div'); + root.id = 'sidebar-file-tree-root'; + root.className = 'sidebar-tree'; + root.setAttribute('data-tree-shell-mode', 'filetree'); + if (workspaceId) root.setAttribute('data-workspace-id', workspaceId); + section.appendChild(root); + panel.appendChild(section); + return root; + } + + function persistStarredFolderScope(workspaceId, rootUri, relativePath) { + var targetUrl = new URL(window.location.href); + if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId); + targetUrl.searchParams.set('sourceKind', 'local_folder'); + targetUrl.searchParams.set('rootUri', rootUri); + targetUrl.searchParams.set('treeView', 'filetree'); + targetUrl.searchParams.set('fileTreeScope', relativePath); + window.history.replaceState(window.history.state, '', targetUrl.pathname + targetUrl.search + targetUrl.hash); + if (document.body instanceof HTMLElement) { + document.body.setAttribute('data-mnote-source-kind', 'local_folder'); + document.body.setAttribute('data-mnote-root-uri', rootUri); + } + } + + function readShortcutRootUri(row) { + if (!(row instanceof HTMLElement)) return ''; + return String(row.getAttribute('data-mnote-shortcut-root-uri') || '').trim(); + } + + async function openStarredFolderShortcut(row) { + if (!(row instanceof HTMLElement)) return false; + var relativePath = String(row.getAttribute('data-mnote-shortcut-relative-path') || '').trim(); + var workspaceId = String(row.getAttribute('data-workspace-id') || '').trim() || sidebarShortcutWorkspaceId(); + var rootUri = readShortcutRootUri(row); + if (!relativePath || !rootUri || !workspaceId) { + document.documentElement.setAttribute('data-mnote-sidebar-shortcut-open-error', !rootUri ? 'missing_root_uri' : 'missing_target'); + return false; + } + document.documentElement.removeAttribute('data-mnote-sidebar-shortcut-open-error'); + var tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"]'); + if (tab instanceof HTMLElement) switchSidebarTreeTab(tab); + var root = ensureStarredFolderFileTreeHost(workspaceId); + if (root instanceof HTMLElement) { + root.setAttribute('data-workspace-id', workspaceId); + root.setAttribute('data-mnote-filetree-scope', relativePath); + root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath); + } + persistStarredFolderScope(workspaceId, rootUri, relativePath); + document.documentElement.setAttribute('data-mnote-filetree-scope', relativePath); + var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin); + sidebarUrl.searchParams.set('workspaceId', workspaceId); + sidebarUrl.searchParams.set('sourceKind', 'local_folder'); + sidebarUrl.searchParams.set('rootUri', rootUri); + var url = new URL('/api/tree/projections/file', window.location.origin); + url.searchParams.set('workspaceId', workspaceId); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('parentRelativePath', relativePath); + var sidebarResponse = await fetch(sidebarUrl.toString(), { + method: 'GET', + credentials: 'include', + headers: { accept: 'application/json' }, + cache: 'no-store' + }); + var sidebarPayload = await sidebarResponse.json().catch(function() { return null; }); + if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status); + var response = await fetch(url.toString(), { + method: 'GET', + credentials: 'include', + headers: { accept: 'application/json' }, + cache: 'no-store' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'scoped_filetree_failed_' + response.status); + var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {}; + var fileProjection = payload && (payload.result || payload); + var rendered = renderSidebarSnapshot(Object.assign({}, sidebarProjection, { + dataset: Object.assign({}, sidebarProjection.dataset || {}, { kernel_file_tree_projection: fileProjection }) + })); + root = document.getElementById('sidebar-file-tree-root'); + if (root instanceof HTMLElement) { + root.setAttribute('data-workspace-id', workspaceId); + root.setAttribute('data-mnote-filetree-scope', relativePath); + root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath); + } + return rendered; + } + const sidebarFileTreeOpen = createSidebarFileTreeOpenRuntime({ copyWorkspaceSourceParams, currentDocumentId, @@ -736,14 +1108,19 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim async function healLegacyOfficeAttachmentParagraphs() { var editor = document.querySelector('.editor-surface .ProseMirror'); if (!(editor instanceof HTMLElement)) return; - var index = await fetchLegacyOfficeAttachmentIndex(); var paragraphs = Array.from(editor.querySelectorAll('p')); - paragraphs.forEach(function(paragraph) { + var candidates = paragraphs.filter(function(paragraph) { if (!(paragraph instanceof HTMLParagraphElement)) return; if (paragraph.querySelector('a, img, video, audio, table, iframe, canvas')) return; if (paragraph.childNodes.length !== 1 || paragraph.firstChild?.nodeType !== Node.TEXT_NODE) return; var fileName = String(paragraph.textContent || '').trim(); if (!fileName) return; + return Boolean(inferOnlyOfficeFileType(fileName, '')); + }); + if (!candidates.length) return; + var index = await fetchLegacyOfficeAttachmentIndex(); + candidates.forEach(function(paragraph) { + var fileName = String(paragraph.textContent || '').trim(); var detail = index[fileName]; if (!detail) return; var link = document.createElement('a'); @@ -1545,6 +1922,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim createPage, cssEscape, currentDocumentId, + currentRootUri, currentSourceKind, deleteSingleFileTreeAsset, dispatchSidebarEvent, @@ -1563,6 +1941,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args), openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args), refreshLocalFolderSidebarSnapshot, + removeFileTreeAssetRow, + revealFileTreeResource, resolveWorkspaceId, runtimeState: sidebarFileTreeCommandState, selectedSidebarFileTreeSelection: sidebarFileTreeSelection, @@ -1620,6 +2000,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim const postSidebarFileTreeJson = (...args) => sidebarFileTreeCommand.postSidebarFileTreeJson(...args); const fileTreeRowsByRowIds = (...args) => sidebarFileTreeCommand.fileTreeRowsByRowIds(...args); const fileTreeChildCount = (...args) => sidebarFileTreeCommand.fileTreeChildCount(...args); + const moveSidebarFileTreeRows = (...args) => sidebarFileTreeCommand.moveSidebarFileTreeRows(...args); const pasteSidebarFileTreeClipboard = (...args) => sidebarFileTreeCommand.pasteSidebarFileTreeClipboard(...args); const deleteSelectedSidebarFileTreeRows = (...args) => sidebarFileTreeCommand.deleteSelectedSidebarFileTreeRows(...args); @@ -1891,6 +2272,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim buildLocalOnlyOfficeOpenUrl, buildOnlyOfficeOpenPath, buildOnlyOfficeOpenUrl, + closestAction, currentDocumentId, currentRootUri, currentWorkspaceSourcePayload, @@ -2274,6 +2656,58 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return; } + var pageShortcutTrigger = closestAction(e.target, '[data-mnote-action="toggle-sidebar-shortcut"]'); + if (pageShortcutTrigger) { + e.preventDefault(); + void toggleCurrentPageSidebarShortcut(pageShortcutTrigger); + return; + } + + var shortcutMenu = closestAction(e.target, '[data-testid="mnote-sidebar-shortcut-menu"]'); + var shortcutMenuAction = closestAction(e.target, '[data-mnote-sidebar-shortcut-menu-action]'); + if (shortcutMenuAction) { + e.preventDefault(); + var action = String(shortcutMenuAction.getAttribute('data-mnote-sidebar-shortcut-menu-action') || '').trim(); + var shortcutRowFromMenu = shortcutMenu && shortcutMenu.__mnoteShortcutRow instanceof HTMLElement + ? shortcutMenu.__mnoteShortcutRow + : null; + if (action === 'remove') { + void removeSidebarShortcutByRow(shortcutRowFromMenu); + return; + } + if (action === 'open') { + closeSidebarShortcutMenu(); + if (shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('data-mnote-shortcut-kind') === 'folder') { + void openStarredFolderShortcut(shortcutRowFromMenu); + } else if (shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('href')) { + window.location.assign(shortcutRowFromMenu.getAttribute('href')); + } + return; + } + if (action === 'copy-link') { + var href = shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('href') || window.location.href; + if (navigator.clipboard && href) void navigator.clipboard.writeText(new URL(href, window.location.origin).toString()); + closeSidebarShortcutMenu(); + return; + } + } + if (!shortcutMenu) closeSidebarShortcutMenu(); + + var shortcutMenuTrigger = closestAction(e.target, '.wolai-starred-section [data-mnote-shortcut-action="menu"]'); + if (shortcutMenuTrigger) { + e.preventDefault(); + var shortcutRow = shortcutMenuTrigger.closest('[data-mnote-shortcut-id]'); + openSidebarShortcutMenu(shortcutRow, shortcutMenuTrigger); + return; + } + + var folderShortcutRow = closestAction(e.target, '.wolai-starred-section [data-mnote-shortcut-kind="folder"]'); + if (folderShortcutRow) { + e.preventDefault(); + void openStarredFolderShortcut(folderShortcutRow); + return; + } + var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]'); if (localFolderTrigger) { e.preventDefault(); @@ -2295,6 +2729,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return; } + var createFolderTrigger = closestAction(e.target, '[data-mnote-action="create-folder"]'); + if (createFolderTrigger) { + e.preventDefault(); + if (currentSourceKind() !== 'local_folder') return; + var scope = currentFileTreeScope(); + var parentId = scope ? 'local:folder:' + scope : null; + void createFileTreeFolder(createFolderTrigger, parentId); + return; + } + var fileTree = document.getElementById('sidebar-file-tree-root'); if (fileTree && fileTree.contains(e.target)) { var fileBtn = closestAction(e.target, '[data-rust-action]'); @@ -2345,7 +2789,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return; } if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) { - navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' }); + navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree', fileTreeScope: currentFileTreeScope() }); } else if (assetId) { 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' }); } @@ -2355,6 +2799,29 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim if (sidebarPageTree.handlePageTreeClick(e, { closestAction: closestAction })) return; }); + window.addEventListener('tree.sidebarShortcut.toggleFolder', function(event) { + var detail = event.detail || {}; + var row = detail.rowId + ? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.rowId) + '"]') + : null; + void toggleFolderSidebarShortcut(detail, row); + }); + + window.addEventListener('tree.filetree.internal-drop', function(event) { + var detail = event.detail || {}; + var rowIds = Array.isArray(detail.rowIds) ? detail.rowIds : []; + if (!rowIds.length) return; + var targetRow = detail.targetRowId + ? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]') + : null; + recordFileTreeAction('internal-drop', { + rowId: detail.targetRowId || '', + sourceRowIds: rowIds, + copy: Boolean(detail.copy) + }); + void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) }); + }); + 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, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); if (editorAttachmentLink instanceof HTMLAnchorElement) { diff --git a/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js b/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js index 074605d2..272f08ad 100644 --- a/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js @@ -156,7 +156,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => { function copyWorkspaceSourceParams(targetUrl) { var params = new URLSearchParams(window.location.search); - ['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) { + ['sourceKind', 'rootUri', 'fileTreeScope', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) { var value = (params.get(name) || '').trim(); if (value) targetUrl.searchParams.set(name, value); }); diff --git a/rust/crates/mnote-web/browser/tree-live-controller.js b/rust/crates/mnote-web/browser/tree-live-controller.js index 152eb894..ac313bfb 100644 --- a/rust/crates/mnote-web/browser/tree-live-controller.js +++ b/rust/crates/mnote-web/browser/tree-live-controller.js @@ -87,6 +87,18 @@ dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap }); }); + source.addEventListener('watch_batch', function(event){ + var payload = JSON.parse(event.data || '{}'); + var revision = payload.revision || payload.cursor || event.lastEventId || null; + document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || '')); + dispatchTreeEvent('tree:local-folder-watch-batch', { payload: payload, revision: revision, bootstrap: bootstrap }); + }); + + source.addEventListener('tree_error', function(event){ + var payload = JSON.parse(event.data || '{}'); + dispatchTreeEvent('tree:error', { payload: payload, bootstrap: bootstrap }); + }); + source.addEventListener('block.delta', function(event){ var payload = JSON.parse(event.data || '{}'); dispatchTreeEvent('tree:block-delta', { payload: payload, bootstrap: bootstrap }); diff --git a/rust/crates/mnote-web/src/document_buffer_store.rs b/rust/crates/mnote-web/src/document_buffer_store.rs index e2cb7f4b..cc8824c3 100644 --- a/rust/crates/mnote-web/src/document_buffer_store.rs +++ b/rust/crates/mnote-web/src/document_buffer_store.rs @@ -174,6 +174,57 @@ impl BufferStore { } } + /// 本地文件 rename/move 后重绑打开的 Markdown buffer。 + pub fn rekey_local_folder_markdown( + &self, + workspace_id: &str, + root_uri: &str, + previous_relative_path: &str, + previous_document_id: &str, + next_relative_path: &str, + next_document_id: &str, + ) -> Option { + let previous_path = build_local_folder_workspace_path( + workspace_id, + root_uri, + previous_relative_path, + previous_document_id, + ); + let next_path = build_local_folder_workspace_path( + workspace_id, + root_uri, + next_relative_path, + next_document_id, + ); + let previous_key = BufferKey::from_workspace_path(&previous_path); + let next_key = BufferKey::from_workspace_path(&next_path); + let mut inner = self.inner.write().expect("BufferStore lock"); + let mut buffer = inner.buffers.remove(&previous_key)?; + buffer.workspace_path = next_path; + inner.buffers.insert(next_key, buffer.clone()); + Some(buffer) + } + + /// 本地文件 delete/archive/purge 后标记打开的 Markdown buffer 已删除。 + pub fn mark_local_folder_markdown_deleted( + &self, + workspace_id: &str, + root_uri: &str, + relative_path: &str, + document_id: &str, + ) -> Option { + let path = + build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id); + let key = BufferKey::from_workspace_path(&path); + let mut inner = self.inner.write().expect("BufferStore lock"); + if let Some(buf) = inner.buffers.get_mut(&key) { + buf.mark_deleted(); + Some(buf.clone()) + } else { + None + } + } + /// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。 pub fn init_buffer( &self, @@ -449,4 +500,70 @@ mod tests { assert_eq!(buf.file_version.as_deref(), Some("v2")); assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved")); } + + #[test] + fn document_buffer_rekeys_after_local_file_operation_rename() { + let store = BufferStore::new(); + let root_uri = "file:///tmp/mnote-buffer-rekey"; + let old_path = build_local_folder_workspace_path( + "local:test", + root_uri, + "docs/Old.md", + "local-md:docs~2FOld.md", + ); + store.init_buffer(&old_path, Some("v1".into()), Some("sha256:old".into())); + + let rekeyed = store + .rekey_local_folder_markdown( + "local:test", + root_uri, + "docs/Old.md", + "local-md:docs~2FOld.md", + "docs/New.md", + "local-md:docs~2FNew.md", + ) + .expect("buffer should be rekeyed"); + + assert_eq!(rekeyed.workspace_path.relative_path, "docs/New.md"); + assert_eq!( + rekeyed + .workspace_path + .object_identity + .document_id + .as_deref(), + Some("local-md:docs~2FNew.md") + ); + assert!(store.get_by_path(&old_path).is_none()); + let next_path = build_local_folder_workspace_path( + "local:test", + root_uri, + "docs/New.md", + "local-md:docs~2FNew.md", + ); + assert!(store.get_by_path(&next_path).is_some()); + } + + #[test] + fn document_buffer_marks_deleted_after_local_file_operation_archive() { + let store = BufferStore::new(); + let root_uri = "file:///tmp/mnote-buffer-delete"; + let path = build_local_folder_workspace_path( + "local:test", + root_uri, + "docs/Delete.md", + "local-md:docs~2FDelete.md", + ); + store.get_or_create(&path); + + let deleted = store + .mark_local_folder_markdown_deleted( + "local:test", + root_uri, + "docs/Delete.md", + "local-md:docs~2FDelete.md", + ) + .expect("buffer should be marked deleted"); + + assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted); + } } diff --git a/rust/crates/mnote-web/src/hermes_tools/page.rs b/rust/crates/mnote-web/src/hermes_tools/page.rs index e1f66120..9eddb390 100644 --- a/rust/crates/mnote-web/src/hermes_tools/page.rs +++ b/rust/crates/mnote-web/src/hermes_tools/page.rs @@ -139,7 +139,12 @@ pub async fn update_options( ) .with_context(context) })?; - let (wired_options, ignored_options, warnings) = filter_wired_page_options(options); + let (wired_options, ignored_options, warnings) = + if input.effective_source_kind().as_deref() == Some("local_folder") { + filter_local_ui_preference_options(options) + } else { + filter_wired_page_options(options) + }; page_command( state, context, @@ -235,7 +240,16 @@ async fn page_command( ) .with_context(context) })?; - crate::routes::update_local_page_options(&root_uri, &document_id, &options)? + crate::routes::ui_preferences::update_page_preferences_from_value( + state, + context, + context.auth.actor_id.trim(), + workspace_id.as_deref().unwrap_or_default(), + "local_folder", + &root_uri, + &document_id, + &options, + )? } _ => { return Err(WebError::bad_request_code( @@ -443,6 +457,41 @@ fn filter_wired_page_options(options: Value) -> (Value, Vec, Vec) (Value::Object(out), ignored, warnings) } +fn filter_local_ui_preference_options(options: Value) -> (Value, Vec, Vec) { + let allowed = [ + "wideLayout", + "smallText", + "layoutDensity", + "pageFont", + "showHeadingNumbers", + "showToc", + "showStructure", + "showWordCount", + "collapseBacklinks", + "hideChildPages", + "showBlockRefCount", + "hideTitleHeader", + ]; + let mut out = serde_json::Map::new(); + let mut ignored = Vec::new(); + let mut warnings = Vec::new(); + if let Value::Object(map) = options { + for (key, value) in map { + if allowed.contains(&key.as_str()) { + out.insert(key, value); + } else { + warnings.push(json!({ + "code": "page_option_not_wired", + "field": key.clone(), + "message": "页面设置字段尚未接入 SQLite UI 偏好,已忽略" + })); + ignored.push(key); + } + } + } + (Value::Object(out), ignored, warnings) +} + fn summarize_blocks(content: &Value) -> Vec { let mut out = Vec::new(); collect_blocks(content, &mut out); diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 655e5a84..7027b39a 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -6,8 +6,7 @@ use crate::routes::command_support::{ execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts, }; use crate::routes::local_folder_source::{ - ensure_local_workspace_access, update_local_markdown_title, update_local_page_options, - write_local_markdown_page_body, + ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body, }; use crate::routes::query_support::{ execute_runtime_query_via_convex, fetch_documents_meta_via_convex, @@ -66,7 +65,15 @@ fn find_document_buffer_state( relative_path, document_id, ); - return buffer_store.get_by_path(&ws_path); + if let Some(buffer) = buffer_store.get_by_path(&ws_path) { + return Some(buffer); + } + return buffer_store.all_buffers().into_iter().find(|buf| { + buf.workspace_path.root_uri == root_uri + && buf.workspace_path.relative_path == relative_path + && buf.workspace_path.object_identity.document_id.as_deref() + == Some(document_id) + }); } return buffer_store.all_buffers().into_iter().find(|buf| { buf.workspace_path.root_uri == root_uri @@ -1022,7 +1029,16 @@ pub async fn options( })?; ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; - let result = update_local_page_options(root_uri, document_id, &body.options)?; + let result = crate::routes::ui_preferences::update_page_preferences_from_value( + &state, + &context, + context.auth.actor_id.trim(), + body.workspace_id.as_deref().unwrap_or_default(), + body.source_kind.as_deref().unwrap_or("local_folder"), + root_uri, + document_id, + &body.options, + )?; return Ok(ok_response(&context, result)); } let effective_workspace_id = @@ -1486,7 +1502,7 @@ mod tests { } #[tokio::test] - async fn local_folder_documents_save_title_and_options_write_to_disk() { + async fn local_folder_documents_save_title_and_options_store_ui_preferences_in_sqlite() { let root = std::env::temp_dir().join(format!( "mnote-local-documents-write-{}", std::process::id() @@ -1615,20 +1631,9 @@ mod tests { assert!(markdown.contains("## Saved Heading")); assert!(markdown.contains("Saved body")); - let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json")) - .expect("read page options"); - let options_json: Value = serde_json::from_str(&options).expect("options json"); - assert_eq!( - options_json["pages"][&renamed_document_id]["wideLayout"], - true - ); - assert_eq!( - options_json["pages"][&renamed_document_id]["showToc"], - false - ); - assert_eq!( - options_json["pages"][&renamed_document_id]["hideTitleHeader"], - false + assert!( + !root.join(".mnote").join("page-options.json").exists(), + "local folder UI 偏好不应继续写入 .mnote/page-options.json" ); let _ = std::fs::remove_dir_all(&root); diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index ee076464..a54e338c 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -8,11 +8,12 @@ use crate::routes::local_folder_source::{ }; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ - build_document_panes_bootstrap_json, build_editor_bootstrap_json, - build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html, - load_sidebar_tree_html, load_workspace_shell_projection, + attach_sidebar_shortcuts_to_dataset, build_document_panes_bootstrap_json, + build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json, + load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection, render_document_title_controller_script, render_editor_island_adapter_script, - render_local_file_tree_html, render_local_sidebar_tree_html, + render_editor_runtime_preload_links, render_local_file_tree_html, + render_local_file_tree_html_scoped, render_local_sidebar_tree_html, }; use crate::transport::convex::execute_convex_mutation_by_name; use crate::workspace_shell::{ @@ -67,6 +68,7 @@ pub(crate) struct RootEntryQuery { workspace_id: Option, source_kind: Option, root_uri: Option, + file_tree_scope: Option, tree_view: Option, restore_focus_row_id: Option, } @@ -300,6 +302,11 @@ pub async fn root_entry( .as_deref() .map(str::trim) .is_some_and(|value| value == "filetree"); + let file_tree_scope = query + .file_tree_scope + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); let ( workspace_id, workspace_projection, @@ -318,15 +325,8 @@ pub async fn root_entry( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?; - let snapshot = if requests_filetree_first { - crate::routes::local_folder_source::load_local_folder_file_tree_snapshot_with_reveal( - root_uri, - requested_page_id.as_deref(), - )? - } else { - load_local_folder_page_tree_snapshot(root_uri)? - }; - let workspace_id = snapshot + let page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?; + let workspace_id = page_tree_snapshot .dataset .get("workspace") .and_then(|workspace| workspace.get("id")) @@ -337,8 +337,15 @@ pub async fn root_entry( .to_string(); let requested_or_recent_page_id = choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None); + let mut workspace_dataset = page_tree_snapshot.dataset.clone(); + attach_sidebar_shortcuts_to_dataset( + &state, + &context, + &workspace_id, + &mut workspace_dataset, + ); let workspace_projection = build_workspace_shell_projection( - &snapshot.dataset, + &workspace_dataset, &workspace_id, requested_or_recent_page_id.as_deref(), "本地文件夹", @@ -352,20 +359,18 @@ pub async fn root_entry( .first() .map(|item| item.id.as_str()), ); - let sidebar_tree_html = if requests_filetree_first { - String::new() - } else { - render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())? - }; + let sidebar_tree_html = + render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?; let restore_focus_row_id = query .restore_focus_row_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); - let file_tree_html = render_local_file_tree_html( + let file_tree_html = render_local_file_tree_html_scoped( root_uri, selected_active_page_id.as_deref(), restore_focus_row_id, + file_tree_scope, )?; ( workspace_id, @@ -399,8 +404,15 @@ pub async fn root_entry( .filter(|value| !value.is_empty()) .unwrap_or("local-folder") .to_string(); + let mut workspace_dataset = snapshot.dataset.clone(); + attach_sidebar_shortcuts_to_dataset( + &state, + &context, + &workspace_id, + &mut workspace_dataset, + ); let workspace_projection = build_workspace_shell_projection( - &snapshot.dataset, + &workspace_dataset, &workspace_id, requested_page_id.as_deref(), &default_workspace_name, @@ -437,6 +449,7 @@ pub async fn root_entry( None, ); let workspace_projection = load_workspace_shell_projection( + Some(&state), state.config(), &context, &workspace_id, @@ -489,6 +502,7 @@ pub async fn root_entry( } else { None }, + file_tree_scope, ); let workspace_name = workspace_projection.workspace_name.clone(); let active_page_id = selected_active_page_id.unwrap_or_default(); @@ -593,12 +607,19 @@ pub async fn root_entry( Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()), } }; + let editor_runtime_preload_links = + if body_extra.contains("document-editor-adapter-runtime.js") { + render_editor_runtime_preload_links() + } else { + "" + }; let mut response = Html(format!( r#" {} + {} @@ -607,6 +628,7 @@ pub async fn root_entry( "#, escape_html(&html_title), + editor_runtime_preload_links, crate::ssr::MNOTE_CSS, escape_html(context.auth.actor_id.as_str()), escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")), @@ -654,21 +676,25 @@ pub async fn trash_entry( crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?; let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default(); let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default(); - let workspace_projection = build_workspace_shell_projection( - &json!({ - "active_workspace_id": workspace_id, - "workspaces": [{ "id": workspace_id, "name": "我的空间" }], - "documents": [], - }), + let mut workspace_dataset = json!({ + "active_workspace_id": workspace_id, + "workspaces": [{ "id": workspace_id, "name": "我的空间" }], + "documents": [], + }); + attach_sidebar_shortcuts_to_dataset( + &state, + &context, &workspace_id, - None, - "我的空间", + &mut workspace_dataset, ); + let workspace_projection = + build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间"); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, + None, ); let trash_workbench_html = render_local_trash_workbench_html( &workspace_id, @@ -714,6 +740,7 @@ pub async fn trash_entry( let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; let workspace_projection = load_workspace_shell_projection( + Some(&state), state.config(), &context, &workspace_id, @@ -732,6 +759,7 @@ pub async fn trash_entry( Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, + None, ); let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id) .await @@ -2779,6 +2807,9 @@ mod tests { assert!(html.contains(r#"data-mnote-shell="workspace""#)); assert!(html.contains("local_folder")); assert!(html.contains("README.md")); + assert!(html.contains(r#"id="sidebar-tree-root""#)); + assert!(html.contains(r#"data-shell-mode="page""#)); + assert!(html.contains(r#"data-node-id="local-md:README.md""#)); assert!(html.contains(r#"data-row-id="local:folder:docs""#)); assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#)); assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#)); @@ -2832,6 +2863,12 @@ mod tests { assert!(html.contains( r#""# )); + assert!(html.contains( + r#""# + )); + assert!(html.contains( + r#""# + )); assert!( include_str!("../../browser/document-resource-tab-runtime.js") .contains("openResourceInActiveTab") diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index 349a7d9d..129ab9e8 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -2966,6 +2966,89 @@ mod tests { let _ = fs::remove_dir_all(&audit_dir); } + #[tokio::test] + async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() { + let root = std::env::temp_dir().join(format!( + "mnote-page-options-local-folder-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join(".mnote")).expect("metadata"); + fs::write( + root.join(".mnote").join("workspace.json"), + r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, + ) + .expect("manifest"); + fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.page.update_options", + "workspaceId": "local-ws-user-1", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "sessionId": "sess_page_options_local", + "runId": "run_page_options_local", + "toolCallId": "call_page_options_local", + "traceId": "trace_page_options_local", + "idempotencyKey": "idem_page_options_local", + "dryRun": false, + "args": { + "options": { + "wideLayout": true, + "showHeadingNumbers": true, + "hideTitleHeader": false + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert_eq!(status, StatusCode::OK, "{text}"); + let payload: Value = serde_json::from_str(&text).expect("json"); + assert_eq!(payload["result"]["source"], "local_folder"); + assert_eq!( + payload["result"]["commandName"], + "page.layout.updateOptions" + ); + assert_eq!( + payload["result"]["result"]["pageOptions"]["wideLayout"], + true + ); + assert_eq!( + payload["result"]["result"]["pageOptions"]["showHeadingNumbers"], + true + ); + assert_eq!( + payload["result"]["result"]["pageOptions"]["hideTitleHeader"], + false + ); + assert!( + !root.join(".mnote").join("page-options.json").exists(), + "AI 页面设置工具不应继续写入 .mnote/page-options.json" + ); + let _ = fs::remove_dir_all(&root); + } + #[tokio::test] async fn hermes_tools_page_save_dry_run_returns_diff_without_write() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index cb046dbc..b40f3dbe 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -19,6 +19,10 @@ use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use serde::Deserialize; use serde_json::{json, Value}; +#[cfg(test)] +static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelProjectionQuery { @@ -89,31 +93,39 @@ async fn project_projection( })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; - let snapshot = if projection == KernelProjectionKind::FileTree { - if let Some(parent_relative_path) = query - .parent_relative_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - load_local_folder_file_tree_children_snapshot(root_uri, parent_relative_path)? - } else if query - .root_node_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - { - load_local_folder_file_tree_snapshot_with_reveal( - root_uri, - query.root_node_id.as_deref(), - )? + let root_uri = root_uri.to_string(); + let parent_relative_path = query + .parent_relative_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let root_node_id = query + .root_node_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let snapshot = tokio::task::spawn_blocking(move || { + #[cfg(test)] + block_local_folder_projection_for_test(); + if projection == KernelProjectionKind::FileTree { + if let Some(parent_relative_path) = parent_relative_path.as_deref() { + load_local_folder_file_tree_children_snapshot(&root_uri, parent_relative_path) + } else if root_node_id.is_some() { + load_local_folder_file_tree_snapshot_with_reveal( + &root_uri, + root_node_id.as_deref(), + ) + } else { + load_local_folder_file_tree_snapshot(&root_uri) + } } else { - load_local_folder_file_tree_snapshot(root_uri)? + load_local_folder_page_tree_snapshot(&root_uri) } - } else { - load_local_folder_page_tree_snapshot(root_uri)? - }; + }) + .await + .map_err(|error| WebError::internal(format!("本地树 projection 构建任务失败: {error}")))??; return Ok(ok_response(&context, snapshot.projection)); } @@ -137,6 +149,14 @@ async fn project_projection( Ok(ok_response(&context, snapshot.projection)) } +#[cfg(test)] +fn block_local_folder_projection_for_test() { + let delay_ms = LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst); + if delay_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } +} + pub async fn project_tree_sidebar( state: State, context: Extension, @@ -245,6 +265,7 @@ mod tests { use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::Value; + use std::time::{Duration, Instant}; use tower::util::ServiceExt; fn app() -> axum::Router { @@ -443,6 +464,70 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn local_folder_file_projection_does_not_block_leptos_runtime_asset_request() { + let root = std::env::temp_dir().join(format!( + "mnote-local-kernel-projection-runtime-asset-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme"); + let root_uri = format!("file://{}", root.display()); + initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); + let app = app(); + let projection_app = app.clone(); + let projection_uri = format!( + "/api/tree/projections/file/children?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs" + ); + super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS + .store(400, std::sync::atomic::Ordering::SeqCst); + struct ResetLocalProjectionBlock; + impl Drop for ResetLocalProjectionBlock { + fn drop(&mut self) { + super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS + .store(0, std::sync::atomic::Ordering::SeqCst); + } + } + let _reset_block = ResetLocalProjectionBlock; + let projection_task = tokio::spawn(async move { + projection_app + .oneshot( + Request::builder() + .uri(projection_uri) + .header("x-mnote-actor-id", "dev-user") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("projection request"), + ) + .await + .expect("projection response") + }); + + let started = Instant::now(); + tokio::task::yield_now().await; + let asset_response = app + .oneshot( + Request::builder() + .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") + .body(Body::empty()) + .expect("asset request"), + ) + .await + .expect("asset response"); + let asset_elapsed = started.elapsed(); + let projection_response = projection_task.await.expect("projection task"); + + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(asset_response.status(), StatusCode::OK); + assert_eq!(projection_response.status(), StatusCode::OK); + assert!( + asset_elapsed < Duration::from_millis(150), + "leptos-tiptap runtime asset 不应被本地 FileTree projection 扫描阻塞,实际等待 {asset_elapsed:?}" + ); + } + #[tokio::test] async fn tree_projection_routes_keep_ok_response_shape() { let response = app() 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 c2eb5fc8..0e55a388 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; use std::pin::Pin; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::broadcast::error::RecvError; -use tokio::time::{interval, MissedTickBehavior}; +use tokio::time::{interval, timeout, MissedTickBehavior}; type BoxedEventStream = Pin> + Send>>; @@ -177,18 +177,38 @@ async fn build_tree_live_stream( loop { match subscription.receiver.recv().await { - Ok(_watcher_payload) => { - // Rebuild full snapshot on any filesystem change - if let Some(resync_payload) = - rebuild_tree_resync_payload(&root_uri, &workspace_id) - { + Ok(watcher_payload) => { + let mut watcher_payloads = vec![watcher_payload]; + loop { + match timeout(Duration::from_millis(120), subscription.receiver.recv()) + .await + { + Ok(Ok(next_payload)) => watcher_payloads.push(next_payload), + Ok(Err(RecvError::Lagged(_))) => continue, + Ok(Err(RecvError::Closed)) => return None, + Err(_) => break, + } + } + if let Some(batch_payload) = build_local_folder_watch_batch_payload( + &root_uri, + &workspace_id, + watcher_payloads, + ) { return Some(( - Ok(stream_event("resync", &resync_payload)), + Ok(stream_event("watch_batch", &batch_payload)), (None, subscription, root_uri, workspace_id), )); } - // Snapshot load failed — continue waiting for next change - continue; + let error_payload = build_tree_live_error_payload( + &root_uri, + &workspace_id, + "tree_live_watch_batch_failed", + "local folder watcher batch payload missing paths", + ); + return Some(( + Ok(stream_event("tree_error", &error_payload)), + (None, subscription, root_uri, workspace_id), + )); } Err(RecvError::Lagged(_)) => continue, Err(RecvError::Closed) => return None, @@ -276,6 +296,96 @@ fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option String { + let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); + if normalized.is_empty() || normalized == "." { + return String::new(); + } + normalized + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .unwrap_or_default() +} + +fn build_local_folder_watch_batch_payload( + root_uri: &str, + workspace_id: &str, + watcher_payloads: Vec, +) -> Option { + let revision = local_folder_watch_revision(root_uri).ok()?; + let mut changed_paths = Vec::new(); + let mut affected_parents = Vec::new(); + let mut event_kinds = Vec::new(); + let mut seen_paths = std::collections::BTreeSet::new(); + let mut seen_parents = std::collections::BTreeSet::new(); + let mut seen_kinds = std::collections::BTreeSet::new(); + for payload in watcher_payloads { + let relative_path = payload + .get("relativePath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let event_kind = payload + .get("eventKind") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + if seen_paths.insert(relative_path.to_string()) { + changed_paths.push(json!({ + "relativePath": relative_path, + "kind": event_kind, + })); + } + if seen_kinds.insert(event_kind.to_string()) { + event_kinds.push(event_kind.to_string()); + } + let parent = parent_relative_path_for_watch_path(relative_path); + if seen_parents.insert(parent.clone()) { + affected_parents.push(json!({ + "relativePath": parent, + "reason": "child-watch", + })); + } + } + if changed_paths.is_empty() { + return None; + } + Some(json!({ + "schema": "mnote.local_folder_watch_batch.v1", + "kind": "watch_batch", + "sourceKind": "local_folder", + "rootUri": root_uri, + "workspaceId": workspace_id, + "revision": revision.revision, + "watchRevision": revision, + "changedPaths": changed_paths, + "affectedParents": affected_parents, + "eventKinds": event_kinds, + "fallbackResync": false, + })) +} + +fn build_tree_live_error_payload( + root_uri: &str, + workspace_id: &str, + code: &str, + message: &str, +) -> Value { + json!({ + "schema": "mnote.tree_live_error.v1", + "kind": "error", + "phase": "tree_live_resync", + "sourceKind": "local_folder", + "rootUri": root_uri, + "workspaceId": workspace_id, + "code": code, + "message": message, + "fallbackResync": true, + "revision": system_time_ms(SystemTime::now()).to_string(), + }) +} + fn build_tree_snapshot_payload( root_uri: &str, workspace_id: &str, @@ -548,4 +658,66 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + + #[test] + fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() { + let root = test_root("tree-live-watch-batch"); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs/README.md"), "# Initial\n").expect("write initial"); + let root_uri = format!("file://{}", root.display()); + let workspace_id = + local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id"); + + let payload = build_local_folder_watch_batch_payload( + &root_uri, + &workspace_id, + vec![ + json!({ + "relativePath": "docs/README.md", + "eventKind": "Modify(Data)", + }), + json!({ + "relativePath": "docs/New.md", + "eventKind": "Create(File)", + }), + ], + ) + .expect("watch batch payload"); + + assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1"); + assert_eq!(payload["kind"], "watch_batch"); + assert_eq!(payload["fallbackResync"], false); + assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2)); + assert!( + payload["affectedParents"] + .as_array() + .expect("affected parents") + .iter() + .any(|parent| parent["relativePath"].as_str() == Some("docs") + && parent["reason"].as_str() == Some("child-watch")), + "watch batch 应声明 docs affected parent: {payload}" + ); + assert!(payload["eventKinds"] + .as_array() + .expect("event kinds") + .iter() + .any(|kind| kind.as_str() == Some("Modify(Data)"))); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn tree_live_error_payload_is_structured() { + let payload = build_tree_live_error_payload( + "file:///test", + "local:test", + "tree_live_resync_failed", + "failed", + ); + + assert_eq!(payload["schema"], "mnote.tree_live_error.v1"); + assert_eq!(payload["phase"], "tree_live_resync"); + assert_eq!(payload["fallbackResync"], true); + assert_eq!(payload["code"], "tree_live_resync_failed"); + } } 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 3302a0b6..aeaebfba 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -52,6 +52,48 @@ struct LocalFolderMetadata { file_order: BTreeMap>, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AffectedParent { + relative_path: String, + reason: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RevealTarget { + relative_path: String, + row_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalFileOperationResource { + document_id: Option, + relative_path: String, + object_identity: KernelObjectIdentity, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalFileOperationResult { + schema: String, + operation_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + batch_id: Option, + action: String, + root_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + resource: Option, + #[serde(skip_serializing_if = "Option::is_none")] + previous_resource: Option, + affected_parents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + reveal_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + select_target: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalWorkspaceManifest { @@ -198,6 +240,12 @@ struct LocalFolderRow { root_source_uri: String, } +#[derive(Debug, Clone)] +struct LocalFolderScanResult { + rows: Vec, + watch_revision: LocalFolderWatchRevision, +} + #[derive(Debug, Clone)] pub(crate) struct LocalUploadFile { name: String, @@ -2618,17 +2666,14 @@ fn load_local_folder_file_tree_scope_snapshot( Some(local_node_id(parent_relative_path)) }; let depth = local_folder_relative_depth(parent_relative_path); - let mut rows = Vec::new(); - scan_directory( + let mut scan_result = scan_directory_shallow_with_revision( &canonical_root, &scan_root, parent_node_id, depth, - Some(depth), &root_source_uri, &workspace_id, &metadata, - &mut rows, )?; if parent_relative_path.is_empty() { append_file_tree_reveal_rows( @@ -2637,16 +2682,15 @@ fn load_local_folder_file_tree_scope_snapshot( &root_source_uri, &workspace_id, &metadata, - &mut rows, + &mut scan_result.rows, )?; } - let items = rows + let items = scan_result + .rows .iter() .map(local_folder_row_to_projection_item) .collect::>(); - let watch_revision = - local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)?; let dataset = json!({ "workspace": { "id": &workspace_id, @@ -2667,7 +2711,7 @@ fn load_local_folder_file_tree_scope_snapshot( "rootUri": root_source_uri, "parentRelativePath": parent_relative_path, "lazy": true, - "watchRevision": watch_revision, + "watchRevision": scan_result.watch_revision, "items": items, }); @@ -3999,32 +4043,6 @@ pub fn update_local_markdown_title( Ok(result) } -pub fn update_local_page_options( - root_uri: &str, - document_id: &str, - options: &Value, -) -> Result { - let root_path = parse_file_root_uri(root_uri)?; - let canonical_root = root_path.canonicalize().map_err(|error| { - WebError::bad_request_code( - "local_folder_unavailable", - format!("无法访问本地文件夹: {error}"), - ) - })?; - let mut metadata = load_local_folder_metadata(&canonical_root)?; - metadata - .page_options - .insert(document_id.to_string(), options.clone()); - write_page_options_metadata(&canonical_root, &metadata.page_options)?; - Ok(json!({ - "ok": true, - "documentId": document_id, - "options": options, - "updated_at": Value::Null, - "sourceKind": "local_folder", - })) -} - pub fn execute_local_tree_command( root_uri: &str, action: &str, @@ -4075,11 +4093,244 @@ pub fn execute_local_tree_command_with_sort( format!("local_folder 暂不支持 tree action: {other}"), )), }?; + let result = add_local_file_operation_contract(root_uri, action, result); let workspace_id = local_workspace_id(&canonical_root); refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); Ok(result) } +fn local_tree_parent_relative_path_for_path(relative_path: &str) -> String { + let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); + if normalized.is_empty() || normalized == "." { + return String::new(); + } + normalized + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .unwrap_or_default() +} + +fn push_local_tree_affected_parent( + parents: &mut Vec, + seen: &mut BTreeSet, + relative_path: String, + reason: &str, +) { + let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); + let key = if normalized == "." { + String::new() + } else { + normalized + }; + if !seen.insert(key.clone()) { + return; + } + parents.push(AffectedParent { + relative_path: key, + reason: reason.to_string(), + }); +} + +fn local_tree_result_string(result: &Value, key: &str) -> Option { + result + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn local_file_operation_hash(values: &[&str]) -> u64 { + let mut hasher = DefaultHasher::new(); + for value in values { + value.hash(&mut hasher); + } + hasher.finish() +} + +fn local_file_operation_row_id( + relative_path: &str, + document_id: Option<&str>, + resource_kind: Option<&str>, +) -> String { + let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); + if normalized.is_empty() { + return String::new(); + } + let document_id = document_id.unwrap_or_default(); + let resource_kind = resource_kind.unwrap_or_default(); + if document_id.starts_with("local-md:") || is_markdown_file(&normalized) { + return format!("local:markdown:{normalized}"); + } + if document_id.starts_with("local-dir:") + || resource_kind == "local_directory" + || resource_kind == "folder" + { + return format!("local:folder:{normalized}"); + } + format!("local:asset:{normalized}") +} + +fn local_file_operation_resource( + relative_path: &str, + document_id: Option, + resource_kind: Option<&str>, +) -> Option { + let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); + if normalized.is_empty() { + return None; + } + let resource_kind = resource_kind.unwrap_or_default(); + let object_kind = if document_id + .as_deref() + .is_some_and(|value| value.starts_with("local-md:")) + || is_markdown_file(&normalized) + { + KernelObjectKind::Page + } else if document_id + .as_deref() + .is_some_and(|value| value.starts_with("local-dir:")) + || resource_kind == "local_directory" + || resource_kind == "folder" + { + KernelObjectKind::Index + } else { + KernelObjectKind::Attachment + }; + Some(LocalFileOperationResource { + document_id: document_id.clone(), + relative_path: normalized, + object_identity: KernelObjectIdentity { + object_kind, + document_id, + block_id: None, + asset_id: None, + }, + }) +} + +fn local_tree_previous_relative_path(result: &Value) -> Option { + local_tree_result_string(result, "previousRelativePath") + .or_else(|| local_tree_result_string(result, "originalRelativePath")) + .or_else(|| local_tree_result_string(result, "originalFilePath")) +} + +fn add_local_tree_command_affected_parents(result: &Value, action: &str) -> Vec { + let mut parents = Vec::new(); + let mut seen = BTreeSet::new(); + let normalized_action = action.trim(); + if let Some(previous_path) = local_tree_previous_relative_path(result) { + push_local_tree_affected_parent( + &mut parents, + &mut seen, + local_tree_parent_relative_path_for_path(&previous_path), + "old-parent", + ); + } + if let Some(parent_path) = local_tree_result_string(result, "parentRelativePath") { + push_local_tree_affected_parent(&mut parents, &mut seen, parent_path, "parent"); + } + if normalized_action != "delete" + && normalized_action != "trash" + && normalized_action != "archive" + && normalized_action != "purge" + { + if let Some(relative_path) = local_tree_result_string(result, "relativePath") { + push_local_tree_affected_parent( + &mut parents, + &mut seen, + local_tree_parent_relative_path_for_path(&relative_path), + "new-parent", + ); + } + } + if let Some(order_path) = local_tree_result_string(result, "orderRelativePath") { + push_local_tree_affected_parent( + &mut parents, + &mut seen, + local_tree_parent_relative_path_for_path(&order_path), + "order-parent", + ); + } + parents +} + +fn add_local_file_operation_contract(root_uri: &str, action: &str, mut result: Value) -> Value { + let resolved_action = + local_tree_result_string(&result, "action").unwrap_or_else(|| action.trim().to_string()); + let relative_path = local_tree_result_string(&result, "relativePath"); + let previous_relative_path = local_tree_previous_relative_path(&result); + let document_id = local_tree_result_string(&result, "documentId") + .or_else(|| local_tree_result_string(&result, "id")); + let previous_document_id = + local_tree_result_string(&result, "previousDocumentId").or_else(|| { + previous_relative_path + .as_deref() + .map(local_markdown_path_page_id) + }); + let resource_kind = local_tree_result_string(&result, "resourceKind"); + let affected_parents = add_local_tree_command_affected_parents(&result, &resolved_action); + let resource = relative_path.as_deref().and_then(|path| { + local_file_operation_resource(path, document_id.clone(), resource_kind.as_deref()) + }); + let previous_resource = previous_relative_path.as_deref().and_then(|path| { + local_file_operation_resource(path, previous_document_id.clone(), resource_kind.as_deref()) + }); + let is_remove = matches!( + resolved_action.as_str(), + "delete" | "trash" | "archive" | "purge" + ); + let reveal_target = if is_remove { + None + } else { + relative_path.as_deref().map(|path| RevealTarget { + relative_path: path.to_string(), + row_id: local_file_operation_row_id( + path, + document_id.as_deref(), + resource_kind.as_deref(), + ), + }) + }; + let select_target = if is_remove { + previous_relative_path.as_deref().map(|path| { + let parent = local_tree_parent_relative_path_for_path(path); + RevealTarget { + row_id: local_file_operation_row_id(&parent, None, Some("folder")), + relative_path: parent, + } + }) + } else { + reveal_target.clone() + }; + let operation_hash = local_file_operation_hash(&[ + &resolved_action, + relative_path.as_deref().unwrap_or_default(), + previous_relative_path.as_deref().unwrap_or_default(), + document_id.as_deref().unwrap_or_default(), + ]); + let contract = LocalFileOperationResult { + schema: "mnote.local_file_operation_result.v1".to_string(), + operation_id: format!("local-op-{resolved_action}-{operation_hash:016x}"), + batch_id: None, + action: resolved_action, + root_uri: root_uri.to_string(), + resource, + previous_resource, + affected_parents, + reveal_target, + select_target, + }; + if let (Some(object), Ok(Value::Object(contract_object))) = + (result.as_object_mut(), serde_json::to_value(contract)) + { + for (key, value) in contract_object { + object.insert(key, value); + } + } + result +} + /// 生成新页面文件名主体。 /// 默认标题“新页面”会追加本地时间 HHMMSS,例如“新页面061240”。 /// 候选名必须同时检查 `.md` 文件和同名目录,避免页面 bundle 错配。 @@ -4107,6 +4358,31 @@ fn determine_page_stem(directory: &Path, title: &str) -> String { candidate } +/// 生成新文件夹名。 +/// 默认标题“新建文件夹”会追加本地时间 HHMMSS,与默认新建页面保持同类命名节奏。 +fn determine_folder_stem(directory: &Path, title: &str) -> String { + let base = if title.trim() == "新建文件夹" { + let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); + format!( + "新建文件夹{:02}{:02}{:02}", + now.hour(), + now.minute(), + now.second() + ) + } else { + sanitize_file_stem(title, "新建文件夹") + }; + let mut candidate = base.clone(); + let mut index = 2u32; + while directory.join(&candidate).exists() + || directory.join(format!("{}.md", candidate)).exists() + { + candidate = format!("{}-{}", base, index); + index += 1; + } + candidate +} + fn determine_page_stem_for_rename( directory: &Path, title: &str, @@ -4262,8 +4538,7 @@ fn create_local_folder( ) -> Result { let metadata = load_local_folder_metadata(root)?; let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; - let target = - next_available_directory_path(&parent_directory, &sanitize_file_stem(title, "新建文件夹")); + let target = parent_directory.join(determine_folder_stem(&parent_directory, title)); fs::create_dir_all(&target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", @@ -4318,7 +4593,10 @@ fn rename_local_raw_file(root: &Path, file: &Path, title: &str) -> Result Result< "ok": true, "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, + "previousRelativePath": old_relative_path, "action": "rename", "sourceKind": "local_folder", })) @@ -4620,6 +4899,8 @@ fn move_local_entry( }; let result = if let Some(directory) = resolve_local_directory_id(root, document_id)? { move_local_directory(root, &directory, parent_id)? + } else if let Some(file) = resolve_local_raw_file_id(root, document_id)? { + move_local_raw_file(root, document_id, &file, parent_id)? } else { move_local_markdown_page(root, document_id, parent_id)? }; @@ -4636,6 +4917,60 @@ fn move_local_entry( Ok(result) } +fn move_local_raw_file( + root: &Path, + entry_id: &str, + file: &Path, + parent_id: Option<&str>, +) -> Result { + let relative_path = normalize_relative_path(root, file)?; + if is_markdown_file(&relative_path) { + return Err(WebError::bad_request_code( + "local_file_resource_not_supported", + "Markdown 文件必须走页面生命周期,不能走 local_file 资源移动", + )); + } + let metadata = load_local_folder_metadata(root)?; + let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + if file.parent() == Some(target_directory.as_path()) { + return Ok(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "relativePath": relative_path, + "orderRelativePath": relative_path, + "action": "move", + "sourceKind": "local_folder", + })); + } + let file_name = file + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("local-file"); + let target = next_available_raw_path(&target_directory, file_name); + fs::rename(file, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法移动本地文件 {}: {error}", file.display()), + ) + })?; + let new_relative_path = normalize_relative_path(root, &target)?; + let new_id = format!("local:asset:{new_relative_path}"); + Ok(json!({ + "ok": true, + "id": new_id, + "documentId": new_id, + "resourceKind": "local_file", + "relativePath": new_relative_path, + "orderRelativePath": new_relative_path, + "previousDocumentId": entry_id, + "previousRelativePath": relative_path, + "action": "move", + "sourceKind": "local_folder", + })) +} + fn move_local_markdown_page( root: &Path, document_id: &str, @@ -4840,6 +5175,7 @@ fn move_local_directory( "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, "orderRelativePath": new_relative_path, + "previousRelativePath": normalize_relative_path(root, directory)?, "action": "move", "sourceKind": "local_folder", })) @@ -4956,7 +5292,7 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result Result Result Resul "documentId": entry_id, "resourceKind": "local_directory", "resourceScope": "local_folder", + "originalRelativePath": relative_path, "originalFilePath": relative_path, "trashEntryId": trash_entry_id, "trashPath": trash_relative_path, @@ -5417,6 +5756,7 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result Result Result "documentId": entry_id, "resourceKind": "local_directory", "resourceScope": "local_folder", + "originalRelativePath": trash_entry.original_relative_path, "trashEntryId": trash_key, "action": "purge", "canonicalCommand": "tree.resource.purge", @@ -5543,6 +5885,7 @@ fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result "documentId": entry_id, "resourceKind": "local_file", "resourceScope": "local_folder", + "originalRelativePath": trash_entry.original_relative_path, "trashEntryId": trash_key, "action": "purge", "canonicalCommand": "tree.resource.purge", @@ -5809,6 +6152,17 @@ pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + Ok(load_local_workspace_manifest(&canonical_root).is_ok()) +} + fn parse_file_root_uri(root_uri: &str) -> Result { let trimmed = root_uri.trim(); if trimmed.is_empty() { @@ -6022,25 +6376,6 @@ fn write_trash_index_metadata( write_json_atomic(&path, &value) } -fn write_page_options_metadata( - root: &Path, - page_options: &BTreeMap, -) -> Result<(), WebError> { - let mnote_dir = root.join(".mnote"); - fs::create_dir_all(&mnote_dir).map_err(|error| { - WebError::bad_request_code( - "local_metadata_write_failed", - format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), - ) - })?; - let path = mnote_dir.join("page-options.json"); - let value = json!({ - "version": 1, - "pages": page_options, - }); - write_json_atomic(&path, &value) -} - fn write_uploaded_asset_index_metadata( root: &Path, entries: &BTreeMap, @@ -6138,6 +6473,7 @@ fn scan_directory( } else { "asset".to_string() }; + let document_id = local_folder_entry_document_id(root, &entry, uploaded_asset); let icon_hint = icon_hint_for_entry(&entry); rows.push(LocalFolderRow { node_id: node_id.clone(), @@ -6153,13 +6489,7 @@ fn scan_directory( child_count, expandable: entry.is_dir && child_count > 0, expanded_by_default: false, - document_id: if let Some(asset) = uploaded_asset { - Some(asset.document_id.clone()) - } else if is_markdown_file(&entry.file_name) { - Some(local_markdown_path_page_id(&entry.relative_path)) - } else { - None - }, + document_id, capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), @@ -6182,6 +6512,121 @@ fn scan_directory( Ok(()) } +fn scan_directory_shallow_with_revision( + root: &Path, + directory: &Path, + parent_node_id: Option, + depth: u32, + root_source_uri: &str, + workspace_id: &str, + metadata: &LocalFolderMetadata, +) -> Result { + let mut entries = read_sorted_entries(directory, root)?; + let parent_key = file_order_parent_key_for_directory(root, directory)?; + sort_entries_with_file_order(&mut entries, metadata, &parent_key); + + let mut rows = Vec::new(); + let mut hasher = DefaultHasher::new(); + let mut latest_modified_ms = 0u128; + + for (position, entry) in entries.iter().enumerate() { + entry.relative_path.hash(&mut hasher); + entry.file_name.hash(&mut hasher); + entry.is_dir.hash(&mut hasher); + entry.is_symlink.hash(&mut hasher); + entry.is_readonly.hash(&mut hasher); + if let Ok(meta) = fs::symlink_metadata(&entry.path) { + meta.len().hash(&mut hasher); + if let Ok(modified) = meta.modified() { + if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { + let modified_ms = delta.as_millis(); + modified_ms.hash(&mut hasher); + if modified_ms > latest_modified_ms { + latest_modified_ms = modified_ms; + } + } + } + } + + let node_id = local_node_id(if entry.relative_path.is_empty() { + "." + } else { + &entry.relative_path + }); + let child_count = if entry.is_dir && !entry.is_symlink { + if has_visible_child(&entry.path, root)? { + 1 + } else { + 0 + } + } else { + 0 + }; + let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); + let row_kind = if entry.is_dir { + "folder".to_string() + } else if uploaded_asset.is_some() { + "asset".to_string() + } else if is_markdown_file(&entry.file_name) { + "markdown".to_string() + } else { + "asset".to_string() + }; + let document_id = local_folder_entry_document_id(root, entry, uploaded_asset); + let icon_hint = icon_hint_for_entry(entry); + rows.push(LocalFolderRow { + node_id: node_id.clone(), + row_id: format!("local:{row_kind}:{}", entry.relative_path), + parent_node_id: parent_node_id.clone(), + title: entry.file_name.clone(), + depth, + position: position as u32, + row_kind, + icon_hint, + relative_path: entry.relative_path.clone(), + source_uri: file_uri_for_path(&entry.path), + child_count, + expandable: entry.is_dir && child_count > 0, + expanded_by_default: false, + document_id, + capabilities: local_entry_capabilities(entry), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), + }); + } + + Ok(LocalFolderScanResult { + watch_revision: LocalFolderWatchRevision { + root_uri: root_source_uri.to_string(), + revision: format!("{:016x}", hasher.finish()), + entry_count: rows.len(), + latest_modified_ms, + }, + rows, + }) +} + +fn local_folder_entry_document_id( + root: &Path, + entry: &LocalFolderEntry, + uploaded_asset: Option<&LocalUploadedAssetEntry>, +) -> Option { + if let Some(asset) = uploaded_asset { + return Some(asset.document_id.clone()); + } + if is_markdown_file(&entry.file_name) { + return Some(local_markdown_path_page_id(&entry.relative_path)); + } + if entry.is_dir && !entry.is_symlink { + if let Some(markdown_path) = nested_bundle_main_markdown(&entry.path) { + if let Ok(relative_path) = normalize_relative_path(root, &markdown_path) { + return Some(local_markdown_path_page_id(&relative_path)); + } + } + } + None +} + fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option { let encoded = document_id.trim().strip_prefix("local-md:")?; decode_local_id_segment(encoded).ok() @@ -6224,7 +6669,10 @@ fn append_file_tree_reveal_rows( else { return Ok(()); }; - let ancestors = ancestor_directories_for_relative_path(reveal_relative_path); + let mut ancestors = ancestor_directories_for_relative_path(reveal_relative_path); + if let Some(bundle_parent) = same_name_markdown_bundle_parent(reveal_relative_path) { + ancestors.retain(|ancestor| ancestor != &bundle_parent); + } if ancestors.is_empty() { return Ok(()); } @@ -6267,6 +6715,24 @@ fn append_file_tree_reveal_rows( Ok(()) } +fn same_name_markdown_bundle_parent(relative_path: &str) -> Option { + let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); + if !normalized.to_ascii_lowercase().ends_with(".md") { + return None; + } + let (parent, file_name) = normalized.rsplit_once('/')?; + if parent.is_empty() { + return None; + } + let parent_name = parent.rsplit('/').next().unwrap_or(parent); + let file_stem = &file_name[..file_name.len().saturating_sub(3)]; + if file_stem == parent_name { + Some(parent.to_string()) + } else { + None + } +} + fn read_sorted_entries(directory: &Path, root: &Path) -> Result, WebError> { let read_dir = fs::read_dir(directory).map_err(|error| { WebError::bad_request_code( @@ -6795,6 +7261,10 @@ fn find_markdown_by_page_id( metadata: &LocalFolderMetadata, document_id: &str, ) -> Result, WebError> { + if let Some(entry) = find_markdown_by_path_document_id(root, document_id)? { + return Ok(Some(entry)); + } + fn walk( root: &Path, directory: &Path, @@ -6822,6 +7292,47 @@ fn find_markdown_by_page_id( walk(root, root, metadata, document_id) } +fn find_markdown_by_path_document_id( + root: &Path, + document_id: &str, +) -> Result, WebError> { + let Some(relative_path) = local_markdown_relative_path_from_document_id(document_id) else { + return Ok(None); + }; + let target = resolve_metadata_relative_path(root, &relative_path)?; + let metadata = match fs::symlink_metadata(&target) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(WebError::bad_request_code( + "local_folder_scan_failed", + format!("无法读取本地 Markdown 元数据 {}: {error}", target.display()), + )); + } + }; + let file_name = target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + let normalized_relative_path = normalize_relative_path(root, &target)?; + if metadata.is_dir() + || should_ignore_entry(&normalized_relative_path, &file_name) + || !is_markdown_file(&file_name) + || local_markdown_path_page_id(&normalized_relative_path) != document_id + { + return Ok(None); + } + Ok(Some(LocalFolderEntry { + path: target, + relative_path: normalized_relative_path, + file_name, + is_dir: false, + is_symlink: metadata.file_type().is_symlink(), + is_readonly: metadata.permissions().readonly(), + })) +} + fn uploaded_asset_markdown_relative_paths_for_document( root: &Path, metadata: &LocalFolderMetadata, @@ -6874,6 +7385,7 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { } }); if let Some(document_id) = row.document_id.as_ref() { + item["documentId"] = Value::String(document_id.clone()); item["resourceMeta"]["documentId"] = Value::String(document_id.clone()); } if row.row_kind == "asset" && !row.relative_path.trim().is_empty() { @@ -6885,7 +7397,17 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { // Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath let object_kind = match row.row_kind.as_str() { "markdown" | "document" => KernelObjectKind::Page, - "folder" | "index" => KernelObjectKind::Index, + "folder" | "index" => { + if row + .document_id + .as_deref() + .is_some_and(|value| value.starts_with("local-md:")) + { + KernelObjectKind::Page + } else { + KernelObjectKind::Index + } + } "asset" => match row.icon_hint.as_str() { "mindmap" => KernelObjectKind::Mindmap, "onlyoffice" | "office" => KernelObjectKind::OnlyOffice, @@ -7065,44 +7587,6 @@ fn local_folder_watch_revision_for_root( }) } -fn local_folder_watch_revision_for_directory( - root: &Path, - directory: &Path, - root_source_uri: &str, -) -> Result { - let mut hasher = DefaultHasher::new(); - let mut entry_count = 0usize; - let mut latest_modified_ms = 0u128; - - for entry in read_sorted_entries(directory, root)? { - entry.relative_path.hash(&mut hasher); - entry.file_name.hash(&mut hasher); - entry.is_dir.hash(&mut hasher); - entry.is_symlink.hash(&mut hasher); - entry.is_readonly.hash(&mut hasher); - if let Ok(meta) = fs::symlink_metadata(&entry.path) { - meta.len().hash(&mut hasher); - if let Ok(modified) = meta.modified() { - if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { - let modified_ms = delta.as_millis(); - modified_ms.hash(&mut hasher); - if modified_ms > latest_modified_ms { - latest_modified_ms = modified_ms; - } - } - } - } - entry_count += 1; - } - - Ok(LocalFolderWatchRevision { - root_uri: root_source_uri.to_string(), - revision: format!("{:016x}", hasher.finish()), - entry_count, - latest_modified_ms, - }) -} - fn file_uri_for_path(path: &Path) -> String { format!("file://{}", path.display()) } @@ -8535,6 +9019,57 @@ mod tests { .expect("init local workspace"); } + #[test] + fn local_folder_file_tree_children_snapshot_scans_parent_once() { + let root = temp_root("mnote-filetree-children-shallow-scan"); + init_workspace(&root); + std::fs::create_dir_all(root.join("design").join("03-rust-web")).expect("create rust web"); + std::fs::create_dir_all(root.join("design").join("05-editor-mainline")) + .expect("create editor"); + std::fs::write(root.join("design").join("README.md"), "# Design\n").expect("write readme"); + std::fs::write( + root.join("design").join("03-rust-web").join("plan.md"), + "# Plan\n", + ) + .expect("write nested"); + let root_uri = format!("file://{}", root.display()); + + let snapshot = + load_local_folder_file_tree_children_snapshot(&root_uri, "design").expect("snapshot"); + let items = snapshot + .projection + .get("items") + .and_then(Value::as_array) + .expect("items"); + let titles = items + .iter() + .filter_map(|item| item.get("title").and_then(Value::as_str)) + .collect::>(); + + assert_eq!( + snapshot + .projection + .get("parentRelativePath") + .and_then(Value::as_str), + Some("design") + ); + assert_eq!(items.len(), 3); + assert!(titles.contains(&"03-rust-web")); + assert!(titles.contains(&"05-editor-mainline")); + assert!(titles.contains(&"README.md")); + assert!(!titles.contains(&"plan.md")); + assert_eq!( + snapshot + .projection + .get("watchRevision") + .and_then(|revision| revision.get("entryCount")) + .and_then(Value::as_u64), + Some(items.len() as u64) + ); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() { let root = temp_root("mnote-local-frontmatter-path-id"); @@ -10605,6 +11140,35 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + #[cfg(unix)] + #[test] + fn local_markdown_path_id_resolves_without_scanning_sibling_directories() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_root("mnote-local-path-id-direct-resolve"); + init_workspace(&root); + std::fs::create_dir_all(root.join("aaa-unreadable")).expect("create unreadable dir"); + std::fs::write(root.join("Target.md"), "# Target\n").expect("write target"); + std::fs::set_permissions( + root.join("aaa-unreadable"), + std::fs::Permissions::from_mode(0o000), + ) + .expect("chmod unreadable"); + let root_uri = format!("file://{}", root.display()); + + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Target.md") + .expect("path id should resolve directly"); + + std::fs::set_permissions( + root.join("aaa-unreadable"), + std::fs::Permissions::from_mode(0o700), + ) + .expect("restore unreadable dir permissions"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(aggregate.title, "Target"); + } + #[test] fn local_markdown_non_managed_folder_default_hides_title_header() { // 非托管本地文件夹:没有 .mnote/workspace.json,应该默认隐藏标题。 @@ -11067,6 +11631,172 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_tree_command_move_returns_affected_parents() { + let root = temp_root("mnote-local-move-affected-parents"); + init_workspace(&root); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("source.md"), "# Source\n").expect("write source"); + let root_uri = format!("file://{}", root.display()); + + let result = execute_local_tree_command( + &root_uri, + "move", + "local-md:source.md", + Some("local-dir:docs"), + None, + ) + .expect("move should succeed"); + let affected = result["affectedParents"] + .as_array() + .expect("affected parents"); + assert!( + affected + .iter() + .any(|parent| parent["relativePath"].as_str() == Some("") + && parent["reason"].as_str() == Some("old-parent")), + "affectedParents 应包含旧父级 root: {affected:?}" + ); + assert!( + affected + .iter() + .any(|parent| parent["relativePath"].as_str() == Some("docs") + && parent["reason"].as_str() == Some("new-parent")), + "affectedParents 应包含新父级 docs: {affected:?}" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_file_tree_marks_page_bundle_folder_with_document_id() { + let root = temp_root("mnote-local-page-bundle-folder-document-id"); + init_workspace(&root); + std::fs::create_dir_all(root.join("Page")).expect("create page bundle"); + std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); + std::fs::write(root.join("Page").join("notes.txt"), "note").expect("write child"); + let root_uri = format!("file://{}", root.display()); + + let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree"); + let items = snapshot + .projection + .get("items") + .and_then(Value::as_array) + .expect("items"); + let page_folder = items + .iter() + .find(|item| item["title"].as_str() == Some("Page")) + .expect("page bundle folder row"); + + assert_eq!(page_folder["rowKind"].as_str(), Some("folder")); + assert_eq!( + page_folder["documentId"].as_str(), + Some("local-md:Page~2FPage.md") + ); + assert_eq!( + page_folder["resourceMeta"]["documentId"].as_str(), + Some("local-md:Page~2FPage.md") + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_tree_command_move_raw_file_into_folder() { + let root = temp_root("mnote-local-move-raw-file"); + init_workspace(&root); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("report.pdf"), "pdf").expect("write report"); + let root_uri = format!("file://{}", root.display()); + + let result = execute_local_tree_command( + &root_uri, + "move", + "local:asset:report.pdf", + Some("local:folder:docs"), + None, + ) + .expect("move raw file"); + + assert!(root.join("docs").join("report.pdf").is_file()); + assert!(!root.join("report.pdf").exists()); + assert_eq!(result["resourceKind"].as_str(), Some("local_file")); + assert_eq!(result["relativePath"].as_str(), Some("docs/report.pdf")); + assert_eq!(result["previousRelativePath"].as_str(), Some("report.pdf")); + assert_eq!( + result["documentId"].as_str(), + Some("local:asset:docs/report.pdf") + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_tree_command_rename_returns_operation_contract() { + let root = temp_root("mnote-local-rename-operation-contract"); + init_workspace(&root); + std::fs::write(root.join("Old.md"), "# Old\n").expect("write old"); + let root_uri = format!("file://{}", root.display()); + + let result = + execute_local_tree_command(&root_uri, "rename", "local-md:Old.md", None, Some("New")) + .expect("rename should succeed"); + + assert_eq!(result["schema"], "mnote.local_file_operation_result.v1"); + assert_eq!(result["action"], "rename"); + assert_eq!(result["rootUri"], root_uri); + assert!( + result["operationId"] + .as_str() + .is_some_and(|value| value.starts_with("local-op-rename-")), + "operationId 应稳定标记 action: {result:?}" + ); + assert_eq!(result["resource"]["relativePath"], "New.md"); + assert_eq!(result["resource"]["documentId"], "local-md:New.md"); + assert_eq!(result["resource"]["objectIdentity"]["objectKind"], "page"); + assert_eq!(result["previousResource"]["relativePath"], "Old.md"); + assert_eq!(result["revealTarget"]["relativePath"], "New.md"); + assert_eq!(result["revealTarget"]["rowId"], "local:markdown:New.md"); + assert_eq!(result["selectTarget"]["rowId"], "local:markdown:New.md"); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_tree_command_delete_returns_parent_focus_contract() { + let root = temp_root("mnote-local-delete-operation-contract"); + init_workspace(&root); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs/remove.md"), "# Remove\n").expect("write remove"); + let root_uri = format!("file://{}", root.display()); + + let result = execute_local_tree_command( + &root_uri, + "delete", + "local-md:docs~2Fremove.md", + None, + None, + ) + .expect("delete should succeed"); + + assert_eq!(result["schema"], "mnote.local_file_operation_result.v1"); + let affected = result["affectedParents"] + .as_array() + .expect("affected parents"); + assert!( + affected + .iter() + .any(|parent| parent["relativePath"].as_str() == Some("docs") + && parent["reason"].as_str() == Some("old-parent")), + "delete affectedParents 应包含旧父级 docs: {affected:?}" + ); + assert_eq!(result["previousResource"]["relativePath"], "docs/remove.md"); + assert_eq!(result["selectTarget"]["relativePath"], "docs"); + assert_eq!(result["selectTarget"]["rowId"], "local:folder:docs"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_save_refreshes_search_index_after_write() { let root = temp_root("mnote-local-save-refresh-search-index"); @@ -11777,6 +12507,40 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_tree_command_create_folder_uses_timestamped_default_name() { + let root = temp_root("mnote-local-tree-create-folder-timestamp"); + init_workspace(&root); + let root_uri = format!("file://{}", root.display()); + + let result = + execute_local_tree_command(&root_uri, "create_folder", "", None, Some("新建文件夹")) + .expect("create folder with default title"); + + let relative_path = result["relativePath"].as_str().expect("relativePath"); + assert!( + relative_path.starts_with("新建文件夹"), + "relativePath should start with 新建文件夹, got: {relative_path}" + ); + assert!( + relative_path.len() > "新建文件夹".len(), + "expected timestamp suffix" + ); + assert!( + root.join(relative_path).is_dir(), + "default folder should create directory" + ); + assert!( + !root + .join(relative_path) + .join(format!("{relative_path}.md")) + .exists(), + "default folder creation must not create page markdown" + ); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_page_tree_projects_nested_bundle_as_page_node() { let root = temp_root("mnote-local-page-tree-nested-bundle"); @@ -11879,14 +12643,22 @@ fn main() {} "file tree should have Root as folder row" ); - // Child.md 应作为 Root/Child 文件夹下的真实文件条目存在。 + // 同名页面包 reveal 时只需要让 Child 目录可见,不展开包内部 Child.md。 + let child_folder = items.iter().find(|item| { + item["title"].as_str() == Some("Child") + && item["resourceMeta"]["resourceKind"].as_str() == Some("folder") + }); + assert!( + child_folder.is_some(), + "file tree should have Child folder under Root" + ); let child_md = items.iter().find(|item| { item["title"].as_str() == Some("Child.md") && item["resourceMeta"]["resourceKind"].as_str() == Some("markdown") }); assert!( - child_md.is_some(), - "file tree should have Child.md under Root" + child_md.is_none(), + "file tree should not auto-expand same-name bundle markdown" ); let _ = std::fs::remove_dir_all(&root); diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs index d0210b2e..8428cfb6 100644 --- a/rust/crates/mnote-web/src/routes/local_search_index.rs +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -56,7 +56,7 @@ pub(crate) fn query_local_search_index( title_only: bool, exact: bool, ) -> Result { - let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?; + let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?; let normalized_query = normalize_search_text(query); let page_id = page_id.map(str::trim).filter(|value| !value.is_empty()); let recent_changes = local_recent_changes_projection(&index.documents, root_uri); @@ -294,6 +294,23 @@ fn rebuild_local_search_index( Ok(index) } +fn load_or_rebuild_local_search_index( + root_path: &Path, + root_uri: &str, + workspace_id: &str, +) -> Result { + match read_local_search_index(root_path) { + Ok(Some(index)) + if index.version == LOCAL_SEARCH_INDEX_VERSION + && index.root_uri == root_uri + && index.workspace_id == workspace_id => + { + Ok(index) + } + Ok(_) | Err(_) => rebuild_local_search_index(root_path, root_uri, workspace_id), + } +} + fn read_local_search_index(root_path: &Path) -> Result, WebError> { let index_path = root_path .join(".mnote") @@ -999,4 +1016,74 @@ mod tests { .any(|document| document.path == "README.md")); let _ = fs::remove_dir_all(&root); } + + #[test] + fn local_search_query_reads_existing_index_without_rebuilding() { + let root = temp_root("mnote-local-search-query-cache"); + let root_uri = format!("file://{}", root.display()); + let workspace_id = "local-ws-query-cache"; + let child_path = root.join("docs").join("child.md"); + fs::write( + &child_path, + "---\ntitle: Child\n---\n# Child\nOriginalToken body.\n", + ) + .expect("write child"); + + let first_projection = query_local_search_index( + &root, + &root_uri, + workspace_id, + "OriginalToken", + None, + 10, + false, + false, + ) + .expect("first query"); + assert_eq!( + first_projection["results"].as_array().map(Vec::len), + Some(1) + ); + + fs::write( + &child_path, + "---\ntitle: Child\n---\n# Child\nUnindexedToken body.\n", + ) + .expect("update child without refresh"); + + let stale_projection = query_local_search_index( + &root, + &root_uri, + workspace_id, + "UnindexedToken", + None, + 10, + false, + false, + ) + .expect("query existing index"); + assert_eq!( + stale_projection["results"].as_array().map(Vec::len), + Some(0) + ); + + refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md") + .expect("incremental refresh"); + let refreshed_projection = query_local_search_index( + &root, + &root_uri, + workspace_id, + "UnindexedToken", + None, + 10, + false, + false, + ) + .expect("query refreshed index"); + assert_eq!( + refreshed_projection["results"].as_array().map(Vec::len), + Some(1) + ); + let _ = fs::remove_dir_all(&root); + } } diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index eab47c0e..865e2202 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -61,6 +61,7 @@ pub async fn mindmap_object_shell( let file_tree_html = render_local_file_tree_html(root_uri, Some(&doc_id), None).unwrap_or_default(); let workspace_projection = load_workspace_shell_projection( + Some(&state), state.config(), &context, workspace_id.as_deref().unwrap_or("local-folder"), @@ -74,6 +75,7 @@ pub async fn mindmap_object_shell( Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, + None, ); ( Some(workspace_name), @@ -82,6 +84,7 @@ pub async fn mindmap_object_shell( ) } else if let Some(workspace_id) = workspace_id.as_deref() { let workspace_projection = load_workspace_shell_projection( + Some(&state), state.config(), &context, workspace_id, @@ -109,6 +112,7 @@ pub async fn mindmap_object_shell( Some(sidebar_tree_html.as_str()), Some(file_tree_html.as_str()), None, + None, ); ( Some(workspace_name), @@ -268,7 +272,7 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str { return window.__mnoteLeptosTiptapRuntimePromise; } window.__mnoteLeptosTiptapRuntimePromise = (async () => { - const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' }); + const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json'); if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`); const manifest = await manifestResponse.json(); if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath'); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index d6e46028..39c29f22 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -23,16 +23,18 @@ mod query_support; mod resource_trash; mod search; mod session; +pub(crate) mod sidebar_shortcuts; mod snapshot_support; mod sse; mod stream_support; mod tree; +pub(crate) mod ui_preferences; pub(crate) mod web_shell; mod ws; pub(crate) use local_folder_source::{ ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri, - update_local_markdown_title, update_local_page_options, write_local_markdown_page_body, + update_local_markdown_title, write_local_markdown_page_body, }; pub(crate) use local_search_index::refresh_local_search_index_for_path; @@ -82,6 +84,14 @@ pub fn build_router(state: AppState) -> Router { "/api/page-aggregate/{document_id}", get(web_shell::page_aggregate), ) + .route( + "/api/ui/preferences/effective", + get(ui_preferences::effective_preferences), + ) + .route( + "/api/ui/preferences", + put(ui_preferences::update_preferences), + ) .route( "/api/leptos-tiptap-runtime/manifest.json", get(web_shell::leptos_tiptap_manifest), @@ -263,6 +273,14 @@ pub fn build_router(state: AppState) -> Router { .route("/api/auth/whoami", get(session::session)) .route("/api/auth/mnote-web-token", get(session::session)) .route("/api/auth/session/refresh", post(session::refresh_session)) + .route( + "/api/sidebar/shortcuts", + get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut), + ) + .route( + "/api/sidebar/shortcuts/{shortcut_id}", + delete(sidebar_shortcuts::delete_shortcut), + ) .route( "/api/admin/access-policy", get(local_folder_source::get_local_access_policy), diff --git a/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs b/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs new file mode 100644 index 00000000..c7652170 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs @@ -0,0 +1,285 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::gateway::current_actor_id; +use axum::extract::{Extension, Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput}; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SidebarShortcutListQuery { + #[serde(default, alias = "workspace_id")] + workspace_id: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SidebarShortcutUpsertRequest { + #[serde(default)] + id: Option, + #[serde(default, alias = "workspace_id")] + workspace_id: String, + #[serde(default, alias = "root_uri")] + root_uri: Option, + #[serde(default)] + kind: String, + #[serde(default, alias = "source_kind")] + source_kind: String, + #[serde(default, alias = "target_id")] + target_id: String, + #[serde(default, alias = "relative_path")] + relative_path: Option, + #[serde(default, alias = "document_id")] + document_id: Option, + #[serde(default)] + title: String, + #[serde(default)] + icon: Option, + #[serde(default, alias = "sort_order")] + sort_order: Option, + #[serde(default, alias = "metadata_json")] + metadata_json: Option, +} + +pub async fn list_shortcuts( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result, WebError> { + let workspace_id = query.workspace_id.trim(); + if workspace_id.is_empty() { + return Err(WebError::bad_request_code( + "sidebar_shortcut_workspace_required", + "缺少 workspaceId", + ) + .with_context(&context)); + } + let actor_id = require_actor_id(&state, &context)?; + let shortcuts = state + .control_plane() + .list_sidebar_shortcuts(&actor_id, workspace_id) + .map_err(|error| { + WebError::internal(format!("读取星标置顶失败: {error}")).with_context(&context) + })?; + Ok(Json(json!({ + "ok": true, + "owner": "mnote-web", + "workspaceId": workspace_id, + "shortcuts": shortcuts.iter().map(shortcut_to_json).collect::>(), + }))) +} + +pub async fn upsert_shortcut( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + let actor_id = require_actor_id(&state, &context)?; + let workspace_id = request.workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err(WebError::bad_request_code( + "sidebar_shortcut_workspace_required", + "缺少 workspaceId", + ) + .with_context(&context)); + } + let kind = request.kind.trim().to_string(); + let target_id = request.target_id.trim().to_string(); + let title = request.title.trim().to_string(); + let root_uri = request + .root_uri + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let metadata_json = + normalize_shortcut_metadata_json(request.metadata_json, root_uri.as_deref())?; + let shortcut = state + .control_plane() + .upsert_sidebar_shortcut(UpsertSidebarShortcutInput { + id: request.id, + user_id: actor_id.clone(), + workspace_id: workspace_id.clone(), + root_uri, + kind, + source_kind: request + .source_kind + .trim() + .to_string() + .if_empty_else(|| "local_folder".to_string()), + target_id, + relative_path: request + .relative_path + .map(|value| value.trim().trim_matches('/').to_string()) + .filter(|value| !value.is_empty()), + document_id: request + .document_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + title, + icon: request + .icon + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + sort_order: request.sort_order.unwrap_or(0), + metadata_json, + }) + .map_err(|error| { + WebError::bad_request_code( + "sidebar_shortcut_upsert_failed", + format!("写入星标置顶失败: {error}"), + ) + .with_context(&context) + })?; + append_shortcut_audit(&state, &actor_id, "sidebar.shortcut.upserted", &shortcut); + Ok(Json(json!({ + "ok": true, + "owner": "mnote-web", + "shortcut": shortcut_to_json(&shortcut), + }))) +} + +pub async fn delete_shortcut( + State(state): State, + Extension(context): Extension, + Path(shortcut_id): Path, +) -> Result, WebError> { + let actor_id = require_actor_id(&state, &context)?; + state + .control_plane() + .delete_sidebar_shortcut(&actor_id, &shortcut_id) + .map_err(|error| { + WebError::bad_request_code( + "sidebar_shortcut_delete_failed", + format!("移除星标置顶失败: {error}"), + ) + .with_context(&context) + })?; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(actor_id), + action: "sidebar.shortcut.removed".to_string(), + target_kind: "sidebar_shortcut".to_string(), + target_id: Some(shortcut_id.clone()), + metadata_json: "{}".to_string(), + }); + Ok(Json(json!({ + "ok": true, + "owner": "mnote-web", + "removedShortcutId": shortcut_id, + }))) +} + +pub(crate) fn load_sidebar_shortcut_dataset( + state: &AppState, + context: &RequestContext, + workspace_id: &str, +) -> Vec { + let Some(actor_id) = current_actor_id(state, context) else { + return Vec::new(); + }; + state + .control_plane() + .list_sidebar_shortcuts_with_global_local(&actor_id, workspace_id) + .map(|shortcuts| shortcuts.iter().map(shortcut_to_json).collect()) + .unwrap_or_default() +} + +fn require_actor_id(state: &AppState, context: &RequestContext) -> Result { + current_actor_id(state, context) + .filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous") + .ok_or_else(|| { + WebError::new( + StatusCode::UNAUTHORIZED, + "sidebar_shortcut_auth_required", + "星标置顶需要登录用户", + ) + .with_context(context) + }) +} + +fn shortcut_to_json(shortcut: &SidebarShortcutRecord) -> Value { + json!({ + "id": shortcut.id, + "userId": shortcut.user_id, + "workspaceId": shortcut.workspace_id, + "rootUri": shortcut.root_uri, + "kind": shortcut.kind, + "sourceKind": shortcut.source_kind, + "targetId": shortcut.target_id, + "relativePath": shortcut.relative_path, + "documentId": shortcut.document_id, + "title": shortcut.title, + "icon": shortcut.icon, + "sortOrder": shortcut.sort_order, + "status": shortcut.status, + "metadata": serde_json::from_str::(&shortcut.metadata_json).unwrap_or(Value::Null), + "createdAt": shortcut.created_at, + "updatedAt": shortcut.updated_at, + "revision": shortcut.revision, + }) +} + +fn normalize_shortcut_metadata_json( + metadata_json: Option, + root_uri: Option<&str>, +) -> Result { + let mut metadata = metadata_json + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| serde_json::from_str::(value)) + .transpose() + .map_err(|error| { + WebError::bad_request_code( + "sidebar_shortcut_metadata_invalid", + format!("星标置顶 metadataJson 必须是 JSON 对象: {error}"), + ) + })? + .unwrap_or_else(|| json!({})); + if !metadata.is_object() { + metadata = json!({}); + } + if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) { + if let Some(object) = metadata.as_object_mut() { + object.insert("rootUri".to_string(), Value::String(root_uri.to_string())); + } + } + serde_json::to_string(&metadata) + .map_err(|error| WebError::internal(format!("星标置顶 metadataJson 序列化失败: {error}"))) +} + +fn append_shortcut_audit( + state: &AppState, + actor_id: &str, + action: &str, + shortcut: &SidebarShortcutRecord, +) { + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(actor_id.to_string()), + action: action.to_string(), + target_kind: "sidebar_shortcut".to_string(), + target_id: Some(shortcut.id.clone()), + metadata_json: json!({ + "workspaceId": shortcut.workspace_id, + "kind": shortcut.kind, + "targetId": shortcut.target_id, + }) + .to_string(), + }); +} + +trait EmptyStringExt { + fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String; +} + +impl EmptyStringExt for String { + fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String { + if self.trim().is_empty() { + fallback() + } else { + self + } + } +} diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 3d32fcd5..207d9a02 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -86,6 +86,7 @@ pub struct TreeCommandEnvelope { pub target_resource_meta: Option, pub selection: Option, pub operation: Option, + pub batch_id: Option, pub document_id: Option, pub parent_id: Option, pub target_parent_id: Option, @@ -114,6 +115,7 @@ pub struct TreeCommandEnvelopeContext { pub target_resource_meta: Option, pub selection: Option, pub operation: Option, + pub batch_id: Option, } impl TreeCommandEnvelopeContext { @@ -130,6 +132,7 @@ impl TreeCommandEnvelopeContext { target_resource_meta: envelope.target_resource_meta.clone(), selection: envelope.selection.clone(), operation: read_optional_non_empty(envelope.operation.clone()), + batch_id: read_optional_non_empty(envelope.batch_id.clone()), } } } @@ -525,6 +528,25 @@ pub(crate) fn collect_filetree_render_rows( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); + let relative_path = item + .get("relativePath") + .and_then(Value::as_str) + .or_else(|| { + resource_meta + .and_then(|meta| meta.get("workspacePath")) + .and_then(|workspace_path| workspace_path.get("relativePath")) + .and_then(Value::as_str) + }) + .or_else(|| { + resource_meta + .and_then(|meta| meta.get("extra")) + .and_then(|extra| extra.get("source")) + .and_then(|source| source.get("relativePath")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); let icon_kind = item .get("iconHint") .and_then(Value::as_str) @@ -571,6 +593,7 @@ pub(crate) fn collect_filetree_render_rows( icon_kind, document_id, asset_id, + relative_path, object_identity: resource_meta .and_then(|meta| meta.get("objectIdentity")) .and_then(|value| serde_json::to_string(value).ok()), @@ -2092,6 +2115,79 @@ async fn resolve_tree_create_workspace_id( }) } +fn operation_resource_relative_path(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_object) + .and_then(|object| object.get("relativePath")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) +} + +fn operation_resource_document_id(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_object) + .and_then(|object| object.get("documentId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| path.starts_with("local-md:")) + .map(ToOwned::to_owned) +} + +fn apply_local_file_operation_participants( + buffer_store: &crate::document_buffer_store::BufferStore, + workspace_id: &str, + root_uri: &str, + action: &str, + execution: &Value, +) { + let previous_relative_path = operation_resource_relative_path(execution, "previousResource"); + let previous_document_id = operation_resource_document_id(execution, "previousResource"); + let next_relative_path = operation_resource_relative_path(execution, "resource"); + let next_document_id = operation_resource_document_id(execution, "resource"); + match action { + "rename" | "move" => { + if let ( + Some(previous_relative_path), + Some(previous_document_id), + Some(next_relative_path), + Some(next_document_id), + ) = ( + previous_relative_path.as_deref(), + previous_document_id.as_deref(), + next_relative_path.as_deref(), + next_document_id.as_deref(), + ) { + let _ = buffer_store.rekey_local_folder_markdown( + workspace_id, + root_uri, + previous_relative_path, + previous_document_id, + next_relative_path, + next_document_id, + ); + } + } + "delete" | "archive" | "trash" | "purge" => { + if let (Some(previous_relative_path), Some(previous_document_id)) = ( + previous_relative_path.as_deref(), + previous_document_id.as_deref(), + ) { + let _ = buffer_store.mark_local_folder_markdown_deleted( + workspace_id, + root_uri, + previous_relative_path, + previous_document_id, + ); + } + } + _ => {} + } +} + pub async fn tree_command( State(state): State, Extension(context): Extension, @@ -2292,10 +2388,18 @@ pub async fn tree_command( .with_context(&context) .with_header("x-error-phase", "tree_local_executor") })?; + let local_workspace_id = local_workspace_id_from_root_uri(root_uri)?; + apply_local_file_operation_participants( + &state.buffer_store, + &local_workspace_id, + root_uri, + action, + &execution, + ); return Ok(json_response( &context, json!({ - "workspaceId": local_workspace_id_from_root_uri(root_uri)?, + "workspaceId": local_workspace_id, "action": action, "documentId": execution .get("documentId") @@ -2304,6 +2408,12 @@ pub async fn tree_command( "parentId": requested_parent_id, "title": requested_title, "sortOrder": requested_sort_order, + "affectedParents": execution.get("affectedParents").cloned().unwrap_or(Value::Null), + "revealTarget": execution.get("revealTarget").cloned().unwrap_or(Value::Null), + "selectTarget": execution.get("selectTarget").cloned().unwrap_or(Value::Null), + "operationId": execution.get("operationId").cloned().unwrap_or(Value::Null), + "batchId": envelope_context.batch_id.clone(), + "schema": execution.get("schema").cloned().unwrap_or(Value::Null), "updatedAt": Value::Null, "execution": execution, "artifacts": Value::Null, @@ -3999,6 +4109,7 @@ mod tests { "rowIds": ["doc:page_child"] })), operation: Some("tree.node.rename".into()), + batch_id: None, }; let rename_wire = create_command_wire( diff --git a/rust/crates/mnote-web/src/routes/ui_preferences.rs b/rust/crates/mnote-web/src/routes/ui_preferences.rs new file mode 100644 index 00000000..ade5eca2 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/ui_preferences.rs @@ -0,0 +1,489 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::page_aggregate::{PageAggregate, PageOptions}; +use crate::routes::gateway::current_actor_id; +use crate::routes::local_folder_source::{ + local_root_has_workspace_manifest, local_workspace_id_from_root_uri, +}; +use axum::extract::{Extension, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord}; +use serde::Deserialize; +use serde_json::Value; +use serde_json::{json, Map}; +use std::collections::BTreeMap; + +pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space"; +pub(crate) const SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER: &str = "external_local_folder"; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UiPreferencesQuery { + #[serde(default, alias = "workspace_id")] + workspace_id: String, + #[serde(default, alias = "source_kind")] + source_kind: String, + #[serde(default, alias = "root_uri")] + root_uri: String, + #[serde(default, alias = "document_id")] + document_id: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UiPreferencesUpdateRequest { + #[serde(default, alias = "workspace_id")] + workspace_id: String, + #[serde(default, alias = "source_kind")] + source_kind: String, + #[serde(default, alias = "root_uri")] + root_uri: String, + #[serde(default, alias = "document_id")] + document_id: String, + #[serde(default)] + updates: BTreeMap, +} + +#[derive(Debug, Clone)] +struct PagePreferenceScope { + workspace_id: String, + source_kind: String, + source_family: String, + document_id: String, +} + +#[derive(Debug, Clone)] +struct EffectivePagePreferences { + scope: PagePreferenceScope, + page_options: PageOptions, + sources: BTreeMap, +} + +pub(crate) async fn effective_preferences( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result, WebError> { + let actor_id = require_actor_id(&state, &context)?; + let effective = resolve_effective_page_preferences( + &state, + &actor_id, + &query.workspace_id, + &query.source_kind, + &query.root_uri, + &query.document_id, + PageOptions::default(), + )?; + Ok(Json(effective_preferences_payload(effective))) +} + +pub(crate) async fn update_preferences( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + let actor_id = require_actor_id(&state, &context)?; + let result = update_page_preferences_from_value( + &state, + &context, + &actor_id, + &request.workspace_id, + &request.source_kind, + &request.root_uri, + &request.document_id, + &Value::Object(request.updates.into_iter().collect()), + )?; + Ok(Json(json!({ + "ok": true, + "owner": "mnote-web", + "result": result, + }))) +} + +pub(crate) fn update_page_preferences_from_value( + state: &AppState, + context: &RequestContext, + actor_id: &str, + workspace_id: &str, + source_kind: &str, + root_uri: &str, + document_id: &str, + updates: &Value, +) -> Result { + ensure_actor_user(state, actor_id)?; + let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?; + let update_map = updates.as_object().ok_or_else(|| { + WebError::bad_request_code("ui_preference_updates_invalid", "updates 必须是对象") + .with_context(context) + })?; + for (key, value) in update_map { + let Some((scope_kind, scope_id)) = preference_scope_for_key(key, &scope) else { + continue; + }; + state + .control_plane() + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: actor_id.to_string(), + workspace_id: preference_workspace_for_scope(&scope.workspace_id, &scope_kind), + source_kind: preference_source_kind_for_scope(&scope.source_kind, &scope_kind), + scope_kind, + scope_id, + key: key.trim().to_string(), + value_json: value.to_string(), + }) + .map_err(|error| { + WebError::bad_request_code( + "ui_preference_write_failed", + format!("写入 UI 偏好失败: {error}"), + ) + .with_context(context) + })?; + } + let effective = resolve_effective_page_preferences( + state, + actor_id, + workspace_id, + source_kind, + root_uri, + document_id, + PageOptions::default(), + )?; + Ok(effective_preferences_payload(effective)["result"].clone()) +} + +pub(crate) fn source_family_for_page( + source_kind: Option<&str>, + root_uri: Option<&str>, +) -> Result { + if source_kind.map(str::trim) != Some("local_folder") { + return Ok("workspace".to_string()); + } + let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string()); + }; + if local_root_has_workspace_manifest(root_uri)? { + Ok(SOURCE_FAMILY_MY_SPACE.to_string()) + } else { + Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string()) + } +} + +pub(crate) fn apply_effective_page_preferences( + state: &AppState, + context: &RequestContext, + aggregate: &mut PageAggregate, + source_kind: Option<&str>, + root_uri: Option<&str>, +) -> Result<(), WebError> { + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + return Ok(()); + } + let effective = resolve_effective_page_preferences( + state, + actor_id, + &aggregate.identity.workspace_id, + source_kind.unwrap_or_default(), + root_uri.unwrap_or_default(), + &aggregate.identity.document_id, + aggregate.layout.page_options.clone(), + )?; + aggregate.layout.page_options = effective.page_options; + aggregate.layout_options = serde_json::to_value(&aggregate.layout.page_options) + .unwrap_or_else(|_| serde_json::json!({})); + Ok(()) +} + +fn resolve_effective_page_preferences( + state: &AppState, + actor_id: &str, + workspace_id: &str, + source_kind: &str, + root_uri: &str, + document_id: &str, + base_options: PageOptions, +) -> Result { + let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?; + let preferences = state + .control_plane() + .list_user_ui_preferences( + actor_id, + Some(&scope.workspace_id), + Some(&scope.source_kind), + ) + .map_err(|error| WebError::internal(format!("SQLite UI 偏好读取失败: {error}")))?; + let mut page_options = base_options; + if scope.source_family == SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER { + page_options.hide_title_header = true; + } + let mut sources = BTreeMap::new(); + apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?; + Ok(EffectivePagePreferences { + scope, + page_options, + sources, + }) +} + +fn apply_preference_records( + page_options: &mut PageOptions, + sources: &mut BTreeMap, + scope: &PagePreferenceScope, + preferences: &[UserUiPreferenceRecord], +) -> Result<(), WebError> { + for scope_kind in ["global", "source_family", "workspace", "document"] { + for preference in preferences + .iter() + .filter(|preference| preference.scope_kind.trim() == scope_kind) + { + let scope_matches = match scope_kind { + "global" => preference.scope_id.trim() == "default", + "source_family" => preference.scope_id.trim() == scope.source_family, + "workspace" => preference.scope_id.trim() == scope.workspace_id, + "document" => preference.scope_id.trim() == scope.document_id, + _ => false, + }; + if !scope_matches { + continue; + } + let value = serde_json::from_str::(&preference.value_json).map_err(|error| { + WebError::internal(format!( + "SQLite UI 偏好 JSON 无效 {}: {error}", + preference.key + )) + })?; + if apply_page_option_value(page_options, &preference.key, &value) { + sources.insert(preference.key.clone(), scope_kind.to_string()); + } + } + } + Ok(()) +} + +fn page_preference_scope( + workspace_id: &str, + source_kind: &str, + root_uri: &str, + document_id: &str, +) -> Result { + let source_kind = source_kind.trim(); + let source_kind = if source_kind.is_empty() { + "convex_workspace" + } else { + source_kind + }; + let workspace_id = workspace_id.trim().to_string().if_empty_else(|| { + if source_kind == "local_folder" { + local_workspace_id_from_root_uri(root_uri).unwrap_or_else(|_| "local-folder".into()) + } else { + "default".into() + } + }); + Ok(PagePreferenceScope { + workspace_id, + source_kind: source_kind.to_string(), + source_family: source_family_for_page(Some(source_kind), Some(root_uri))?, + document_id: document_id.trim().to_string(), + }) +} + +fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> { + match key.trim() { + "hideTitleHeader" | "hide_title_header" => { + Some(("source_family".to_string(), scope.source_family.clone())) + } + "showHeadingNumbers" | "show_heading_numbers" | "showWordCount" | "show_word_count" => { + Some(("global".to_string(), "default".to_string())) + } + "wideLayout" + | "wide_layout" + | "smallText" + | "small_text" + | "layoutDensity" + | "layout_density" + | "pageFont" + | "page_font" + | "showToc" + | "show_toc" + | "showStructure" + | "show_structure" + | "collapseBacklinks" + | "collapse_backlinks" + | "hideChildPages" + | "hide_child_pages" + | "showBlockRefCount" + | "show_block_ref_count" => Some(("workspace".to_string(), scope.workspace_id.clone())), + _ => None, + } +} + +fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option { + if scope_kind == "workspace" || scope_kind == "document" { + Some(workspace_id.to_string()) + } else { + None + } +} + +fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option { + if scope_kind == "workspace" || scope_kind == "document" { + Some(source_kind.to_string()) + } else { + None + } +} + +fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value { + let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({})); + let sources = effective + .sources + .into_iter() + .map(|(key, value)| (key, Value::String(value))) + .collect::>(); + json!({ + "ok": true, + "owner": "mnote-web", + "result": { + "scope": { + "sourceFamily": effective.scope.source_family, + "workspaceId": effective.scope.workspace_id, + "sourceKind": effective.scope.source_kind, + "documentId": effective.scope.document_id, + }, + "pageOptions": page_options, + "sources": Value::Object(sources), + } + }) +} + +fn require_actor_id(state: &AppState, context: &RequestContext) -> Result { + current_actor_id(state, context) + .filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous") + .ok_or_else(|| { + WebError::new( + StatusCode::UNAUTHORIZED, + "ui_preference_auth_required", + "UI 偏好需要登录用户", + ) + .with_context(context) + }) +} + +fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> { + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(actor_id.to_string()), + email: None, + username: actor_id.to_string(), + display_name: actor_id.to_string(), + role: None, + password_hash: None, + }) + .map(|_| ()) + .map_err(|error| WebError::internal(format!("SQLite 用户初始化失败: {error}"))) +} + +fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value) -> bool { + match key { + "hideTitleHeader" | "hide_title_header" => { + if let Some(value) = value.as_bool() { + options.hide_title_header = value; + return true; + } + } + "showHeadingNumbers" | "show_heading_numbers" => { + if let Some(value) = value.as_bool() { + options.show_heading_numbers = value; + return true; + } + } + "wideLayout" | "wide_layout" => { + if let Some(value) = value.as_bool() { + options.wide_layout = value; + return true; + } + } + "smallText" | "small_text" => { + if let Some(value) = value.as_bool() { + options.small_text = value; + return true; + } + } + "layoutDensity" | "layout_density" => { + if let Some(value) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + options.layout_density = value.to_string(); + return true; + } + } + "pageFont" | "page_font" => { + if let Some(value) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + options.page_font = value.to_string(); + return true; + } + } + "showToc" | "show_toc" => { + if let Some(value) = value.as_bool() { + options.show_toc = value; + return true; + } + } + "showStructure" | "show_structure" => { + if let Some(value) = value.as_bool() { + options.show_structure = value; + return true; + } + } + "showWordCount" | "show_word_count" => { + if let Some(value) = value.as_bool() { + options.show_word_count = value; + return true; + } + } + "collapseBacklinks" | "collapse_backlinks" => { + if let Some(value) = value.as_bool() { + options.collapse_backlinks = value; + return true; + } + } + "hideChildPages" | "hide_child_pages" => { + if let Some(value) = value.as_bool() { + options.hide_child_pages = value; + return true; + } + } + "showBlockRefCount" | "show_block_ref_count" => { + if let Some(value) = value.as_bool() { + options.show_block_ref_count = value; + return true; + } + } + _ => {} + } + false +} + +trait EmptyStringExt { + fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String; +} + +impl EmptyStringExt for String { + fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String { + if self.trim().is_empty() { + fallback() + } else { + self + } + } +} diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 9600024d..2cc76a90 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -10,6 +10,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_with_state, is_local_access_policy_admin_context, + load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate, }; @@ -35,7 +36,7 @@ use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; use std::fs; use std::path::{Component, Path as FsPath, PathBuf}; @@ -43,6 +44,10 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell"; const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id"; +#[cfg(test)] +static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentShellQuery { @@ -50,6 +55,7 @@ pub struct DocumentShellQuery { pub source_kind: Option, pub root_uri: Option, pub tree_view: Option, + pub file_tree_scope: Option, pub secondary_document_id: Option, pub secondary_source_kind: Option, pub secondary_root_uri: Option, @@ -129,6 +135,7 @@ pub async fn document_page_shell( }; let default_workspace_name = default_workspace_name_for_context(&state, &context); let mut workspace_projection = load_workspace_shell_projection( + Some(&state), state.config(), &context, &workspace_id, @@ -148,11 +155,17 @@ pub async fn document_page_shell( .as_deref() .map(str::trim) .is_some_and(|value| value == "filetree"); + let file_tree_scope = query + .file_tree_scope + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); let (sidebar_tree_html, file_tree_html) = if is_local_folder { let root_uri = query.root_uri.as_deref().unwrap_or_default(); ( render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(), - render_local_file_tree_html(root_uri, Some(&document_id), None).unwrap_or_default(), + render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope) + .unwrap_or_default(), ) } else { ( @@ -179,6 +192,7 @@ pub async fn document_page_shell( } else { None }, + file_tree_scope, ); let workspace_name = workspace_projection.workspace_name.clone(); let page_subtree_json = @@ -245,6 +259,7 @@ pub async fn document_page_shell( {} + {} @@ -261,6 +276,7 @@ pub async fn document_page_shell( "#, escape_html(title), + render_editor_runtime_preload_links(), crate::ssr::MNOTE_CSS, escape_html(&document_id), escape_html(primary_source_kind.unwrap_or("convex_workspace")), @@ -646,6 +662,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { r#""# } +pub(crate) fn render_editor_runtime_preload_links() -> &'static str { + r#" + "# +} + fn runtime_asset_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../spikes/leptos-tiptap-spike/generated/island") @@ -678,6 +699,10 @@ fn runtime_asset_content_type(asset_path: &str) -> &'static str { } } +fn runtime_asset_cache_control() -> &'static str { + "public, max-age=3600, stale-while-revalidate=86400" +} + pub async fn editor_image_placeholder_asset() -> Response { const SVG: &str = r##" @@ -705,7 +730,7 @@ pub async fn resource_open_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -719,7 +744,7 @@ pub async fn local_upload_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -733,7 +758,7 @@ pub async fn sidebar_tree_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -747,7 +772,7 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -761,7 +786,7 @@ pub async fn sidebar_page_settings_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -775,7 +800,7 @@ pub async fn sidebar_shell_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -789,7 +814,7 @@ pub async fn sidebar_workspace_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -803,7 +828,7 @@ pub async fn sidebar_page_tree_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -817,7 +842,7 @@ pub async fn sidebar_tree_live_apply_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -831,7 +856,7 @@ pub async fn sidebar_filetree_open_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -845,7 +870,7 @@ pub async fn sidebar_filetree_command_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -859,7 +884,7 @@ pub async fn sidebar_filetree_upload_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -873,7 +898,7 @@ pub async fn sidebar_attachment_open_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -887,7 +912,7 @@ pub async fn filetree_keyboard_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -901,7 +926,7 @@ pub async fn filetree_dnd_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -915,7 +940,7 @@ pub async fn filetree_context_menu_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -929,7 +954,7 @@ pub async fn filetree_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -943,7 +968,7 @@ pub async fn filetree_selection_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -957,7 +982,7 @@ pub async fn tree_live_controller_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -971,7 +996,7 @@ pub async fn tree_shell_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -985,7 +1010,7 @@ pub async fn tree_shell_render_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -999,7 +1024,7 @@ pub async fn tree_shell_page_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1013,7 +1038,7 @@ pub async fn tree_shell_state_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1027,7 +1052,7 @@ pub async fn tree_shell_icons_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1041,7 +1066,7 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1055,7 +1080,7 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1069,7 +1094,7 @@ pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1083,7 +1108,7 @@ pub async fn tree_shell_picker_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1097,7 +1122,7 @@ pub async fn tree_shell_dom_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1112,7 +1137,7 @@ pub async fn document_conflict_panel_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1126,7 +1151,7 @@ pub async fn document_pane_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1140,7 +1165,7 @@ pub async fn document_mindmap_host_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1154,7 +1179,7 @@ pub async fn document_resource_tab_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1168,7 +1193,7 @@ pub async fn document_session_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1182,7 +1207,7 @@ pub async fn document_slash_position_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1196,7 +1221,7 @@ pub async fn document_tiptap_conversion_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1210,7 +1235,7 @@ pub async fn document_editor_adapter_runtime_asset() -> Response { header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .header(header::CACHE_CONTROL, "no-store") + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .body(Body::from(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) @@ -1228,6 +1253,10 @@ pub async fn leptos_tiptap_manifest() -> Response { }); let mut response = Json(manifest).into_response(); stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime"); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(runtime_asset_cache_control()), + ); response } @@ -1251,7 +1280,7 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path) -> Result String { } pub(crate) async fn load_workspace_shell_projection( + state: Option<&AppState>, config: &AppConfig, context: &RequestContext, workspace_id: &str, @@ -1412,7 +1459,7 @@ pub(crate) async fn load_workspace_shell_projection( query: None, max_results: None, }; - let dataset = match load_projection_snapshot(config, context, &spec).await { + let mut dataset = match load_projection_snapshot(config, context, &spec).await { Ok(snapshot) => snapshot.dataset, Err(_) if config.allow_dev_fixtures => { let documents = active_document_id @@ -1444,6 +1491,9 @@ pub(crate) async fn load_workspace_shell_projection( "degraded_reason": "projection_unavailable" }), }; + if let Some(state) = state { + attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset); + } build_workspace_shell_projection( &dataset, @@ -1453,6 +1503,25 @@ pub(crate) async fn load_workspace_shell_projection( ) } +pub(crate) fn attach_sidebar_shortcuts_to_dataset( + state: &AppState, + context: &RequestContext, + workspace_id: &str, + dataset: &mut Value, +) { + let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset( + state, + context, + workspace_id, + ); + if shortcuts.is_empty() { + return; + } + if let Some(object) = dataset.as_object_mut() { + object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts)); + } +} + /// 加载侧栏页面树 HTML(SSR) /// /// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。 @@ -1605,7 +1674,23 @@ pub(crate) fn render_local_file_tree_html( active_document_id: Option<&str>, active_row_id: Option<&str>, ) -> Result { - let snapshot = load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?; + render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None) +} + +pub(crate) fn render_local_file_tree_html_scoped( + root_uri: &str, + active_document_id: Option<&str>, + active_row_id: Option<&str>, + file_tree_scope: Option<&str>, +) -> Result { + let snapshot = if let Some(scope) = file_tree_scope + .map(str::trim) + .filter(|value| !value.is_empty()) + { + load_local_folder_file_tree_children_snapshot(root_uri, scope)? + } else { + load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)? + }; let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id); Ok(render_initial_filetree_html(&FileTreeInitialRenderInput { @@ -1613,14 +1698,23 @@ pub(crate) fn render_local_file_tree_html( })) } +#[cfg(test)] +fn block_local_page_aggregate_for_test() { + let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst); + if delay_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } +} + #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; 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 control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput}; use serde_json::Value; + use std::time::{Duration, Instant}; use tower::util::ServiceExt; const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str = @@ -1917,6 +2011,12 @@ mod tests { assert!(html.contains("data-mnote-resource-tab-host")); assert!(runtime.contains("openPrimaryMindmap")); assert!(runtime.contains("openResourceInActiveTab")); + assert!(html.contains( + r#""# + )); + assert!(html.contains( + r#""# + )); assert!(resource_runtime.contains("/api/local-folder/resource/read")); assert!(resource_runtime.contains("/api/local-folder/resource/write")); let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS; @@ -2033,6 +2133,78 @@ mod tests { ); } + #[tokio::test] + async fn mnote_browser_runtime_assets_are_cacheable() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("cache-control"); + assert_ne!(cache_control, "no-store"); + assert!( + cache_control.contains("max-age"), + "runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉" + ); + } + + #[tokio::test] + async fn leptos_tiptap_runtime_assets_are_cacheable() { + let manifest_response = app() + .clone() + .oneshot( + Request::builder() + .uri("/api/leptos-tiptap-runtime/manifest.json") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(manifest_response.status(), StatusCode::OK); + let manifest_cache_control = manifest_response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("manifest cache-control"); + assert_ne!(manifest_cache_control, "no-store"); + assert!( + manifest_cache_control.contains("max-age"), + "leptos-tiptap manifest 应允许浏览器缓存,避免每次重新发现 runtime 入口" + ); + + let response = app() + .oneshot( + Request::builder() + .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("cache-control"); + assert_ne!(cache_control, "no-store"); + assert!( + cache_control.contains("max-age"), + "leptos-tiptap runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉" + ); + } + #[tokio::test] async fn page_aggregate_endpoint_errors_without_convex_or_fixture() { let response = app_with_unreachable_convex_without_fixture() @@ -2243,6 +2415,190 @@ mod tests { assert!(aggregate.body.content.to_string().contains("Grant Heading")); } + #[tokio::test] + async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() { + let root = std::env::temp_dir().join(format!( + "mnote-local-page-aggregate-runtime-asset-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle"); + std::fs::write( + root.join("Cold Start").join("Cold Start.md"), + "# Cold Start\n\nEditor cold start target.\n", + ) + .expect("write local md"); + + let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); + let app = app(); + let aggregate_app = app.clone(); + let aggregate_uri = format!( + "/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}" + ); + super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS + .store(400, std::sync::atomic::Ordering::SeqCst); + struct ResetLocalAggregateBlock; + impl Drop for ResetLocalAggregateBlock { + fn drop(&mut self) { + super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS + .store(0, std::sync::atomic::Ordering::SeqCst); + } + } + let _reset_block = ResetLocalAggregateBlock; + let aggregate_task = tokio::spawn(async move { + aggregate_app + .oneshot( + Request::builder() + .uri(aggregate_uri) + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("aggregate request"), + ) + .await + .expect("aggregate response") + }); + + let started = Instant::now(); + tokio::task::yield_now().await; + let asset_response = app + .oneshot( + Request::builder() + .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") + .body(Body::empty()) + .expect("asset request"), + ) + .await + .expect("asset response"); + let asset_elapsed = started.elapsed(); + let aggregate_response = aggregate_task.await.expect("aggregate task"); + + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(asset_response.status(), StatusCode::OK); + assert_eq!(aggregate_response.status(), StatusCode::OK); + assert!( + asset_elapsed < Duration::from_millis(150), + "leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞,实际等待 {asset_elapsed:?}" + ); + } + + #[tokio::test] + async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() { + let root = std::env::temp_dir().join(format!( + "mnote-local-page-aggregate-ui-pref-{}", + 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"), "# Readme 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(), + }); + grant_local_workspace_read_access(&state, "user_test", &root_uri, &root); + let workspace_id = + crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri) + .expect("workspace id"); + state + .control_plane() + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "user_test".into(), + workspace_id: Some(workspace_id), + source_kind: Some("local_folder".into()), + scope_kind: "source_family".into(), + scope_id: "external_local_folder".into(), + key: "hideTitleHeader".into(), + value_json: "false".into(), + }) + .expect("upsert title header preference"); + + let context = request_context("user_test", "user"); + let aggregate = super::build_page_aggregate_snapshot( + &state, + &context, + "local-md:README.md", + None, + Some("local_folder"), + Some(&root_uri), + ) + .await + .expect("local page aggregate"); + + let _ = std::fs::remove_dir_all(&root); + + assert!(!aggregate.layout.page_options.hide_title_header); + } + + #[tokio::test] + async fn ui_preferences_api_updates_and_returns_effective_page_options() { + let app = app(); + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/ui/preferences") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "workspaceId": "ws_demo", + "sourceKind": "convex_workspace", + "documentId": "doc_1", + "updates": { + "showHeadingNumbers": true, + "layoutDensity": "compact" + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1") + .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: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true); + assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact"); + assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global"); + assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace"); + } + #[tokio::test] async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() { let root = @@ -2469,6 +2825,7 @@ mod tests { let filetree_html = super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await; let workspace_projection = super::load_workspace_shell_projection( + None, &config, &context, "ws_demo", diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index bc8f6598..a176d0ea 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -88,6 +88,7 @@ pub fn PageLayout( Some(sidebar_tree_html.as_str()), None, None, + None, ) }); let tree_live_bootstrap = serde_json::json!({ @@ -175,8 +176,8 @@ pub fn PageLayout(
- "全网公开" - + + @@ -199,6 +200,8 @@ pub fn PageLayout( #[cfg(test)] mod tests { + use leptos::prelude::ElementChild; + const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-runtime.js"); const SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-live-apply-runtime.js"); @@ -260,6 +263,8 @@ mod tests { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS .contains("data-global-option-checkbox=\"showHeadingNumbers\"")); + assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences")); + assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command")); @@ -341,6 +346,51 @@ mod tests { assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'")); } + #[test] + fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() { + let html = crate::ssr::render_view(leptos::view! { + +
"正文"
+
+ }); + + assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#)); + assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#)); + assert!(html.contains(r#"data-mnote-shortcut-kind="page""#)); + } + + #[test] + fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() { + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("inferLocalRootUriFromWorkspaceId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-scope")); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("toggle-sidebar-folder-shortcut")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar")); + let scope_start = SIDEBAR_TREE_RUNTIME_JS + .find("persistStarredFolderScope(workspaceId, rootUri, relativePath);") + .expect("starred folder should persist scope before loading"); + let sidebar_fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..] + .find("fetch(sidebarUrl.toString()") + .expect("starred folder should fetch local page projection") + + scope_start; + let fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..] + .find("fetch(url.toString()") + .expect("starred folder should fetch scoped projection") + + scope_start; + assert!( + sidebar_fetch_start < fetch_start, + "星标文件夹请求 scoped filetree 前必须先拉本地页面树投影,避免我的空间旧页面残留" + ); + assert!( + scope_start < fetch_start, + "星标文件夹应先写入 fileTreeScope,再请求大目录,避免 live refresh 把视图折回根目录" + ); + } + #[test] fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() { assert!( @@ -503,9 +553,10 @@ mod tests { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS - .contains("renderSidebarSnapshot(sidebarPayload.result || sidebarPayload)")); + .contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS - .contains("renderFileProjection(filePayload.result || filePayload)")); + .contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)")); assert!( SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied") ); @@ -565,6 +616,29 @@ mod tests { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("editorRoot: uploadContext.root")); } + #[test] + fn sidebar_attachment_open_runtime_receives_closest_action_dependency() { + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function closestAction")); + let injection_start = SIDEBAR_TREE_RUNTIME_JS + .find("const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({") + .expect("attachment runtime injection"); + let injection_end = SIDEBAR_TREE_RUNTIME_JS[injection_start..] + .find(" });") + .map(|offset| injection_start + offset) + .expect("attachment runtime injection end"); + let injection = &SIDEBAR_TREE_RUNTIME_JS[injection_start..injection_end]; + assert!( + injection.contains("closestAction"), + "attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError" + ); + assert!( + SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS + .contains("closestAction = typeof injectedClosestAction === 'function'") + || SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("dependencies.closestAction"), + "attachment open runtime 应从 dependencies 读取 closestAction,而不是依赖外层闭包" + ); + } + #[test] fn local_upload_runtime_contains_editor_upload_context_helpers() { const LOCAL_UPLOAD_RUNTIME_JS: &str = @@ -803,8 +877,9 @@ mod tests { "selectSidebarFileTreeDocument", ); assert!( - select_document_body - .contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"), + select_document_body.contains("activateSidebarFileTreeRow(row, options)") + && SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function activateSidebarFileTreeRow") + && SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"), "document selection 应复用 filetree row selection runtime/fallback" ); } @@ -1006,8 +1081,173 @@ mod tests { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file/children")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("parentRelativePath")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-filetree-children-loaded")); - assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeLazyChildrenCache")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowsByParent: new Map()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadedParents: new Set()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadingParents: new Map()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("dirtyParents: new Set()")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeExpandedRelativePaths")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("if (fileTreeState.loadingParents.has(key))")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("return fileTreeState.loadingParents.get(key);")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("function markExistingFileTreeChildrenLoaded")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("if (markExistingFileTreeChildrenLoaded(row, button))")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("scheduleRestorePersistedFileTreeExpansionState();")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("return patchFileTreeParentChildren(parentRelativePath, rows);")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)")); + let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .find("function renderFileProjection(projection)") + .expect("renderFileProjection"); + let render_end = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..] + .find("function renderSidebarSnapshot(payload)") + .map(|offset| render_start + offset) + .expect("renderFileProjection end"); + let render_body = &SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..render_end]; + assert!(!render_body.contains("tree.innerHTML =")); + assert!(render_body.contains("tree.replaceChildren")); + let patch_branch = render_body + .find("patchFileTreeParentChildren(parentRelativePath, rows)") + .expect("non-root parent patch"); + let root_replace = render_body + .find("tree.replaceChildren") + .expect("root replacement"); + assert!( + patch_branch < root_replace, + "非 scope parent projection 必须先局部 patch,不能替换整棵 filetree" + ); + let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .find("async function loadFileTreeChildren(row, button)") + .expect("lazy children loader"); + let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..] + .find("setTreeRowExpanded(row, button, true);") + .expect("lazy loading should mark the requested folder expanded before fetch") + + load_start; + let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..] + .find("getFileTreeChildren(relativePath)") + .expect("lazy children datasource call") + + load_start; + assert!( + optimistic_expand < fetch_start, + "慢目录加载期间必须先保存 expanded 状态,否则 refresh/create-page 会把刚点开的文件夹折叠" + ); + } + + #[test] + fn sidebar_filetree_runtime_discards_stale_generation_results() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("staleParents: new Set()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("requestGeneration: 0")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function beginFileTreeRequest")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function isLatestFileTreeRequest")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("if (!isLatestFileTreeRequest(key, generation))")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.staleParents.add(key)")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.requestGeneration += 1")); + } + + #[test] + fn sidebar_filetree_runtime_prefers_command_affected_parents() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("function addAffectedParentsFromCommandResult")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("Array.isArray(result && result.affectedParents)")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("result && result.execution && result.execution.affectedParents")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("addAffectedParentsFromCommandResult(parents, result)")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("data-mnote-filetree-command-refresh-fallback")); + } + + #[test] + fn sidebar_filetree_runtime_batches_local_command_refresh() { + assert!( + SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function nextFileTreeOperationBatchId") + ); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("removeFileTreeAssetRow")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("removeFileTreeAssetRow,")); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("batchId: batchId")); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("tree:local-command-batch-complete")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function queueFileTreeBatchRefresh")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function flushFileTreeBatchRefresh")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("data-mnote-filetree-batch-refresh-pending")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("data-mnote-filetree-batch-refresh-applied")); + } + + #[test] + fn sidebar_filetree_runtime_handles_watch_batch_and_structured_error() { + assert!(TREE_LIVE_CONTROLLER_JS.contains("watch_batch")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:local-folder-watch-batch")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("data-mnote-local-folder-watch-batch-applied")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema")); + } + + #[test] + fn sidebar_filetree_runtime_has_view_state_namespace() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("expandedParents: new Set()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("selectedRowIds: new Set()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("focusedRowId: ''")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("activeRowId: ''")); + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeState = fileTreeViewState;") + ); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains( + "if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;" + )); + } + + #[test] + fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() { + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function rememberFileTreeSelectionState") + ); + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function reprojectFileTreeSelectionState") + ); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("fileTreeViewState.selectedRowIds.add(rowId)")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains( + "row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))" + )); + } + + #[test] + fn sidebar_filetree_runtime_can_reveal_unloaded_resource_path() { + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("async function revealFileTreeResource") + ); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("function fileTreeParentChainForRelativePath")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("await getFileTreeChildren(parentRelativePath)")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("fileTreeViewState.selectedRowIds = new Set([targetRowId])")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("revealFileTreeResource,")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args);")); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("revealFileTreeResource({")); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS + .contains("decodeLocalEncodedPath(id.slice('local-md:'.length))")); + assert!( + SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function localMarkdownBundleParentPath") + ); + assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS + .contains("visibleFileTreeRowByRelativePath(bundleParentPath)")); } #[test] diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 71394168..d21b315c 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1522,6 +1522,18 @@ body { color: var(--atelier-text); } +.wolai-section-add + .wolai-section-add { + margin-left: 2px; +} + +.wolai-section-add .material-symbols-outlined { + width: 15px; + height: 15px; + font-size: 15px; + display: block; + color: currentColor; +} + .wolai-page-row { min-height: 30px; gap: 7px; @@ -1560,6 +1572,39 @@ body { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; + min-width: 0; +} + +.wolai-row-more { + width: 26px; + height: 26px; + flex: 0 0 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: transparent; + color: #8C8983; + opacity: 0; + cursor: pointer; +} + +.wolai-page-row:hover .wolai-row-more, +.wolai-row-more:focus-visible, +.wolai-row-more[aria-expanded="true"] { + opacity: 1; +} + +.wolai-row-more:hover { + background: #E7E4E0; + color: #4B4945; +} + +.wolai-row-more .material-symbols-outlined { + font-size: 18px; + line-height: 1; } .sidebar-tree-section { 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 cef0b00d..5c45824f 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -14,6 +14,7 @@ pub struct FileTreeRenderRow { pub icon_kind: String, pub document_id: Option, pub asset_id: Option, + pub relative_path: Option, pub object_identity: Option, pub selected: bool, } @@ -90,7 +91,7 @@ fn render_filetree_row( }; let owner_document_id = row.document_id.as_deref().unwrap_or_default(); html.push_str(&format!( - r#"
  • {toggle_html}
    {create_action_html}
    "#, + r#"
  • {toggle_html}
    {create_action_html}
    "#, node_id = escape_html(&row.node_id), aria_level = row.depth + 1, expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, @@ -101,6 +102,7 @@ fn render_filetree_row( document_id = escape_html(command_document_id), owner_document_id = escape_html(owner_document_id), asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()), + relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()), object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()), selected = row.selected, toggle_html = toggle_html, @@ -176,6 +178,7 @@ mod tests { icon_kind: "page".into(), document_id: Some("page_root".into()), asset_id: None, + relative_path: None, object_identity: Some( r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(), ), @@ -193,6 +196,7 @@ mod tests { icon_kind: "mindmap".into(), document_id: Some("page_root".into()), asset_id: Some("mind_1".into()), + relative_path: Some("assets/思维导图.json".into()), object_identity: Some( r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(), ), @@ -210,6 +214,7 @@ mod tests { assert!(html.contains("data-doc-id=\"page_root\"")); assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\"")); assert!(html.contains("data-asset-id=\"mind_1\"")); + assert!(html.contains("data-local-relative-path=\"assets/思维导图.json\"")); assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap"")); assert!(html.contains("tree-children")); assert!(html.contains("首页 <安全>")); @@ -234,6 +239,7 @@ mod tests { icon_kind: "folder".into(), document_id: None, asset_id: None, + relative_path: Some("docs".into()), object_identity: None, selected: false, }, @@ -249,6 +255,7 @@ mod tests { icon_kind: "file".into(), document_id: Some("local-md:docs~2FREADME.md".into()), asset_id: None, + relative_path: Some("docs/README.md".into()), object_identity: None, selected: false, }, @@ -256,8 +263,34 @@ mod tests { }); assert!(html.contains("data-row-id=\"local:folder:docs\"")); + assert!(html.contains("data-local-relative-path=\"docs\"")); assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\"")); assert!(!html.contains("README.md")); assert!(!html.contains("tree-children--collapsed")); } + + #[test] + fn filetree_ssr_rows_include_local_relative_path() { + let html = render_initial_filetree_html(&FileTreeInitialRenderInput { + rows: vec![FileTreeRenderRow { + row_id: "local:folder:design/03-rust-web".into(), + row_kind: "folder".into(), + node_id: "local:node:design/03-rust-web".into(), + parent_node_id: None, + title: "03-rust-web".into(), + depth: 1, + expandable: true, + expanded: false, + icon_kind: "folder".into(), + document_id: None, + asset_id: None, + relative_path: Some("design/03-rust-web".into()), + object_identity: None, + selected: false, + }], + }); + + assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#)); + assert!(html.contains(r#"
  • {my_pages}
    {file_tree_content}
    "#, + r#"{status_markers}
    {}星标置顶
    {starred_rows}
    {my_pages}
    {file_tree_content}
    "#, render_symbol("star", "wolai-section-icon"), render_symbol("folder_open", "wolai-folder-icon"), escape_html(&projection.workspace_id), + escape_html(&projection.workspace_id), ) } @@ -613,20 +919,105 @@ fn render_item_row(item: &WorkspaceShellItem) -> String { .as_deref() .map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id))) .unwrap_or_default(); + let workspace_attr = item + .workspace_id + .as_deref() + .map(|workspace_id| format!(r#" data-workspace-id="{}""#, escape_html(workspace_id))) + .unwrap_or_default(); + let shortcut_id_attr = item + .shortcut_id + .as_deref() + .map(|shortcut_id| format!(r#" data-mnote-shortcut-id="{}""#, escape_html(shortcut_id))) + .unwrap_or_default(); + let source_kind_attr = item + .source_kind + .as_deref() + .map(|source_kind| { + format!( + r#" data-mnote-shortcut-source-kind="{}""#, + escape_html(source_kind) + ) + }) + .unwrap_or_default(); + let root_uri_attr = item + .root_uri + .as_deref() + .map(|root_uri| { + format!( + r#" data-mnote-shortcut-root-uri="{}""#, + escape_html(root_uri) + ) + }) + .unwrap_or_default(); let aria_current = if item.active { r#" aria-current="page""# } else { "" }; - format!( - r#"{}{}"#, - escape_html(&item.href), - escape_html(&item.id), - escape_html(&item.id), - item.depth, - item.active, + let kind_attr = item + .kind + .as_deref() + .map(|kind| format!(r#" data-mnote-shortcut-kind="{}""#, escape_html(kind))) + .unwrap_or_default(); + let target_attr = item + .target_id + .as_deref() + .map(|target_id| { + format!( + r#" data-mnote-shortcut-target-id="{}""#, + escape_html(target_id) + ) + }) + .unwrap_or_default(); + let relative_attr = item + .relative_path + .as_deref() + .map(|relative_path| { + format!( + r#" data-mnote-shortcut-relative-path="{}""#, + escape_html(relative_path) + ) + }) + .unwrap_or_default(); + let document_attr = item + .document_id + .as_deref() + .map(|document_id| { + format!( + r#" data-mnote-shortcut-document-id="{}""#, + escape_html(document_id) + ) + }) + .unwrap_or_default(); + let row_body = format!( + r#"{}{}"#, render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"), escape_html(&item.title), + ); + let shortcut_action = item + .shortcut_id + .as_deref() + .map(|_| { + r#""# + .to_string() + }) + .unwrap_or_default(); + if item.href.trim().is_empty() { + return format!( + r#"
    {row_body}{shortcut_action}
    "#, + escape_html(&item.id), + escape_html(item.document_id.as_deref().unwrap_or(&item.id)), + item.depth, + item.active, + ); + } + format!( + r#"{row_body}{shortcut_action}"#, + escape_html(&item.href), + escape_html(&item.id), + escape_html(item.document_id.as_deref().unwrap_or(&item.id)), + item.depth, + item.active, ) } diff --git a/scripts/task-page-aggregate-options-sync-smoke.js b/scripts/task-page-aggregate-options-sync-smoke.js index fb4f990a..20a35286 100644 --- a/scripts/task-page-aggregate-options-sync-smoke.js +++ b/scripts/task-page-aggregate-options-sync-smoke.js @@ -92,7 +92,7 @@ async function readRuntimePageOptions(page) { async function waitForOptionsResponse(page, documentId, mutate) { const responsePromise = page.waitForResponse( async (response) => { - if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") { + if (!response.url().includes("/api/ui/preferences") || response.request().method() !== "PUT") { return false; } const payload = response.request().postDataJSON(); @@ -103,8 +103,8 @@ async function waitForOptionsResponse(page, documentId, mutate) { await mutate(); const response = await responsePromise; const payload = await response.json(); - assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions"); - assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定"); + assert.equal(payload?.owner, "mnote-web", "页面设置写入必须由 mnote-web 持有"); + assert(payload?.result?.pageOptions, "页面设置写入必须返回 SQLite 合并后的 pageOptions"); return payload; } diff --git a/scripts/task-page-aggregate-refresh-persistence-smoke.js b/scripts/task-page-aggregate-refresh-persistence-smoke.js index 538d95a8..f2144da2 100644 --- a/scripts/task-page-aggregate-refresh-persistence-smoke.js +++ b/scripts/task-page-aggregate-refresh-persistence-smoke.js @@ -174,7 +174,7 @@ async function openPageSettingsDialog(page) { async function waitForOptionsResponse(page, documentId, mutate) { const responsePromise = page.waitForResponse( async (response) => { - if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") { + if (!response.url().includes("/api/ui/preferences") || response.request().method() !== "PUT") { return false; } const payload = response.request().postDataJSON(); @@ -185,8 +185,8 @@ async function waitForOptionsResponse(page, documentId, mutate) { await mutate(); const response = await responsePromise; const payload = await response.json(); - assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions"); - assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定"); + assert.equal(payload?.owner, "mnote-web", "页面设置写入必须由 mnote-web 持有"); + assert(payload?.result?.pageOptions, "页面设置写入必须返回 SQLite 合并后的 pageOptions"); return response.status(); } diff --git a/scripts/task163-local-folder-unified-tree-browser-smoke.js b/scripts/task163-local-folder-unified-tree-browser-smoke.js index 0af31083..68202de3 100644 --- a/scripts/task163-local-folder-unified-tree-browser-smoke.js +++ b/scripts/task163-local-folder-unified-tree-browser-smoke.js @@ -286,7 +286,8 @@ async function run() { request.url().includes("/api/tree/commands") || request.url().includes("/api/documents/title") || request.url().includes("/api/documents/save") || - request.url().includes("/api/documents/options") + request.url().includes("/api/documents/options") || + request.url().includes("/api/ui/preferences") ) { requests.push({ url: request.url(), @@ -298,7 +299,6 @@ async function run() { try { await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await page.evaluate(() => window.localStorage.removeItem("mnote.global.showHeadingNumbers")); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForText(page, "Frontmatter Title"); const dialogs = []; @@ -393,21 +393,14 @@ async function run() { if (!optionsResponse.ok) throw new Error(`options_failed_${optionsResponse.status}`); }, { rootUri: fileUrl(root) }); assert(fs.readFileSync(path.join(root, "README.md"), "utf8").includes("Browser saved body"), "浏览器保存应写回 Markdown 正文"); - assert(fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存应写入 .mnote/page-options.json"); + assert(!fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存不应继续写入 .mnote/page-options.json"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForText(page, "Browser Saved Title"); await waitForText(page, "Browser saved body"); - const globallyDisabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) => - window.getComputedStyle(node, "::before").content - ); - assert(globallyDisabledHeadingBefore === "none", "全局关闭时,即使页面设置显式开启 showHeadingNumbers,也不应显示标题自动编号"); - await page.evaluate(() => window.localStorage.setItem("mnote.global.showHeadingNumbers", "true")); - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await waitForText(page, "Browser Saved Title"); const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) => window.getComputedStyle(node, "::before").content ); - assert(enabledHeadingBefore !== "none", "全局开启后应显示标题自动编号"); + assert(enabledHeadingBefore !== "none", "SQLite 偏好开启后应显示标题自动编号"); await page.goto(documentUrl(root, "local-md:docs~2Fblocks.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForText(page, "Complex Title"); await waitForText(page, "Bullet item"); diff --git a/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js b/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js index 8dba94d4..42cb536f 100644 --- a/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js +++ b/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js @@ -193,8 +193,10 @@ async function main() { ); assert(markdown.includes("# 正文标题"), "正文 H1 应写回 markdown"); assert(markdown.includes("正文已保存"), "正文段落应写回 markdown"); - const options = fs.readFileSync(path.join(managedRoot, ".mnote", "page-options.json"), "utf8"); - assert(options.includes("showToc"), "页面设置应写入 .mnote/page-options.json"); + assert( + !fs.existsSync(path.join(managedRoot, ".mnote", "page-options.json")), + "页面设置不应继续写入 .mnote/page-options.json", + ); console.log("task167 local markdown title/body/options no-convex smoke passed"); } finally { diff --git a/scripts/task471-local-folder-bulk-resource-trash-smoke.js b/scripts/task471-local-folder-bulk-resource-trash-smoke.js index c2a4787d..31c15014 100644 --- a/scripts/task471-local-folder-bulk-resource-trash-smoke.js +++ b/scripts/task471-local-folder-bulk-resource-trash-smoke.js @@ -81,11 +81,21 @@ async function main() { }); const page = await context.newPage(); const treeCommands = []; + const treeCommandResponses = []; + const unexpectedDialogs = []; page.on("request", (request) => { if (!request.url().includes("/api/tree/commands")) return; const body = request.postDataJSON?.() || null; treeCommands.push({ method: request.method(), body }); }); + page.on("response", async (response) => { + if (!response.url().includes("/api/tree/commands")) return; + let body = ""; + try { + body = await response.text(); + } catch (_) {} + treeCommandResponses.push({ status: response.status(), body }); + }); try { await quickLogin(page); @@ -95,6 +105,14 @@ async function main() { url.searchParams.set("treeView", "filetree"); await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const docsRow = page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="docs"]').first(); + if (await docsRow.isVisible({ timeout: UI_TIMEOUT_MS }).catch(() => false)) { + const expanded = await docsRow.getAttribute("aria-expanded").catch(() => null); + if (expanded !== "true") { + await docsRow.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS }); + } + } + const selectors = assets.map((asset) => `#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`); await page.locator(selectors[0]).waitFor({ state: "visible", @@ -137,8 +155,16 @@ async function main() { await page.locator('.mnote-tree-context-menu__item[data-action="delete-trash"]').click({ timeout: UI_TIMEOUT_MS }); const confirmText = await dialogPromise; assert.match(confirmText, /2 个附件/, `右键 bulk delete 确认文案应包含附件数量: ${confirmText}`); + page.on("dialog", async (dialog) => { + unexpectedDialogs.push(dialog.message()); + await dialog.accept().catch(() => undefined); + }); - await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, { + await page.waitForFunction(() => { + const status = document.documentElement.getAttribute("data-mnote-filetree-last-action-status"); + return document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true" + || status === "failed"; + }, null, { timeout: UI_TIMEOUT_MS, }); const actionState = await page.evaluate(() => ({ @@ -146,8 +172,11 @@ async function main() { status: document.documentElement.getAttribute("data-mnote-filetree-last-action-status"), rowId: document.documentElement.getAttribute("data-mnote-filetree-last-action-row-id"), applied: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied"), + batchId: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-batch-id"), + batchRefresh: document.documentElement.getAttribute("data-mnote-filetree-batch-refresh-applied"), })); assert.equal(actionState.action, "bulk-delete", `bulk delete 应记录 action 名: ${JSON.stringify(actionState)}`); + assert.deepEqual(unexpectedDialogs, [], `bulk delete 不应出现失败 alert: ${JSON.stringify({ unexpectedDialogs, treeCommands, treeCommandResponses })}`); assert.equal(actionState.status, "archived", `bulk delete 应记录 undo/archive 状态: ${JSON.stringify(actionState)}`); assert.equal(actionState.rowId, rowIds[1], `bulk delete 应记录触发目标 row: ${JSON.stringify(actionState)}`); for (const asset of assets) { @@ -159,6 +188,13 @@ async function main() { } const archiveCommands = treeCommands.filter((entry) => entry.body && entry.body.action === "archive"); assert.equal(archiveCommands.length, 2, `bulk delete 应发出两个 archive tree command: ${JSON.stringify(treeCommands)}`); + assert(actionState.batchId, `bulk delete 应生成 batch id: ${JSON.stringify(actionState)}`); + assert.equal(actionState.batchRefresh, actionState.batchId, `bulk delete 应按 batch 完成后统一刷新: ${JSON.stringify(actionState)}`); + assert.deepEqual( + Array.from(new Set(archiveCommands.map((entry) => entry.body.batchId))).sort(), + [actionState.batchId], + `archive command 应共享同一个 batchId: ${JSON.stringify(archiveCommands)}`, + ); assert.deepEqual( archiveCommands.map((entry) => entry.body.documentId).sort(), assets.map((asset) => asset.assetId).sort(), diff --git a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js index 58cd9191..4bff21ee 100644 --- a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js +++ b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js @@ -850,22 +850,17 @@ async function main() { await hideTitleCheckbox.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const hideTitleCheckedBefore = await hideTitleCheckbox.isChecked(); assert.equal(hideTitleCheckedBefore, false, "托管工作区(我的空间)默认不隐藏文件标题"); - await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS }); const optionsPath = path.join(root, ".mnote", "page-options.json"); - const optionsHidden = await waitForFileContent( - optionsPath, - (content) => { - try { - const payload = JSON.parse(content); - return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === true; - } catch { - return false; - } + await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => { + const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); + return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false; }, - UI_TIMEOUT_MS, + null, + { timeout: UI_TIMEOUT_MS }, ); - assert(optionsHidden.ok, `隐藏标题应持久化 hideTitleHeader=true,实际: ${optionsHidden.content}`); - await page.waitForTimeout(500); + assert(!fs.existsSync(optionsPath), "隐藏标题不应继续写入 .mnote/page-options.json"); const titleHeaderHiddenAfterCheck = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false; @@ -873,20 +868,15 @@ async function main() { assert(titleHeaderHiddenAfterCheck, "勾选隐藏标题后标题应隐藏"); await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS }); - const optionsShown = await waitForFileContent( - optionsPath, - (content) => { - try { - const payload = JSON.parse(content); - return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === false; - } catch { - return false; - } + await page.waitForFunction( + () => { + const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); + return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; }, - UI_TIMEOUT_MS, + null, + { timeout: UI_TIMEOUT_MS }, ); - assert(optionsShown.ok, `显示标题应持久化 hideTitleHeader=false,实际: ${optionsShown.content}`); - await page.waitForTimeout(500); + assert(!fs.existsSync(optionsPath), "显示标题不应继续写入 .mnote/page-options.json"); const titleHeaderVisibleAfterToggle = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; diff --git a/scripts/task492-sidebar-starred-shortcuts-smoke.js b/scripts/task492-sidebar-starred-shortcuts-smoke.js new file mode 100644 index 00000000..8bc5cbfc --- /dev/null +++ b/scripts/task492-sidebar-starred-shortcuts-smoke.js @@ -0,0 +1,323 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); + +const TASK = "task492-sidebar-starred-shortcuts-smoke"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function resolveChromiumExecutablePath() { + const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; + if (explicit && fs.existsSync(explicit)) return explicit; + return [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/snap/bin/chromium", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].find((candidate) => fs.existsSync(candidate)) || ""; +} + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) { + resolve(); + return; + } + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { + request.destroy(); + retry(); + }); + }; + const retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + tick(); + }); +} + +function fileUrlToPath(value) { + const url = new URL(value); + return decodeURIComponent(url.pathname); +} + +async function main() { + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`)); + const dbPath = path.join(dataRoot, "control-plane.sqlite3"); + const actorId = `${TASK}-${process.pid}-${Date.now()}`; + const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(__dirname, "..", "rust"), + env: { + ...process.env, + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", + MNOTE_CONTROL_PLANE_DB_PATH: dbPath, + MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, + CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", + NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const executablePath = resolveChromiumExecutablePath(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + const context = await browser.newContext(); + const page = await context.newPage(); + const browserDiagnostics = []; + page.on("console", (message) => { + browserDiagnostics.push(`console:${message.type()}:${message.text()}`); + }); + page.on("pageerror", (error) => { + browserDiagnostics.push(`pageerror:${error.message}`); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/projections/file")) { + browserDiagnostics.push(`request:${url}`); + } + }); + + try { + await waitForHttpOk(`${baseUrl}/health`, 60_000); + const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: `${actorId}@example.com`, + username: actorId, + name: actorId, + password: TEST_PASSWORD, + flow: "signUp", + }, + }, + }, + }); + assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`); + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); + if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await createButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { + timeout: UI_TIMEOUT_MS, + }); + } + + const rootUri = new URL(page.url()).searchParams.get("rootUri") + || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); + assert(rootUri, "应进入 local_folder workspace"); + const rootPath = fileUrlToPath(rootUri); + fs.mkdirSync(path.join(rootPath, "design"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "other"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "Brief.md"), "# Brief\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "other", "Other.md"), "# Other\n", "utf8"); + + const activeDocumentId = "local-md:Home.md"; + const url = new URL(page.url()); + url.pathname = `/documents/${encodeURIComponent(activeDocumentId)}`; + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + url.searchParams.set("treeView", "filetree"); + await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + assert.equal(await page.locator('[data-testid="wolai-public-state"]').isVisible(), false, "未公开页面不应显示全网公开"); + await page.locator('[data-mnote-action="toggle-sidebar-shortcut"][data-mnote-shortcut-kind="page"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="page"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }).catch(async (error) => { + const debug = await page.evaluate(() => ({ + href: location.href, + action: document.documentElement.getAttribute("data-mnote-sidebar-shortcut-last-action"), + workspaceId: document.getElementById("sidebar-file-tree-root")?.getAttribute("data-workspace-id") || "", + rootUri: document.body.getAttribute("data-mnote-root-uri") || "", + topbar: document.querySelector(".wolai-topbar-actions")?.innerHTML || "", + })); + throw new Error(`${error.message}; debug=${JSON.stringify(debug)}; browser=${browserDiagnostics.slice(-8).join(" | ")}`); + }); + + const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first(); + await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); + await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS }); + const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first(); + await designShortcut.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + const cloudSpaceUrl = new URL(baseUrl); + cloudSpaceUrl.searchParams.set("workspaceId", "default"); + await page.goto(cloudSpaceUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const cloudDesignShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first(); + await cloudDesignShortcut.waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + assert.equal( + await cloudDesignShortcut.getAttribute("data-mnote-shortcut-root-uri"), + rootUri, + "回到我的空间后,本地文件夹星标应携带自己的 rootUri", + ); + await page.evaluate(() => { + const panel = document.querySelector("#wolai-sidebar-page-tree-panel, [data-mnote-sidebar-tree-panel='page']"); + let root = document.querySelector("#sidebar-tree-root"); + if (!(root instanceof HTMLElement) && panel instanceof HTMLElement) { + root = document.createElement("div"); + root.id = "sidebar-tree-root"; + root.className = "sidebar-tree"; + root.setAttribute("data-tree-shell-mode", "page"); + panel.appendChild(root); + } + if (!(root instanceof HTMLElement)) throw new Error("无法注入旧页面树 root"); + root.innerHTML = [ + '
      ', + '
    • ', + '
      ', + '", + "
      ", + "
    • ", + "
    ", + ].join(""); + }); + + await cloudDesignShortcut.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const scopedExplorerUrl = new URL(page.url()); + assert.equal(scopedExplorerUrl.searchParams.get("sourceKind"), "local_folder", "点击星标文件夹后应切到本地文件夹 source"); + assert.equal(scopedExplorerUrl.searchParams.get("rootUri"), rootUri, "点击星标文件夹后 URL 应保留显式 rootUri"); + assert.equal(scopedExplorerUrl.searchParams.get("fileTreeScope"), "design", "点击星标文件夹后 URL 应进入 design scope"); + assert.equal( + await page.evaluate(() => document.documentElement.getAttribute("data-mnote-sidebar-shortcut-open-error") || ""), + "", + "星标文件夹打开不应依赖 workspaceId 反推 rootUri", + ); + const scopedText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS }); + assert.match(scopedText, /Brief/); + assert.doesNotMatch(scopedText, /Other/); + const scopedPageTreeState = await page.evaluate(() => { + const root = document.querySelector("#sidebar-tree-root"); + return { + text: root?.innerText || "", + rowCount: root?.querySelectorAll('.tree-row[data-shell-mode="page"]').length || 0, + staleRowCount: root?.querySelectorAll('.tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').length || 0, + activeRows: Array.from(root?.querySelectorAll('.tree-row[data-shell-mode="page"][data-active="true"]') || []).map((row) => ({ + nodeId: row.getAttribute("data-node-id") || "", + title: row.querySelector(".tree-link-title")?.textContent?.trim() || "", + })), + }; + }); + assert.equal( + scopedPageTreeState.staleRowCount, + 0, + `打开星标本地文件夹后不应保留原我的空间页面树: ${JSON.stringify(scopedPageTreeState)}`, + ); + assert( + scopedPageTreeState.rowCount > 0 && /Brief/.test(scopedPageTreeState.text), + `打开星标本地文件夹后应显示本地 Markdown 页面树投影: ${JSON.stringify(scopedPageTreeState)}`, + ); + + await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Brief.md"] .tree-link').first().click({ + timeout: UI_TIMEOUT_MS, + }); + await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); + const scopedDocumentUrl = page.url(); + const scopedDocumentResponse = await context.request.fetch(scopedDocumentUrl); + const scopedDocumentHtml = await scopedDocumentResponse.text(); + assert(scopedDocumentHtml.includes('data-mnote-filetree-scope="design"'), "SSR document shell 应输出 filetree scope"); + assert(scopedDocumentHtml.includes("Brief.md"), "SSR document shell 应输出 scoped 文件树内容"); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const documentScopedText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS }); + assert.match(documentScopedText, /Brief/, `打开 scoped md 后应保留 design scope: url=${page.url()} text=${documentScopedText} browser=${browserDiagnostics.slice(-12).join(" | ")}`); + assert.doesNotMatch(documentScopedText, /Other/, `打开 scoped md 后不应回到根目录: url=${page.url()} text=${documentScopedText} browser=${browserDiagnostics.slice(-12).join(" | ")}`); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const reloadedDesignShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first(); + await reloadedDesignShortcut.waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await reloadedDesignShortcut.hover({ timeout: UI_TIMEOUT_MS }); + await reloadedDesignShortcut.locator('[data-mnote-shortcut-action="menu"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-sidebar-shortcut-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.getByRole("menuitem", { name: "取消星标" }).click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').waitFor({ + state: "detached", + timeout: UI_TIMEOUT_MS, + }); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + assert.equal( + await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').count(), + 0, + "三点菜单取消星标后刷新不应恢复该文件夹星标", + ); + + console.log(JSON.stringify({ ok: true, task: TASK, baseUrl }, null, 2)); + } finally { + await browser.close(); + server.kill("SIGINT"); + fs.rmSync(dataRoot, { recursive: true, force: true }); + if (server.exitCode == null) { + await new Promise((resolve) => server.once("exit", resolve)); + } + if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) { + process.stderr.write(stderr); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task493-page-settings-sqlite-preferences-smoke.js b/scripts/task493-page-settings-sqlite-preferences-smoke.js new file mode 100644 index 00000000..af6811c2 --- /dev/null +++ b/scripts/task493-page-settings-sqlite-preferences-smoke.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); + +const TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 30_000); + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) { + resolve(); + return; + } + retry(); + }); + request.on("error", retry); + request.setTimeout(1_000, () => { + request.destroy(); + retry(); + }); + }; + const retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + tick(); + }); +} + +async function requestJson(requestContext, baseUrl, pathname, init = {}) { + const response = await requestContext.fetch(`${baseUrl}${pathname}`, { + method: init.method || "GET", + headers: { + "content-type": "application/json", + ...(init.headers || {}), + }, + data: init.data, + timeout: TIMEOUT_MS, + }); + const payload = await response.json().catch(() => null); + assert( + response.ok(), + `${init.method || "GET"} ${pathname} failed ${response.status()}: ${JSON.stringify(payload)}`, + ); + return payload; +} + +async function loadAggregate(requestContext, baseUrl, documentId, rootUri) { + const url = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, baseUrl); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + const payload = await requestJson(requestContext, baseUrl, `${url.pathname}${url.search}`); + return payload.result || payload; +} + +async function effectivePreferences(requestContext, baseUrl, documentId, rootUri) { + const url = new URL("/api/ui/preferences/effective", baseUrl); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + url.searchParams.set("documentId", documentId); + const payload = await requestJson(requestContext, baseUrl, `${url.pathname}${url.search}`); + return payload.result; +} + +async function main() { + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-ui-pref-smoke-")); + const policyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-ui-pref-policy-")); + const policyFile = path.join(policyRoot, "access-policy.json"); + const actorId = `ui-pref-smoke-${process.pid}-${Date.now()}`; + const otherActorId = `${actorId}-other`; + const documentId = "local-md:README.md"; + const rootUri = `file://${root}`; + fs.writeFileSync(path.join(root, "README.md"), "# README\n\n正文\n", "utf8"); + fs.writeFileSync( + policyFile, + JSON.stringify({ + grants: [ + { + userId: actorId, + rootUri, + permission: "write", + recursive: true, + }, + ], + }), + "utf8", + ); + + const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(__dirname, "..", "rust"), + env: { + ...process.env, + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", + MNOTE_LOCAL_ACCESS_POLICY_FILE: policyFile, + CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", + NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const browser = await chromium.launch({ headless: true }); + const firstContext = await browser.newContext({ + extraHTTPHeaders: { + "x-mnote-actor-id": actorId, + "x-mnote-actor-type": "user", + }, + }); + const secondContext = await browser.newContext({ + extraHTTPHeaders: { + "x-mnote-actor-id": actorId, + "x-mnote-actor-type": "user", + }, + }); + const otherContext = await browser.newContext({ + extraHTTPHeaders: { + "x-mnote-actor-id": otherActorId, + "x-mnote-actor-type": "user", + }, + }); + + try { + await waitForHttpOk(`${baseUrl}/health`, 60_000); + + const initialAggregate = await loadAggregate(firstContext.request, baseUrl, documentId, rootUri); + assert.equal( + initialAggregate.layout.pageOptions.hideTitleHeader, + true, + "普通外部 local folder 默认隐藏本地 Markdown 标题", + ); + + await requestJson(firstContext.request, baseUrl, "/api/ui/preferences", { + method: "PUT", + data: { + sourceKind: "local_folder", + rootUri, + documentId, + updates: { + hideTitleHeader: false, + showHeadingNumbers: true, + wideLayout: true, + pageFont: "song", + }, + }, + }); + + assert( + !fs.existsSync(path.join(root, ".mnote", "page-options.json")), + "SQLite 偏好写入不应生成 .mnote/page-options.json", + ); + + const sameUserEffective = await effectivePreferences(secondContext.request, baseUrl, documentId, rootUri); + assert.equal(sameUserEffective.pageOptions.hideTitleHeader, false, "同用户第二浏览器应读到隐藏标题偏好"); + assert.equal(sameUserEffective.pageOptions.showHeadingNumbers, true, "同用户第二浏览器应读到标题编号偏好"); + assert.equal(sameUserEffective.pageOptions.wideLayout, true, "同用户第二浏览器应读到页面宽度偏好"); + assert.equal(sameUserEffective.pageOptions.pageFont, "song", "同用户第二浏览器应读到页面字体偏好"); + + const otherUserEffective = await effectivePreferences(otherContext.request, baseUrl, documentId, rootUri); + assert.equal(otherUserEffective.pageOptions.hideTitleHeader, true, "不同用户不应串读 source family 偏好"); + assert.equal(otherUserEffective.pageOptions.showHeadingNumbers, false, "不同用户不应串读 global 偏好"); + + console.log("task493 page settings sqlite preferences smoke passed"); + } finally { + await firstContext.close().catch(() => {}); + await secondContext.close().catch(() => {}); + await otherContext.close().catch(() => {}); + await browser.close().catch(() => {}); + server.kill("SIGINT"); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(policyRoot, { recursive: true, force: true }); + if (server.exitCode == null) { + await new Promise((resolve) => server.once("exit", resolve)); + } + if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) { + process.stderr.write(stderr); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task494-filetree-lazy-loading-dedup-smoke.js b/scripts/task494-filetree-lazy-loading-dedup-smoke.js new file mode 100644 index 00000000..0e082e3c --- /dev/null +++ b/scripts/task494-filetree-lazy-loading-dedup-smoke.js @@ -0,0 +1,544 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); + +const TASK = "task494-filetree-lazy-loading-dedup-smoke"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function resolveChromiumExecutablePath() { + const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; + if (explicit && fs.existsSync(explicit)) return explicit; + return [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/snap/bin/chromium", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].find((candidate) => fs.existsSync(candidate)) || ""; +} + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) { + resolve(); + return; + } + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { + request.destroy(); + retry(); + }); + }; + const retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + tick(); + }); +} + +function fileUrlToPath(value) { + const url = new URL(value); + return decodeURIComponent(url.pathname); +} + +function localMarkdownDocumentPath(documentId) { + const encoded = String(documentId || "").replace(/^local-md:/, ""); + return decodeURIComponent(encoded.replace(/~/g, "%")); +} + +async function main() { + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`)); + const dbPath = path.join(dataRoot, "control-plane.sqlite3"); + const actorId = `${TASK}-${process.pid}-${Date.now()}`; + const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(__dirname, "..", "rust"), + env: { + ...process.env, + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", + MNOTE_CONTROL_PLANE_DB_PATH: dbPath, + MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, + CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", + NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const executablePath = resolveChromiumExecutablePath(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + const context = await browser.newContext(); + const page = await context.newPage(); + const browserDiagnostics = []; + let targetChildrenRequests = 0; + let staleScopeChildrenRequests = 0; + let scopedRootProjectionRequests = 0; + let workspaceRootProjectionRequests = 0; + let forceChangingWatchRevision = false; + let localWatchRevision = 0; + + page.on("console", (message) => { + browserDiagnostics.push(`console:${message.type()}:${message.text()}`); + }); + page.on("pageerror", (error) => { + browserDiagnostics.push(`pageerror:${error.message}`); + }); + await page.route("**/api/tree/projections/file**", async (route) => { + const url = new URL(route.request().url()); + const parentRelativePath = url.searchParams.get("parentRelativePath") || ""; + if (url.pathname.endsWith("/api/tree/projections/file") && parentRelativePath === "design") { + scopedRootProjectionRequests += 1; + } + if (url.pathname.endsWith("/api/tree/projections/file") && !parentRelativePath) { + workspaceRootProjectionRequests += 1; + } + if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/03-rust-web") { + targetChildrenRequests += 1; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/slow-scope") { + staleScopeChildrenRequests += 1; + await new Promise((resolve) => setTimeout(resolve, 600)); + } + await route.continue(); + }); + await page.route("**/api/tree/local-folder-watch**", async (route) => { + if (!forceChangingWatchRevision) { + await route.continue(); + return; + } + localWatchRevision += 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + result: { + revision: `forced-watch-${localWatchRevision}`, + }, + }), + }); + }); + + try { + await waitForHttpOk(`${baseUrl}/health`, 60_000); + const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: `${actorId}@example.com`, + username: actorId, + name: actorId, + password: TEST_PASSWORD, + flow: "signUp", + }, + }, + }, + }); + assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`); + + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); + if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await createButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { + timeout: UI_TIMEOUT_MS, + }); + } + + const initialUrl = new URL(page.url()); + const workspaceId = initialUrl.searchParams.get("workspaceId") || ""; + const rootUri = initialUrl.searchParams.get("rootUri") + || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); + assert(rootUri, "应进入 local_folder workspace"); + const rootPath = fileUrlToPath(rootUri); + fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "done"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "process"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "reference"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "reveal-parent", "child"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "slow-scope", "child"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "docs", "target"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "Overview.md"), "Plain paragraph without office attachment.\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "plan.md"), "# Plan\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "reveal-parent", "child", "note.md"), "# Reveal\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "slow-scope", "child", "note.md"), "# Slow child\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "docs", "target", "note.md"), "# Docs child\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "docs", "opened-rename.md"), "# Opened rename\n", "utf8"); + + const fileTreeUrl = new URL(baseUrl); + if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId); + fileTreeUrl.searchParams.set("sourceKind", "local_folder"); + fileTreeUrl.searchParams.set("rootUri", rootUri); + fileTreeUrl.searchParams.set("treeView", "filetree"); + await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + + await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); + await page.waitForTimeout(300); + const firstCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || ""); + const firstCreatedRelativePath = localMarkdownDocumentPath(firstCreatedDocumentId); + const firstCreatedParentPath = firstCreatedRelativePath.split("/").slice(0, -1).join("/"); + + await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => { + const documentId = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() || ""); + return url.pathname.startsWith("/documents/") && documentId !== firstCreatedDocumentId; + }, { timeout: UI_TIMEOUT_MS }); + await page.waitForTimeout(500); + const secondCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || ""); + const secondCreatedRelativePath = localMarkdownDocumentPath(secondCreatedDocumentId); + const secondCreatedParentPath = secondCreatedRelativePath.split("/").slice(0, -1).join("/"); + const createExpansionState = await page.evaluate(({ firstParent, secondParent }) => { + const readRow = (relativePath) => { + const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(relativePath)}"]`); + return row + ? { + relativePath, + expanded: row.getAttribute("aria-expanded"), + selected: row.getAttribute("data-selected"), + active: row.getAttribute("data-active"), + } + : null; + }; + return { + first: readRow(firstParent), + second: readRow(secondParent), + firstMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(firstParent + "/" + firstParent.split("/").pop() + ".md")}"]`)), + secondMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(secondParent + "/" + secondParent.split("/").pop() + ".md")}"]`)), + }; + }, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath }); + assert.equal( + createExpansionState.first?.expanded, + "false", + `连续新建页面不应把上一个页面包目录自动展开: ${JSON.stringify(createExpansionState)}`, + ); + assert.equal( + createExpansionState.second?.expanded, + "false", + `连续新建页面不应为了选中内部 md 而展开当前页面包目录: ${JSON.stringify(createExpansionState)}`, + ); + assert.equal(createExpansionState.firstMarkdownVisible, false, `上一个页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`); + assert.equal(createExpansionState.secondMarkdownVisible, false, `当前页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`); + assert.equal(createExpansionState.second?.active, "true", `当前页面包目录应承接 active 状态: ${JSON.stringify(createExpansionState)}`); + + const scopedUrl = new URL(baseUrl); + if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId); + scopedUrl.searchParams.set("sourceKind", "local_folder"); + scopedUrl.searchParams.set("rootUri", rootUri); + scopedUrl.searchParams.set("treeView", "filetree"); + scopedUrl.searchParams.set("fileTreeScope", "design"); + await page.goto(scopedUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + const scopedRootRequestsAfterInitialLoad = scopedRootProjectionRequests; + const workspaceRootRequestsAfterInitialLoad = workspaceRootProjectionRequests; + await page.evaluate(() => { + window.__mnoteScopedFileOpenNoReloadMarker = "kept"; + document.documentElement.setAttribute("data-mnote-scoped-file-open-no-reload-marker", "kept"); + }); + await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Overview.md"] .tree-link').first().click({ + timeout: UI_TIMEOUT_MS, + }); + await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.evaluate(() => { + if (typeof window.__mnoteEnhanceEditorAttachmentLinks === "function") { + window.__mnoteEnhanceEditorAttachmentLinks(); + } + }); + await page.waitForTimeout(800); + const scopedOpenMarker = await page.evaluate(() => window.__mnoteScopedFileOpenNoReloadMarker || ""); + assert.equal(scopedOpenMarker, "kept", "scoped 文件树打开 md 应走 pane 内导航,不应整页 reload 重建侧栏"); + assert.equal( + scopedRootProjectionRequests, + scopedRootRequestsAfterInitialLoad, + "scoped 文件树打开已可见 md 不应重新请求 scope 根 projection", + ); + assert.equal( + workspaceRootProjectionRequests, + workspaceRootRequestsAfterInitialLoad, + "普通 Markdown 打开不应为了 legacy office 附件兼容重拉 workspace root projection", + ); + const scopedRootRequestsBeforeRootSnapshot = scopedRootProjectionRequests; + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("tree:snapshot", { + detail: { + payload: { + dataset: { + kernel_file_tree_projection: { + parentRelativePath: "", + items: [ + { + rowId: "local:folder:root-probe", + nodeId: "local:folder:root-probe", + title: "root-probe", + rowKind: "folder", + resourceMeta: { + workspacePath: { relativePath: "root-probe" }, + }, + }, + ], + }, + }, + }, + }, + })); + }); + await page.waitForTimeout(350); + assert.equal( + scopedRootProjectionRequests, + scopedRootRequestsBeforeRootSnapshot, + "scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection", + ); + const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests; + forceChangingWatchRevision = true; + await page.waitForTimeout(1600); + forceChangingWatchRevision = false; + assert.equal( + scopedRootProjectionRequests, + scopedRootRequestsBeforeCoarseWatch, + "scoped 文件树收到 coarse local-folder-watch revision 不应重拉 scope 根 projection", + ); + + const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]'; + const childSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path^="design/03-rust-web/"]'; + await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + assert.equal(targetChildrenRequests, 0, "scoped 首屏不应预加载 design/03-rust-web children"); + + await page.locator(rowSelector).evaluate((row) => { + const toggle = row.querySelector('[data-rust-action="toggle"]'); + for (let index = 0; index < 5; index += 1) toggle.click(); + }); + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + return row && row.getAttribute("aria-expanded") === "true" && row.getAttribute("data-filetree-children-loaded") === "true"; + }, rowSelector, { timeout: UI_TIMEOUT_MS }); + await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + assert.equal(targetChildrenRequests, 1, "同一路径快速重复展开最多只能产生一个 children 请求"); + + const firstExpandRequests = targetChildrenRequests; + await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + return row && row.getAttribute("aria-expanded") === "false"; + }, rowSelector, { timeout: UI_TIMEOUT_MS }); + const expandStartedAt = Date.now(); + await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + const child = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]'); + return row && child && row.getAttribute("aria-expanded") === "true"; + }, rowSelector, { timeout: UI_TIMEOUT_MS }); + const cachedExpandMs = Date.now() - expandStartedAt; + assert.equal(targetChildrenRequests, firstExpandRequests, "收起后再次展开应命中 cache,不应再次请求 children"); + + await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => { + row.setAttribute("data-selected", "true"); + row.setAttribute("data-focused", "true"); + row.setAttribute("data-active", "true"); + }); + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("tree:local-command", { + detail: { + body: { + action: "rename", + documentId: "local-md:design~2F03-rust-web~2Fplan.md", + title: "plan", + parentRelativePath: "design/03-rust-web", + }, + result: { + parentRelativePath: "design/03-rust-web", + }, + }, + })); + }); + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + const done = document.querySelector('[data-local-relative-path="design/03-rust-web/done"]'); + const process = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]'); + return row && done && process && row.getAttribute("aria-expanded") === "true"; + }, rowSelector, { timeout: UI_TIMEOUT_MS }); + assert.equal(targetChildrenRequests, firstExpandRequests, "局部 parent refresh 不应绕过 cache 触发 children 重复请求"); + const selectedAfterRefresh = await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => ({ + selected: row.getAttribute("data-selected"), + focused: row.getAttribute("data-focused"), + active: row.getAttribute("data-active"), + })); + assert.deepEqual(selectedAfterRefresh, { + selected: "true", + focused: "true", + active: "true", + }, "局部 parent refresh 后应按 logical state 复投影 selection/focus/active"); + + fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "watch-created.md"), "# Watch\n", "utf8"); + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", { + detail: { + payload: { + schema: "mnote.local_folder_watch_batch.v1", + revision: "smoke-watch-1", + affectedParents: [{ relativePath: "design/03-rust-web", reason: "child-watch" }], + changedPaths: [{ relativePath: "design/03-rust-web/watch-created.md", kind: "Create(File)" }], + eventKinds: ["Create(File)"], + fallbackResync: false, + }, + }, + })); + }); + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + const created = document.querySelector('[data-local-relative-path="design/03-rust-web/watch-created.md"]'); + return row && created && row.getAttribute("aria-expanded") === "true"; + }, rowSelector, { timeout: UI_TIMEOUT_MS }); + assert.equal( + await page.locator(rowSelector).getAttribute("aria-expanded"), + "true", + "watch batch 局部刷新后已展开 parent 不应折叠", + ); + + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", { + detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" }, + })); + }); + const revealTargetSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/reveal-parent/child/note.md"]'; + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + return row + && row.getAttribute("data-selected") === "true" + && row.getAttribute("data-focused") === "true" + && row.getAttribute("data-active") === "true"; + }, revealTargetSelector, { timeout: UI_TIMEOUT_MS }); + + const slowScopeSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/slow-scope"]'; + await page.locator(slowScopeSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator(`${slowScopeSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); + const docsUrl = new URL(scopedUrl.toString()); + docsUrl.searchParams.set("fileTreeScope", "docs"); + await page.goto(docsUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="docs"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForTimeout(800); + assert.equal(staleScopeChildrenRequests, 1, "慢目录切 scope 前应触发一次 children 请求"); + const staleRowsInDocsScope = await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path^="design/slow-scope/"]').count(); + assert.equal(staleRowsInDocsScope, 0, "慢请求返回后不得把旧 design scope children patch 到 docs scope"); + + const rootText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS }); + assert.match(rootText, /target/); + + const openedDocumentId = "local-md:docs~2Fopened-rename.md"; + const renamedDocumentId = "local-md:docs~2Fopened-renamed.md"; + const documentUrl = new URL(`${baseUrl}/documents/${encodeURIComponent(openedDocumentId)}`); + if (workspaceId) documentUrl.searchParams.set("workspaceId", workspaceId); + documentUrl.searchParams.set("sourceKind", "local_folder"); + documentUrl.searchParams.set("rootUri", rootUri); + await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const renameResponse = await context.request.fetch(`${baseUrl}/api/tree/commands`, { + method: "POST", + data: { + action: "rename", + workspaceId, + sourceKind: "local_folder", + rootUri, + documentId: openedDocumentId, + title: "opened-renamed", + }, + }); + assert(renameResponse.ok(), `opened markdown rename command failed: ${renameResponse.status()} ${await renameResponse.text()}`); + const bufferStateUrl = new URL(`${baseUrl}/api/documents/buffer-state`); + bufferStateUrl.searchParams.set("documentId", renamedDocumentId); + if (workspaceId) bufferStateUrl.searchParams.set("workspaceId", workspaceId); + bufferStateUrl.searchParams.set("sourceKind", "local_folder"); + bufferStateUrl.searchParams.set("rootUri", rootUri); + bufferStateUrl.searchParams.set("relativePath", "docs/opened-renamed.md"); + const bufferStateResponse = await context.request.fetch(bufferStateUrl.toString()); + assert(bufferStateResponse.ok(), `opened markdown rename should rekey buffer: ${bufferStateResponse.status()} ${await bufferStateResponse.text()}`); + const bufferStatePayload = await bufferStateResponse.json(); + assert.equal(bufferStatePayload.result.documentId, renamedDocumentId, "opened markdown rename 后 buffer key 应更新到新 documentId"); + + console.log(JSON.stringify({ + ok: true, + task: TASK, + baseUrl, + targetChildrenRequests, + staleScopeChildrenRequests, + cachedExpandMs, + }, null, 2)); + } finally { + await browser.close(); + server.kill("SIGINT"); + fs.rmSync(dataRoot, { recursive: true, force: true }); + if (server.exitCode == null) { + await new Promise((resolve) => server.once("exit", resolve)); + } + if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) { + process.stderr.write(stderr); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task495-filetree-copy-id-absolute-path-smoke.js b/scripts/task495-filetree-copy-id-absolute-path-smoke.js new file mode 100644 index 00000000..526b7869 --- /dev/null +++ b/scripts/task495-filetree-copy-id-absolute-path-smoke.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const REPO_ROOT = path.resolve(__dirname, ".."); +const RUNTIME_PATH = path.join( + REPO_ROOT, + "rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js", +); + +function loadRuntimeFactory() { + const source = fs + .readFileSync(RUNTIME_PATH, "utf8") + .replace( + "export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {", + "const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {", + ); + const context = { + console, + CustomEvent: class CustomEvent { + constructor(type, init) { + this.type = type; + this.detail = init && init.detail; + } + }, + HTMLElement: class HTMLElement {}, + navigator: { + clipboard: { + writeText: async (text) => { + context.__clipboardText = text; + }, + }, + }, + document: { + body: {}, + documentElement: { + attrs: {}, + setAttribute(name, value) { + this.attrs[name] = String(value); + }, + }, + createElement() { + return {}; + }, + querySelector() { + return null; + }, + querySelectorAll() { + return []; + }, + }, + window: { + alert() {}, + confirm() { + return true; + }, + dispatchEvent() {}, + }, + URL, + setTimeout, + clearTimeout, + }; + context.globalThis = context; + vm.createContext(context); + vm.runInContext( + `${source}\nglobalThis.__factory = createSidebarFileTreeCommandRuntime;`, + context, + { filename: RUNTIME_PATH }, + ); + return context; +} + +async function runCopyId(runtime, context, detail) { + context.__clipboardText = ""; + runtime.handleTreeContextMenuAction("copy-id", detail, null); + await new Promise((resolve) => setImmediate(resolve)); + return context.__clipboardText; +} + +async function main() { + const context = loadRuntimeFactory(); + const runtime = context.__factory({ + currentRootUri: () => "file:///mnt/Data1T/mnote", + currentSourceKind: () => "local_folder", + cssEscape: (value) => String(value).replace(/"/g, '\\"'), + fileTreeRuntimeFunction: () => null, + localFilePathFromAssetId: (assetId) => + String(assetId || "").replace(/^local-file:/, ""), + resolveWorkspaceId: () => "local-folder", + runtimeState: { activeTreeContextMenu: null }, + selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null }, + }); + + assert.equal( + await runCopyId(runtime, context, { + contextKind: "filetree", + rowKind: "folder", + documentId: "local-dir:docs", + rowId: "local:folder:docs", + title: "docs", + }), + "/mnt/Data1T/mnote/docs", + "复制本地文件夹页面 ID 应得到绝对路径", + ); + + assert.equal( + await runCopyId(runtime, context, { + contextKind: "filetree", + rowKind: "markdown", + documentId: "local-md:docs~2FPage.md", + rowId: "local:markdown:docs~2FPage.md", + title: "Page", + }), + "/mnt/Data1T/mnote/docs/Page.md", + "复制本地 Markdown 页面 ID 应得到绝对路径", + ); + + assert.equal( + await runCopyId(runtime, context, { + contextKind: "filetree", + rowKind: "asset", + assetId: "local-file:assets~2Freport.pdf", + rowId: "local:asset:assets~2Freport.pdf", + title: "report.pdf", + }), + "/mnt/Data1T/mnote/assets/report.pdf", + "复制本地资源 ID 应得到绝对路径", + ); + + console.log( + JSON.stringify( + { + ok: true, + copied: [ + "/mnt/Data1T/mnote/docs", + "/mnt/Data1T/mnote/docs/Page.md", + "/mnt/Data1T/mnote/assets/report.pdf", + ], + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js b/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js new file mode 100644 index 00000000..4699c58d --- /dev/null +++ b/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); + +const TASK = "task496-editor-open-parallel-runtime-aggregate-smoke"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function resolveChromiumExecutablePath() { + const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; + if (explicit && fs.existsSync(explicit)) return explicit; + return [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/snap/bin/chromium", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].find((candidate) => fs.existsSync(candidate)) || ""; +} + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) { + resolve(); + return; + } + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { + request.destroy(); + retry(); + }); + }; + const retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + tick(); + }); +} + +function fileUrlToPath(value) { + const url = new URL(value); + return decodeURIComponent(url.pathname); +} + +async function main() { + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`)); + const dbPath = path.join(dataRoot, "control-plane.sqlite3"); + const actorId = `${TASK}-${process.pid}-${Date.now()}`; + const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(__dirname, "..", "rust"), + env: { + ...process.env, + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", + MNOTE_CONTROL_PLANE_DB_PATH: dbPath, + MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, + CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", + NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const executablePath = resolveChromiumExecutablePath(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + const context = await browser.newContext(); + const page = await context.newPage(); + const requestTimes = { + manifest: 0, + pageAggregate: 0, + }; + + await page.addInitScript(() => { + window.requestIdleCallback = () => 0; + }); + await page.route("**/api/leptos-tiptap-runtime/manifest.json", async (route) => { + if (!requestTimes.manifest) requestTimes.manifest = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await route.continue(); + }); + await page.route("**/api/page-aggregate/**", async (route) => { + if (!requestTimes.pageAggregate) requestTimes.pageAggregate = Date.now(); + await route.continue(); + }); + + try { + await waitForHttpOk(`${baseUrl}/health`, 60_000); + const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: `${actorId}@example.com`, + username: actorId, + name: actorId, + password: TEST_PASSWORD, + flow: "signUp", + }, + }, + }, + }); + assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`); + + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); + if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await createButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { + timeout: UI_TIMEOUT_MS, + }); + } + + const rootUri = new URL(page.url()).searchParams.get("rootUri") + || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); + assert(rootUri, "应进入 local_folder workspace"); + const rootPath = fileUrlToPath(rootUri); + fs.writeFileSync(path.join(rootPath, "Target.md"), "# Target\n\nEditor cold open target.\n", "utf8"); + + const fileTreeUrl = new URL(baseUrl); + const workspaceId = new URL(page.url()).searchParams.get("workspaceId") || ""; + if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId); + fileTreeUrl.searchParams.set("sourceKind", "local_folder"); + fileTreeUrl.searchParams.set("rootUri", rootUri); + fileTreeUrl.searchParams.set("treeView", "filetree"); + await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="Target.md"] .tree-link').click({ + timeout: UI_TIMEOUT_MS, + }); + await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + assert(requestTimes.manifest > 0, "应在打开文档时加载 editor runtime manifest"); + assert(requestTimes.pageAggregate > 0, "应在打开文档时请求 Page Aggregate"); + const aggregateDelayMs = requestTimes.pageAggregate - requestTimes.manifest; + assert( + aggregateDelayMs < 500, + `Page Aggregate fetch 应与 editor runtime load 并行启动,实际晚于 manifest ${aggregateDelayMs}ms`, + ); + + console.log(JSON.stringify({ + ok: true, + task: TASK, + aggregateDelayMs, + requestTimes, + }, null, 2)); + } finally { + await browser.close(); + server.kill("SIGINT"); + fs.rmSync(dataRoot, { recursive: true, force: true }); + if (server.exitCode == null) { + setTimeout(() => server.kill("SIGKILL"), 2000).unref(); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});