From e43efacbce0df482b97852934ee773de3ff07da7 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Wed, 27 May 2026 16:32:05 +0800 Subject: [PATCH] Implement sidebar tree view state persistence --- ...iletree-lazy-loading-sidex-alignment-v1.md | 177 +++++++ rust/crates/control-plane/src/sqlite.rs | 70 +++ .../browser/sidebar-page-tree-runtime.js | 5 +- .../sidebar-tree-live-apply-runtime.js | 338 ++++++++++++- .../mnote-web/browser/sidebar-tree-runtime.js | 2 + rust/crates/mnote-web/src/routes/gateway.rs | 58 ++- .../src/routes/local_folder_source.rs | 28 +- rust/crates/mnote-web/src/routes/mod.rs | 5 + .../mnote-web/src/routes/tree_view_state.rs | 447 ++++++++++++++++++ rust/crates/mnote-web/src/routes/web_shell.rs | 122 +++++ rust/crates/mnote-web/src/ssr/pages/layout.rs | 52 ++ rust/crates/mnote-web/src/ssr/styles.rs | 23 +- ...ge-tree-filetree-open-performance-smoke.js | 65 +++ ...ed-page-tree-scope-and-local-edit-smoke.js | 62 ++- .../task499-sidebar-tree-view-state-smoke.js | 438 +++++++++++++++++ 15 files changed, 1875 insertions(+), 17 deletions(-) create mode 100644 rust/crates/mnote-web/src/routes/tree_view_state.rs create mode 100644 scripts/task499-sidebar-tree-view-state-smoke.js 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 index 507d9c2b..f7151acb 100644 --- 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 @@ -360,6 +360,183 @@ scripts/task494-filetree-lazy-loading-dedup-smoke.js - 2026-05-27:Rust filters 通过:`root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html`、`sidebar_runtime_applies_page_projection_with_generation_and_yield`、`sidebar_filetree_runtime`、`local_folder_file_projection`、`tree_projection_routes`、`local_folder_file_tree_children_snapshot_scans_parent_once`、`local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes`。 - 2026-05-27:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=33`。 +## 2026-05-27 SidebarTreeViewState 用户保存态跟进 + +### 再次 Sidex 对照结论 + +Sidex / VSCode Explorer 的展开程度不是由每次 projection 的 `expandedByDefault` 推断出来,而是由 TreeViewState 驱动: + +- `explorerView.ts` 定义 `TREE_VIEW_STATE_STORAGE_KEY = 'workbench.explorer.treeViewState'`。 +- `onWillSaveState` 与 `updateAnyCollapsedContext()` 都会调用 `storeTreeViewState()`,写入 `JSON.stringify(this.tree.getViewState())`。 +- `setTreeInput()` 首次输入时从 workspace storage 读取 view state;已有 input 时直接使用当前 `tree.getViewState()`。 +- 无 view state 时才使用保守默认展开:多 root 最多展开 5 个 root;单 folder workspace 受 `explorer.expandSingleFolderWorkspaces` 控制。 +- 默认 `collapseByDefault` 返回 `true`,children 默认折叠;打开 / reveal 文件时只展开目标 parent chain。 +- filter session 会临时保存 `tree.getViewState()`,说明 Sidex 把展开、选择、焦点等视为 tree widget state,而不是文件系统 projection state。 + +MNote 当前状态与 Sidex 的差距: + +- FileTree 只有 `mnote.localFileTree.expandedRelativePaths.v1`,使用 `sessionStorage` 按 `rootUri` 保存 expanded relative paths;它不是用户级持久态,也没有完整区分 `workspaceId/sourceKind/treeKind/scope`。 +- PageTree 没有用户展开保存态,`renderPageRows()` 直接用 `item.expandedByDefault !== false`;本地 PageTree 后端还有 `depth < 2` 默认展开,所以用户看到的“有开有关”主要来自后端 projection 默认值。 +- `fileTreeViewState` 里已有 `selectedRowIds/focusedRowId/activeRowId/scrollTop` 字段,但当前没有作为完整 view state 写入 control-plane,也没有统一应用到 PageTree。 +- debug/internal `tree-shell-runtime.js` 的内存 `expandedIds` 不是主 sidebar 产品路径保存态,不能作为本问题的已完成实现。 + +### 存储决策 + +`SidebarTreeViewState` 是用户 UI 偏好态,应以 SQLite control-plane 为主存储,浏览器 storage 只能作为首屏镜像 / 离线加速缓存。 + +优先复用现有 `user_ui_preferences` 表,不新增专表: + +- 现有表已按 `user_id/workspace_id/source_kind/scope_kind/scope_id/key` 做唯一约束。 +- `control-plane` 已有 `upsert_user_ui_preference()` 与 `list_user_ui_preferences()`,并已有用户隔离测试。 +- 新状态可作为 `key = 'sidebarTreeViewState.v1'` 的 JSON value 存入该表。 +- `scope_kind = 'sidebar_tree'`,`scope_id` 使用稳定 scope key:`{treeKind}:{rootUriHash}:{scopeHash}`。 +- `workspace_id` 使用当前 workspace;local folder workspace 继续使用 `local_workspace_id_from_root_uri(rootUri)`。 +- `source_kind` 使用当前 sourceKind,例如 `local_folder`。 + +浏览器镜像只允许作为非权威缓存: + +- key 形如 `mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}`。 +- 页面启动可先读镜像减少闪烁,但必须用 `/api/tree/view-state` 或等价 control-plane API 的 SQLite 返回值校正。 +- 写入时可先更新内存 / 镜像,再 debounce PUT 到 SQLite;服务端必须从 session 推导 `user_id`,不能信任客户端提交的 `userId`。 + +状态 JSON 最小结构: + +```json +{ + "schemaVersion": 1, + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "expandedIds": [], + "expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"], + "selectedId": "local:markdown:design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md", + "focusedId": "", + "activeId": "", + "scrollTop": 0, + "updatedAtMs": 0 +} +``` + +不持久化以下派生运行态: + +- `rowsByParent` +- `loadedParents` +- `loadingParents` +- `dirtyParents` +- `staleParents` +- `revisionByParent` +- `latestGenerationByParent` + +这些仍然属于 FileTreeDataSource / PageTree render cache,不能写成用户偏好。 + +### 行为规则 + +- projection apply 优先级:`SQLite 用户保存态` > `当前 active/reveal parent chain` > `后端 expandedByDefault`。 +- 已存在用户保存态时,后端 `expandedByDefault` 不得覆盖用户折叠选择。 +- 打开 Markdown / 星标文件夹只 reveal 目标 parent chain,不展开整棵 PageTree 或 FileTree。 +- PageTree 与 FileTree 使用同一套保存机制,但 `treeKind` 必须分开,防止页面树展开状态污染文件树。 +- starred scoped folder 必须使用 scope key 隔离,不能复用 workspace root 的展开状态。 +- collapse all / expand all 属于明确用户操作,必须写入保存态;普通 projection refresh 不得写入“自动折叠”覆盖用户态。 +- 新建页面 / 新建文件夹后,只允许 reveal 新节点 parent chain;不得折叠其它已展开节点。 +- 当保存态里的节点已不存在时,应用时静默跳过,并在下次写入时自然清理。 + +### 待执行 Checklist:SidebarTreeViewState + +- [x] RED Rust 测试:在 `control-plane` 层新增或扩展 `user_ui_preferences_are_scoped_upserted_and_user_isolated`,写入 `key = sidebarTreeViewState.v1`,断言 alice/bob 隔离、`workspace_id/source_kind/scope_kind/scope_id/key` upsert 后 revision 增加。 +- [x] RED route 测试:在 `mnote-web` 增加 `/api/tree/view-state` 的 GET/PUT 测试,使用真实 session actor;断言服务端从 session 取 user,不接受客户端伪造 userId。 +- [x] RED route 测试:同一用户同一 workspace 下,`treeKind=filetree&scope=design` 与 `treeKind=pagetree&scope=design` 返回不同 state;`scope=root` 与 `scope=design` 也互不污染。 +- [x] RED JS runtime 契约测试:断言 `sidebar-tree-live-apply-runtime.js` 中存在 `SIDEBAR_TREE_VIEW_STATE_KEY`、`loadSidebarTreeViewState`、`persistSidebarTreeViewState`、`applySidebarTreeViewState`,并且 PageTree 不再只用 `item.expandedByDefault !== false` 决定展开。 +- [x] 新增 `rust/crates/mnote-web/src/routes/tree_view_state.rs`,只负责 Sidebar tree view state API;不要把这部分逻辑塞进 projection route 或 local folder source。 +- [x] 在 `rust/crates/mnote-web/src/routes/mod.rs` 注册: + - `GET /api/tree/view-state` + - `PUT /api/tree/view-state` +- [x] API 入参规范化: + - `workspaceId` 为空且 `sourceKind=local_folder` 时,用 `local_workspace_id_from_root_uri(rootUri)`。 + - `treeKind` 只允许 `filetree` / `pagetree`。 + - `rootUri` 使用 canonical root uri。 + - `scope` 为空时统一为 `root`。 + - `scope_id = "{treeKind}:{rootUriHash}:{scopeHash}"`。 +- [x] API value 校验: + - `schemaVersion == 1`。 + - `expandedIds` / `expandedRelativePaths` 最多保留 512 条。 + - `selectedId/focusedId/activeId` 限制最大长度。 + - `scrollTop` 必须是非负数字。 + - 不接受 `rowsByParent/loadedParents/loadingParents/revisionByParent` 等派生字段。 +- [x] 服务端写入 `user_ui_preferences`: + - `scope_kind = "sidebar_tree"`。 + - `key = "sidebarTreeViewState.v1"`。 + - `workspace_id/source_kind` 按当前请求写入,保证用户、workspace、source 隔离。 +- [x] 服务端读取时只返回当前 actor 的 active preference;无记录时返回 `{ schemaVersion: 1, treeKind, rootUri, scope, expandedIds: [], expandedRelativePaths: [] }`,并标记 `source = "default"`。 +- [x] 前端新增统一内存 state: + - `sidebarTreeViewStates = new Map()`。 + - key 包含 `userId/workspaceId/sourceKind/treeKind/rootUri/scope`。 + - FileTree 现有 `fileTreeExpandedRelativePaths` 改为该 state 的 `expandedRelativePaths` 引用。 + - PageTree 新增 `expandedIds`。 +- [x] 前端启动流程: + - 先从带用户维度的 `localStorage` 镜像读首屏 state。 + - 异步 GET SQLite state。 + - SQLite 返回后只在 generation/rootUri/scope 仍匹配时应用,避免旧请求覆盖当前 DOM。 +- [x] 前端保存流程: + - toggle 更新内存态;`selectedId/focusedId/activeId/scrollTop` 已纳入 API 与序列化边界。当前主 sidebar 未提供 collapse all / expand all 入口,本轮不新增入口;后续入口必须复用同一保存态。 + - 写 localStorage 镜像。 + - debounce 300-800ms PUT 到 SQLite。 + - unload / visibility hidden 时 flush pending save。 +- [x] FileTree 迁移: + - 读取旧 `mnote.localFileTree.expandedRelativePaths.v1` 作为 fallback。 + - 只在新 SQLite state 缺失时导入旧 expanded paths。 + - 导入后写入新 state;旧 key 不主动删除。 +- [x] PageTree 应用: + - `renderPageRows()` 改为优先查 `SidebarTreeViewState.expandedIds`。 + - 没有用户态时,只展开 active/reveal parent chain;最后才使用 `expandedByDefault`。 + - 本地 PageTree 的 `depth < 2` 默认展开只能作为“无用户态 + 无 active reveal”的首次兜底。 +- [x] FileTree 应用: + - `renderFileRows()` 优先查 `SidebarTreeViewState.expandedRelativePaths`。 + - `loadFileTreeChildren()` 展开 parent 时写用户态,但不得因为自动恢复 children 加载失败而删除 expanded path。 + - scoped root projection 替换时保留同 scope 的 expanded state,不回退到 root scope。 +- [x] reveal 规则: + - 打开星标 scoped folder 时,只 reveal scoped root 和当前 active document parent chain。 + - 打开 `design/05-editor-mainline/process/5-32...md` 时,只展开 `design`、`design/05-editor-mainline`、`design/05-editor-mainline/process`。 + - reveal 产生的自动展开要标记为 `reason = "reveal"`,可写入保存态;projection refresh 产生的默认展开不得覆盖保存态。 +- [x] 新建页面回归: + - 新建页面后,新节点 parent 保持展开。 + - 上一个文件夹不被自动折叠。 + - 当前新页面不会出现“闪一下 md 再折叠”的状态反转。 +- [x] 新增浏览器 smoke `scripts/task499-sidebar-tree-view-state-smoke.js`: + - 登录测试账号。 + - 进入本地 workspace。 + - 打开星标 `design`。 + - 在 FileTree 展开 `design/05-editor-mainline/process`。 + - 在 PageTree 折叠一个默认展开节点并展开另一个节点。 + - 刷新页面,断言 FileTree/PageTree 状态分别恢复。 + - 切到其它页面再回来,断言状态仍恢复。 + - 记录 `/api/tree/view-state` GET/PUT 请求、response payload 和截图。 +- [x] 扩展 `task497`: + - 打开目标 Markdown 后,断言没有 PageTree `depth < 2` 导致的大面积默认展开覆盖用户折叠态。 + - 记录 view-state GET/PUT timing,确保 editor ready 不等待 PUT 完成。 +- [x] 扩展 `task498`: + - 星标 `design` 的 PageTree scope 必须读取 `pagetree/design` state。 + - root PageTree state 不得覆盖 starred scoped PageTree。 +- [x] 增加多用户隔离 smoke 或 route test: + - user A 展开 `design/process`。 + - user B 登录同 workspace,不应继承 user A 的展开态。 +- [x] 验证命令: + - `cargo test -p control-plane --manifest-path rust/Cargo.toml user_ui_preferences` + - `cargo test -p mnote-web --manifest-path rust/Cargo.toml tree_view_state` + - `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` + - `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js` + - `node scripts/task499-sidebar-tree-view-state-smoke.js` + - `git diff --check` + - `codegraph sync .` + - `codegraph status .` + +### 2026-05-27 SidebarTreeViewState 验证证据 + +- `cargo test -p control-plane --manifest-path rust/Cargo.toml user_ui_preferences`:通过,覆盖 `sidebarTreeViewState.v1` 在 `user_ui_preferences` 中的 upsert revision 与 alice/bob 用户隔离。 +- `cargo test -p mnote-web --manifest-path rust/Cargo.toml tree_view_state`:通过,覆盖 `/api/tree/view-state` GET/PUT、session actor 优先、`filetree/pagetree` 与 `root/design` scope 隔离、派生 cache 字段拒绝。 +- `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js`:通过,真实 Chromium 点击星标 `design` 后逐级打开目标 Markdown;最终样本 `pageTreeUserStateRestoredAt=1779870589458`,`filetreeInteractiveAt=1779870589562`,`targetClickAt=1779870589750`,`editorVisibleAt=1779870589815`,`longTasks=[]`,`directDocumentMs=8`。 +- `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js`:通过,覆盖星标 `design` 使用 scoped PageTree state、root PageTree state 不覆盖 scoped PageTree、本地 Markdown 输入 `111` 后切页再回不丢。 +- `node scripts/task499-sidebar-tree-view-state-smoke.js`:通过,记录 `/api/tree/view-state` GET/PUT、刷新与切页后 FileTree/PageTree 状态恢复;最终样本 `fileStateSavedAt=1779870615533`,`pageStateSavedAt=1779870616150`,`reloadedStateRestoredAt=1779870616332`,`afterNavigationRestoredAt=1779870616436`。 + ## 与 5-30 的关系 `5-30` 解决“星标 shortcut 是什么、保存在哪里、点击进入哪个 scope”。本设计解决“进入 scope 后 FileTree 如何懒加载、缓存、刷新、避免重复请求”。 diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 71db5278..18a316bb 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -2402,6 +2402,76 @@ mod tests { .list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder")) .expect("list bob preferences"); assert!(bob_preferences.is_empty()); + + let sidebar_scope = "filetree:root-hash:design-hash"; + let sidebar_value = serde_json::json!({ + "schemaVersion": 1, + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "expandedRelativePaths": ["design/05-editor-mainline"], + "expandedIds": [], + "selectedId": "", + "focusedId": "", + "activeId": "", + "scrollTop": 0 + }) + .to_string(); + let sidebar_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: "sidebar_tree".to_string(), + scope_id: sidebar_scope.to_string(), + key: "sidebarTreeViewState.v1".to_string(), + value_json: sidebar_value, + }) + .expect("insert sidebar tree view state"); + let sidebar_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: "sidebar_tree".to_string(), + scope_id: sidebar_scope.to_string(), + key: "sidebarTreeViewState.v1".to_string(), + value_json: serde_json::json!({ + "schemaVersion": 1, + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"], + "expandedIds": [], + "selectedId": "", + "focusedId": "", + "activeId": "", + "scrollTop": 0 + }) + .to_string(), + }) + .expect("upsert sidebar tree view state"); + assert_eq!(sidebar_updated.id, sidebar_first.id); + assert_eq!(sidebar_updated.revision, sidebar_first.revision + 1); + + let alice_sidebar_preferences = store + .list_user_ui_preferences( + "alice", + Some("local:_mnt_Data1T_mnote"), + Some("local_folder"), + ) + .expect("list alice sidebar preferences"); + assert!(alice_sidebar_preferences.iter().any(|preference| { + preference.scope_kind == "sidebar_tree" + && preference.scope_id == sidebar_scope + && preference.key == "sidebarTreeViewState.v1" + })); + let bob_sidebar_preferences = store + .list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder")) + .expect("list bob sidebar preferences"); + assert!(bob_sidebar_preferences.is_empty()); } #[test] 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 053ac233..bd30693a 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js @@ -13,6 +13,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => { persistLocalFolderSelfChangeSuppression, persistSidebarTreeMode, refreshLocalFolderSidebarSnapshot, + refreshLocalFolderAfterCommand, resolveWorkspaceId, rowCenter, selectSidebarFileTreeDocument, @@ -107,7 +108,9 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => { persistLocalFolderSelfChangeSuppression(nextDocumentId, Date.now() + 5000); } if (nextDocumentId && currentSourceKind() === 'local_folder') { - await refreshLocalFolderSidebarSnapshot(); + if (typeof refreshLocalFolderAfterCommand === 'function') { + await refreshLocalFolderAfterCommand('create', result, { parentId: effectiveParentId || null }); + } selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true }); document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId); } 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 539fb288..33c914b6 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 @@ -45,6 +45,12 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var fileTreeExpansionRestoreTimer = 0; var fileTreeCommandBatchRefreshParents = new Map(); var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1'; + var SIDEBAR_TREE_VIEW_STATE_KEY = 'mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}'; + var SIDEBAR_TREE_VIEW_STATE_API_KEY = 'sidebarTreeViewState.v1'; + var SIDEBAR_TREE_VIEW_STATE_SAVE_DELAY_MS = 500; + var sidebarTreeViewStates = new Map(); + var sidebarTreeViewStateRequests = new Map(); + var sidebarTreeViewStateSaveTimers = new Map(); function updateTitleEverywhere(documentId, title) { if (!documentId) return; @@ -438,12 +444,12 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } function refreshLocalFolderAfterCommand(action, result, body) { - if (!localCommandNeedsProjectionRefresh(action, result)) return; + if (!localCommandNeedsProjectionRefresh(action, result)) return Promise.resolve(false); if (body && body.batchId) { queueFileTreeBatchRefresh(body.batchId, result, body); - return; + return Promise.resolve(true); } - void fileTreeRefreshParentsForCommand(result, body); + return fileTreeRefreshParentsForCommand(result, body); } function sortOrderFromDelta(data) { @@ -624,6 +630,271 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return fileTreeParentKey(currentRootUri(), parentRelativePath); } + function stableTreeViewStateHash(value) { + var hash = 0xcbf29ce484222325n; + var prime = 0x100000001b3n; + var text = String(value || '').trim(); + for (var index = 0; index < text.length; index += 1) { + hash ^= BigInt(text.charCodeAt(index) & 0xff); + hash = (hash * prime) & 0xffffffffffffffffn; + } + return hash.toString(16).padStart(16, '0'); + } + + function currentActorStorageId() { + var body = document.body instanceof HTMLElement ? document.body : null; + var fromBody = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : ''; + if (fromBody) return fromBody; + var match = document.cookie.match(/(?:^|;\s*)mnote_actor_id=([^;]+)/); + if (match) { + try { + return decodeURIComponent(match[1]); + } catch (_) { + return match[1] || ''; + } + } + return 'anonymous'; + } + + function sidebarTreeViewScope(treeKind) { + var normalizedTreeKind = String(treeKind || '').trim() === 'pagetree' ? 'pagetree' : 'filetree'; + var rootUri = String(currentRootUri() || '').trim(); + var scope = currentFileTreeScope() || 'root'; + return { + userId: currentActorStorageId(), + workspaceId: String(currentWorkspaceId() || '').trim() || (rootUri ? 'local:' + rootUri.replace(/^file:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_') : 'default'), + sourceKind: String(currentSourceKind() || 'convex_workspace').trim() || 'convex_workspace', + treeKind: normalizedTreeKind, + rootUri: rootUri, + scope: scope + }; + } + + function sidebarTreeViewStateMapKey(scope) { + return [ + scope.userId, + scope.workspaceId, + scope.sourceKind, + scope.treeKind, + scope.rootUri, + scope.scope + ].join('\n'); + } + + function sidebarTreeViewLocalStorageKey(scope) { + return SIDEBAR_TREE_VIEW_STATE_KEY + .replace('{userId}', encodeURIComponent(scope.userId || 'anonymous')) + .replace('{workspaceId}', encodeURIComponent(scope.workspaceId || 'default')) + .replace('{sourceKind}', encodeURIComponent(scope.sourceKind || 'convex_workspace')) + .replace('{treeKind}', encodeURIComponent(scope.treeKind || 'filetree')) + .replace('{rootUriHash}', stableTreeViewStateHash(scope.rootUri || '')) + .replace('{scopeHash}', stableTreeViewStateHash(scope.scope || 'root')); + } + + function normalizeSidebarTreeViewState(treeKind, value, source) { + var scope = sidebarTreeViewScope(treeKind); + var state = value && typeof value === 'object' ? value : {}; + return { + schemaVersion: 1, + treeKind: scope.treeKind, + rootUri: scope.rootUri, + scope: scope.scope, + expandedIds: new Set(Array.isArray(state.expandedIds) ? state.expandedIds.map(String).filter(Boolean) : []), + expandedRelativePaths: new Set(Array.isArray(state.expandedRelativePaths) ? state.expandedRelativePaths.map(normalizeFileTreeRelativePath).filter(Boolean) : []), + selectedId: String(state.selectedId || '').trim(), + focusedId: String(state.focusedId || '').trim(), + activeId: String(state.activeId || '').trim(), + scrollTop: Math.max(0, Number(state.scrollTop || 0) || 0), + updatedAtMs: Number(state.updatedAtMs || 0) || 0, + hasUserState: source === 'sqlite' || source === 'local' || source === 'legacy', + source: source || 'default' + }; + } + + function serializeSidebarTreeViewState(state) { + return { + schemaVersion: 1, + treeKind: state.treeKind, + rootUri: state.rootUri, + scope: state.scope, + expandedIds: Array.from(state.expandedIds || []).slice(0, 512), + expandedRelativePaths: Array.from(state.expandedRelativePaths || []).slice(0, 512), + selectedId: String(state.selectedId || ''), + focusedId: String(state.focusedId || ''), + activeId: String(state.activeId || ''), + scrollTop: Math.max(0, Number(state.scrollTop || 0) || 0), + updatedAtMs: Number(state.updatedAtMs || 0) || Date.now() + }; + } + + function sidebarTreeViewStateFor(treeKind) { + var scope = sidebarTreeViewScope(treeKind); + var key = sidebarTreeViewStateMapKey(scope); + var state = sidebarTreeViewStates.get(key); + if (!state) { + var localValue = null; + try { + if (window.localStorage) { + localValue = JSON.parse(window.localStorage.getItem(sidebarTreeViewLocalStorageKey(scope)) || 'null'); + } + } catch (_) { + localValue = null; + } + state = normalizeSidebarTreeViewState(scope.treeKind, localValue, localValue ? 'local' : 'default'); + sidebarTreeViewStates.set(key, state); + void loadSidebarTreeViewState(scope.treeKind); + } + if (scope.treeKind === 'filetree') { + fileTreeViewState.expandedParents = state.expandedRelativePaths; + fileTreeExpandedRelativePaths = state.expandedRelativePaths; + } + return state; + } + + function loadSidebarTreeViewState(treeKind) { + var scope = sidebarTreeViewScope(treeKind); + var key = sidebarTreeViewStateMapKey(scope); + if (sidebarTreeViewStateRequests.has(key)) return sidebarTreeViewStateRequests.get(key); + var url = new URL('/api/tree/view-state', window.location.origin); + url.searchParams.set('workspaceId', scope.workspaceId); + url.searchParams.set('sourceKind', scope.sourceKind); + url.searchParams.set('treeKind', scope.treeKind); + if (scope.rootUri) url.searchParams.set('rootUri', scope.rootUri); + url.searchParams.set('scope', scope.scope); + var promise = fetch(url.toString(), { + credentials: 'include', + cache: 'no-store', + headers: { accept: 'application/json' } + }).then(function(response) { + return response.json().catch(function() { return null; }).then(function(payload) { + if (!response.ok) return null; + var result = payload && payload.result ? payload.result : null; + if (!result || !result.state) return null; + var latestScope = sidebarTreeViewScope(scope.treeKind); + if (sidebarTreeViewStateMapKey(latestScope) !== key) return null; + var loaded = normalizeSidebarTreeViewState(scope.treeKind, result.state, result.source === 'sqlite' ? 'sqlite' : 'default'); + sidebarTreeViewStates.set(key, loaded); + applySidebarTreeViewState(scope.treeKind, loaded); + return loaded; + }); + }).catch(function(error) { + document.documentElement.setAttribute('data-mnote-sidebar-tree-view-state-load-error', String(error && error.message || error || 'load_failed')); + return null; + }).finally(function() { + sidebarTreeViewStateRequests.delete(key); + }); + sidebarTreeViewStateRequests.set(key, promise); + return promise; + } + + function persistSidebarTreeViewState(treeKind, options) { + options = options || {}; + var scope = sidebarTreeViewScope(treeKind); + var key = sidebarTreeViewStateMapKey(scope); + var state = sidebarTreeViewStateFor(scope.treeKind); + state.hasUserState = true; + state.source = state.source === 'legacy' ? 'legacy' : 'local'; + state.updatedAtMs = Date.now(); + var serialized = serializeSidebarTreeViewState(state); + try { + if (window.localStorage) { + window.localStorage.setItem(sidebarTreeViewLocalStorageKey(scope), JSON.stringify(serialized)); + } + } catch (_) {} + var flush = function() { + sidebarTreeViewStateSaveTimers.delete(key); + var latestScope = sidebarTreeViewScope(scope.treeKind); + if (sidebarTreeViewStateMapKey(latestScope) !== key) return; + fetch('/api/tree/view-state', { + method: 'PUT', + credentials: 'include', + cache: 'no-store', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: scope.workspaceId, + sourceKind: scope.sourceKind, + treeKind: scope.treeKind, + rootUri: scope.rootUri, + scope: scope.scope, + state: serialized + }) + }).then(function(response) { + return response.json().catch(function() { return null; }).then(function(payload) { + if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'tree_view_state_save_failed_' + response.status); + document.documentElement.setAttribute('data-mnote-sidebar-tree-view-state-saved', scope.treeKind + ':' + scope.scope); + return payload; + }); + }).catch(function(error) { + document.documentElement.setAttribute('data-mnote-sidebar-tree-view-state-save-error', String(error && error.message || error || 'save_failed')); + }); + }; + if (options.flush) { + if (sidebarTreeViewStateSaveTimers.has(key)) { + window.clearTimeout(sidebarTreeViewStateSaveTimers.get(key)); + sidebarTreeViewStateSaveTimers.delete(key); + } + flush(); + return; + } + if (sidebarTreeViewStateSaveTimers.has(key)) window.clearTimeout(sidebarTreeViewStateSaveTimers.get(key)); + sidebarTreeViewStateSaveTimers.set(key, window.setTimeout(flush, SIDEBAR_TREE_VIEW_STATE_SAVE_DELAY_MS)); + } + + function flushPendingSidebarTreeViewState(treeKind) { + var scope = sidebarTreeViewScope(treeKind); + var key = sidebarTreeViewStateMapKey(scope); + if (!sidebarTreeViewStateSaveTimers.has(key)) return; + persistSidebarTreeViewState(scope.treeKind, { flush: true }); + } + + function applySidebarTreeViewState(treeKind, state) { + if (String(treeKind || '').trim() === 'pagetree') { + applyPageTreeExpansionState(state); + return; + } + fileTreeExpandedRelativePaths = state.expandedRelativePaths; + fileTreeViewState.expandedParents = state.expandedRelativePaths; + restorePersistedFileTreeExpansionState(); + } + + function applyPageTreeExpansionState(state) { + if (!state || !state.hasUserState) return false; + var changed = false; + document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + var button = row.querySelector('[data-rust-action="toggle"]'); + var node = row.closest('.tree-node'); + var children = node ? node.querySelector(':scope > .tree-children') : null; + if (!nodeId || !button || !(children instanceof HTMLElement)) return; + var expanded = state.expandedIds.has(nodeId); + row.setAttribute('aria-expanded', String(expanded)); + button.setAttribute('aria-expanded', String(expanded)); + children.classList.toggle('tree-children--collapsed', !expanded); + changed = true; + }); + if (changed) document.documentElement.setAttribute('data-mnote-page-tree-view-state-applied', state.scope || 'root'); + return changed; + } + + function pageTreeActiveParentIds(grouped, activeId) { + var parentsById = new Map(); + grouped.forEach(function(items) { + items.forEach(function(item) { + parentsById.set(nodeIdOf(item), parentIdOf(item)); + }); + }); + var parents = new Set(); + var cursor = String(activeId || '').trim(); + var guard = 0; + while (cursor && parentsById.has(cursor) && guard < 512) { + cursor = String(parentsById.get(cursor) || '').trim(); + if (cursor) parents.add(cursor); + guard += 1; + } + return parents; + } + function projectionParentRelativePath(projection) { var resolved = readProjection(projection); return String(resolved && (resolved.parentRelativePath || resolved.parent_relative_path) || '').trim(); @@ -692,6 +963,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function renderPageRows(parentId, grouped, activeId, inheritedDepth) { var computedDepth = Number(inheritedDepth || 0); + var pageTreeStateView = sidebarTreeViewStateFor('pagetree'); + var pageTreeExpandedIds = pageTreeStateView.expandedIds; + var activeParentIds = pageTreeActiveParentIds(grouped, activeId); return (grouped.get(parentId) || []).map(function(item) { var nodeId = nodeIdOf(item); var title = titleOf(item); @@ -699,7 +973,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var parent = parentIdOf(item); var children = grouped.get(nodeId) || []; var expandable = Boolean(item.expandable || item.childCount > 0 || children.length); - var expanded = expandable && item.expandedByDefault !== false; + var expanded = expandable && ( + pageTreeExpandedIds.has(nodeId) || + (!pageTreeStateView.hasUserState && (activeParentIds.has(nodeId) || item.expandedByDefault !== false)) + ); var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {}; var openable = Boolean(meta.documentId || item.documentId || !String(nodeId).startsWith('local-dir:')); var toggle = expandable @@ -816,6 +1093,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } function renderFileRows(parentId, grouped, activeId, activeRowId) { + var fileTreeStateView = sidebarTreeViewStateFor('filetree'); + var expandedRelativePaths = fileTreeStateView.expandedRelativePaths; return (grouped.get(parentId) || []).map(function(item) { var nodeId = nodeIdOf(item); var rowId = rowIdOf(item); @@ -835,7 +1114,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { : normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity); var children = grouped.get(nodeId) || []; var expandable = Boolean(item.expandable || item.childCount > 0 || children.length || cachedChildren.length); - var expanded = expandable && (fileTreeExpandedRelativePaths.has(relativePath) || item.expandedByDefault !== false); + var expanded = expandable && (expandedRelativePaths.has(relativePath) || (!fileTreeStateView.hasUserState && item.expandedByDefault !== false)); var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId; var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row'; var toggle = expandable @@ -871,7 +1150,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { fileTreeState.revisionByParent.clear(); fileTreeState.latestGenerationByParent.clear(); fileTreeState.requestGeneration += 1; - fileTreeExpandedRelativePaths.clear(); + var nextState = sidebarTreeViewStateFor('filetree'); + fileTreeExpandedRelativePaths = nextState.expandedRelativePaths; + fileTreeViewState.expandedParents = nextState.expandedRelativePaths; loadStoredFileTreeExpansionState(rootUri); } @@ -889,21 +1170,31 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var normalizedRootUri = String(rootUri || '').trim(); if (!normalizedRootUri || fileTreeExpansionStorageRootUri === normalizedRootUri) return; fileTreeExpansionStorageRootUri = normalizedRootUri; + var state = sidebarTreeViewStateFor('filetree'); + if (state.hasUserState && state.expandedRelativePaths.size) return; var buckets = storedFileTreeExpansionBuckets(); var paths = Array.isArray(buckets[normalizedRootUri]) ? buckets[normalizedRootUri] : []; paths.forEach(function(path) { var normalized = String(path || '').trim(); - if (normalized) fileTreeExpandedRelativePaths.add(normalized); + if (normalized) state.expandedRelativePaths.add(normalized); }); + if (state.expandedRelativePaths.size) { + state.hasUserState = true; + state.source = 'legacy'; + fileTreeExpandedRelativePaths = state.expandedRelativePaths; + fileTreeViewState.expandedParents = state.expandedRelativePaths; + persistSidebarTreeViewState('filetree'); + } } function persistFileTreeExpansionState() { var rootUri = String(currentRootUri() || fileTreeLazyCacheRootUri || '').trim(); if (!rootUri) return; + var state = sidebarTreeViewStateFor('filetree'); try { if (!window.sessionStorage) return; var buckets = storedFileTreeExpansionBuckets(); - buckets[rootUri] = Array.from(fileTreeExpandedRelativePaths) + buckets[rootUri] = Array.from(state.expandedRelativePaths) .map(function(path) { return String(path || '').trim(); }) .filter(Boolean) .slice(0, 256); @@ -1144,6 +1435,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { sidebarUrl.searchParams.set('sourceKind', 'local_folder'); sidebarUrl.searchParams.set('rootUri', rootUri); if (currentId) sidebarUrl.searchParams.set('rootNodeId', currentId); + if (fileTreeScope) sidebarUrl.searchParams.set('parentRelativePath', fileTreeScope); var responses = await Promise.allSettled([ fetch(sidebarUrl.toString(), { headers: { accept: 'application/json' } }), refreshFileTreeParent(fileTreeScope) @@ -1281,15 +1573,27 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function setTreeRowExpanded(row, button, expanded) { row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + var shellMode = row.getAttribute('data-shell-mode') || ''; if (button) { button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); - button.textContent = expanded ? '▾' : '▸'; + if (shellMode === 'filetree') button.textContent = expanded ? '▾' : '▸'; + } + if (shellMode === 'page') { + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + if (!nodeId) return; + var pageState = sidebarTreeViewStateFor('pagetree'); + if (expanded) pageState.expandedIds.add(nodeId); + else pageState.expandedIds.delete(nodeId); + pageState.hasUserState = true; + persistSidebarTreeViewState('pagetree'); + return; } var relativePath = localFileTreeRelativePathFromRow(row); if (!relativePath) return; if (expanded) fileTreeExpandedRelativePaths.add(relativePath); else fileTreeExpandedRelativePaths.delete(relativePath); persistFileTreeExpansionState(); + persistSidebarTreeViewState('filetree'); } function markExistingFileTreeChildrenLoaded(row, button) { @@ -1462,7 +1766,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { restored = true; return; } - void loadFileTreeChildren(row, button); + void loadFileTreeChildren(row, button).then(function(loaded) { + if (loaded) restorePersistedFileTreeExpansionState(); + }); restored = true; }); if (restored) document.documentElement.setAttribute('data-mnote-filetree-expansion-restored', 'true'); @@ -1471,6 +1777,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function installTreeLiveApplyEventListeners() { + sidebarTreeViewStateFor('pagetree'); + document.addEventListener('visibilitychange', function() { + if (!document.hidden) return; + flushPendingSidebarTreeViewState('filetree'); + flushPendingSidebarTreeViewState('pagetree'); + }); + window.addEventListener('pagehide', function() { + flushPendingSidebarTreeViewState('filetree'); + flushPendingSidebarTreeViewState('pagetree'); + }); + window.addEventListener('tree:title-updated', function(event) { var detail = event.detail || {}; var previousDocumentId = detail.previousDocumentId || (detail.payload && detail.payload.result && detail.payload.result.previousDocumentId) || ''; @@ -1611,6 +1928,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { normalizeFileTreePageRenameTitle, objectIdentityAttr, refreshLocalFolderSidebarSnapshot, + refreshLocalFolderAfterCommand, removeDocumentRowForMode, renderSidebarSnapshot, revealFileTreeResource, diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 2523108b..22187a81 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -362,6 +362,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim persistLocalFolderSelfChangeSuppression, persistSidebarTreeMode, refreshLocalFolderSidebarSnapshot: (...args) => refreshLocalFolderSidebarSnapshot(...args), + refreshLocalFolderAfterCommand: (...args) => refreshLocalFolderAfterCommand(...args), resolveWorkspaceId, rowCenter: (...args) => rowCenter(...args), selectSidebarFileTreeDocument: (...args) => selectSidebarFileTreeDocument(...args), @@ -377,6 +378,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim const removeDocumentRowForMode = (...args) => sidebarTreeLiveApply.removeDocumentRowForMode(...args); const applyCreatedDocumentLocally = (...args) => sidebarTreeLiveApply.applyCreatedDocumentLocally(...args); const localCommandNeedsProjectionRefresh = (...args) => sidebarTreeLiveApply.localCommandNeedsProjectionRefresh(...args); + const refreshLocalFolderAfterCommand = (...args) => sidebarTreeLiveApply.refreshLocalFolderAfterCommand(...args); const applyMoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyMoveDocumentDelta(...args); const applyRemoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyRemoveDocumentDelta(...args); const setTreeLiveApplyError = (...args) => sidebarTreeLiveApply.setTreeLiveApplyError(...args); diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 970a2b51..d06c0fbc 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -4,7 +4,8 @@ use crate::error::WebError; use crate::routes::local_folder_source::{ control_plane_db_path_display, create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, - load_local_folder_page_tree_snapshot, load_local_trash_entries, + load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot, + load_local_trash_entries, }; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ @@ -326,7 +327,11 @@ 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 page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?; + let page_tree_snapshot = if let Some(scope) = file_tree_scope { + load_local_folder_page_tree_scope_snapshot(root_uri, scope)? + } else { + load_local_folder_page_tree_snapshot(root_uri)? + }; let workspace_id = page_tree_snapshot .dataset .get("workspace") @@ -2860,6 +2865,55 @@ mod tests { ); } + #[tokio::test] + async fn root_entry_local_folder_filetree_scope_renders_scoped_page_tree() { + let root = temp_root("mnote-root-local-folder-scoped-page-tree"); + std::fs::create_dir_all(root.join("design").join("05-editor-mainline")) + .expect("create scoped design tree"); + std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page"); + std::fs::write( + root.join("design") + .join("05-editor-mainline") + .join("Target.md"), + "# Target\n", + ) + .expect("write scoped page"); + + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_real", + &root_uri, + ) + .expect("init local workspace"); + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri(format!( + "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design" + )) + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#)); + assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)); + assert!( + !html.contains(r#"data-node-id="local-md:Home.md""#), + "星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树" + ); + } + #[tokio::test] async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() { let root = temp_root("mnote-root-local-folder-no-active-page"); 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 73b697b5..6212184f 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -4330,8 +4330,8 @@ fn add_local_tree_command_affected_parents(result: &Value, action: &str) -> Vec< "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 let Some(parent_path) = result.get("parentRelativePath").and_then(Value::as_str) { + push_local_tree_affected_parent(&mut parents, &mut seen, parent_path.to_string(), "parent"); } if normalized_action != "delete" && normalized_action != "trash" @@ -4607,6 +4607,7 @@ fn create_local_markdown_page( let stem = determine_page_stem(&parent_directory, title); let dir_target = parent_directory.join(&stem); let target = dir_target.join(format!("{}.md", stem)); + let parent_relative_path = normalize_relative_path(root, &parent_directory)?; let relative_path = normalize_relative_path(root, &target)?; let page_id = local_markdown_path_page_id(&relative_path); let display_title = file_stem_title(&format!("{stem}.md")); @@ -4628,6 +4629,7 @@ fn create_local_markdown_page( "id": page_id, "documentId": page_id, "title": display_title, + "parentRelativePath": parent_relative_path, "relativePath": relative_path, "action": "create", "sourceKind": "local_folder", @@ -4642,6 +4644,7 @@ fn create_local_folder( let metadata = load_local_folder_metadata(root)?; let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; let target = parent_directory.join(determine_folder_stem(&parent_directory, title)); + let parent_relative_path = normalize_relative_path(root, &parent_directory)?; fs::create_dir_all(&target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", @@ -4651,6 +4654,7 @@ fn create_local_folder( Ok(json!({ "ok": true, "id": local_directory_group_id(&normalize_relative_path(root, &target)?), + "parentRelativePath": parent_relative_path, "relativePath": normalize_relative_path(root, &target)?, "action": "createFolder", "sourceKind": "local_folder", @@ -12620,6 +12624,16 @@ fn main() {} ); assert_eq!(result["action"], "create"); assert_eq!(result["sourceKind"], "local_folder"); + assert_eq!(result["parentRelativePath"], ""); + assert!( + result["affectedParents"] + .as_array() + .expect("affected parents") + .iter() + .any(|parent| parent["relativePath"].as_str() == Some("")), + "create page affectedParents 应包含承载新页面包的父级 root: {}", + result["affectedParents"] + ); // 页面标题来自文件名,默认正文不再写第二个标题真相。 let content = std::fs::read_to_string(root.join(relative_path)).expect("read markdown"); @@ -12661,6 +12675,16 @@ fn main() {} .exists(), "default folder creation must not create page markdown" ); + assert_eq!(result["parentRelativePath"], ""); + assert!( + result["affectedParents"] + .as_array() + .expect("affected parents") + .iter() + .any(|parent| parent["relativePath"].as_str() == Some("")), + "create folder affectedParents 应包含承载新文件夹的父级 root: {}", + result["affectedParents"] + ); let _ = std::fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 39c29f22..d54bb984 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -28,6 +28,7 @@ mod snapshot_support; mod sse; mod stream_support; mod tree; +mod tree_view_state; pub(crate) mod ui_preferences; pub(crate) mod web_shell; mod ws; @@ -92,6 +93,10 @@ pub fn build_router(state: AppState) -> Router { "/api/ui/preferences", put(ui_preferences::update_preferences), ) + .route( + "/api/tree/view-state", + get(tree_view_state::get_tree_view_state).put(tree_view_state::put_tree_view_state), + ) .route( "/api/leptos-tiptap-runtime/manifest.json", get(web_shell::leptos_tiptap_manifest), diff --git a/rust/crates/mnote-web/src/routes/tree_view_state.rs b/rust/crates/mnote-web/src/routes/tree_view_state.rs new file mode 100644 index 00000000..9387435f --- /dev/null +++ b/rust/crates/mnote-web/src/routes/tree_view_state.rs @@ -0,0 +1,447 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::gateway::current_actor_id; +use crate::routes::local_folder_source::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::{json, Value}; + +const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree"; +const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1"; +const MAX_STATE_ITEMS: usize = 512; +const MAX_ID_LENGTH: usize = 2048; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TreeViewStateQuery { + #[serde(default, alias = "workspace_id")] + workspace_id: String, + #[serde(default, alias = "source_kind")] + source_kind: String, + #[serde(default, alias = "tree_kind")] + tree_kind: String, + #[serde(default, alias = "root_uri")] + root_uri: String, + #[serde(default)] + scope: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TreeViewStateUpdateRequest { + #[serde(default, alias = "workspace_id")] + workspace_id: String, + #[serde(default, alias = "source_kind")] + source_kind: String, + #[serde(default, alias = "tree_kind")] + tree_kind: String, + #[serde(default, alias = "root_uri")] + root_uri: String, + #[serde(default)] + scope: String, + #[serde(default)] + state: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TreeViewStateScope { + workspace_id: String, + source_kind: String, + tree_kind: String, + root_uri: String, + scope: String, + scope_id: String, +} + +pub(crate) async fn get_tree_view_state( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result, WebError> { + let actor_id = require_actor_id(&state, &context)?; + let scope = resolve_scope( + &context, + &query.workspace_id, + &query.source_kind, + &query.tree_kind, + &query.root_uri, + &query.scope, + )?; + 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 tree view state 读取失败: {error}")) + .with_context(&context) + })?; + let Some(record) = find_tree_view_state_record(&preferences, &scope) else { + return Ok(Json(response_payload( + &actor_id, + &scope, + "default", + None, + default_state(&scope), + ))); + }; + let parsed = serde_json::from_str::(&record.value_json).map_err(|error| { + WebError::internal(format!("SQLite tree view state JSON 无效: {error}")) + .with_context(&context) + })?; + let normalized = normalize_state(&context, &scope, parsed)?; + Ok(Json(response_payload( + &actor_id, + &scope, + "sqlite", + Some(record.revision), + normalized, + ))) +} + +pub(crate) async fn put_tree_view_state( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + let actor_id = require_actor_id(&state, &context)?; + ensure_actor_user(&state, &actor_id)?; + let scope = resolve_scope( + &context, + &request.workspace_id, + &request.source_kind, + &request.tree_kind, + &request.root_uri, + &request.scope, + )?; + let normalized = normalize_state(&context, &scope, request.state)?; + let record = state + .control_plane() + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: actor_id.clone(), + workspace_id: Some(scope.workspace_id.clone()), + source_kind: Some(scope.source_kind.clone()), + scope_kind: SIDEBAR_TREE_SCOPE_KIND.to_string(), + scope_id: scope.scope_id.clone(), + key: SIDEBAR_TREE_VIEW_STATE_KEY.to_string(), + value_json: normalized.to_string(), + }) + .map_err(|error| { + WebError::bad_request_code( + "tree_view_state_write_failed", + format!("写入 tree view state 失败: {error}"), + ) + .with_context(&context) + })?; + Ok(Json(response_payload( + &actor_id, + &scope, + "sqlite", + Some(record.revision), + normalized, + ))) +} + +fn find_tree_view_state_record<'a>( + preferences: &'a [UserUiPreferenceRecord], + scope: &TreeViewStateScope, +) -> Option<&'a UserUiPreferenceRecord> { + preferences.iter().find(|preference| { + preference.scope_kind == SIDEBAR_TREE_SCOPE_KIND + && preference.scope_id == scope.scope_id + && preference.key == SIDEBAR_TREE_VIEW_STATE_KEY + }) +} + +fn resolve_scope( + context: &RequestContext, + workspace_id: &str, + source_kind: &str, + tree_kind: &str, + root_uri: &str, + scope: &str, +) -> Result { + let source_kind = normalize_or_default(source_kind, "convex_workspace"); + let tree_kind = normalize_tree_kind(context, tree_kind)?; + let root_uri = root_uri.trim().to_string(); + let scope = normalize_or_default(scope, "root"); + let workspace_id = if source_kind == "local_folder" { + local_workspace_id_from_root_uri(&root_uri).map_err(|error| error.with_context(context))? + } else { + normalize_or_default(workspace_id, "default") + }; + let scope_id = format!( + "{}:{}:{}", + tree_kind, + stable_hash_16(&root_uri), + stable_hash_16(&scope) + ); + Ok(TreeViewStateScope { + workspace_id, + source_kind, + tree_kind, + root_uri, + scope, + scope_id, + }) +} + +fn normalize_tree_kind(context: &RequestContext, tree_kind: &str) -> Result { + match tree_kind.trim() { + "filetree" => Ok("filetree".to_string()), + "pagetree" => Ok("pagetree".to_string()), + _ => Err(WebError::bad_request_code( + "tree_view_state_tree_kind_invalid", + "treeKind 只允许 filetree 或 pagetree", + ) + .with_context(context)), + } +} + +fn normalize_state( + context: &RequestContext, + scope: &TreeViewStateScope, + value: Value, +) -> Result { + let mut object = value + .as_object() + .cloned() + .ok_or_else(|| { + WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象") + .with_context(context) + })?; + let schema_version = object + .get("schemaVersion") + .and_then(Value::as_i64) + .unwrap_or(1); + if schema_version != 1 { + return Err(WebError::bad_request_code( + "tree_view_state_schema_invalid", + "schemaVersion 必须为 1", + ) + .with_context(context)); + } + for derived_key in [ + "rowsByParent", + "loadedParents", + "loadingParents", + "dirtyParents", + "staleParents", + "revisionByParent", + "latestGenerationByParent", + ] { + if object.contains_key(derived_key) { + return Err(WebError::bad_request_code( + "tree_view_state_contains_derived_cache", + format!("tree view state 不能包含派生运行态 {derived_key}"), + ) + .with_context(context)); + } + } + object.insert("schemaVersion".to_string(), json!(1)); + object.insert("treeKind".to_string(), json!(scope.tree_kind)); + object.insert("rootUri".to_string(), json!(scope.root_uri)); + object.insert("scope".to_string(), json!(scope.scope)); + object.insert( + "expandedIds".to_string(), + json!(bounded_string_array(object.get("expandedIds"), MAX_STATE_ITEMS)), + ); + object.insert( + "expandedRelativePaths".to_string(), + json!(bounded_string_array( + object.get("expandedRelativePaths"), + MAX_STATE_ITEMS + )), + ); + for key in ["selectedId", "focusedId", "activeId"] { + object.insert(key.to_string(), json!(bounded_string(object.get(key)))); + } + object.insert( + "scrollTop".to_string(), + json!(object + .get("scrollTop") + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 0.0) + .unwrap_or(0.0)), + ); + if !object.contains_key("updatedAtMs") { + object.insert("updatedAtMs".to_string(), json!(0)); + } + Ok(Value::Object(object)) +} + +fn default_state(scope: &TreeViewStateScope) -> Value { + normalize_state( + &RequestContext::from_http_parts( + &axum::http::Method::GET, + &"/api/tree/view-state".parse().expect("static uri"), + &axum::http::HeaderMap::new(), + ), + scope, + json!({ "schemaVersion": 1 }), + ) + .expect("default tree view state is valid") +} + +fn response_payload( + actor_id: &str, + scope: &TreeViewStateScope, + source: &str, + revision: Option, + state: Value, +) -> Value { + json!({ + "ok": true, + "owner": "mnote-web", + "result": { + "userId": actor_id, + "workspaceId": scope.workspace_id, + "sourceKind": scope.source_kind, + "treeKind": scope.tree_kind, + "rootUri": scope.root_uri, + "scope": scope.scope, + "scopeId": scope.scope_id, + "scopeKind": SIDEBAR_TREE_SCOPE_KIND, + "key": SIDEBAR_TREE_VIEW_STATE_KEY, + "source": source, + "revision": revision, + "state": state + } + }) +} + +fn bounded_string_array(value: Option<&Value>, limit: usize) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.chars().take(MAX_ID_LENGTH).collect::()) + .take(limit) + .collect() + }) + .unwrap_or_default() +} + +fn bounded_string(value: Option<&Value>) -> String { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(MAX_ID_LENGTH).collect()) + .unwrap_or_default() +} + +fn normalize_or_default(value: &str, default: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + default.to_string() + } else { + trimmed.to_string() + } +} + +fn stable_hash_16(value: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in value.trim().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +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, + "tree_view_state_auth_required", + "tree view state 需要登录用户", + ) + .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!("确保用户记录失败: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_scope_id_separates_tree_kind_and_scope() { + let context = RequestContext::from_http_parts( + &axum::http::Method::GET, + &"/api/tree/view-state".parse().expect("uri"), + &axum::http::HeaderMap::new(), + ); + let filetree = resolve_scope( + &context, + "local:_mnt_Data1T_mnote", + "local_folder", + "filetree", + "file:///mnt/Data1T/mnote", + "design", + ) + .expect("filetree scope"); + let pagetree = resolve_scope( + &context, + "local:_mnt_Data1T_mnote", + "local_folder", + "pagetree", + "file:///mnt/Data1T/mnote", + "design", + ) + .expect("pagetree scope"); + assert_ne!(filetree.scope_id, pagetree.scope_id); + assert!(filetree.scope_id.starts_with("filetree:")); + assert!(pagetree.scope_id.starts_with("pagetree:")); + } + + #[test] + fn normalize_state_rejects_derived_cache() { + let context = RequestContext::from_http_parts( + &axum::http::Method::GET, + &"/api/tree/view-state".parse().expect("uri"), + &axum::http::HeaderMap::new(), + ); + let root = std::env::current_dir().expect("cwd"); + let root_uri = format!("file://{}", root.display()); + let scope = resolve_scope( + &context, + "ws", + "local_folder", + "filetree", + &root_uri, + "root", + ) + .expect("scope"); + let error = normalize_state( + &context, + &scope, + json!({ "schemaVersion": 1, "rowsByParent": {} }), + ) + .expect_err("derived cache rejected"); + assert_eq!(error.code(), "tree_view_state_contains_derived_cache"); + } +} diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 9d9a7d6f..0d937875 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -2610,6 +2610,128 @@ mod tests { assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace"); } + #[tokio::test] + async fn tree_view_state_api_is_user_scoped_and_tree_scope_isolated() { + let app = app(); + let put_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/tree/view-state") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "alice") + .header("x-mnote-actor-type", "user") + .body(Body::from( + serde_json::json!({ + "userId": "mallory", + "workspaceId": "default", + "sourceKind": "local_folder", + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "state": { + "schemaVersion": 1, + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "expandedRelativePaths": ["design/05-editor-mainline"], + "expandedIds": [], + "selectedId": "local:folder:design/05-editor-mainline", + "focusedId": "", + "activeId": "", + "scrollTop": 12 + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(put_response.status(), StatusCode::OK); + let put_body = to_bytes(put_response.into_body(), usize::MAX) + .await + .expect("body"); + let put_payload: Value = serde_json::from_slice(&put_body).expect("json"); + assert_eq!(put_payload["result"]["userId"], "alice"); + assert_ne!(put_payload["result"]["workspaceId"], "default"); + assert!(put_payload["result"]["scopeId"] + .as_str() + .expect("scope id") + .starts_with("filetree:")); + + let get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design") + .header("x-mnote-actor-id", "alice") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(get_response.status(), StatusCode::OK); + let get_body = to_bytes(get_response.into_body(), usize::MAX) + .await + .expect("body"); + let get_payload: Value = serde_json::from_slice(&get_body).expect("json"); + assert_eq!(get_payload["result"]["source"], "sqlite"); + assert_eq!( + get_payload["result"]["state"]["expandedRelativePaths"][0], + "design/05-editor-mainline" + ); + + let page_tree_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=pagetree&rootUri=file:///mnt/Data1T/mnote&scope=design") + .header("x-mnote-actor-id", "alice") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(page_tree_response.status(), StatusCode::OK); + let page_tree_body = to_bytes(page_tree_response.into_body(), usize::MAX) + .await + .expect("body"); + let page_tree_payload: Value = serde_json::from_slice(&page_tree_body).expect("json"); + assert_eq!(page_tree_payload["result"]["source"], "default"); + assert_eq!( + page_tree_payload["result"]["state"]["expandedRelativePaths"] + .as_array() + .expect("array") + .len(), + 0 + ); + + let bob_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design") + .header("x-mnote-actor-id", "bob") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(bob_response.status(), StatusCode::OK); + let bob_body = to_bytes(bob_response.into_body(), usize::MAX) + .await + .expect("body"); + let bob_payload: Value = serde_json::from_slice(&bob_body).expect("json"); + assert_eq!(bob_payload["result"]["source"], "default"); + } + #[tokio::test] async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() { let root = diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 4c528808..6a95541a 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -346,6 +346,29 @@ mod tests { assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'")); } + #[test] + fn sidebar_live_apply_runtime_uses_persisted_tree_view_state() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("SIDEBAR_TREE_VIEW_STATE_KEY")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function loadSidebarTreeViewState")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function persistSidebarTreeViewState")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applySidebarTreeViewState")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/view-state")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}")); + + let render_page_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows"); + assert!(render_page_rows.contains("sidebarTreeViewStateFor('pagetree'")); + assert!(render_page_rows.contains("expandedIds")); + assert!( + !render_page_rows.contains("var expanded = expandable && item.expandedByDefault !== false"), + "PageTree 不能只依赖 projection expandedByDefault 决定展开" + ); + + let render_file_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows"); + assert!(render_file_rows.contains("sidebarTreeViewStateFor('filetree'")); + assert!(render_file_rows.contains("expandedRelativePaths")); + } + #[test] fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() { let html = crate::ssr::render_view(leptos::view! { @@ -1163,6 +1186,35 @@ mod tests { .contains("if (!isLatestPageTreeRequest(generation))")); } + #[test] + fn sidebar_filetree_runtime_refreshes_scoped_page_tree_projection() { + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("sidebarUrl.searchParams.set('parentRelativePath', fileTreeScope)"), + "scoped filetree 入口刷新 PageTree 时必须请求同 scope projection" + ); + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("sidebarTreeViewStateFor('pagetree')"), + "SSR 初始 PageTree 也必须触发 pagetree 用户保存态加载" + ); + } + + #[test] + fn sidebar_page_create_does_not_full_refresh_local_folder_sidebar() { + assert!( + !SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("await refreshLocalFolderSidebarSnapshot();"), + "本地新建页面不能全量刷新 sidebar,否则会折叠/重建其它已展开节点" + ); + assert!( + SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("await refreshLocalFolderAfterCommand('create'"), + "本地新建页面应等待局部 parent refresh,而不是全量刷新 sidebar" + ); + assert!( + SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("selectSidebarFileTreeDocument(nextDocumentId"), + "本地新建页面仍应只 reveal/select 新节点" + ); + } + #[test] fn sidebar_filetree_runtime_prefers_command_affected_parents() { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index d21b315c..6e04c057 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -622,6 +622,7 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before { min-height: 0; display: flex; flex-direction: column; + overflow: hidden; } .mnote-sidebar-nav a, @@ -1374,7 +1375,7 @@ body { background: var(--atelier-sidebar); border-right: 0; box-shadow: none; - overflow-x: hidden; + overflow: hidden; } .mnote-sidebar-header, @@ -1439,6 +1440,13 @@ body { padding: 0 8px 18px; } +.wolai-my-pages-section { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + .wolai-section-title, .wolai-sidebar-tabs { height: 32px; @@ -1496,7 +1504,10 @@ body { } .wolai-sidebar-tab-panels { + flex: 1 1 auto; min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; } .wolai-sidebar-tab-panel[hidden] { @@ -1996,6 +2007,7 @@ body { } .wolai-sidebar-footer { + flex: 0 0 auto; border-top: 0; background: var(--atelier-sidebar); } @@ -4470,6 +4482,15 @@ mod tests { assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb")); } + #[test] + fn sidebar_scroll_is_contained_to_tree_panels() { + assert!(MNOTE_CSS.contains(".mnote-sidebar,\n.wolai-sidebar {\n width: var(--mnote-sidebar-width, 248px);\n background: var(--atelier-sidebar);\n border-right: 0;\n box-shadow: none;\n overflow: hidden;")); + assert!(MNOTE_CSS.contains(".wolai-sidebar-body {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;")); + assert!(MNOTE_CSS.contains(".wolai-my-pages-section {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;")); + assert!(MNOTE_CSS.contains(".wolai-sidebar-tab-panels {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;")); + assert!(MNOTE_CSS.contains(".wolai-sidebar-footer {\n flex: 0 0 auto;")); + } + #[test] fn admin_policy_rows_wrap_long_grant_values() { assert!(MNOTE_CSS.contains( diff --git a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js index f81ae224..aa927ea0 100644 --- a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js +++ b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js @@ -74,6 +74,10 @@ function fileUrlToPath(value) { return decodeURIComponent(url.pathname); } +function cssEscape(value) { + return String(value).replace(/["\\]/g, "\\$&"); +} + async function signUp(context, baseUrl, actorId) { const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { method: "POST", @@ -126,6 +130,23 @@ async function expandFolder(page, relativePath) { } } +async function setPageRowExpanded(page, nodeId, expanded) { + const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`; + await page.locator(selector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const current = await page.locator(selector).first().getAttribute("aria-expanded"); + if ((current === "true") !== expanded) { + await page.locator(`${selector} [data-rust-action="toggle"]`).first().click({ timeout: UI_TIMEOUT_MS }); + } + await page.waitForFunction( + ({ targetNodeId, expected }) => { + const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`); + return row && row.getAttribute("aria-expanded") === String(expected); + }, + { targetNodeId: nodeId, expected: expanded }, + { timeout: UI_TIMEOUT_MS }, + ); +} + async function main() { fs.mkdirSync(RESULT_DIR, { recursive: true }); const port = await pickPort(); @@ -219,6 +240,7 @@ async function main() { const url = request.url(); if ( url.includes("/api/tree/projections/") + || url.includes("/api/tree/view-state") || url.includes("/api/page-aggregate/") || url.includes("/api/leptos-tiptap-runtime/") || url.includes("/documents/") @@ -329,6 +351,49 @@ async function main() { const staleRowsAfterStarredOpen = await page.locator('#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').count(); assert.equal(staleRowsAfterStarredOpen, 0, "星标 design 打开后旧我的空间页面树不应长期残留"); + await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS }); + const pageStatePut = page.waitForResponse((response) => { + const postData = response.request().postData() || ""; + return response.url().includes("/api/tree/view-state") + && response.request().method() === "PUT" + && /"treeKind":"pagetree"/.test(postData) + && /"scope":"design"/.test(postData); + }, { timeout: UI_TIMEOUT_MS }); + const collapsedPageNodeId = "local-dir:design~2F05-editor-mainline~2Fprocess"; + const expandedPageNodeId = "local-dir:design~2F05-editor-mainline~2Freference"; + await setPageRowExpanded(page, expandedPageNodeId, false); + await setPageRowExpanded(page, collapsedPageNodeId, false); + await setPageRowExpanded(page, expandedPageNodeId, true); + const pageStateResponse = await pageStatePut; + assert(pageStateResponse.ok(), `PageTree scoped view-state 保存失败: ${pageStateResponse.status()}`); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + ({ collapsedPageNodeId, expandedPageNodeId }) => { + const collapsed = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(collapsedPageNodeId)}"]`); + const expanded = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(expandedPageNodeId)}"]`); + return collapsed && expanded + && collapsed.getAttribute("aria-expanded") === "false" + && expanded.getAttribute("aria-expanded") === "true"; + }, + { collapsedPageNodeId, expandedPageNodeId }, + { timeout: UI_TIMEOUT_MS }, + ).catch(async (error) => { + const state = await page.evaluate(() => ({ + href: location.href, + pageApplied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied") || "", + viewStateSaved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || "", + pageTree: Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")).map((row) => ({ + nodeId: row.getAttribute("data-node-id") || "", + expanded: row.getAttribute("aria-expanded") || "", + text: row.textContent || "", + })), + })); + throw new Error(`${error.message}; page_tree_state=${JSON.stringify(state)}; requestTimings=${JSON.stringify(requestTimings.filter((entry) => entry.url.includes("/api/tree/view-state")))}`); + }); + timeline.pageTreeUserStateRestoredAt = Date.now(); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + await page.screenshot({ path: path.join(RESULT_DIR, "after-starred-design.png"), fullPage: true }); timeline.filetreeInteractiveAt = Date.now(); await expandFolder(page, "design/05-editor-mainline"); diff --git a/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js b/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js index 4602211e..533a32cd 100644 --- a/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js +++ b/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js @@ -72,6 +72,27 @@ function fileUrlToPath(value) { return decodeURIComponent(url.pathname); } +function cssEscape(value) { + return String(value).replace(/["\\]/g, "\\$&"); +} + +async function setPageRowExpanded(page, nodeId, expanded) { + const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`; + await page.locator(selector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const current = await page.locator(selector).first().getAttribute("aria-expanded"); + if ((current === "true") !== expanded) { + await page.locator(`${selector} [data-rust-action="toggle"]`).first().click({ timeout: UI_TIMEOUT_MS }); + } + await page.waitForFunction( + ({ targetNodeId, expected }) => { + const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`); + return row && row.getAttribute("aria-expanded") === String(expected); + }, + { targetNodeId: nodeId, expected: expanded }, + { timeout: UI_TIMEOUT_MS }, + ); +} + async function signUp(context, baseUrl, actorId) { const response = await context.request.fetch(`${baseUrl}/api/auth`, { method: "POST", @@ -163,8 +184,28 @@ async function main() { const context = await browser.newContext({ viewport: { width: 1360, height: 920 } }); const page = await context.newPage(); const diagnostics = []; + const viewStateRequests = []; page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`)); page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`)); + page.on("request", (request) => { + if (!request.url().includes("/api/tree/view-state")) return; + viewStateRequests.push({ + method: request.method(), + url: request.url(), + postData: request.postData() || "", + at: Date.now(), + }); + }); + page.on("response", async (response) => { + if (!response.url().includes("/api/tree/view-state")) return; + const request = response.request(); + const entry = viewStateRequests.findLast((candidate) => candidate.url === request.url() && candidate.method === request.method() && !candidate.status); + if (!entry) return; + entry.status = response.status(); + entry.doneAt = Date.now(); + entry.durationMs = entry.doneAt - entry.at; + entry.body = await response.text().catch(() => ""); + }); try { await waitForHttpOk(`${baseUrl}/health`, 60_000); @@ -182,6 +223,17 @@ async function main() { 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="page"]').click({ timeout: UI_TIMEOUT_MS }); + const rootPageTreePut = page.waitForResponse((response) => { + const postData = response.request().postData() || ""; + return response.url().includes("/api/tree/view-state") + && response.request().method() === "PUT" + && /"treeKind":"pagetree"/.test(postData) + && /"scope":"root"/.test(postData); + }, { timeout: UI_TIMEOUT_MS }); + await setPageRowExpanded(page, "local-dir:design", false); + const rootPageTreeResponse = await rootPageTreePut; + assert(rootPageTreeResponse.ok(), `root PageTree view-state 保存失败: ${rootPageTreeResponse.status()}`); await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); 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 }); @@ -220,6 +272,14 @@ async function main() { const pageTreeText = await page.locator("#sidebar-tree-root").innerText({ timeout: UI_TIMEOUT_MS }); assert.match(pageTreeText, /Brief/, `星标 design 后页面树应显示 design 内 Markdown: ${pageTreeText}`); assert.doesNotMatch(pageTreeText, /Home|Other/, `星标 design 后页面树不应显示根目录 Markdown: ${pageTreeText}`); + assert( + viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /"treeKind":"pagetree"/.test(entry.postData) && /"scope":"root"/.test(entry.postData)), + `应记录 root PageTree view-state PUT: ${JSON.stringify(viewStateRequests)}`, + ); + assert( + viewStateRequests.some((entry) => entry.method === "GET" && entry.status === 200 && entry.url.includes("treeKind=pagetree") && entry.url.includes("scope=design")), + `星标 design 应读取 scoped PageTree view-state: ${JSON.stringify(viewStateRequests)}`, + ); await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md"); const editor = page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first(); @@ -258,7 +318,7 @@ async function main() { const reloadedText = await readEditorText(page); assert.match(reloadedText, /111/, `切换页面再回 Home.md 后编辑内容应保留: ${reloadedText}`); - console.log(JSON.stringify({ ok: true, task: TASK, baseUrl }, null, 2)); + console.log(JSON.stringify({ ok: true, task: TASK, baseUrl, viewStateRequests }, null, 2)); } finally { await browser.close(); server.kill("SIGINT"); diff --git a/scripts/task499-sidebar-tree-view-state-smoke.js b/scripts/task499-sidebar-tree-view-state-smoke.js new file mode 100644 index 00000000..7651b908 --- /dev/null +++ b/scripts/task499-sidebar-tree-view-state-smoke.js @@ -0,0 +1,438 @@ +#!/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 = "task499-sidebar-tree-view-state-smoke"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK); + +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 retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + 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(); + }); + }; + tick(); + }); +} + +function fileUrlToPath(value) { + const url = new URL(value); + return decodeURIComponent(url.pathname); +} + +function cssEscape(value) { + return String(value).replace(/["\\]/g, "\\$&"); +} + +async function signUp(context, baseUrl, actorId) { + const response = 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(response.ok(), `测试账号注册失败: ${response.status()} ${await response.text()}`); +} + +async function openDefaultLocalWorkspace(page, baseUrl) { + 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 url = new URL(page.url()); + const rootUri = url.searchParams.get("rootUri") + || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); + assert(rootUri, "应进入 local_folder workspace"); + return { + rootUri, + workspaceId: url.searchParams.get("workspaceId") || "", + }; +} + +async function setFileTreeExpanded(page, relativePath, expanded) { + const rowSelector = `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath}"]`; + await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const row = page.locator(rowSelector).first(); + const current = await row.getAttribute("aria-expanded"); + if ((current === "true") !== expanded) { + await row.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS }); + } + await page.waitForFunction( + ({ selector, expected }) => { + const row = document.querySelector(selector); + return row && row.getAttribute("aria-expanded") === String(expected); + }, + { selector: rowSelector, expected: expanded }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function setPageRowExpanded(page, nodeId, expanded) { + const selector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(nodeId)}"]`; + const row = page.locator(selector).first(); + await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const current = await row.getAttribute("aria-expanded"); + if ((current === "true") !== expanded) { + await row.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS }); + } + await page.waitForFunction( + ({ targetNodeId, expected }) => { + const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(targetNodeId)}"]`); + return row && row.getAttribute("aria-expanded") === String(expected); + }, + { targetNodeId: nodeId, expected: expanded }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function visibleTreeState(page) { + return page.evaluate(() => ({ + href: location.href, + fileTree: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({ + path: row.getAttribute("data-local-relative-path") || "", + expanded: row.getAttribute("aria-expanded") || "", + text: row.textContent || "", + })).filter((row) => row.path), + pageTree: Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")).map((row) => ({ + nodeId: row.getAttribute("data-node-id") || "", + expanded: row.getAttribute("aria-expanded") || "", + text: row.textContent || "", + })), + markers: { + saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || "", + pageApplied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied") || "", + loadError: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error") || "", + saveError: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error") || "", + }, + })); +} + +async function main() { + fs.mkdirSync(RESULT_DIR, { recursive: true }); + 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({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + const diagnostics = []; + const viewStateRequests = []; + const screenshots = []; + const timeline = {}; + page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`)); + page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`)); + page.on("request", (request) => { + if (request.url().includes("/api/tree/view-state")) { + viewStateRequests.push({ + method: request.method(), + url: request.url(), + at: Date.now(), + postData: request.postData() || "", + }); + } + }); + page.on("response", async (response) => { + if (!response.url().includes("/api/tree/view-state")) return; + const matching = viewStateRequests.findLast((entry) => entry.url === response.url() && !entry.status); + if (!matching) return; + matching.status = response.status(); + matching.doneAt = Date.now(); + matching.durationMs = matching.doneAt - matching.at; + matching.body = await response.text().catch(() => ""); + }); + + try { + await waitForHttpOk(`${baseUrl}/health`, 60_000); + await signUp(context, baseUrl, actorId); + const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl); + const rootPath = fileUrlToPath(rootUri); + fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "process"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "reference"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "process", "Target.md"), "# Target\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "reference", "Ref.md"), "# Ref\n", "utf8"); + + const workspaceUrl = new URL(baseUrl); + if (workspaceId) workspaceUrl.searchParams.set("workspaceId", workspaceId); + workspaceUrl.searchParams.set("sourceKind", "local_folder"); + workspaceUrl.searchParams.set("rootUri", rootUri); + workspaceUrl.searchParams.set("treeView", "filetree"); + await page.goto(workspaceUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + 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 }); + await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first(); + timeline.starClickAt = Date.now(); + await designShortcut.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-local-relative-path="design/05-editor-mainline"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction(() => { + const root = document.querySelector("#sidebar-tree-root"); + return root instanceof HTMLElement && /05-editor-mainline|Target|Ref/.test(root.innerText || ""); + }, { timeout: UI_TIMEOUT_MS }); + screenshots.push(path.join(RESULT_DIR, "01-after-starred-design.png")); + await page.screenshot({ path: screenshots.at(-1), fullPage: true }); + + const fileSavePromise = page.waitForResponse((response) => { + return response.url().includes("/api/tree/view-state") + && response.request().method() === "PUT" + && /"treeKind":"filetree"/.test(response.request().postData() || ""); + }, { timeout: UI_TIMEOUT_MS }).catch((error) => error); + await setFileTreeExpanded(page, "design/05-editor-mainline", true); + await setFileTreeExpanded(page, "design/05-editor-mainline/process", true); + const fileSaveResult = await fileSavePromise; + if (fileSaveResult instanceof Error) { + throw new Error(`未观察到 FileTree view-state PUT: ${fileSaveResult.message}; state=${JSON.stringify(await visibleTreeState(page))}; requests=${JSON.stringify(viewStateRequests)}`); + } + assert(fileSaveResult.ok(), `FileTree view-state PUT 失败: ${fileSaveResult.status()}`); + timeline.fileStateSavedAt = Date.now(); + + await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-panel="page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const pageRows = await page.evaluate(() => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page'][aria-expanded]")).filter((row) => { + const rect = row.getBoundingClientRect(); + const style = window.getComputedStyle(row); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }).map((row) => ({ + nodeId: row.getAttribute("data-node-id") || "", + parentId: row.getAttribute("data-parent-id") || "", + expanded: row.getAttribute("aria-expanded") || "", + text: row.textContent || "", + })).filter((row) => row.nodeId)); + const siblingPair = pageRows.flatMap((row, index) => { + return pageRows.slice(index + 1).filter((candidate) => candidate.parentId === row.parentId).map((candidate) => [row, candidate]); + })[0]; + assert(siblingPair, `PageTree 应至少有两个同级可展开节点: ${JSON.stringify(pageRows)}`); + const pageCollapsed = siblingPair[0].nodeId; + const pageExpanded = siblingPair[1].nodeId; + const pageSavePromise = page.waitForResponse((response) => { + return response.url().includes("/api/tree/view-state") + && response.request().method() === "PUT" + && /"treeKind":"pagetree"/.test(response.request().postData() || ""); + }, { timeout: UI_TIMEOUT_MS }).catch((error) => error); + await setPageRowExpanded(page, pageExpanded, false); + await setPageRowExpanded(page, pageCollapsed, false); + await setPageRowExpanded(page, pageExpanded, true); + const pageSaveResult = await pageSavePromise; + if (pageSaveResult instanceof Error) { + throw new Error(`未观察到 PageTree view-state PUT: ${pageSaveResult.message}; state=${JSON.stringify(await visibleTreeState(page))}; requests=${JSON.stringify(viewStateRequests)}`); + } + assert(pageSaveResult.ok(), `PageTree view-state PUT 失败: ${pageSaveResult.status()}`); + timeline.pageStateSavedAt = Date.now(); + screenshots.push(path.join(RESULT_DIR, "02-after-state-edits.png")); + await page.screenshot({ path: screenshots.at(-1), fullPage: true }); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved") || document.querySelector("#sidebar-file-tree-root"), { 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 }); + await page.waitForFunction(() => { + const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process"]'); + return row && row.getAttribute("aria-expanded") === "true"; + }, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + ({ pageCollapsed, pageExpanded }) => { + const collapsed = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(pageCollapsed)}"]`); + const expanded = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(pageExpanded)}"]`); + return collapsed && expanded + && collapsed.getAttribute("aria-expanded") === "false" + && expanded.getAttribute("aria-expanded") === "true"; + }, + { pageCollapsed, pageExpanded }, + { timeout: UI_TIMEOUT_MS }, + ); + timeline.reloadedStateRestoredAt = Date.now(); + + const targetRow = page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process/Target.md"]').first(); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + await targetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await targetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => location.pathname.startsWith("/documents/") + && decodeURIComponent(location.pathname).includes("Target.md") + && new URLSearchParams(location.search).get("fileTreeScope") === "design", + { timeout: UI_TIMEOUT_MS }, + ); + await page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.goBack({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }).catch(() => page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS })); + await page.waitForFunction(() => { + const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-local-relative-path="design/05-editor-mainline/process"]'); + return row && row.getAttribute("aria-expanded") === "true"; + }, { timeout: UI_TIMEOUT_MS }); + timeline.afterNavigationRestoredAt = Date.now(); + screenshots.push(path.join(RESULT_DIR, "03-after-reload-and-navigation.png")); + await page.screenshot({ path: screenshots.at(-1), fullPage: true }); + + const finalState = await visibleTreeState(page); + assert.equal( + finalState.fileTree.find((row) => row.path === "design/05-editor-mainline")?.expanded, + "true", + `FileTree 父节点应恢复展开: ${JSON.stringify(finalState.fileTree)}`, + ); + assert.equal( + finalState.fileTree.find((row) => row.path === "design/05-editor-mainline/process")?.expanded, + "true", + `FileTree process 节点应恢复展开: ${JSON.stringify(finalState.fileTree)}`, + ); + assert.equal( + finalState.pageTree.find((row) => row.nodeId === pageCollapsed)?.expanded, + "false", + `PageTree 折叠态应恢复: ${JSON.stringify(finalState.pageTree)}`, + ); + assert.equal( + finalState.pageTree.find((row) => row.nodeId === pageExpanded)?.expanded, + "true", + `PageTree 展开态应恢复: ${JSON.stringify(finalState.pageTree)}`, + ); + assert(viewStateRequests.some((entry) => entry.method === "GET" && entry.status === 200), "应记录 view-state GET"); + assert(viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /filetree/.test(entry.postData)), "应记录 FileTree view-state PUT"); + assert(viewStateRequests.some((entry) => entry.method === "PUT" && entry.status === 200 && /pagetree/.test(entry.postData)), "应记录 PageTree view-state PUT"); + + const result = { + ok: true, + task: TASK, + baseUrl, + rootUri, + workspaceId, + pageCollapsed, + pageExpanded, + timeline, + viewStateRequests, + finalState, + screenshots, + }; + fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + const failureState = await visibleTreeState(page).catch(() => null); + const failureScreenshot = path.join(RESULT_DIR, "99-failure.png"); + await page.screenshot({ path: failureScreenshot, fullPage: true }).catch(() => {}); + const result = { + ok: false, + task: TASK, + error: error && error.stack ? error.stack : String(error), + diagnostics: diagnostics.slice(-30), + viewStateRequests, + failureState, + screenshot: failureScreenshot, + stderr: stderr.slice(-8000), + }; + fs.writeFileSync(path.join(RESULT_DIR, "failure.json"), JSON.stringify(result, null, 2)); + console.error(JSON.stringify(result, null, 2)); + process.exitCode = 1; + } finally { + await browser.close().catch(() => {}); + server.kill("SIGTERM"); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});