Implement sidebar tree view state persistence

This commit is contained in:
lix-2026
2026-05-27 16:32:05 +08:00
parent bd7470d03e
commit e43efacbce
15 changed files with 1875 additions and 17 deletions
@@ -360,6 +360,183 @@ scripts/task494-filetree-lazy-loading-dedup-smoke.js
- 2026-05-27Rust 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` 使用当前 workspacelocal 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;不得折叠其它已展开节点。
- 当保存态里的节点已不存在时,应用时静默跳过,并在下次写入时自然清理。
### 待执行 ChecklistSidebarTreeViewState
- [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 如何懒加载、缓存、刷新、避免重复请求”。
+70
View File
@@ -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]
@@ -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);
}
@@ -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,
@@ -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);
+56 -2
View File
@@ -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");
@@ -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);
}
+5
View File
@@ -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),
@@ -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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<TreeViewStateQuery>,
) -> Result<Json<Value>, 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::<Value>(&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<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<TreeViewStateUpdateRequest>,
) -> Result<Json<Value>, 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<TreeViewStateScope, WebError> {
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<String, WebError> {
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<Value, WebError> {
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<i64>,
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<String> {
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::<String>())
.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<String, WebError> {
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");
}
}
@@ -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 =
@@ -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
+22 -1
View File
@@ -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(
@@ -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");
@@ -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");
@@ -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;
});