Improve local filetree view state and sidebar performance
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# local search query 每次重建索引导致大工作区搜索卡顿
|
||||
|
||||
> 状态:done
|
||||
> Owner:04-tree-domain / 03-rust-web
|
||||
> 发现日期:2026-05-27
|
||||
> 来源:Sidex 全项目 review / 本地搜索性能审查
|
||||
|
||||
## 现象
|
||||
|
||||
在 local-first 工作区中,侧边栏搜索输入虽然有前端 debounce,但后端每次 query 都会重建并写入整个 local search index。大工作区下,连续输入会触发多次递归扫描、读取 Markdown、写 `.mnote/index/search-index.json`,导致搜索和主界面卡顿。
|
||||
|
||||
## 证据
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/local_search_index.rs`
|
||||
- `query_local_search_index()` 第一步调用 `rebuild_local_search_index(...)`。
|
||||
- `rebuild_local_search_index()` 递归 `collect_markdown_documents(...)` 后写入 index。
|
||||
- 前端 `sidebar-tree-runtime.js` 的搜索渲染 debounce 不能避免后端每次 query 全盘 rebuild。
|
||||
- 同文件已经存在 `refresh_local_search_index_for_path(...)` 增量更新入口,说明 watcher/单文件刷新方向已有基础,但 query path 没有复用它。
|
||||
|
||||
## Sidex / VSCode 对照
|
||||
|
||||
Sidex / VSCode 搜索路径不会把每次 query 等价为全盘同步 rebuild:
|
||||
|
||||
- quick access/search 使用 throttle、cache state 和 cancellation token。
|
||||
- Rust search crate 有 max_results、ignore-aware、binary skip 和并行扫描。
|
||||
- watcher 变化应驱动增量缓存更新,query 只消费当前 cache 或启动后台 refresh。
|
||||
|
||||
MNote 不需要照搬 VSCode 搜索 UI,但应采用同一原则:query path 不做强制全量重建。
|
||||
|
||||
## 期望行为
|
||||
|
||||
- query 优先读取已有 index。
|
||||
- index 缺失、版本不匹配、rootUri/workspaceId 不匹配时,才同步 rebuild。
|
||||
- index 过期但仍可读时,query 返回旧 index 结果,并后台触发 refresh。
|
||||
- watcher/path event 继续调用 `refresh_local_search_index_for_path(...)` 做增量更新。
|
||||
- 连续输入时,旧 query 可以被取消或自然过期,不能排队多次全盘扫描。
|
||||
|
||||
## 建议修复切片
|
||||
|
||||
1. 新增 `load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)`:
|
||||
- 先 `read_local_search_index(root_path)`。
|
||||
- 若 version/rootUri/workspaceId 匹配,直接返回 index。
|
||||
- 不匹配或缺失时才调用 `rebuild_local_search_index(...)`。
|
||||
2. 将 `query_local_search_index()` 改为调用 `load_or_rebuild_local_search_index(...)`。
|
||||
3. 保留显式 refresh API 调用 `refresh_local_search_index(...)`。
|
||||
4. 加单测:
|
||||
- 首次 query 会创建 index。
|
||||
- 第二次 query 不改写 index `built_at`。
|
||||
- refresh 后 query 能读到新内容。
|
||||
- 单文件 `refresh_local_search_index_for_path(...)` 后 query 命中更新内容。
|
||||
5. 后续中期再做后台 refresh、取消令牌和搜索 worker。
|
||||
|
||||
## 验收
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_index -- --nocapture` 通过。
|
||||
- 连续两次相同 query 时,第二次不会调用 full rebuild,不更新 `built_at`。
|
||||
- 侧边栏连续输入 5 个字符时,不出现 5 次全盘 rebuild。
|
||||
- 大目录 smoke 中搜索响应不阻塞 FileTree 展开。
|
||||
|
||||
## 修复记录
|
||||
|
||||
- 2026-05-27:新增 `load_or_rebuild_local_search_index(...)`,`query_local_search_index(...)` 优先读取已有匹配 version/rootUri/workspaceId 的索引;只有索引缺失、无效或 root/workspace 不匹配时才 full rebuild。
|
||||
- 2026-05-27:新增回归测试 `local_search_query_reads_existing_index_without_rebuilding`,验证 query 不会看到未 refresh 的磁盘新内容,增量 refresh 后才命中新内容。
|
||||
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_index -- --nocapture`,4 passed。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在本缺陷单实现全文搜索排序重构。
|
||||
- 不引入新的搜索 UI。
|
||||
- 不实现全局后台 index worker;这是后续性能设计项。
|
||||
@@ -0,0 +1,29 @@
|
||||
# FileTree 连续新建页面会展开上一个页面包目录
|
||||
|
||||
> 状态:done
|
||||
> Owner:05-editor-mainline / 04-tree-domain
|
||||
> 发现日期:2026-05-27
|
||||
> 来源:用户反馈 / Sidex Explorer 行为对照
|
||||
|
||||
## 现象
|
||||
|
||||
在 local folder 的“我的空间”中连续点击新建页面时,FileTree 会先短暂显示内部 `.md` 行,然后折叠;再次新建页面后,上一个页面包目录会被自动展开,当前页面包目录保持折叠,视觉上像是展开状态串到了旧页面。
|
||||
|
||||
## 根因
|
||||
|
||||
本地页面是 `页面名/页面名.md` 的同名目录包。新建完成后,前端 `selectSidebarFileTreeDocument(...)` 找不到折叠目录内部的 `.md` 行,会调用 `revealFileTreeResource(...)` 展开目录包并持久化展开状态。后端 `load_local_folder_file_tree_snapshot_with_reveal(...)` 也会把同名页面包目录当作普通祖先展开,导致下一次刷新时旧页面包恢复展开。
|
||||
|
||||
Sidex / VSCode Explorer 的对齐原则是:刷新 collapsed 节点只标记 stale;reveal 只展开必要祖先,不应把当前资源自身的内部实现目录当作用户展开状态持久化。
|
||||
|
||||
## 修复
|
||||
|
||||
- `local-md:` 路径解码改为完整 `~XX -> %XX -> decodeURIComponent`,避免中文路径无法匹配可见目录包行。
|
||||
- 前端选择 local markdown 时,若目标是同名页面包 `目录/目录.md` 且目录包行已可见,则直接选中/激活目录包行,不展开内部 `.md`。
|
||||
- 后端 FileTree reveal 对同名页面包只展开到页面包父级,不扫描并展开包内部 `.md`。
|
||||
- `task494-filetree-lazy-loading-dedup-smoke.js` 增加连续新建页面回归:上一个页面包不自动展开,当前页面包不展开内部 md,当前页面包行保持 active。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 先失败于上一个页面包 `expanded=true`,修复后通过。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --nocapture --test-threads=1`,46 passed。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_file_tree -- --nocapture --test-threads=1`,4 passed。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 星标 scoped 文件夹打开 Markdown 会重载并重建 FileTree
|
||||
|
||||
> 状态:done
|
||||
> Owner:05-editor-mainline / 03-rust-web
|
||||
> 发现日期:2026-05-27
|
||||
> 来源:用户反馈 / Sidex Explorer 行为对照
|
||||
|
||||
## 现象
|
||||
|
||||
从“星标置顶”点击本地文件夹 `design` 后,继续在 scoped FileTree 中打开任意 `.md` 文件,页面会明显卡顿,表现为侧栏重新构建、文件树重新加载。用户在 localhost 下也能感到卡顿,远程延迟时更明显。
|
||||
|
||||
## 根因
|
||||
|
||||
这次实际有三条慢链路叠在一起:
|
||||
|
||||
1. `navigateToDocument(...)` 在 `fileTreeScope` 存在时绕过 `window.__mnoteDocumentPaneRuntime.openPrimaryDocument(...)`,直接走 `window.location.assign(...)`。因此 scoped 文件树中打开 Markdown 不是 pane 内导航,而是整页 SSR reload。
|
||||
2. scoped 模式收到 root live snapshot 时,`renderLiveSidebarSnapshot(...)` 会调用 `refreshLocalFolderSidebarSnapshot()`,兜底重拉 scope 根 projection。
|
||||
3. tree live disabled / fallback polling 模式下,coarse `/api/tree/local-folder-watch` revision 变化会整刷 scoped FileTree;普通 Markdown mount 时,旧 `healLegacyOfficeAttachmentParagraphs()` 也会为了 legacy office 附件兼容拉 workspace root projection。
|
||||
|
||||
Sidex / VSCode Explorer 的对齐原则是:打开文件只切换 editor/input 和 selection,不重建 Explorer 数据源;Explorer 的 scoped/root view state 与文件打开解耦。
|
||||
|
||||
## 修复
|
||||
|
||||
- 允许 scoped FileTree 打开 Markdown 时继续走 `openPrimaryDocument(...)` pane 内导航。
|
||||
- URL 仍保留 `fileTreeScope`,因此刷新页面时仍能恢复 scoped Explorer。
|
||||
- scoped 模式收到非当前 scope 的 root snapshot 时只标记忽略,不再兜底重拉 scope 根。
|
||||
- fallback polling 发现 coarse root revision 变化时,对 scoped FileTree 只标记 `scope_stale`,不做即时 scope refresh。
|
||||
- legacy office 附件兼容逻辑先检查 DOM 中是否存在“单段 office 文件名”候选;普通 Markdown 不再触发 workspace root projection。
|
||||
- `task494-filetree-lazy-loading-dedup-smoke.js` 增加回归:点击 scoped 中已可见 Markdown 后,页面 JS marker 必须保留,且不应再次请求 scope 根 projection;root snapshot / coarse watch revision 也不得重拉 scope 根。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 先失败于 marker 被整页 reload 清空,修复后通过。
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 继续失败于 scoped root snapshot 和 coarse watch revision 重拉 scope 根,修复后通过。
|
||||
- 真实 `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md` 链路验证通过:`scopeRootRequests=0`、`workspaceRootRequests=0`、`childrenRequests=2`,请求只剩 `design/05-editor-mainline` 与 `design/05-editor-mainline/process` 两个必要 children projection。
|
||||
@@ -0,0 +1,238 @@
|
||||
# 4-49 Local File Operation 与 Watch Event 合同 v1
|
||||
|
||||
> 状态:process
|
||||
> Owner:04-tree-domain / 03-rust-web
|
||||
> 背景:Sidex / VSCode Explorer 对照显示,MNote local_folder 的 tree command、watch event、refresh/reveal 和 opened resource 生命周期之间仍缺少稳定合同。当前前端常靠 `relativePath` / `previousRelativePath` 反推刷新父级,watch event 又容易退回全量 resync,导致大目录、批量操作和已打开资源场景下出现重复刷新、状态漂移和错误恢复困难。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
local_folder 命令结果必须从“执行成功 + 少量路径字段”升级为“文件操作事件合同”:
|
||||
|
||||
1. 后端声明 `affectedParents`,前端不再猜 old/new/current parent。
|
||||
2. 命令结果携带 `revealTarget` / `selectTarget`,刷新后可稳定落焦。
|
||||
3. watch event 按 root 合并批次,输出 `changedPaths`、`affectedParents`、`eventKinds`。
|
||||
4. opened editor/resource 通过 participant 参与 rename/move/delete,处理 buffer rekey、stale、close 或 redirect。
|
||||
5. 批量 paste/delete/move 用 batch id 聚合执行结果和刷新点。
|
||||
|
||||
## 2. Sidex / VSCode 对照
|
||||
|
||||
### 2.1 可借鉴的行为
|
||||
|
||||
- ExplorerService 对 create/copy/move/delete 明确计算 old parent、new parent、nested parent,并刷新对应 item。
|
||||
- file changes 先进入延迟队列,批量判断是否影响已解析模型,再决定是否 refresh。
|
||||
- WorkingCopyFileService 在 move/delete 前后处理 dirty working copy,失败时发结构化 fail event。
|
||||
- BulkFileEdits 按批次执行,收集 per-item result 和 undo 操作。
|
||||
|
||||
### 2.2 不照搬的内容
|
||||
|
||||
- 不引入 VSCode undo/redo service 全模型。
|
||||
- 不引入完整 working copy service container。
|
||||
- 不改变 MNote 当前 local-first 文件系统事实源。
|
||||
- 不把 Convex command 分支重新变成本地默认路径。
|
||||
|
||||
## 3. 当前缺口
|
||||
|
||||
### 3.1 command result 刷新边界不足
|
||||
|
||||
当前 `local_folder` command 走 `execute_local_tree_command_with_sort` 后返回 `execution`,前端再用 `relativePath`、`previousRelativePath`、`parentRelativePath` 和 request body 推断刷新父级。这个推断对 rename、move、trash、restore、bulk paste 都容易遗漏 old parent 或 new parent。
|
||||
|
||||
### 3.2 批量操作没有一致提交点
|
||||
|
||||
paste / bulk delete 当前逐项 dispatch command,单项成功后可能立即 patch DOM。多个结果交错时,UI 会经历多次 refresh,失败项也缺少结构化状态。
|
||||
|
||||
### 3.3 watch event 粒度偏粗
|
||||
|
||||
watcher 可以观察 path 事件,但 live event route 仍容易 rebuild full snapshot / resync。前端 fallback 有 debounce,服务端 SSE/resync 路径缺同等批处理语义。
|
||||
|
||||
### 3.4 opened resource 生命周期缺 participant
|
||||
|
||||
rename/move/delete 对已打开 Markdown buffer、mindmap、office resource tab 的影响仍分散:
|
||||
|
||||
- Markdown buffer key 绑定 relative path。
|
||||
- resource tab identity 由前端字符串拼接。
|
||||
- delete/archive 后是否 close、mark stale、redirect,没有统一参与层。
|
||||
|
||||
## 4. 目标
|
||||
|
||||
- 所有 local_folder tree command 成功结果都能说明影响哪些 parent。
|
||||
- 前端 refresh scheduler 只消费 `affectedParents`,不解析 action-specific 路径字段。
|
||||
- 批量操作只在 batch 完成后统一 refresh/apply。
|
||||
- watcher event 按 root 合并,优先刷新 visible/loaded affected parents。
|
||||
- opened resource 在文件操作前后收到 participant 通知,能处理 dirty、rekey、stale、close。
|
||||
- 所有失败路径发结构化 error,包含 phase/rootUri/action/path/batchId。
|
||||
|
||||
## 5. 非目标
|
||||
|
||||
- 不实现全局 undo/redo。
|
||||
- 不重写 trash 物理存储。
|
||||
- 不改变现有 tree command URL。
|
||||
- 不把 resource tab group persistence 放进本设计;那属于 editor-mainline 后续。
|
||||
|
||||
## 6. 协议草案
|
||||
|
||||
### 6.1 Command result
|
||||
|
||||
建议 local command result 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "mnote.local_file_operation_result.v1",
|
||||
"operationId": "uuid-or-short-id",
|
||||
"batchId": "",
|
||||
"action": "rename",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"resource": {
|
||||
"documentId": "local-md:design/foo.md",
|
||||
"relativePath": "design/foo.md",
|
||||
"objectIdentity": "resource:file:file:///mnt/Data1T/mnote:design/foo.md"
|
||||
},
|
||||
"previousResource": {
|
||||
"documentId": "local-md:design/old.md",
|
||||
"relativePath": "design/old.md",
|
||||
"objectIdentity": "resource:file:file:///mnt/Data1T/mnote:design/old.md"
|
||||
},
|
||||
"affectedParents": [
|
||||
{ "relativePath": "design", "reason": "old-parent" },
|
||||
{ "relativePath": "design/new", "reason": "new-parent" }
|
||||
],
|
||||
"revealTarget": {
|
||||
"relativePath": "design/foo.md",
|
||||
"rowId": "local:file:design/foo.md"
|
||||
},
|
||||
"selectTarget": {
|
||||
"relativePath": "design/foo.md",
|
||||
"rowId": "local:file:design/foo.md"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- rename same-parent 也必须返回 affected parent。
|
||||
- move cross-parent 必须返回 old parent 和 new parent。
|
||||
- delete/archive 必须返回 old parent 和 next focus hint。
|
||||
- restore 必须返回 restored parent 和 reveal target。
|
||||
- `objectIdentity` 必须来自后端 canonical identity,不由前端拼接。
|
||||
|
||||
### 6.2 Batch result
|
||||
|
||||
批量操作返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "mnote.local_file_operation_batch_result.v1",
|
||||
"batchId": "batch-20260527-001",
|
||||
"action": "paste",
|
||||
"succeeded": 3,
|
||||
"failed": 1,
|
||||
"results": [],
|
||||
"affectedParents": [],
|
||||
"primaryRevealTarget": {}
|
||||
}
|
||||
```
|
||||
|
||||
前端规则:
|
||||
|
||||
- batch 未完成前不做全量 refresh。
|
||||
- per-item 可做乐观占位,但最终以 batch result 的 affected parents 为准。
|
||||
- 部分失败时展示结构化失败列表,不用 `alert` 拼字符串作为唯一反馈。
|
||||
|
||||
### 6.3 Watch event batch
|
||||
|
||||
服务端按 root 聚合短窗口:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "mnote.local_folder_watch_batch.v1",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"watchRevision": {},
|
||||
"changedPaths": [
|
||||
{ "relativePath": "design/foo.md", "kind": "modified" }
|
||||
],
|
||||
"affectedParents": [
|
||||
{ "relativePath": "design", "reason": "child-modified" }
|
||||
],
|
||||
"eventKinds": ["modified"],
|
||||
"fallbackResync": false
|
||||
}
|
||||
```
|
||||
|
||||
前端规则:
|
||||
|
||||
- visible/loaded affected parent:局部 refresh。
|
||||
- collapsed/loaded parent:标记 stale。
|
||||
- 未 loaded parent:只记录 dirty,不加载。
|
||||
- `fallbackResync=true`:刷新当前 scope parent,保留 expanded cache。
|
||||
|
||||
### 6.4 FileOperationParticipant
|
||||
|
||||
后端或 runtime 层新增参与点:
|
||||
|
||||
```text
|
||||
beforeLocalFileOperation
|
||||
afterLocalFileOperation
|
||||
onLocalFileOperationFailed
|
||||
```
|
||||
|
||||
参与者:
|
||||
|
||||
- `DocumentBufferStore`:rename/move 后 rekey,delete/archive 后 mark stale 或 close。
|
||||
- `ResourceTabRuntime`:resource identity 改变时更新 tab key,目标被删除时显示 stale/closed state。
|
||||
- `SearchIndex`:按 changed path 增量刷新,避免 query 时 rebuild。
|
||||
- `FileTreeRefreshScheduler`:收集 affected parents。
|
||||
|
||||
## 7. Checklist
|
||||
|
||||
- [x] 审查 `execute_local_tree_command_with_sort` 每个 action 当前返回字段。
|
||||
- [x] 定义 Rust `LocalFileOperationResult` / `AffectedParent` / `RevealTarget` 类型。
|
||||
- [x] 让 create/rename/move/archive/restore/purge 返回统一 `affectedParents`。
|
||||
- [x] 增加过渡期 `affectedParents` 合成器,根据后端已有 `previousRelativePath` / `relativePath` / `parentRelativePath` 声明 old/new/current parent。
|
||||
- [x] 修改前端 `fileTreeRefreshParentsForCommand`,优先消费 `result.affectedParents`。
|
||||
- [x] 为 legacy result 保留兼容 fallback,但加日志标记。
|
||||
- [x] 给 bulk paste/delete 增加 batch id,完成后统一 refresh。
|
||||
- [x] watch registry 或 event route 增加 root 级 debounce batch。
|
||||
- [x] `tree:resync` payload 缺失或 rebuild 失败时发结构化 error event。
|
||||
- [x] 加 `DocumentBufferStore` participant 设计切片:rename/move rekey,delete/archive mark stale。
|
||||
- [x] 补 smoke:批量删除、跨父级移动、rename opened markdown、watch batch 不全量折叠。
|
||||
|
||||
## 8. 验收
|
||||
|
||||
- rename same-parent 只刷新该 parent,目标 row 保持 selected/focused。
|
||||
- move cross-parent 刷新 old parent 和 new parent,不刷新 workspace root。
|
||||
- bulk delete 10 个文件只触发一次 batch refresh。
|
||||
- 外部连续创建/删除多个文件时,watch event 合并成一个 batch。
|
||||
- 已打开 Markdown rename 后,buffer key 和 tab identity 更新,不出现旧路径保存覆盖。
|
||||
- 已打开 resource 被 archive/delete 后,tab 进入 stale/closed state,不继续写旧路径。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_command -- --test-threads=1` 通过。
|
||||
- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 通过。
|
||||
- 新增批量/watch smoke 通过。
|
||||
|
||||
## 9. 风险
|
||||
|
||||
- 如果 command result 同时保留新旧字段,前端可能继续依赖旧推断。迁移期需要明确优先级:`affectedParents` 优先,旧字段只作 fallback。
|
||||
- participant 不能阻塞文件系统操作太久;dirty/conflict 处理需要有明确 timeout 或失败策略。
|
||||
- watch batch 不能吞掉不可恢复错误;需要把 watcher unhealthy / fallback polling 状态暴露给前端。
|
||||
|
||||
## 10. 执行记录
|
||||
|
||||
- 2026-05-27:在 `execute_local_tree_command_with_sort(...)` 出口新增 `add_local_tree_command_affected_parents(...)`,基于现有 command result 字段生成 `affectedParents`。
|
||||
- 2026-05-27:新增 `local_tree_command_move_returns_affected_parents`,验证跨父级 move 返回旧父级 root 和新父级 `docs`。
|
||||
- 2026-05-27:前端 `fileTreeRefreshParentsForCommand(...)` 新增 `addAffectedParentsFromCommandResult(...)`,优先消费 `result.affectedParents`;旧字段推断保留为 fallback。
|
||||
- 2026-05-27:新增 `sidebar_filetree_runtime_prefers_command_affected_parents` 契约测试。
|
||||
- 2026-05-27:完成 `execute_local_tree_command_with_sort(...)` action 返回字段审查;发现 delete/archive/purge、目录 move、legacy wrapper 是主要缺口。
|
||||
- 2026-05-27:新增 Rust `LocalFileOperationResult` / `AffectedParent` / `RevealTarget` / `LocalFileOperationResource` 类型,并在 local_folder 命令出口统一补 `schema= mnote.local_file_operation_result.v1`、`operationId`、`rootUri`、`resource/previousResource`、`affectedParents`、`revealTarget/selectTarget`。
|
||||
- 2026-05-27:补齐 delete/archive/purge 的 `originalRelativePath`、目录 move 的 `previousRelativePath`;前端 affectedParents fallback 支持 `result.execution.affectedParents`,legacy fallback 标记 `data-mnote-filetree-command-refresh-fallback`。
|
||||
- 2026-05-27:bulk paste/delete 增加 `batchId`,`tree:local-command` 按 batch 暂存 affected parents,`tree:local-command-batch-complete` 后统一 refresh;修复 bulk delete local asset 缺少 `removeFileTreeAssetRow` 依赖导致命令成功但 UI 记录失败的问题。
|
||||
- 2026-05-27:local folder tree live SSE 从逐事件 full resync 改为 120ms root 级 `watch_batch`,payload 输出 `changedPaths/affectedParents/eventKinds/fallbackResync=false`;重建失败/缺 payload 输出 `mnote.tree_live_error.v1` 结构化 error。
|
||||
- 2026-05-27:`DocumentBufferStore` 增加 local file operation participant:rename/move rekey opened Markdown buffer,delete/archive/purge mark deleted;tree command local_folder 分支执行后调用 participant。
|
||||
- 2026-05-27:扩展 smoke:`task494` 覆盖 watch batch 不折叠、opened Markdown rename buffer rekey、未加载深层 reveal;`task471` 覆盖 bulk delete batchId 与 batch 完成后统一 refresh。
|
||||
- 验证:
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_move_returns_affected_parents -- --nocapture`,通过。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture`,5 passed。
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_ -- --nocapture --test-threads=1`,11 passed。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_events -- --nocapture --test-threads=1`,7 passed。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_ -- --nocapture --test-threads=1`,13 passed。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture --test-threads=1`,10 passed。
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,覆盖 lazy dedup、scope generation、watch batch、reveal、opened rename buffer rekey。
|
||||
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:38291 node scripts/task471-local-folder-bulk-resource-trash-smoke.js`,通过,覆盖 bulk delete batchId 与 batch refresh。
|
||||
@@ -0,0 +1,212 @@
|
||||
# 5-33 FileTree ViewState 与请求代际收口 v1
|
||||
|
||||
> 状态:process
|
||||
> Owner:05-editor-mainline / 03-rust-web
|
||||
> 前置:`design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`
|
||||
> 背景:5-32 已把 FileTree lazy children、cache 和局部 refresh 推到可用层,但 Sidex / VSCode 对照显示,当前仍缺少统一 view-state、request generation、collapsed stale 和 reveal command。继续只做“展开恢复”会掩盖慢请求、重复请求和过期结果覆盖的问题。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
FileTree 下一步不应继续围绕 DOM 还原做补丁,而应收口为一个显式状态机:
|
||||
|
||||
1. `FileTreeViewState` 负责展开、选择、焦点、滚动、loaded/stale parent。
|
||||
2. `FileTreeRequestTracker` 负责同一 parent 的 in-flight 去重、request generation 和过期结果丢弃。
|
||||
3. `FileTreeRevealCommand` 负责从 active resource / command result 精确展开父链、选中目标、聚焦目标。
|
||||
|
||||
这三层只管理浏览器 view model,不改变 local-first 文件系统事实源,也不新增第二套树真相。数据真相仍来自 Rust projection / kernel command result。
|
||||
|
||||
## 2. Sidex / VSCode 对照
|
||||
|
||||
### 2.1 可借鉴的行为
|
||||
|
||||
- `AsyncDataTree.refreshNode` 会复用相交 subtree refresh promise,避免同一节点重复 refresh。
|
||||
- collapsed node 被刷新时只标记 `stale`,不立即解析 children。
|
||||
- `doGetChildren` 对同一 node 的 children promise 去重。
|
||||
- 慢请求超过阈值后进入 slow/loading state,完成后清理。
|
||||
- Explorer `selectResource` 负责展开 parent chain、reveal、focus、selection,而不是依赖 DOM 模糊匹配。
|
||||
|
||||
### 2.2 不照搬的内容
|
||||
|
||||
- 不引入 VSCode workbench service container。
|
||||
- 不照搬完整 AsyncDataTree 实现。
|
||||
- 不把前端 view state 升级成系统事实源。
|
||||
- 不把 PageTree / ResourceTree 强行塞进同一个前端树组件。
|
||||
|
||||
## 3. MNote 当前缺口
|
||||
|
||||
### 3.1 状态分散
|
||||
|
||||
当前 FileTree 展开状态至少分散在:
|
||||
|
||||
- DOM `aria-expanded` / `data-filetree-children-loaded`
|
||||
- `fileTreeExpandedRelativePaths`
|
||||
- `sessionStorage`
|
||||
- `fileTreeState.loadedParents/loadingParents/dirtyParents`
|
||||
- 后端 projection 的 expanded/default scope
|
||||
|
||||
这会导致 refresh 替换 DOM 后,逻辑展开状态与实际 children DOM 不一致。
|
||||
|
||||
### 3.2 缺少 request generation
|
||||
|
||||
当前已能用 `loadingParents` 复用 in-flight promise,但缺少 generation:
|
||||
|
||||
- A 请求慢,B 请求快时,A 完成后仍可能 patch 旧 rows。
|
||||
- scope/rootUri 切换后,旧请求返回仍可能误写当前 DOM。
|
||||
- watcher refresh 与用户展开请求并发时,无法判定谁是最新。
|
||||
|
||||
### 3.3 collapsed stale 语义不清
|
||||
|
||||
当前未展开且未 loaded 的 parent 在局部 patch 时直接跳过,但没有显式 `stale` 标记。结果是:
|
||||
|
||||
- 用户展开时不知道是否应该强制拉最新 children。
|
||||
- watch/command 只知道 parent dirty,不知道 dirty 是“已展开需后台更新”还是“折叠后下次展开更新”。
|
||||
|
||||
### 3.4 reveal/focus 仍靠 DOM 匹配
|
||||
|
||||
resource tab 激活、命令执行后,FileTree 的 active row/reveal 仍依赖 DOM 查询、path includes 或当前可见节点。lazy children 下,目标父链可能还没加载,必须由 tree runtime command 负责展开父链。
|
||||
|
||||
## 4. 目标
|
||||
|
||||
- 同一 `rootUri + scope + parentRelativePath` 在任一时刻最多一个 active children 请求。
|
||||
- 任一 fetch/render 结果必须带 generation;过期结果不得 patch DOM 或覆盖 cache。
|
||||
- collapsed parent 收到 refresh 时只标记 stale,不立即加载 children。
|
||||
- expanded parent 收到 refresh 时保留当前 children,后台刷新并 patch。
|
||||
- active resource / command result 可通过 canonical row id 或 relative path 精确 reveal。
|
||||
- render/patch 后 selection/focus 基于 logical id 复投影,不依赖“当前 DOM 还在”。
|
||||
|
||||
## 5. 非目标
|
||||
|
||||
- 不实现虚拟滚动;大目录 viewport virtualization 另行设计。
|
||||
- 不重写 Rust projection 协议。
|
||||
- 不改变 FileTree 排序、DND、右键命令语义。
|
||||
- 不修搜索索引 rebuild 问题;该问题独立记录在 `bugs/04-tree-domain/process/2026-05-27-local-search-query-rebuilds-index.md`。
|
||||
|
||||
## 6. 设计
|
||||
|
||||
### 6.1 FileTreeViewState
|
||||
|
||||
建议在 `sidebar-tree-live-apply-runtime.js` 中把 FileTree 状态收敛为:
|
||||
|
||||
```js
|
||||
const fileTreeViewState = {
|
||||
rootUri: '',
|
||||
scope: '',
|
||||
expandedParents: new Set(),
|
||||
selectedRowIds: new Set(),
|
||||
focusedRowId: '',
|
||||
activeRowId: '',
|
||||
scrollTop: 0,
|
||||
loadedParents: new Set(),
|
||||
staleParents: new Set(),
|
||||
dirtyParents: new Set(),
|
||||
rowsByParent: new Map(),
|
||||
revisionByParent: new Map(),
|
||||
};
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- key 必须包含 `rootUri`,避免多个 workspace/local root 串状态。
|
||||
- `selectedRowIds/focusedRowId` 存 logical row id,不存 DOM element。
|
||||
- `expandedParents` 只表达用户视图意图;children 是否已加载由 `loadedParents/staleParents` 表达。
|
||||
- scope/rootUri 变化时必须 reset 或 hydrate 对应 namespace 的状态。
|
||||
|
||||
### 6.2 FileTreeRequestTracker
|
||||
|
||||
建议新增轻量 request tracker:
|
||||
|
||||
```js
|
||||
const fileTreeRequests = {
|
||||
generation: 0,
|
||||
loadingByParent: new Map(),
|
||||
latestGenerationByParent: new Map(),
|
||||
};
|
||||
```
|
||||
|
||||
请求流程:
|
||||
|
||||
1. `beginFileTreeRequest(parent)` 递增 generation,并记录 parent 最新 generation。
|
||||
2. 如果 parent 已有 in-flight promise,直接返回该 promise。
|
||||
3. fetch 结果回来后检查 `isLatestFileTreeRequest(parent, generation)`。
|
||||
4. 若不是最新,返回 `{ stale: true }`,不得写 cache,不得 patch DOM。
|
||||
5. scope/rootUri 切换时递增全局 generation,旧请求全部自然失效。
|
||||
|
||||
### 6.3 stale parent 规则
|
||||
|
||||
- collapsed + dirty:`staleParents.add(parent)`,不拉 children。
|
||||
- expanded + dirty:保留旧 rows,后台 refresh parent。
|
||||
- expand parent:
|
||||
- 若 loaded 且不 stale,直接渲染 cache。
|
||||
- 若 stale 或未 loaded,进入 loading state 并 fetch children。
|
||||
- fetch 失败:
|
||||
- 不清旧 rows。
|
||||
- parent 标记 `stale`。
|
||||
- row 展示可恢复错误状态,允许重试。
|
||||
|
||||
### 6.4 reveal command
|
||||
|
||||
新增内部 command:
|
||||
|
||||
```js
|
||||
revealFileTreeResource({
|
||||
rootUri,
|
||||
relativePath,
|
||||
rowId,
|
||||
select: true,
|
||||
focus: true,
|
||||
scroll: true,
|
||||
})
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
1. 校验 rootUri 是否当前 root。
|
||||
2. 计算 parent chain,例如 `design/03-rust-web/process`。
|
||||
3. 逐层调用 `getFileTreeChildren(parent)`,确保父链已加载。
|
||||
4. 渲染每层 children 后设置 expanded。
|
||||
5. 使用 rowId 或 relativePath 精确选中目标。
|
||||
6. 同步 `selectedRowIds/focusedRowId/activeRowId`。
|
||||
|
||||
## 7. Checklist
|
||||
|
||||
- [x] 审查 `sidebar-tree-live-apply-runtime.js` 中所有读写展开、选择、focus 的入口,列出迁移点。
|
||||
- [x] 新增 `fileTreeViewState`,把 `fileTreeExpandedRelativePaths` 迁入 view-state namespace。
|
||||
- [x] 新增 request generation,覆盖 `getFileTreeChildren` 的 children 请求结果写入。
|
||||
- [x] 让 scope/rootUri 切换使旧 generation 失效。
|
||||
- [x] 把 collapsed + dirty 改成 stale parent,不立即加载 children。
|
||||
- [x] render/patch 后按 logical state 复投影 selection/focus。
|
||||
- [x] 新增 `revealFileTreeResource` 内部 command,替代 path includes DOM 匹配。
|
||||
- [x] 补 JS/Rust 字符串契约测试,检查 generation/stale 关键函数存在。
|
||||
- [x] 扩展 `scripts/task494-filetree-lazy-loading-dedup-smoke.js`,覆盖慢请求后切 scope、快速展开/收起、watch refresh 后不折叠。
|
||||
|
||||
## 8. 验收
|
||||
|
||||
- 快速连续点击同一目录 5 次,只产生 1 个 in-flight children 请求。
|
||||
- 人为延迟第一个 children 请求,再触发 refresh,旧请求返回后不会覆盖新 rows。
|
||||
- collapsed parent 收到 watcher/command dirty 后不发 children 请求;下一次展开才请求。
|
||||
- active resource tab 能 reveal 未加载的深层 filetree row。
|
||||
- 创建/重命名/删除后 selection/focus 不因局部 patch 丢失。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --test-threads=1` 通过。
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过。
|
||||
|
||||
## 9. 风险
|
||||
|
||||
- 如果把 view-state 写成新的事实源,会和 Rust projection 冲突。必须限定为浏览器视图状态。
|
||||
- 如果 generation 只绑定 parent,不绑定 rootUri/scope,仍会出现跨 workspace 旧请求污染。
|
||||
- 如果 reveal command 隐式加载过多 parent chain,深层大目录仍可能慢;需要只加载 path chain,不加载 siblings 的 children。
|
||||
|
||||
## 10. 执行记录
|
||||
|
||||
- 2026-05-27:在 `sidebar-tree-live-apply-runtime.js` 中新增 `staleParents`、`requestGeneration`、`latestGenerationByParent`、`beginFileTreeRequest(...)`、`isLatestFileTreeRequest(...)`。
|
||||
- 2026-05-27:`getFileTreeChildren(...)` 写入 cache/DOM 前检查 generation;scope/rootUri 切换时递增 generation 并清理 latest generation map,使旧请求自然失效。
|
||||
- 2026-05-27:`refreshFileTreeParent(...)` 对 collapsed non-root parent 只标记 stale,不立即加载 children;下一次展开绕过旧 cache 拉取最新 children。
|
||||
- 2026-05-27:新增 `sidebar_filetree_runtime_discards_stale_generation_results` 契约测试。
|
||||
- 2026-05-27:审查 FileTree 展开/selection/focus 入口后,确认 `fileTreeViewState` 已承接 expanded/loaded/stale/dirty/selection/focus/active;`fileTreeExpandedRelativePaths` 保留为 `fileTreeViewState.expandedParents` 的兼容 alias。
|
||||
- 2026-05-27:`renderFileProjection(...)`、`patchFileTreeParentChildren(...)` 以及 lazy children render 后按 logical view-state 复投影 selection/focus/active,避免局部 patch 后丢失选中态。
|
||||
- 2026-05-27:`revealFileTreeResource(...)` 接入 `selectSidebarFileTreeDocument(...)` 的 DOM miss fallback;active document 变更可按 local markdown documentId 解出 relative path,逐层加载未展开父链并选中目标 row。
|
||||
- 2026-05-27:扩展 `task494-filetree-lazy-loading-dedup-smoke.js`,覆盖 watch batch 局部刷新不折叠、未加载深层 resource reveal、opened Markdown rename 后 BufferStore rekey。
|
||||
- 验证:
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture`,5 passed。
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,`targetChildrenRequests=1`,`cachedExpandMs=33`。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture --test-threads=1`,10 passed。
|
||||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=32`。
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# 5-30 Sidebar 星标置顶快捷入口与 Scoped Explorer 设计 v1
|
||||
|
||||
> 状态:process
|
||||
> Owner:05-editor-mainline / 03-rust-web / control-plane
|
||||
> 背景:星标置顶需要对齐 Wolai 的 Sidebar 顶部快速访问体验,但 MNote 还需要支持把高频本地文件夹固定成 Explorer scoped root,避免每次进入大 workspace 时加载完整根目录。
|
||||
|
||||
## 目标
|
||||
|
||||
星标置顶不是文件或文件夹自身的属性,而是用户在 Sidebar 固定的一组快捷入口。
|
||||
|
||||
- 当前页面 `.md` 可以加入星标置顶,点击后直接打开该页面。
|
||||
- FileTree 文件夹右键可以加入星标置顶,点击后切到 Explorer,并把该文件夹作为临时 scoped root 显示。
|
||||
- 星标置顶按登录用户持久化到 SQLite control-plane;同一用户在不同设备登录时应看到同一组快捷入口。
|
||||
- 顶栏“全网公开”不能默认误导显示;没有真实公开分享状态时隐藏。
|
||||
- scoped Explorer 必须避免扫描完整大 workspace,例如 `/mnt/Data1T/mnote` 下只打开 `design/` 时只加载 `design/` 子树。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把星标写入 Markdown frontmatter。
|
||||
- 不写入 `.mnote/starred-pins.json` 作为长期真相。
|
||||
- 不把文件夹变成 Resource Tree 的业务属性。
|
||||
- 不在前端 `localStorage` 中持久化最终星标真相;前端最多做加载态/乐观态缓存。
|
||||
- 不在本阶段实现跨设备文件内容同步;本设计只定义用户快捷入口在 control-plane 的一致性。
|
||||
|
||||
## 用户模型
|
||||
|
||||
星标置顶等价于“我的 Sidebar 快捷入口”:
|
||||
|
||||
- 页面快捷入口:`kind=page`,目标是某个 workspace 下的页面 identity。
|
||||
- 文件夹快捷入口:`kind=folder`,目标是某个 workspace 下的相对路径 scope。
|
||||
- 快捷入口属于用户,不属于文件夹,不影响其他用户。
|
||||
- 如果某台设备没有对应 workspace 或没有该 folder 相对路径,星标项保留但显示失效态,允许移除或重新绑定。
|
||||
|
||||
## SQLite 数据模型
|
||||
|
||||
新增 control-plane 表:`sidebar_shortcuts`。
|
||||
|
||||
建议字段:
|
||||
|
||||
```sql
|
||||
CREATE TABLE sidebar_shortcuts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
root_uri TEXT,
|
||||
kind TEXT NOT NULL, -- page | folder
|
||||
source_kind TEXT NOT NULL DEFAULT 'local_folder',
|
||||
target_id TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
document_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, workspace_id, kind, target_id)
|
||||
);
|
||||
```
|
||||
|
||||
`target_id` 规范:
|
||||
|
||||
- 页面:优先 `document_id`,例如 `local-md:design%2FREADME.md`。
|
||||
- 文件夹:`local-dir:<encoded relative path>` 或稳定的 `local:folder:<relative path>` 归一化值。
|
||||
|
||||
`relative_path` 用于 scoped Explorer 与跨设备路径映射,必须保存 workspace 内相对路径,不保存绝对本机路径作为主键。
|
||||
|
||||
`root_uri` 必须作为显式字段保存和返回,而不是只塞在 `metadata_json` 中。原因:
|
||||
|
||||
- 星标文件夹点击必须直接消费保存时的 workspace root 上下文,不能从 `workspace_id` 字符串反推本机路径。
|
||||
- local workspace id 是派生标识,不是路径编码协议;用它反推 `file:///...` 会把 UI fallback 变成事实源。
|
||||
- `metadata_json.rootUri` 只作为兼容冗余字段,不能作为唯一来源。
|
||||
|
||||
长期跨设备映射仍以 `workspace_id + relative_path` 为主;当前设备打开本地 workspace 时必须把实际 `root_uri` 一起写入 shortcut,缺失时应显示失效/需重绑定状态,而不是自动猜测路径。
|
||||
|
||||
## API / 命令面
|
||||
|
||||
新增最小 API:
|
||||
|
||||
- `GET /api/sidebar/shortcuts?workspaceId=...`
|
||||
- 返回当前用户当前 workspace 的星标快捷入口。
|
||||
- `POST /api/sidebar/shortcuts`
|
||||
- upsert 快捷入口。
|
||||
- body 包含 `workspaceId/rootUri/kind/sourceKind/targetId/relativePath/documentId/title/icon`。
|
||||
- `DELETE /api/sidebar/shortcuts/:id`
|
||||
- 移除当前用户自己的快捷入口。
|
||||
|
||||
实现边界:
|
||||
|
||||
- API 只操作 control-plane 用户快捷入口表。
|
||||
- API 不写本地文件,不改 Markdown,不改 `.mnote` 元数据。
|
||||
- 写操作要求真实登录用户;dev fallback 不应静默创建跨用户快捷入口。
|
||||
- 每次写入追加 audit:`sidebar.shortcut.created` / `sidebar.shortcut.removed`。
|
||||
- 后续需要实时同步时,通过 `outbox_events` 或现有 WS delta 广播 `sidebar.shortcut.*`。
|
||||
|
||||
## Projection 与 Sidebar 渲染
|
||||
|
||||
`WorkspaceShellProjection.starred_items` 需要从 `documents[].is_starred` 迁移为 control-plane `sidebar_shortcuts` 投影。
|
||||
|
||||
投影项扩展:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `kind: page | folder`
|
||||
- `href`
|
||||
- `workspaceId`
|
||||
- `sourceKind`
|
||||
- `targetId`
|
||||
- `relativePath`
|
||||
- `rootUri`
|
||||
- `documentId`
|
||||
- `active`
|
||||
- `unavailable`
|
||||
|
||||
页面快捷入口:
|
||||
|
||||
- `href` 指向当前页面打开 URL。
|
||||
- 点击后走现有 document open 主链。
|
||||
|
||||
文件夹快捷入口:
|
||||
|
||||
- `href` 可为当前页面 URL 加 query,例如 `?treeView=filetree&filetreeScope=design`。
|
||||
- 更推荐 runtime 拦截点击,调用 `openScopedFileTree(relativePath)`,避免不必要的页面跳转。
|
||||
- 点击后切换到 Explorer tab,并显示 scoped Explorer。
|
||||
|
||||
## Scoped Explorer 行为
|
||||
|
||||
文件夹星标点击后:
|
||||
|
||||
1. Sidebar 切到 Explorer tab。
|
||||
2. Explorer 标题显示 scoped 状态,例如 `Explorer / design`。
|
||||
3. FileTree root 只渲染该文件夹下的 children。
|
||||
4. 子目录继续按需加载。
|
||||
5. 提供“返回工作区根”入口;点击后恢复 root Explorer。
|
||||
|
||||
数据读取:
|
||||
|
||||
- scoped root 为 `relativePath=design` 时,调用现有 `load_local_folder_file_tree_children_snapshot(rootUri, "design")` 或对应 API。
|
||||
- 不调用完整 root snapshot。
|
||||
- 不预加载 scoped root 之外的兄弟目录。
|
||||
|
||||
URL / 状态:
|
||||
|
||||
- scoped 状态可以写入 URL query:`treeView=filetree&filetreeScope=design`。
|
||||
- 刷新页面后恢复 scoped Explorer。
|
||||
- 切回“我的页面”不清除 scope;点击“返回工作区根”才清除 scope。
|
||||
|
||||
## 顶栏星标与公开状态
|
||||
|
||||
顶栏星标按钮:
|
||||
|
||||
- 未星标:空心星;tooltip 对齐 Wolai:“点击 加入星标置顶,可在左侧边栏顶部快速访问”。
|
||||
- 已星标:高亮星;tooltip “取消星标置顶”。
|
||||
- 点击当前页面星标只操作 `sidebar_shortcuts`。
|
||||
|
||||
顶栏公开状态:
|
||||
|
||||
- `wolai-public-pill` 只有当当前页面存在 active share link / public permission 时显示。
|
||||
- 没有公开状态时隐藏,不显示“全网公开”。
|
||||
- 公开分享弹窗仍属于 share link / permission 领域,不与星标快捷入口混用。
|
||||
|
||||
## FileTree 右键菜单
|
||||
|
||||
文件夹行增加:
|
||||
|
||||
- 未星标:`加入星标置顶`
|
||||
- 已星标:`取消星标置顶`
|
||||
|
||||
启用条件:
|
||||
|
||||
- `sourceKind == local_folder`
|
||||
- `rowKind == folder | directory | index`
|
||||
- 当前用户对 workspace 有至少 read access;写入快捷入口只需要用户偏好写权限,不要求文件系统写权限。
|
||||
|
||||
不支持项:
|
||||
|
||||
- 普通附件文件暂不加入星标置顶。
|
||||
- 多选批量星标暂不做。
|
||||
|
||||
## 失效与重命名处理
|
||||
|
||||
MVP 处理:
|
||||
|
||||
- 文件夹被删除:快捷入口保留,显示失效态;点击提示“文件夹不存在”,允许移除。
|
||||
- 文件夹重命名:如果重命名由 MNote 发起,可同步更新对应 `relative_path/title/target_id`;如果外部重命名,先显示失效态。
|
||||
- 页面被删除:页面快捷入口显示失效态,允许移除。
|
||||
|
||||
长期可选:
|
||||
|
||||
- 引入 local object stable id 后,星标目标可由 stable object id 跟随重命名。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 无公开分享状态时,顶栏不显示“全网公开”。
|
||||
- 当前页面点击星标后,Sidebar 星标置顶出现该页面;再次点击移除。
|
||||
- 右键 `design/` 文件夹可加入星标置顶。
|
||||
- 点击 `design/` 星标后,切到 Explorer scoped root,只加载并显示 `design/` 下内容,不显示 workspace root 的其他大目录。
|
||||
- 刷新页面后,`filetreeScope=design` 能恢复 scoped Explorer。
|
||||
- 同一用户重新登录后,星标快捷入口仍存在。
|
||||
- 不同用户登录同一 workspace 时,星标列表互不污染。
|
||||
- 文件夹不存在时,星标项显示失效态并可移除。
|
||||
|
||||
## 测试计划
|
||||
|
||||
Rust/control-plane:
|
||||
|
||||
- migration 创建 `sidebar_shortcuts`。
|
||||
- store 可 upsert/list/delete 当前用户快捷入口。
|
||||
- unique 约束防止同一用户同一 workspace 重复固定同一目标。
|
||||
- 不同用户同一目标互不影响。
|
||||
|
||||
mnote-web:
|
||||
|
||||
- API 鉴权:只能读写当前用户自己的快捷入口。
|
||||
- `WorkspaceShellProjection` 从 shortcuts 投影 starred rows。
|
||||
- local folder scoped snapshot 不扫描完整 root。
|
||||
|
||||
浏览器 smoke:
|
||||
|
||||
- 顶栏公开 pill 隐藏断言。
|
||||
- 顶栏星标页面 add/remove。
|
||||
- FileTree 文件夹右键 add/remove。
|
||||
- 点击星标文件夹进入 scoped Explorer,并断言 root siblings 不渲染。
|
||||
- 刷新恢复 scoped Explorer。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. control-plane migration / model / store:新增 `sidebar_shortcuts`。
|
||||
2. mnote-web API:list/upsert/delete shortcuts。
|
||||
3. workspace shell projection:星标区改读 shortcuts,并保留历史 `is_starred` fixture 兼容测试。
|
||||
4. 顶栏:星标按钮接 API;公开 pill 改为条件显示。
|
||||
5. FileTree 右键菜单:文件夹加入/取消星标置顶。
|
||||
6. Scoped Explorer runtime:支持 `filetreeScope`,只加载 scoped children,并提供返回 root。
|
||||
7. smoke 与 Rust 测试补齐。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 2026-05-26:星标置顶确定为用户级 Sidebar 快捷入口,不是文件夹/页面自身属性。
|
||||
- 2026-05-26:星标快捷入口必须写入 SQLite control-plane,满足同一用户跨设备一致显示。
|
||||
- 2026-05-26:文件夹星标点击进入 scoped Explorer,避免大 workspace root 扫描。
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
# 5-31 页面设置 SQLite 状态收口设计 v1
|
||||
|
||||
> 状态:process
|
||||
>
|
||||
> Owner:05-editor-mainline / control-plane / mnote-web
|
||||
>
|
||||
> 更新时间:2026-05-26
|
||||
>
|
||||
> 背景:`隐藏本地 Markdown 文件标题` 在普通 local folder 中暴露出多状态覆盖问题。进一步复核后,问题不只属于该字段:当前页面设置同时散落在 `.mnote/page-options.json`、Page Aggregate runtime cache、浏览器 `localStorage` 和前端临时状态里,导致同一用户不同浏览器之间不能稳定同步,也让 local folder watcher / stale aggregate 更容易把 UI 状态恢复成旧值。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把页面设置与阅读偏好统一收口到 Rust SQLite control-plane,形成跨浏览器一致的状态来源。
|
||||
|
||||
- 用户在一个浏览器中修改页面显示设置后,另一个浏览器刷新或重新打开同一用户空间时能读到同一状态。
|
||||
- 普通 local folder 不再把页面设置长期写入项目目录下的 `.mnote/page-options.json`。
|
||||
- `hideTitleHeader` 不再按单个 Markdown 文件保存,改为按用户和 source family 保存:
|
||||
- `my_space`:我的空间 / 托管 local workspace
|
||||
- `external_local_folder`:其它本地文件夹
|
||||
- Page Aggregate 继续作为前端读取页面运行态的统一入口,但其 `layout.pageOptions` 应是 SQLite 偏好合并后的有效值,而不是前端本地缓存或 sidecar 文件的直接回声。
|
||||
- 前端页面设置 runtime 不再直接管理多个长期状态来源,只负责乐观更新、失败回滚和重新拉取。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 本阶段不改变 Markdown 正文真相;本地 `.md` 仍是正文事实源。
|
||||
- 不把 UI 偏好写入 Markdown frontmatter。
|
||||
- 不用 `.mnote/page-options.json` 作为长期写入目标;它只保留 legacy read fallback。
|
||||
- 不把所有字段无脑变成全局开关。SQLite 是存储层,字段仍需按语义选择作用域。
|
||||
- 不在本阶段实现多人协作共享页面设置。当前目标是“同一用户跨浏览器同步”。
|
||||
|
||||
## 3. 当前状态盘点
|
||||
|
||||
### 3.1 前端页面设置字段
|
||||
|
||||
`sidebar-page-settings-runtime.js` 当前默认字段:
|
||||
|
||||
| 字段 | 当前用途 | 当前长期状态来源 | 问题 |
|
||||
| --- | --- | --- | --- |
|
||||
| `wideLayout` | 主内容列宽 | Page Aggregate / `.mnote/page-options.json` / compat command | local folder 写入项目目录;跨浏览器不稳定 |
|
||||
| `smallText` | 正文小字体 | Page Aggregate / `.mnote/page-options.json` | 同上 |
|
||||
| `layoutDensity` | 段落密度 | Page Aggregate / `.mnote/page-options.json` | 同上 |
|
||||
| `pageFont` | 页面字体 | Page Aggregate runtime 字段 | 当前更像用户阅读偏好,不应是文件属性 |
|
||||
| `showHeadingNumbers` | 标题编号 | `localStorage` 强制覆盖 | 只能单浏览器生效 |
|
||||
| `hideTitleHeader` | 本地 Markdown 页头标题显隐 | Page Aggregate / `.mnote/page-options.json` / 前端竞态保护 | 不应按文件保存,容易被 stale aggregate 覆盖 |
|
||||
| `showToc` | 目录面板 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
|
||||
| `showStructure` | 结构显示 | PageOptions 字段,当前未完整接线 | 语义更像用户视图偏好 |
|
||||
| `showWordCount` | 字数统计 | PageOptions 字段 | 语义更像用户视图偏好 |
|
||||
| `collapseBacklinks` | 折叠反链 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
|
||||
| `hideChildPages` | 隐藏子页面 | PageOptions 字段,当前待接线 | 语义可能是用户视图偏好 |
|
||||
| `showBlockRefCount` | 块引用数字 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
|
||||
| `protectEditing` | 编辑保护 | PageOptions 字段,当前待接线 | 语义可能是页面策略,不应直接归入用户偏好 |
|
||||
| `embedDefaultBlockId` | 嵌入默认块 | PageOptions 字段 | 语义更像页面/嵌入属性,不是纯 UI 偏好 |
|
||||
|
||||
### 3.2 后端与协议现状
|
||||
|
||||
- `core-protocol::PageOptions` 当前把页面布局、阅读偏好、编辑策略和嵌入属性都放在一个结构里。
|
||||
- `mnote-web` 的 local folder 写链是 `POST /api/documents/options -> update_local_page_options -> .mnote/page-options.json`。
|
||||
- local folder aggregate 构建会读取 `.mnote/page-options.json`,没有值时用“托管工作区显示标题、普通本地文件夹隐藏标题”的默认规则。
|
||||
- 非 local folder 仍走 `page.layout.updateOptions` compat command。
|
||||
- `showHeadingNumbers` 已经被前端单独放进 `localStorage`,说明当前系统事实上承认存在“用户级 UI 偏好”,但没有 SQLite-backed 实现。
|
||||
|
||||
## 4. 核心判断
|
||||
|
||||
页面设置需要拆成两类概念:
|
||||
|
||||
1. **用户视图偏好**:同一用户在不同浏览器应该一致,但不应改变文件、页面正文或其他用户视图。
|
||||
2. **页面共享属性 / 策略**:属于页面或工作区事实,可能影响所有用户,必须与权限、分享和协作边界一起设计。
|
||||
|
||||
当前暴露问题的字段基本属于第 1 类。它们应进入 SQLite control-plane 的用户偏好层,而不是继续写 local folder sidecar。
|
||||
|
||||
## 5. 推荐方案
|
||||
|
||||
### 5.1 新增 SQLite 用户偏好表
|
||||
|
||||
新增 control-plane migration,例如 `004-user-ui-preferences.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_ui_preferences (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
source_kind TEXT,
|
||||
scope_kind TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_ui_preferences_lookup
|
||||
ON user_ui_preferences(user_id, workspace_id, source_kind, scope_kind, scope_id);
|
||||
```
|
||||
|
||||
`scope_kind` 建议取值:
|
||||
|
||||
| scope_kind | scope_id 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `global` | `default` | 同用户全局偏好 |
|
||||
| `source_family` | `my_space` / `external_local_folder` | 同一类数据源的默认显示策略 |
|
||||
| `workspace` | `ws_xxx` 或 `local:_mnt_Data1T_mnote` | 单工作区偏好 |
|
||||
| `document` | `local-md:README.md` | 保留能力,MVP 不用于 `hideTitleHeader` |
|
||||
|
||||
### 5.2 字段归属
|
||||
|
||||
MVP 先迁移当前已接通且影响 UI 的字段:
|
||||
|
||||
| 字段 | SQLite scope | 理由 |
|
||||
| --- | --- | --- |
|
||||
| `hideTitleHeader` | `source_family` | 用户明确希望只区分“我的空间”和“其它本地文件夹”,不按文件保存 |
|
||||
| `showHeadingNumbers` | `global` 或 `workspace` | 当前 localStorage 字段,迁 SQLite 后才能跨浏览器同步 |
|
||||
| `wideLayout` | `workspace`,可 fallback 到 `global` | 阅读布局偏好,通常用户对某类工作区有稳定习惯 |
|
||||
| `smallText` | `workspace`,可 fallback 到 `global` | 同上 |
|
||||
| `layoutDensity` | `workspace`,可 fallback 到 `global` | 同上 |
|
||||
| `pageFont` | `workspace`,可 fallback 到 `global` | 字体是用户阅读偏好,不是 Markdown 文件属性 |
|
||||
| `showToc` | `workspace`,可 fallback 到 `global` | 目录面板属于用户视图 |
|
||||
| `collapseBacklinks` | `workspace`,可 fallback 到 `global` | 反链面板展示偏好 |
|
||||
| `showWordCount` | `global` | 统计展示偏好 |
|
||||
| `hideChildPages` | `workspace` | 页面树/子页面展示偏好 |
|
||||
| `showBlockRefCount` | `workspace` | 块引用计数展示偏好 |
|
||||
|
||||
暂不迁入用户偏好的字段:
|
||||
|
||||
| 字段 | 处理 |
|
||||
| --- | --- |
|
||||
| `protectEditing` | 暂停作为可写 UI 偏好;后续进入页面策略 / 权限设计 |
|
||||
| `embedDefaultBlockId` | 保留在 Page Aggregate / 页面属性合同中;不作为用户偏好 |
|
||||
|
||||
### 5.3 默认值与合并顺序
|
||||
|
||||
页面打开时,后端合并出 effective options:
|
||||
|
||||
```text
|
||||
协议默认值
|
||||
-> 用户 global 偏好
|
||||
-> 用户 source_family 偏好
|
||||
-> 用户 workspace 偏好
|
||||
-> 用户 document 偏好(MVP 只保留能力,不主动使用)
|
||||
-> legacy .mnote/page-options.json fallback(只在 SQLite 完全没有对应值时生效)
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
- SQLite 值永远优先于 `.mnote/page-options.json`。
|
||||
- `.mnote/page-options.json` 只读,不再由页面设置写链更新。
|
||||
- `hideTitleHeader` 的默认值仍保持当前体验:
|
||||
- `my_space` 默认 `false`
|
||||
- `external_local_folder` 默认 `true`
|
||||
- 一旦用户修改 `hideTitleHeader`,写入对应 `source_family` scope,之后所有同类页面使用该值。
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
新增最小 API:
|
||||
|
||||
### 6.1 读取有效页面偏好
|
||||
|
||||
`GET /api/ui/preferences/effective`
|
||||
|
||||
Query:
|
||||
|
||||
- `workspaceId`
|
||||
- `sourceKind`
|
||||
- `rootUri`
|
||||
- `documentId`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": {
|
||||
"scope": {
|
||||
"sourceFamily": "external_local_folder",
|
||||
"workspaceId": "local:_mnt_Data1T_mnote",
|
||||
"documentId": "local-md:README.md"
|
||||
},
|
||||
"pageOptions": {
|
||||
"wideLayout": false,
|
||||
"smallText": false,
|
||||
"layoutDensity": "normal",
|
||||
"pageFont": "default",
|
||||
"showHeadingNumbers": false,
|
||||
"hideTitleHeader": true
|
||||
},
|
||||
"sources": {
|
||||
"hideTitleHeader": "source_family",
|
||||
"showHeadingNumbers": "global"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 写入偏好
|
||||
|
||||
`PUT /api/ui/preferences`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceId": "local:_mnt_Data1T_mnote",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"documentId": "local-md:README.md",
|
||||
"updates": {
|
||||
"hideTitleHeader": false,
|
||||
"layoutDensity": "compact"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
后端根据字段决定 scope,不信任前端直接指定 scope:
|
||||
|
||||
- `hideTitleHeader` 写入 `source_family`
|
||||
- `showHeadingNumbers` 写入 `global`
|
||||
- `wideLayout/smallText/layoutDensity/pageFont/showToc/...` 写入 `workspace`
|
||||
|
||||
返回同一份 effective preferences,前端用返回值刷新当前 shell。
|
||||
|
||||
## 7. Page Aggregate 合同调整
|
||||
|
||||
Page Aggregate 继续输出:
|
||||
|
||||
- `layout.pageOptions`
|
||||
- `layout_options`
|
||||
|
||||
但含义调整为:
|
||||
|
||||
> `layout.pageOptions` 是后端合并后的 effective UI options。
|
||||
|
||||
为避免后续再混淆,建议追加调试字段:
|
||||
|
||||
```json
|
||||
"layout": {
|
||||
"pageOptions": {},
|
||||
"pageOptionsSource": {
|
||||
"wideLayout": "workspace",
|
||||
"hideTitleHeader": "source_family",
|
||||
"showHeadingNumbers": "global"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
协议层可分两步:
|
||||
|
||||
1. MVP:保持 `PageOptions` 结构不变,只改变读取与写入来源。
|
||||
2. 后续:把 `PageOptions` 拆为 `effectiveUiOptions` 与 `pagePolicyOptions`,再处理 `protectEditing/embedDefaultBlockId`。
|
||||
|
||||
## 8. 前端 runtime 调整
|
||||
|
||||
`sidebar-page-settings-runtime.js` 的长期职责应简化为:
|
||||
|
||||
- 从 `__MNOTE_PAGE_AGGREGATE__` 读取 effective options 初始化 UI。
|
||||
- 用户修改设置时调用 `PUT /api/ui/preferences`。
|
||||
- 用返回的 effective options 更新当前 shell 和 aggregate script。
|
||||
- 不再写 `localStorage` 保存 `showHeadingNumbers`。
|
||||
- 不再把 local folder options 写入 `/api/documents/options`。
|
||||
- 不再需要 `data-mnote-page-options-local-write-at` 这类 stale aggregate 竞态补丁作为长期机制。
|
||||
|
||||
`/api/documents/options` 的定位调整:
|
||||
|
||||
- 保留为 cloud/compat 或未来页面策略命令入口。
|
||||
- local folder 的 UI 偏好写入不再走这里。
|
||||
- 如果 body 中包含 `protectEditing/embedDefaultBlockId` 这类非用户偏好字段,再进入页面策略链路;MVP 可以保持 disabled。
|
||||
|
||||
## 9. local folder sidecar 兼容策略
|
||||
|
||||
`.mnote/page-options.json` 保留读兼容:
|
||||
|
||||
- 读取时只作为 fallback。
|
||||
- 写入时不再更新。
|
||||
- 如果同一字段在 SQLite 已存在,以 SQLite 为准。
|
||||
- 不自动删除用户已有 sidecar 文件,避免破坏历史证据。
|
||||
|
||||
建议后续提供显式迁移工具:
|
||||
|
||||
```bash
|
||||
node scripts/migrate-local-page-options-to-sqlite.js --root /path/to/root --actor mnote-e2e
|
||||
```
|
||||
|
||||
迁移工具只在用户明确执行时运行。
|
||||
|
||||
## 10. 实施切片
|
||||
|
||||
### Phase A:SQLite 偏好底座
|
||||
|
||||
- 新增 `user_ui_preferences` migration。
|
||||
- control-plane model/store/sqlite 增加 upsert/list/effective merge 方法。
|
||||
- 增加单测覆盖 scope 优先级与 user 隔离。
|
||||
|
||||
验收:
|
||||
|
||||
- 同一用户同一 key 在不同 scope 下可合并。
|
||||
- 不同用户互不影响。
|
||||
- migration idempotent。
|
||||
|
||||
### Phase B:mnote-web API 与 Page Aggregate 合并
|
||||
|
||||
- 新增 `/api/ui/preferences/effective` 与 `PUT /api/ui/preferences`。
|
||||
- local folder aggregate 构建时合并 SQLite preferences。
|
||||
- `hideTitleHeader` 默认策略改为 source_family 默认值。
|
||||
- `.mnote/page-options.json` 只作为 fallback。
|
||||
|
||||
验收:
|
||||
|
||||
- 普通 local folder 默认隐藏标题。
|
||||
- 我的空间默认显示标题。
|
||||
- SQLite 写入后优先于 sidecar。
|
||||
|
||||
### Phase C:前端页面设置写链迁移
|
||||
|
||||
- `sidebar-page-settings-runtime.js` 改写设置保存 API。
|
||||
- 删除 `showHeadingNumbers` 的 localStorage 长期真相。
|
||||
- `persistPageOptionsPatch` 用后端返回 effective options 更新 UI。
|
||||
- 保留失败回滚。
|
||||
|
||||
验收:
|
||||
|
||||
- 一个浏览器修改设置,另一个浏览器刷新后同步。
|
||||
- 切换文档、local folder watcher refresh 后不恢复旧状态。
|
||||
- `hideTitleHeader` 对同类 source 生效,不按单个文件漂移。
|
||||
|
||||
### Phase D:兼容与清理
|
||||
|
||||
- local folder `/api/documents/options` 不再写 `.mnote/page-options.json`,或只保留 legacy endpoint 并标记 deprecated。
|
||||
- 移除上一轮针对 per-file sidecar 竞态的长期补丁,只保留必要的 aggregate script 同步事件。
|
||||
- 更新 `hermes_tools::page` 中页面 options 写入语义,避免 AI tool 写 UI 偏好到 sidecar。
|
||||
|
||||
验收:
|
||||
|
||||
- 不再产生新的 `.mnote/page-options.json` 写入。
|
||||
- 旧 sidecar 存在时不破坏读取。
|
||||
- CodeGraph 可清楚定位 UI preference API、control-plane store、Page Aggregate merge 三个边界。
|
||||
|
||||
## 11. Smoke / 测试计划
|
||||
|
||||
新增或更新 smoke:
|
||||
|
||||
| Smoke | 断言 |
|
||||
| --- | --- |
|
||||
| `task493-page-settings-sqlite-preferences-smoke.js` | 页面设置写入 SQLite,刷新后保持 |
|
||||
| `task494-page-settings-cross-browser-sync-smoke.js` | 同一用户两个 browser context 同步页面设置 |
|
||||
| `task495-local-title-source-family-smoke.js` | `my_space` 与 `external_local_folder` 的 `hideTitleHeader` 独立 |
|
||||
| `task496-page-options-sidecar-readonly-compat-smoke.js` | 旧 `.mnote/page-options.json` 只读 fallback,不再被写入 |
|
||||
|
||||
Rust 单测:
|
||||
|
||||
- control-plane migration / store merge
|
||||
- mnote-web preference API route
|
||||
- local folder aggregate merge preference
|
||||
- Page Aggregate effective options source 标记
|
||||
|
||||
当前实施记录(2026-05-26):
|
||||
|
||||
- 已完成 Phase A/B/C 最小闭环:`user_ui_preferences` migration/store、`/api/ui/preferences`、Page Aggregate SQLite 合并、前端页面设置写链迁移。
|
||||
- 已完成 Phase D 的旧写链收口:local folder `/api/documents/options` 与 `mnote.page.update_options` 均不再写 `.mnote/page-options.json`。
|
||||
- 已新增 `task493-page-settings-sqlite-preferences-smoke.js`,覆盖两个 browser context 的同用户同步、不同用户隔离和不生成 sidecar。
|
||||
- 已更新旧 smoke 的页面设置写链断言,从 `/api/documents/options` UI 主链改为 `/api/ui/preferences`;`/api/documents/options` 仅保留兼容入口。
|
||||
|
||||
## 12. 风险与边界
|
||||
|
||||
- 如果把所有字段都做成 user preference,未来协作页面的共享设置会缺位。因此必须保留“用户偏好”和“页面策略”两类边界。
|
||||
- 如果继续让 Page Aggregate 暴露字段但不标注来源,后续仍可能误以为它们是页面事实源。建议尽快追加 `pageOptionsSource`。
|
||||
- 对 local folder 的旧 sidecar 只能读兼容,不能自动删除。删除或批量迁移需要用户显式确认。
|
||||
- 当前工作区已有 sidebar shortcuts / control-plane 未提交改动;实施时需要先确认是否基于那些改动继续,避免 migration 编号冲突。
|
||||
|
||||
## 13. 推荐下一步
|
||||
|
||||
先做 Phase A + B 的最小闭环:
|
||||
|
||||
1. 在当前 control-plane migration 链上新增 `user_ui_preferences`。
|
||||
2. 增加 effective preference merge API。
|
||||
3. 只迁移 `hideTitleHeader` 与 `showHeadingNumbers` 两个问题最明确的字段。
|
||||
4. 通过跨浏览器 smoke 验证后,再迁移 `wideLayout/smallText/layoutDensity/pageFont`。
|
||||
|
||||
这样可以先解决“状态恢复原样”和“不同浏览器不同步”两个根问题,同时控制改动范围。
|
||||
@@ -0,0 +1,363 @@
|
||||
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
|
||||
|
||||
> 状态:process
|
||||
> Owner:05-editor-mainline / 03-rust-web
|
||||
> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。
|
||||
|
||||
## 结论
|
||||
|
||||
当前问题不能只用“加载后恢复展开”解决。正确模型应对齐 VS Code/Sidex Explorer 与 MUI X lazy tree 的三个原则:
|
||||
|
||||
1. 同一个 `rootUri + parentRelativePath` 的 children 请求必须有 in-flight 去重和结果缓存。
|
||||
2. refresh/watch/local command 只刷新受影响 parent,不整棵替换已展开子树。
|
||||
3. 后端 projection 应一次扫描生成 children、`childrenCount/expandable` 和 revision,避免为了 child_count/watch revision 重复读目录。
|
||||
|
||||
## 2026-05-27 页面树 / 文件树打开性能纠偏方案
|
||||
|
||||
### 根因判断
|
||||
|
||||
本轮纠偏不再把“隐藏页面树”作为性能策略。页面树必须继续可见,且星标 scoped folder 打开后必须用本地 Markdown 页面树投影替换旧“我的空间”页面树。
|
||||
|
||||
当前卡顿的核心是:`页面树可见` 被实现成 `打开文件树 / 星标文件夹 / 文档前同步完成全量页面树 projection`。即使在 localhost,这也会把全盘 Markdown 扫描、页面树 DOM 替换和 editor mount 串到同一条打开链路上。
|
||||
|
||||
已确认的高风险点:
|
||||
|
||||
- `gateway.rs` 本地 root 入口已经读取一次 `load_local_folder_page_tree_snapshot(root_uri)`,后续 `render_local_sidebar_tree_html(...)` 仍可能再次触发同一 root 的页面树扫描。
|
||||
- `sidebar-tree-runtime.js` 星标文件夹打开必须并行请求 `/api/tree/projections/sidebar` 与 scoped `/api/tree/projections/file`;scoped FileTree 应先可交互,慢页面树只负责替换旧页面树。
|
||||
- `sidebar-tree-live-apply-runtime.js` 不应把 `renderSidebarSnapshot(...)` 写成 page tree 与 file tree 同步整块应用;二者需要独立 generation / stale guard。
|
||||
- Page Aggregate / editor runtime 已经与 runtime asset 并行,但如果 workspace shell 先同步重建页面树,editor mount 仍会被前置 shell 构建拖慢。
|
||||
|
||||
### Sidex / Context7 对齐原则
|
||||
|
||||
- Sidex / VSCode Explorer 使用 `AsyncDataTree` 按需 resolve children,慢 children 进入 slow/loading state,不把整个树同步塞进 DOM。
|
||||
- Explorer refresh 按受影响 parent、view state 和 request promise 管理,而不是 root projection 返回后整棵替换。
|
||||
- 浏览器侧参考 MDN:用 `PerformanceObserver` 记录 long task / long animation frame;用 `AbortController` 或 request generation 丢弃过期 fetch;避免大段同步 DOM insertion 阻塞主线程。
|
||||
- Tokio 侧 `spawn_blocking` 只能避免阻塞 async worker,不能消除全盘扫描成本;本地 projection 仍需要减少触发次数、限并发、缓存和局部化。
|
||||
|
||||
### 新策略
|
||||
|
||||
1. 页面树保留,但从打开关键路径降级为“可见的异步 projection”:
|
||||
- SSR 可以输出已有/轻量页面树壳。
|
||||
- scoped filetree 先应用并可点击。
|
||||
- page tree projection 完成后只替换 `#sidebar-tree-root`。
|
||||
- 旧“我的空间”页面树必须在本地 rootUri 确认后标记 stale/loading,不能继续被当成当前页面树。
|
||||
|
||||
2. FileTree / PageTree projection 拆开应用:
|
||||
- `applyFileProjection(...)` 和 `applyPageProjection(...)` 独立 generation。
|
||||
- scope/rootUri 切换时旧请求自然失效。
|
||||
- 页面树慢请求返回后,不得覆盖新的 scoped filetree state。
|
||||
|
||||
3. gateway SSR 去重:
|
||||
- 同一请求内复用已加载的 `page_tree_snapshot`。
|
||||
- 禁止 `load_local_folder_page_tree_snapshot` 和 `render_local_sidebar_tree_html` 对同一 root 重复扫描。
|
||||
- Page Aggregate/editor mount 不等待 sidebar root 重建。
|
||||
|
||||
4. 本地 PageTree 进入 lazy / cached projection:
|
||||
- 短期:按 `rootUri + watchRevision` 做 page tree snapshot cache / stale-while-revalidate。
|
||||
- 中期:PageTree 改为顶层 + active/reveal path projection,不再每次全量递归 Markdown。
|
||||
|
||||
5. 观测与验收必须落到真实浏览器:
|
||||
- 记录 projection request start/end、filetree interactive time、Page Aggregate TTFB、editor ready、longtask count。
|
||||
- localhost 打开目标 Markdown 时,不能因页面树 projection 重建而阻塞 editor surface。
|
||||
|
||||
### 待执行 Checklist
|
||||
|
||||
- [ ] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。
|
||||
- [ ] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`,记录 sidebar/filetree/page aggregate/editor ready 时序和 long task。
|
||||
- [ ] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。
|
||||
- [ ] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file projection 可以先应用,sidebar projection 后补页面树。
|
||||
- [ ] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。
|
||||
- [ ] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。
|
||||
- [ ] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。
|
||||
- [ ] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。
|
||||
- [ ] 验证:`node scripts/task492-sidebar-starred-shortcuts-smoke.js`、`node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、新增 task497、相关 Rust filters、`git diff --check`、`codegraph sync/status`。
|
||||
|
||||
## Context7 参考结论
|
||||
|
||||
Context7 查询结果:
|
||||
|
||||
- VS Code `DataTree` / `AsyncDataTree` 面向“数据懒发现”的树,例如文件浏览器。数据源用 `hasChildren(element)` 和 `getChildren(element)` 异步解析 children,tree 组件负责并发 refresh 管理和 slow loading 状态。
|
||||
- MUI X `RichTreeViewPro` lazy loading 要求 `dataSource.getChildrenCount()` 与 `dataSource.getTreeItems()`,并建议通过 `dataSourceCache` 缓存 children 数据;大数据场景还需要 request throttle,避免用户快速操作时打爆服务端。
|
||||
|
||||
映射到 MNote:
|
||||
|
||||
- `parentRelativePath` 就是 tree item identity。
|
||||
- `child_count/expandable` 就是 `getChildrenCount` 的最小版本。
|
||||
- `fileTreeLazyChildrenCache` 不能只是渲染后的临时兜底,应该成为 FileTreeDataSource cache。
|
||||
- `refreshLocalFolderSidebarSnapshot()` 不能绕过 cache 直接 `tree.innerHTML = ...`。
|
||||
|
||||
## CodeGraph / Sidex 对照
|
||||
|
||||
已用 CodeGraph 索引确认:
|
||||
|
||||
- Sidex 项目索引存在:`reference-code/sidex-main`,约 2904 files / 124438 nodes。
|
||||
- MNote 项目索引存在:`/mnt/Data1T/mnote`,约 382 files / 9520 nodes。
|
||||
|
||||
Sidex 关键链路:
|
||||
|
||||
- `reference-code/sidex-main/src/vs/base/browser/ui/tree/asyncDataTree.ts`
|
||||
- `refreshNode` 会检查 `subTreeRefreshPromises`,相交 refresh 会复用已有 promise。
|
||||
- collapsed node refresh 时只标记 `stale` 并清 children,不立即解析子树。
|
||||
- `doGetChildren` 用 `refreshPromises` 对同一 node 的 `getChildren` 去重。
|
||||
- 异步 children 超过 800ms 会进入 slow state,由 UI 显示 loading。
|
||||
- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/views/explorerView.ts`
|
||||
- `refresh(recursive, item)` 刷新指定 item,默认不是整棵 DOM 替换。
|
||||
- `setTreeInput` 读取/保存 view state,不把展开状态绑在一次 DOM 结果上。
|
||||
- `selectResource` 显式展开 parent chain,用于 reveal,而不是预加载整棵树。
|
||||
- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/explorerService.ts`
|
||||
- 文件 create/copy/move 事件刷新 parent item。
|
||||
- parent 未 resolved 时先 resolve parent,再 refresh parent。
|
||||
|
||||
MNote 当前链路:
|
||||
|
||||
- `rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js`
|
||||
- `renderFileProjection()` 会直接替换 `#sidebar-file-tree-root.innerHTML`,已展开子树 DOM 会丢失。
|
||||
- `refreshLocalFolderSidebarSnapshot()` 同时拉 sidebar projection 和 file projection,随后整树渲染。
|
||||
- `loadFileTreeChildren()` 只用 row 上的 `data-filetree-children-loading` 做加载中状态;如果 refresh 替换 DOM,loading 标记会丢。
|
||||
- `fileTreeLazyChildrenCache` 只在请求成功后写入,缺少 in-flight promise 去重。
|
||||
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
|
||||
- `load_local_folder_file_tree_scope_snapshot()` 调 `scan_directory()`。
|
||||
- `scan_directory()` 读取当前目录后,对每个子目录调用 `has_visible_child()` 再读一次该子目录。
|
||||
- 之后又调用 `local_folder_watch_revision_for_directory()` 再读一次当前目录生成 revision。
|
||||
- `rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs`
|
||||
- SSR filetree row 目前缺少 `data-local-relative-path`,JS renderer 有该属性;这导致首屏 SSR 与后续 JS patch 数据属性不一致。
|
||||
|
||||
## 实测证据
|
||||
|
||||
在当前 3000 登录态下,直接访问:
|
||||
|
||||
```text
|
||||
/?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&rootUri=file:///mnt/Data1T/mnote&treeView=filetree&fileTreeScope=design
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- SSR 首屏无 `/api/tree/projections/file` 网络请求。
|
||||
- `#sidebar-file-tree-root` 显示 `design/` 子项 17 行。
|
||||
- `documentElement` 未设置 `data-mnote-filetree-scope`,但 URL 有 `fileTreeScope=design`。
|
||||
- 首屏 row 的 `data-local-relative-path` 为空,rowId 为 `local:folder:design/03-rust-web`。
|
||||
|
||||
展开 `design/03-rust-web`:
|
||||
|
||||
```text
|
||||
GET /api/tree/projections/file/children?...&parentRelativePath=design/03-rust-web
|
||||
status=200
|
||||
time=14ms
|
||||
size=3119 bytes
|
||||
children=done/process/reference
|
||||
```
|
||||
|
||||
这说明本机此子目录单次后端请求并不慢。用户感知慢更可能来自:
|
||||
|
||||
- 某些更大的目录触发多次 children 请求或 watch refresh。
|
||||
- refresh 整树替换导致已加载 children 丢失,看起来像“加载完又折叠”。
|
||||
- 首屏 SSR / JS renderer 属性不一致,导致 lazy loader 依赖 rowId fallback。
|
||||
- watcher/local command refresh 与用户展开请求并发,造成同一路径重复请求或请求完成后 DOM 被替换。
|
||||
|
||||
## 目标
|
||||
|
||||
- 打开 scoped folder 后只显示该 scope 的直接 children,不加载 workspace root siblings。
|
||||
- 展开某个目录时,同一 `rootUri + parentRelativePath` 在 in-flight 期间最多一个网络请求。
|
||||
- 请求完成后不因 watch refresh / command refresh 折叠已展开子树。
|
||||
- 第一次展开慢目录时显示 loading 状态;第二次展开命中 cache,不发网络请求。
|
||||
- 创建/重命名/删除只刷新受影响 parent,不重刷整棵 filetree。
|
||||
- SSR 与 JS 渲染输出同一套 row data attributes。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在本阶段实现虚拟滚动。
|
||||
- 不引入 React/MUI TreeView;只吸收 lazy tree 的数据源/cache模型。
|
||||
- 不重写 Rust kernel 树协议。
|
||||
- 不改变 local-first 文件系统事实源。
|
||||
|
||||
## 新 FileTreeDataSource 模型
|
||||
|
||||
在 `sidebar-tree-live-apply-runtime.js` 中收口为浏览器端 FileTreeDataSource:
|
||||
|
||||
```js
|
||||
const fileTreeState = {
|
||||
rootUri: '',
|
||||
scope: '',
|
||||
rowsByParent: new Map(), // key: parentRelativePath -> rows[]
|
||||
loadedParents: new Set(), // loaded parentRelativePath
|
||||
loadingParents: new Map(), // parentRelativePath -> Promise
|
||||
expandedParents: new Set(), // parentRelativePath
|
||||
dirtyParents: new Set(), // parentRelativePath
|
||||
revisionByParent: new Map(), // parentRelativePath -> watchRevision
|
||||
};
|
||||
```
|
||||
|
||||
Key 规则:
|
||||
|
||||
```js
|
||||
function fileTreeParentKey(rootUri, parentRelativePath) {
|
||||
return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
|
||||
}
|
||||
```
|
||||
|
||||
请求规则:
|
||||
|
||||
```js
|
||||
async function getFileTreeChildren(parentRelativePath) {
|
||||
const key = fileTreeParentKey(currentRootUri(), parentRelativePath);
|
||||
if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key)) {
|
||||
return fileTreeState.rowsByParent.get(key) || [];
|
||||
}
|
||||
if (fileTreeState.loadingParents.has(key)) {
|
||||
return fileTreeState.loadingParents.get(key);
|
||||
}
|
||||
const promise = fetchFileTreeChildren(parentRelativePath)
|
||||
.then(rows => {
|
||||
fileTreeState.rowsByParent.set(key, rows);
|
||||
fileTreeState.loadedParents.add(key);
|
||||
fileTreeState.dirtyParents.delete(key);
|
||||
return rows;
|
||||
})
|
||||
.finally(() => fileTreeState.loadingParents.delete(key));
|
||||
fileTreeState.loadingParents.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
```
|
||||
|
||||
渲染规则:
|
||||
|
||||
- `renderFileProjection()` 不再无条件替换整棵 `innerHTML`。
|
||||
- 新 projection 只更新当前 scope/root parent 的 `rowsByParent`。
|
||||
- 对每个 expanded parent,渲染时优先使用 `rowsByParent` 已缓存 children。
|
||||
- 如果 parent 被标记 dirty,保留展开 UI 和旧 children,后台刷新该 parent;刷新完成后 patch 该 parent children。
|
||||
- collapsed parent 不加载 children,只保留 `expandable`。
|
||||
|
||||
## 后端 projection 优化
|
||||
|
||||
当前后端一次 children projection 至少可能读:
|
||||
|
||||
1. 当前目录 `read_sorted_entries()`。
|
||||
2. 每个子目录一次 `has_visible_child()`。
|
||||
3. 当前目录一次 `local_folder_watch_revision_for_directory()`。
|
||||
|
||||
改为单 pass helper:
|
||||
|
||||
```rust
|
||||
struct LocalFolderScanResult {
|
||||
rows: Vec<LocalFolderRow>,
|
||||
watch_revision: LocalFolderWatchRevision,
|
||||
}
|
||||
|
||||
fn scan_directory_shallow_with_revision(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
parent_node_id: Option<String>,
|
||||
depth: u32,
|
||||
root_source_uri: &str,
|
||||
workspace_id: &str,
|
||||
metadata: &LocalFolderMetadata,
|
||||
) -> Result<LocalFolderScanResult, WebError>
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 只读取当前目录一次。
|
||||
- 对子目录只做 `has_visible_child_fast()`,且最多读到第一个可见 child 后停止。
|
||||
- 生成 `watchRevision` 时复用当前目录 entries,不再二次 `read_sorted_entries()`。
|
||||
- 返回 projection 时明确 `parentRelativePath`、`watchRevision.entryCount`、`items.length`。
|
||||
|
||||
## Watch / refresh 策略
|
||||
|
||||
替代当前“watch root 变更后刷新 sidebar + filetree root/scope”的粗刷新:
|
||||
|
||||
- `local-folder-watch` 仍可作为 fallback,但 filetree 侧只标记 dirty,不立即整树替换。
|
||||
- 如果当前 scope root 变更,刷新 scope parent。
|
||||
- 如果已展开 parent 变更,刷新对应 parent。
|
||||
- 如果无法定位 parent,刷新当前 scope parent,但保留 expanded parent cache。
|
||||
- local command 成功后用 command result 的 `parentRelativePath` 定位刷新 parent;没有 parent 信息时才降级刷新 scope parent。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
### Rust 单测
|
||||
|
||||
- `local_folder_file_tree_children_snapshot_scans_parent_once`
|
||||
- 构造含多个目录的 temp root。
|
||||
- 调用 `load_local_folder_file_tree_children_snapshot(rootUri, "design")`。
|
||||
- 断言 `projection.parentRelativePath == "design"`。
|
||||
- 断言 `items` 只包含直接 children。
|
||||
- 断言 `watchRevision.entryCount == items.len()`。
|
||||
- `filetree_ssr_rows_include_local_relative_path`
|
||||
- 构造 `FileTreeRenderRow`。
|
||||
- 断言 HTML 包含 `data-local-relative-path="design/03-rust-web"`。
|
||||
|
||||
### JS runtime 字符串/契约测试
|
||||
|
||||
在 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中增加断言:
|
||||
|
||||
- 包含 `loadingParents` / `loadedParents` / `rowsByParent`。
|
||||
- `loadFileTreeChildren` 在 fetch 前检查 `loadingParents.has(key)`。
|
||||
- `renderFileProjection` 不包含无条件 `tree.innerHTML =`。
|
||||
- `refreshLocalFolderSidebarSnapshot` 不直接绕过 FileTreeDataSource。
|
||||
|
||||
### 浏览器 smoke
|
||||
|
||||
新增脚本:
|
||||
|
||||
```text
|
||||
scripts/task494-filetree-lazy-loading-dedup-smoke.js
|
||||
```
|
||||
|
||||
断言:
|
||||
|
||||
- 登录测试账号。
|
||||
- 访问 `fileTreeScope=design`。
|
||||
- 展开 `design/03-rust-web`。
|
||||
- 记录 `/api/tree/projections/file/children?...parentRelativePath=design/03-rust-web` 请求数为 1。
|
||||
- 收起再展开同一目录,children 请求数仍为 1。
|
||||
- 触发一次 `tree:resync` 或等待 watcher tick 后,该 row 仍 `aria-expanded=true`,children 仍存在。
|
||||
- 第二次展开耗时低于 50ms 或无网络请求。
|
||||
- 从星标 scoped 文件夹打开已可见 Markdown 时,必须走 pane 内导航;页面 JS marker 不丢失,且不重新请求 scope 根 projection。
|
||||
- scoped 模式收到 root snapshot 或 coarse local-folder-watch revision 时,不得兜底重拉 scope 根 projection。
|
||||
- 普通 Markdown mount 时,不得为了 legacy office 附件兼容重拉 workspace root projection。
|
||||
|
||||
### 2026-05-27 scoped 文件打开补充验收
|
||||
|
||||
- RED:`task494-filetree-lazy-loading-dedup-smoke.js` 在 scoped `design` 文件树点击 `design/Overview.md` 后失败于 JS marker 被清空,确认当前实现发生整页 reload。
|
||||
- RED:同一 smoke 继续失败于 root live snapshot / coarse local-folder-watch revision 触发 scope 根 projection。
|
||||
- GREEN:`navigateToDocument(...)` 允许 `fileTreeScope` 场景继续使用 `openPrimaryDocument(...)`;scoped root snapshot 改为忽略非 scope projection;fallback polling 只标记 scoped stale;legacy office 兼容先检查候选段落再取索引。
|
||||
- 真实路径验证:进入 `fileTreeScope=design`,展开 `design/05-editor-mainline` 与 `design/05-editor-mainline/process`,打开 `5-32-filetree-lazy-loading-sidex-alignment-v1.md` 后页面 marker 保留,`scopeRootRequests=0`、`workspaceRootRequests=0`、`childrenRequests=2`。
|
||||
|
||||
### 性能目标
|
||||
|
||||
- localhost 下 `design/03-rust-web` children 请求保持单次 `<100ms`。
|
||||
- 1000 个 direct children 的目录 projection 目标 `<300ms`,不得因每个子目录重复深扫导致线性倍增到秒级。
|
||||
- 同一路径 5 次快速点击最多 1 个 in-flight 请求。
|
||||
|
||||
## 实施 Checklist
|
||||
|
||||
- [x] 修改 `FileTreeRenderRow` 增加 `relative_path` 字段,并在 SSR renderer 输出 `data-local-relative-path`。
|
||||
- [x] `collect_filetree_render_rows` 从 projection item 读取 `relativePath`。
|
||||
- [x] 把 `fileTreeLazyChildrenCache` 升级为 `fileTreeState.rowsByParent/loadingParents/loadedParents/dirtyParents`。
|
||||
- [x] `loadFileTreeChildren` 改为 `getFileTreeChildren(parentRelativePath)`,复用 in-flight promise。
|
||||
- [x] `renderFileProjection` 改为更新 datasource state,再按 visible state patch DOM,不整树替换 expanded subtree。
|
||||
- [x] `refreshLocalFolderSidebarSnapshot` 只刷新当前 scope parent,且不清除 expanded children。
|
||||
- [x] local command 成功后按 parent 刷新;无 parent 才刷新 scope。
|
||||
- [x] 后端新增 `scan_directory_shallow_with_revision`,减少 children projection 重复读目录。
|
||||
- [x] 新增 Rust 单测和 browser smoke。
|
||||
- [x] 重跑 `cargo test -p mnote-web --manifest-path rust/Cargo.toml`、`cargo test -p control-plane --manifest-path rust/Cargo.toml`、`cargo fmt --check --all --manifest-path rust/Cargo.toml`。
|
||||
- [x] 跑 `codegraph sync .` 并确认 status;已有无关 pending 只记录不清理。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 2026-05-26:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,收起后再次展开无 children 重复请求,cache 展开耗时 `33ms`。
|
||||
- 2026-05-26:`cargo test -p mnote-web --manifest-path rust/Cargo.toml` 通过,`567 passed`。
|
||||
- 2026-05-26:`cargo test -p control-plane --manifest-path rust/Cargo.toml` 通过,`23 passed`。
|
||||
- 2026-05-26:`cargo fmt --check --all --manifest-path rust/Cargo.toml` 通过。
|
||||
- 2026-05-26:`codegraph sync .` 已执行;`codegraph status .` 仍报告 `Pending Changes: Added: 5 files`,属于当前工作树既有未提交/新增文件状态,本轮不清理。
|
||||
|
||||
## 与 5-30 的关系
|
||||
|
||||
`5-30` 解决“星标 shortcut 是什么、保存在哪里、点击进入哪个 scope”。本设计解决“进入 scope 后 FileTree 如何懒加载、缓存、刷新、避免重复请求”。
|
||||
|
||||
`5-30` 中的 `rootUri` 必须成为 shortcut 显式字段;缺失时星标文件夹应失效提示,不允许从 `workspaceId` 反推路径。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 2026-05-26:`展开恢复`只能作为兼容保护,不是性能修复。
|
||||
- 2026-05-26:FileTree 懒加载主模型按 DataSource/cache/in-flight 去重设计,不按 DOM 替换后再恢复。
|
||||
- 2026-05-26:后端 children projection 需要单 pass shallow scan,并把 watch revision 与 rows 同时产出。
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS sidebar_shortcuts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL DEFAULT 'local_folder',
|
||||
target_id TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
document_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, workspace_id, kind, target_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sidebar_shortcuts_user_workspace
|
||||
ON sidebar_shortcuts(user_id, workspace_id, status, sort_order);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 004-user-ui-preferences.sql
|
||||
-- User-scoped UI preferences for cross-browser page setting sync.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_ui_preferences (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
source_kind TEXT NOT NULL DEFAULT '',
|
||||
scope_kind TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_ui_preferences_lookup
|
||||
ON user_ui_preferences(user_id, workspace_id, source_kind, scope_kind, scope_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE sidebar_shortcuts ADD COLUMN root_uri TEXT;
|
||||
|
||||
UPDATE sidebar_shortcuts
|
||||
SET root_uri = COALESCE(
|
||||
NULLIF(json_extract(metadata_json, '$.rootUri'), ''),
|
||||
NULLIF(json_extract(metadata_json, '$.root_uri'), '')
|
||||
)
|
||||
WHERE root_uri IS NULL OR root_uri = '';
|
||||
@@ -14,6 +14,18 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"v2-ai-runtime-store",
|
||||
include_str!("../migrations/002-ai-runtime-store.sql"),
|
||||
),
|
||||
(
|
||||
"v3-sidebar-shortcuts",
|
||||
include_str!("../migrations/003-sidebar-shortcuts.sql"),
|
||||
),
|
||||
(
|
||||
"v4-user-ui-preferences",
|
||||
include_str!("../migrations/004-user-ui-preferences.sql"),
|
||||
),
|
||||
(
|
||||
"v5-sidebar-shortcut-root-uri",
|
||||
include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Create the `_migrations` meta-table if it does not exist.
|
||||
@@ -94,6 +106,8 @@ mod tests {
|
||||
"legacy_id_map",
|
||||
"ai_runtime_runs",
|
||||
"ai_runtime_events",
|
||||
"sidebar_shortcuts",
|
||||
"user_ui_preferences",
|
||||
] {
|
||||
assert!(table_exists(&conn, table), "table {table} should exist");
|
||||
}
|
||||
|
||||
@@ -177,6 +177,72 @@ pub struct OutboxEventInput {
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SidebarShortcutRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: EntityId,
|
||||
pub root_uri: Option<String>,
|
||||
pub kind: String,
|
||||
pub source_kind: String,
|
||||
pub target_id: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub title: String,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: i64,
|
||||
pub status: String,
|
||||
pub metadata_json: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertSidebarShortcutInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: EntityId,
|
||||
pub root_uri: Option<String>,
|
||||
pub kind: String,
|
||||
pub source_kind: String,
|
||||
pub target_id: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub title: String,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: i64,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserUiPreferenceRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub source_kind: Option<String>,
|
||||
pub scope_kind: String,
|
||||
pub scope_id: String,
|
||||
pub key: String,
|
||||
pub value_json: String,
|
||||
pub status: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertUserUiPreferenceInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub source_kind: Option<String>,
|
||||
pub scope_kind: String,
|
||||
pub scope_id: String,
|
||||
pub key: String,
|
||||
pub value_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiPolicyRecord {
|
||||
pub id: EntityId,
|
||||
|
||||
@@ -12,8 +12,9 @@ use crate::model::{
|
||||
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
|
||||
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
|
||||
DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession,
|
||||
ShareLinkRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertSyncStateInput, UpsertUserInput, UserRecord, WorkspaceRecord,
|
||||
ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput,
|
||||
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
@@ -175,6 +176,60 @@ fn row_to_audit_log(row: &rusqlite::Row<'_>) -> rusqlite::Result<AuditLogRecord>
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_sidebar_shortcut(row: &rusqlite::Row<'_>) -> rusqlite::Result<SidebarShortcutRecord> {
|
||||
Ok(SidebarShortcutRecord {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
workspace_id: row.get(2)?,
|
||||
root_uri: row.get(3)?,
|
||||
kind: row.get(4)?,
|
||||
source_kind: row.get(5)?,
|
||||
target_id: row.get(6)?,
|
||||
relative_path: row.get(7)?,
|
||||
document_id: row.get(8)?,
|
||||
title: row.get(9)?,
|
||||
icon: row.get(10)?,
|
||||
sort_order: row.get(11)?,
|
||||
status: row.get(12)?,
|
||||
metadata_json: row.get(13)?,
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
revision: row.get(16)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_string_to_option(value: String) -> Option<String> {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn option_to_stored_text(value: Option<String>) -> String {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
|
||||
Ok(UserUiPreferenceRecord {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
workspace_id: empty_string_to_option(row.get(2)?),
|
||||
source_kind: empty_string_to_option(row.get(3)?),
|
||||
scope_kind: row.get(4)?,
|
||||
scope_id: row.get(5)?,
|
||||
key: row.get(6)?,
|
||||
value_json: row.get(7)?,
|
||||
status: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
revision: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
|
||||
Ok(AiPolicyRecord {
|
||||
id: row.get(0)?,
|
||||
@@ -1065,6 +1120,268 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_sidebar_shortcut(
|
||||
&self,
|
||||
input: UpsertSidebarShortcutInput,
|
||||
) -> Result<SidebarShortcutRecord, ControlPlaneError> {
|
||||
let user_id = input.user_id.trim().to_string();
|
||||
let workspace_id = input.workspace_id.trim().to_string();
|
||||
let root_uri = input
|
||||
.root_uri
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let kind = input.kind.trim().to_string();
|
||||
let source_kind = input.source_kind.trim().to_string();
|
||||
let target_id = input.target_id.trim().to_string();
|
||||
let title = input.title.trim().to_string();
|
||||
if user_id.is_empty()
|
||||
|| workspace_id.is_empty()
|
||||
|| kind.is_empty()
|
||||
|| source_kind.is_empty()
|
||||
|| target_id.is_empty()
|
||||
|| title.is_empty()
|
||||
{
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"sidebar shortcut user/workspace/kind/target/title 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if kind != "page" && kind != "folder" {
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"sidebar shortcut kind 只能是 page 或 folder".to_string(),
|
||||
));
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
conn.execute(
|
||||
"INSERT INTO sidebar_shortcuts (
|
||||
id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
|
||||
document_id, title, icon, sort_order, status, metadata_json, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'active', ?13, ?14, ?15, 1)
|
||||
ON CONFLICT(user_id, workspace_id, kind, target_id)
|
||||
DO UPDATE SET
|
||||
root_uri = excluded.root_uri,
|
||||
relative_path = excluded.relative_path,
|
||||
document_id = excluded.document_id,
|
||||
title = excluded.title,
|
||||
icon = excluded.icon,
|
||||
sort_order = excluded.sort_order,
|
||||
status = 'active',
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at,
|
||||
revision = sidebar_shortcuts.revision + 1",
|
||||
params![
|
||||
input.id.unwrap_or_else(|| new_id("shortcut")),
|
||||
user_id,
|
||||
workspace_id,
|
||||
root_uri,
|
||||
kind,
|
||||
source_kind,
|
||||
target_id,
|
||||
input.relative_path,
|
||||
input.document_id,
|
||||
title,
|
||||
input.icon,
|
||||
input.sort_order,
|
||||
input.metadata_json,
|
||||
now,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
let record = conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
|
||||
document_id, title, icon, sort_order, status, metadata_json, created_at,
|
||||
updated_at, revision
|
||||
FROM sidebar_shortcuts
|
||||
WHERE user_id = ?1 AND workspace_id = ?2 AND kind = ?3 AND target_id = ?4
|
||||
LIMIT 1",
|
||||
params![user_id, workspace_id, kind, target_id],
|
||||
row_to_sidebar_shortcut,
|
||||
)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn list_sidebar_shortcuts(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
let workspace_id = workspace_id.trim();
|
||||
if user_id.is_empty() || workspace_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
|
||||
document_id, title, icon, sort_order, status, metadata_json, created_at,
|
||||
updated_at, revision
|
||||
FROM sidebar_shortcuts
|
||||
WHERE user_id = ?1 AND workspace_id = ?2 AND status = 'active'
|
||||
ORDER BY sort_order ASC, created_at ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(ControlPlaneError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn list_sidebar_shortcuts_with_global_local(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
let workspace_id = workspace_id.trim();
|
||||
if user_id.is_empty() || workspace_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
|
||||
document_id, title, icon, sort_order, status, metadata_json, created_at,
|
||||
updated_at, revision
|
||||
FROM sidebar_shortcuts
|
||||
WHERE user_id = ?1
|
||||
AND status = 'active'
|
||||
AND (workspace_id = ?2 OR source_kind = 'local_folder')
|
||||
ORDER BY sort_order ASC, created_at ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(ControlPlaneError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn delete_sidebar_shortcut(
|
||||
&self,
|
||||
user_id: &str,
|
||||
shortcut_id: &str,
|
||||
) -> Result<(), ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
let shortcut_id = shortcut_id.trim();
|
||||
if user_id.is_empty() || shortcut_id.is_empty() {
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"sidebar shortcut user_id/shortcut_id 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let changed = conn.execute(
|
||||
"UPDATE sidebar_shortcuts
|
||||
SET status = 'removed', updated_at = ?1, revision = revision + 1
|
||||
WHERE id = ?2 AND user_id = ?3 AND status = 'active'",
|
||||
params![now_text(), shortcut_id, user_id],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Err(ControlPlaneError::NotFound(format!(
|
||||
"sidebar shortcut not found: {shortcut_id}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_user_ui_preference(
|
||||
&self,
|
||||
input: UpsertUserUiPreferenceInput,
|
||||
) -> Result<UserUiPreferenceRecord, ControlPlaneError> {
|
||||
let user_id = input.user_id.trim().to_string();
|
||||
let workspace_id = option_to_stored_text(input.workspace_id);
|
||||
let source_kind = option_to_stored_text(input.source_kind);
|
||||
let scope_kind = input.scope_kind.trim().to_string();
|
||||
let scope_id = input.scope_id.trim().to_string();
|
||||
let key = input.key.trim().to_string();
|
||||
if user_id.is_empty() || scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"user UI preference user/scope/key 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&input.value_json)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
conn.execute(
|
||||
"INSERT INTO user_ui_preferences (
|
||||
id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
|
||||
value_json, status, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, 1)
|
||||
ON CONFLICT(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
|
||||
DO UPDATE SET
|
||||
value_json = excluded.value_json,
|
||||
status = 'active',
|
||||
updated_at = excluded.updated_at,
|
||||
revision = user_ui_preferences.revision + 1",
|
||||
params![
|
||||
input.id.unwrap_or_else(|| new_id("ui_pref")),
|
||||
user_id,
|
||||
workspace_id,
|
||||
source_kind,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
key,
|
||||
input.value_json,
|
||||
now,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
let record = conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
|
||||
value_json, status, created_at, updated_at, revision
|
||||
FROM user_ui_preferences
|
||||
WHERE user_id = ?1
|
||||
AND workspace_id = ?2
|
||||
AND source_kind = ?3
|
||||
AND scope_kind = ?4
|
||||
AND scope_id = ?5
|
||||
AND key = ?6
|
||||
LIMIT 1",
|
||||
params![
|
||||
user_id,
|
||||
workspace_id,
|
||||
source_kind,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
key
|
||||
],
|
||||
row_to_user_ui_preference,
|
||||
)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn list_user_ui_preferences(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
source_kind: Option<&str>,
|
||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
|
||||
let source_kind = source_kind.map(str::trim).unwrap_or_default();
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
|
||||
value_json, status, created_at, updated_at, revision
|
||||
FROM user_ui_preferences
|
||||
WHERE user_id = ?1
|
||||
AND status = 'active'
|
||||
AND (workspace_id = '' OR workspace_id = ?2)
|
||||
AND (source_kind = '' OR source_kind = ?3)
|
||||
ORDER BY created_at ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
params![user_id, workspace_id, source_kind],
|
||||
row_to_user_ui_preference,
|
||||
)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(ControlPlaneError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
@@ -1668,7 +1985,7 @@ mod tests {
|
||||
use crate::model::{
|
||||
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertSyncStateInput,
|
||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput,
|
||||
};
|
||||
|
||||
fn store() -> SqliteControlPlaneStore {
|
||||
@@ -1960,6 +2277,133 @@ mod tests {
|
||||
assert_eq!(pending[0].id, second.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_shortcuts_are_user_scoped_and_upserted_by_target() {
|
||||
let store = store();
|
||||
create_user(&store, "alice");
|
||||
create_user(&store, "bob");
|
||||
let workspace = store.ensure_default_workspace("alice").expect("workspace");
|
||||
|
||||
let first = store
|
||||
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
root_uri: Some("file:///tmp/mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
target_id: "local-dir:design".to_string(),
|
||||
relative_path: Some("design".to_string()),
|
||||
document_id: None,
|
||||
title: "design".to_string(),
|
||||
icon: Some("folder_open".to_string()),
|
||||
sort_order: 0,
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert shortcut");
|
||||
|
||||
let updated = store
|
||||
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
root_uri: Some("file:///tmp/mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
target_id: "local-dir:design".to_string(),
|
||||
relative_path: Some("design".to_string()),
|
||||
document_id: None,
|
||||
title: "Design".to_string(),
|
||||
icon: Some("folder_open".to_string()),
|
||||
sort_order: 4,
|
||||
metadata_json: "{\"scope\":\"filetree\"}".to_string(),
|
||||
})
|
||||
.expect("upsert shortcut");
|
||||
|
||||
assert_eq!(updated.id, first.id);
|
||||
assert_eq!(updated.title, "Design");
|
||||
assert_eq!(updated.root_uri.as_deref(), Some("file:///tmp/mnote"));
|
||||
assert_eq!(updated.sort_order, 4);
|
||||
assert_eq!(updated.revision, first.revision + 1);
|
||||
|
||||
let alice_shortcuts = store
|
||||
.list_sidebar_shortcuts("alice", &workspace.id)
|
||||
.expect("list alice shortcuts");
|
||||
assert_eq!(alice_shortcuts.len(), 1);
|
||||
assert_eq!(alice_shortcuts[0].target_id, "local-dir:design");
|
||||
let alice_global_local_shortcuts = store
|
||||
.list_sidebar_shortcuts_with_global_local("alice", "cloud-workspace")
|
||||
.expect("list alice global local shortcuts");
|
||||
assert_eq!(alice_global_local_shortcuts.len(), 1);
|
||||
assert_eq!(alice_global_local_shortcuts[0].workspace_id, workspace.id);
|
||||
|
||||
let bob_shortcuts = store
|
||||
.list_sidebar_shortcuts("bob", &workspace.id)
|
||||
.expect("list bob shortcuts");
|
||||
assert!(bob_shortcuts.is_empty());
|
||||
|
||||
store
|
||||
.delete_sidebar_shortcut("alice", &updated.id)
|
||||
.expect("delete shortcut");
|
||||
let after_delete = store
|
||||
.list_sidebar_shortcuts("alice", &workspace.id)
|
||||
.expect("list deleted shortcuts");
|
||||
assert!(after_delete.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_ui_preferences_are_scoped_upserted_and_user_isolated() {
|
||||
let store = store();
|
||||
create_user(&store, "alice");
|
||||
create_user(&store, "bob");
|
||||
|
||||
let first = store
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
|
||||
source_kind: Some("local_folder".to_string()),
|
||||
scope_kind: "source_family".to_string(),
|
||||
scope_id: "external_local_folder".to_string(),
|
||||
key: "hideTitleHeader".to_string(),
|
||||
value_json: "false".to_string(),
|
||||
})
|
||||
.expect("insert preference");
|
||||
|
||||
let updated = store
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
|
||||
source_kind: Some("local_folder".to_string()),
|
||||
scope_kind: "source_family".to_string(),
|
||||
scope_id: "external_local_folder".to_string(),
|
||||
key: "hideTitleHeader".to_string(),
|
||||
value_json: "true".to_string(),
|
||||
})
|
||||
.expect("upsert preference");
|
||||
|
||||
assert_eq!(updated.id, first.id);
|
||||
assert_eq!(updated.value_json, "true");
|
||||
assert_eq!(updated.revision, first.revision + 1);
|
||||
|
||||
let alice_preferences = store
|
||||
.list_user_ui_preferences(
|
||||
"alice",
|
||||
Some("local:_mnt_Data1T_mnote"),
|
||||
Some("local_folder"),
|
||||
)
|
||||
.expect("list alice preferences");
|
||||
assert_eq!(alice_preferences.len(), 1);
|
||||
assert_eq!(alice_preferences[0].scope_kind, "source_family");
|
||||
assert_eq!(alice_preferences[0].scope_id, "external_local_folder");
|
||||
|
||||
let bob_preferences = store
|
||||
.list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder"))
|
||||
.expect("list bob preferences");
|
||||
assert!(bob_preferences.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
|
||||
let store = store();
|
||||
|
||||
@@ -6,9 +6,10 @@ use crate::model::{
|
||||
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SyncStateRecord,
|
||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UserRecord, WorkspaceRecord,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||
SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput,
|
||||
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||
UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
@@ -100,6 +101,41 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
|
||||
fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn upsert_sidebar_shortcut(
|
||||
&self,
|
||||
input: UpsertSidebarShortcutInput,
|
||||
) -> Result<SidebarShortcutRecord, ControlPlaneError>;
|
||||
|
||||
fn list_sidebar_shortcuts(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError>;
|
||||
|
||||
fn list_sidebar_shortcuts_with_global_local(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError>;
|
||||
|
||||
fn delete_sidebar_shortcut(
|
||||
&self,
|
||||
user_id: &str,
|
||||
shortcut_id: &str,
|
||||
) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn upsert_user_ui_preference(
|
||||
&self,
|
||||
input: UpsertUserUiPreferenceInput,
|
||||
) -> Result<UserUiPreferenceRecord, ControlPlaneError>;
|
||||
|
||||
fn list_user_ui_preferences(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
source_kind: Option<&str>,
|
||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
}
|
||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
|
||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||
const manifest = await manifestResponse.json();
|
||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||
@@ -368,16 +368,19 @@ import {
|
||||
|
||||
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
|
||||
mindmapHost.unmountMindmapPane(paneRole);
|
||||
const runtime = await loadRuntime();
|
||||
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
|
||||
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
|
||||
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
|
||||
const runtimePromise = loadRuntime();
|
||||
const aggregatePromise = options.aggregate
|
||||
? Promise.resolve(options.aggregate)
|
||||
: fetchPageAggregateForPane(descriptor);
|
||||
const previousView = paneViewRegistry.get(paneRole);
|
||||
const [runtime, aggregate] = await Promise.all([runtimePromise, aggregatePromise]);
|
||||
if (previousView) {
|
||||
unmountEditorViewBinding(previousView);
|
||||
paneViewRegistry.delete(paneRole);
|
||||
}
|
||||
const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor);
|
||||
const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole);
|
||||
syncPageAggregateScript({ pageAggregateScriptId: bootstrap.pageAggregateScriptId }, aggregate);
|
||||
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap };
|
||||
|
||||
@@ -24,7 +24,7 @@ function fileTreeRowAssetId(row, deps) {
|
||||
}
|
||||
|
||||
function decodeLocalEncodedPath(value) {
|
||||
var path = String(value || '').trim().replace(/~2F/g, '/');
|
||||
var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1');
|
||||
if (!path) return '';
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
|
||||
@@ -5,6 +5,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
buildLocalOnlyOfficeOpenUrl,
|
||||
buildOnlyOfficeOpenPath,
|
||||
buildOnlyOfficeOpenUrl,
|
||||
closestAction: injectedClosestAction,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentWorkspaceSourcePayload,
|
||||
@@ -23,6 +24,11 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
uploadedFileSize,
|
||||
} = dependencies;
|
||||
|
||||
const closestAction = typeof injectedClosestAction === 'function' ? injectedClosestAction : function(target, selector) {
|
||||
var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
|
||||
return node && typeof node.closest === 'function' ? node.closest(selector) : null;
|
||||
};
|
||||
|
||||
var activeEditorAttachmentLink = null;
|
||||
var attachmentActionsHideTimer = 0;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
createPage,
|
||||
cssEscape,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
deleteSingleFileTreeAsset,
|
||||
dispatchSidebarEvent,
|
||||
@@ -23,6 +24,8 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
openEditorAttachmentEditTab,
|
||||
openEditorAttachmentNewWindow,
|
||||
refreshLocalFolderSidebarSnapshot,
|
||||
removeFileTreeAssetRow,
|
||||
revealFileTreeResource,
|
||||
resolveWorkspaceId,
|
||||
runtimeState,
|
||||
selectedSidebarFileTreeSelection,
|
||||
@@ -30,6 +33,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
validateFileTreeRename,
|
||||
} = dependencies;
|
||||
const sidebarFileTreeSelection = selectedSidebarFileTreeSelection;
|
||||
var fileTreeOperationBatchCounter = 0;
|
||||
|
||||
function nextFileTreeOperationBatchId(action) {
|
||||
fileTreeOperationBatchCounter += 1;
|
||||
return 'filetree-' + String(action || 'operation') + '-' + Date.now() + '-' + fileTreeOperationBatchCounter;
|
||||
}
|
||||
|
||||
function rowTitle(row) {
|
||||
var title = row ? row.querySelector(':scope > .tree-link > .tree-link-title') : null;
|
||||
@@ -59,8 +68,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var rowKind = row.getAttribute('data-row-kind') || '';
|
||||
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
|
||||
var assetId = row.getAttribute('data-asset-id') || '';
|
||||
if (!documentId && !assetId) return false;
|
||||
if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId) return false;
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
var localFolderSource = currentSourceKind() === 'local_folder';
|
||||
var localFolderDirectory = localFolderSource && (rowKind === 'folder' || rowKind === 'directory');
|
||||
var commandTargetId = documentId || (localFolderSource ? (assetId || rowId) : '');
|
||||
if (!commandTargetId && !assetId) return false;
|
||||
if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId && !localFolderDirectory) return false;
|
||||
var link = row.querySelector(':scope > .tree-link');
|
||||
var title = rowTitle(row);
|
||||
if (!(link instanceof HTMLElement)) return false;
|
||||
@@ -68,7 +81,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'tree-rename-input';
|
||||
input.setAttribute('data-rename-id', row.getAttribute('data-row-id') || '');
|
||||
input.setAttribute('data-rename-id', rowId);
|
||||
input.value = title;
|
||||
input.style.minWidth = '0';
|
||||
input.style.flex = '1 1 auto';
|
||||
@@ -117,7 +130,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var commandTitle = isFileTreePageRow(row) ? normalizeFileTreePageRenameTitle(nextTitle) : nextTitle;
|
||||
committing = true;
|
||||
input.disabled = true;
|
||||
var work = assetId
|
||||
var work = assetId && !localFolderSource
|
||||
? Promise.resolve().then(function(){
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-asset-id="' + cssEscape(assetId) + '"] .tree-link-title').forEach(function(titleNode) {
|
||||
titleNode.textContent = commandTitle;
|
||||
@@ -128,9 +141,11 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
: dispatchTreeCommand(row, {
|
||||
action: 'rename',
|
||||
workspaceId: resolveWorkspaceId(row),
|
||||
documentId: documentId,
|
||||
documentId: commandTargetId,
|
||||
title: commandTitle
|
||||
}).then(function(){ updateTitleEverywhere(documentId, commandTitle); });
|
||||
}).then(function(){
|
||||
if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle);
|
||||
});
|
||||
void work.then(close).catch(function(error) {
|
||||
committing = false;
|
||||
input.disabled = false;
|
||||
@@ -293,6 +308,51 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
return detail.title || '';
|
||||
}
|
||||
|
||||
function localRootPathFromRootUri(rootUri) {
|
||||
var value = String(rootUri || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/^file:\/\//i.test(value)) return value.charAt(0) === '/' ? value : '';
|
||||
var pathPart = value.replace(/^file:\/\//i, '');
|
||||
if (pathPart.indexOf('localhost/') === 0) pathPart = pathPart.slice('localhost'.length);
|
||||
if (pathPart.charAt(0) !== '/') pathPart = '/' + pathPart;
|
||||
try {
|
||||
return decodeURIComponent(pathPart);
|
||||
} catch (_) {
|
||||
return pathPart;
|
||||
}
|
||||
}
|
||||
|
||||
function joinLocalAbsolutePath(rootUri, relativePath) {
|
||||
var rootPath = localRootPathFromRootUri(rootUri);
|
||||
var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, '');
|
||||
if (!rootPath || !normalized) return rootPath || normalized;
|
||||
return rootPath.replace(/\/+$/g, '') + '/' + normalized;
|
||||
}
|
||||
|
||||
function fileTreeCopyLocalAbsolutePath(detail, trigger) {
|
||||
var rootUri = typeof currentRootUri === 'function' ? currentRootUri() : '';
|
||||
if (!rootUri && trigger && typeof trigger.closest === 'function') {
|
||||
var row = trigger.closest('.tree-row[data-shell-mode="filetree"]');
|
||||
if (row instanceof HTMLElement) rootUri = row.getAttribute('data-root-uri') || '';
|
||||
}
|
||||
if (!rootUri && document.body) rootUri = document.body.getAttribute('data-mnote-root-uri') || '';
|
||||
var relativePath = String(detail && detail.localRelativePath || '').trim();
|
||||
if (!relativePath && detail && detail.assetId) relativePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (!relativePath && detail && detail.documentId) {
|
||||
relativePath = String(detail.documentId || '')
|
||||
.replace(/^local-md:/, '')
|
||||
.replace(/^local-dir:/, '');
|
||||
}
|
||||
if (!relativePath && detail && detail.rowId) {
|
||||
relativePath = String(detail.rowId || '')
|
||||
.replace(/^local:asset:/, '')
|
||||
.replace(/^local:markdown:/, '')
|
||||
.replace(/^local:folder:/, '')
|
||||
.replace(/^local:node:/, '');
|
||||
}
|
||||
return joinLocalAbsolutePath(rootUri, relativePath);
|
||||
}
|
||||
|
||||
function fileTreeMenuTargetParentId(detail, trigger) {
|
||||
var rowKind = String(detail && detail.rowKind || '').trim();
|
||||
var rowId = String(detail && detail.rowId || '').trim();
|
||||
@@ -361,6 +421,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
|
||||
var title = detail.title || '无标题';
|
||||
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
if (detail.contextKind === 'filetree' && action === 'toggle-sidebar-folder-shortcut') {
|
||||
dispatchSidebarEvent('tree.sidebarShortcut.toggleFolder', detail);
|
||||
return;
|
||||
}
|
||||
if (detail.contextKind === 'filetree' && action === 'download') {
|
||||
downloadSelectedFileTreeAssetRows(detail, trigger);
|
||||
return;
|
||||
@@ -408,7 +472,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
return;
|
||||
}
|
||||
if (action === 'copy-id') {
|
||||
void copyTreeContextValue(documentId || detail.assetId || detail.rowId || '', 'copy-id');
|
||||
var copyIdValue = currentSourceKind() === 'local_folder'
|
||||
? fileTreeCopyLocalAbsolutePath(detail, trigger)
|
||||
: '';
|
||||
void copyTreeContextValue(copyIdValue || documentId || detail.assetId || detail.rowId || '', 'copy-id');
|
||||
return;
|
||||
}
|
||||
if (action === 'delete-trash' && detail.contextKind === 'filetree') {
|
||||
@@ -776,6 +843,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
{ separator: true },
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
|
||||
{ action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||
@@ -915,23 +983,32 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]')
|
||||
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]')
|
||||
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]');
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
if (!rowId) return false;
|
||||
selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false });
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
|
||||
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
var relativePath = options && options.relativePath
|
||||
? String(options.relativePath || '').trim()
|
||||
: id.indexOf('local-md:') === 0
|
||||
? decodeLocalEncodedPath(id.slice('local-md:'.length))
|
||||
: '';
|
||||
var bundleParentPath = localMarkdownBundleParentPath(relativePath);
|
||||
var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null;
|
||||
if (bundleRow instanceof HTMLElement) {
|
||||
return activateSidebarFileTreeRow(bundleRow, options);
|
||||
}
|
||||
if (relativePath && typeof revealFileTreeResource === 'function') {
|
||||
void revealFileTreeResource({
|
||||
rootUri: options && options.rootUri,
|
||||
relativePath: relativePath,
|
||||
rowId: options && options.rowId,
|
||||
select: true,
|
||||
focus: true,
|
||||
scroll: options ? options.scrollIntoView !== false : true
|
||||
});
|
||||
row.setAttribute('data-active', 'true');
|
||||
if (options && options.scrollIntoView !== false) {
|
||||
try {
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
} catch (_) {
|
||||
row.scrollIntoView();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return activateSidebarFileTreeRow(row, options);
|
||||
}
|
||||
|
||||
function selectSidebarFileTreeRowById(rowId, options) {
|
||||
var id = String(rowId || '').trim();
|
||||
@@ -1063,7 +1140,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
function decodeLocalEncodedPath(value) {
|
||||
var runtimeFn = fileTreeRuntimeFunction('decodeLocalEncodedPath');
|
||||
if (runtimeFn) return runtimeFn(value);
|
||||
var path = String(value || '').trim().replace(/~2F/g, '/');
|
||||
var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1');
|
||||
if (!path) return '';
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
@@ -1072,6 +1149,62 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFileTreeRelativePath(value) {
|
||||
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
return normalized === '.' ? '' : normalized;
|
||||
}
|
||||
|
||||
function parentRelativePathForFileTreePath(relativePath) {
|
||||
var normalized = normalizeFileTreeRelativePath(relativePath);
|
||||
if (!normalized) return '';
|
||||
var index = normalized.lastIndexOf('/');
|
||||
return index > 0 ? normalized.slice(0, index) : '';
|
||||
}
|
||||
|
||||
function fileNameStem(value) {
|
||||
var name = String(value || '').trim();
|
||||
var dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(0, dot) : name;
|
||||
}
|
||||
|
||||
function localMarkdownBundleParentPath(relativePath) {
|
||||
var normalized = normalizeFileTreeRelativePath(relativePath);
|
||||
if (!/\.md$/i.test(normalized)) return '';
|
||||
var parent = parentRelativePathForFileTreePath(normalized);
|
||||
if (!parent) return '';
|
||||
var fileName = normalized.slice(normalized.lastIndexOf('/') + 1);
|
||||
var parentName = parent.slice(parent.lastIndexOf('/') + 1);
|
||||
return fileNameStem(fileName) === parentName ? parent : '';
|
||||
}
|
||||
|
||||
function visibleFileTreeRowByRelativePath(relativePath) {
|
||||
var normalized = normalizeFileTreeRelativePath(relativePath);
|
||||
if (!normalized) return null;
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]');
|
||||
if (!(row instanceof HTMLElement)) return null;
|
||||
if (row.closest('.tree-children--collapsed')) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
function activateSidebarFileTreeRow(row, options) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
if (!rowId) return false;
|
||||
selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false });
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
|
||||
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
|
||||
});
|
||||
row.setAttribute('data-active', 'true');
|
||||
if (options && options.scrollIntoView !== false) {
|
||||
try {
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
} catch (_) {
|
||||
row.scrollIntoView();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function fileTreeRowLocalRelativePath(row) {
|
||||
var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath');
|
||||
if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps());
|
||||
@@ -1239,7 +1372,8 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var assetRows = [];
|
||||
rows.forEach(function(row) {
|
||||
var kind = fileTreeRowKind(row);
|
||||
if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown') && fileTreeRowDocumentId(row)) {
|
||||
var documentId = fileTreeRowDocumentId(row);
|
||||
if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || documentId.indexOf('local-md:') === 0) && documentId) {
|
||||
docRows.push(row);
|
||||
return;
|
||||
}
|
||||
@@ -1338,79 +1472,113 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
return children.length;
|
||||
}
|
||||
|
||||
async function pasteSidebarFileTreeClipboard(trigger) {
|
||||
if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false;
|
||||
var targetRow = trigger instanceof HTMLElement ? trigger : null;
|
||||
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
|
||||
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
|
||||
function fileTreeMoveTargetParentId(targetRow) {
|
||||
if (!(targetRow instanceof HTMLElement)) return null;
|
||||
return fileTreeMenuTargetParentId({
|
||||
rowKind: fileTreeRowKind(targetRow),
|
||||
rowId: targetRow.getAttribute('data-row-id') || '',
|
||||
documentId: fileTreeRowDocumentId(targetRow)
|
||||
}, targetRow) || null;
|
||||
}
|
||||
var targetDocumentId = fileTreeRowDocumentId(targetRow) || currentDocumentId();
|
||||
if (!targetDocumentId) return false;
|
||||
var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
|
||||
var rows = fileTreeRowsByRowIds(runtimeState.sidebarFileTreeClipboard.rowIds);
|
||||
|
||||
function fileTreeMoveSourceId(row) {
|
||||
if (!(row instanceof HTMLElement)) return '';
|
||||
return fileTreeRowDocumentId(row)
|
||||
|| (currentSourceKind() === 'local_folder'
|
||||
? (fileTreeRowAssetId(row) || row.getAttribute('data-row-id') || '')
|
||||
: fileTreeRowAssetId(row));
|
||||
}
|
||||
|
||||
async function moveSidebarFileTreeRows(rowIds, targetRow, options) {
|
||||
var rows = fileTreeRowsByRowIds(rowIds || []);
|
||||
if (rows.length === 0) return false;
|
||||
var writable = await ensureFileTreeWritableTarget('paste', targetRow, runtimeState.sidebarFileTreeClipboard.rowIds, action === 'copy');
|
||||
if (!writable) return false;
|
||||
recordFileTreeAction('paste', {
|
||||
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
|
||||
documentId: targetDocumentId,
|
||||
sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds,
|
||||
clipboardAction: runtimeState.sidebarFileTreeClipboard.action
|
||||
});
|
||||
var plan = buildSidebarFileTreeDeletePlan(rows);
|
||||
var copy = Boolean(options && options.copy);
|
||||
var targetParentId = fileTreeMoveTargetParentId(targetRow);
|
||||
var workspaceId = resolveWorkspaceId(targetRow || document.body);
|
||||
var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy);
|
||||
if (!writable) return false;
|
||||
var batchId = nextFileTreeOperationBatchId(copy ? 'copy' : 'move');
|
||||
var plan = buildSidebarFileTreeDeletePlan(rows);
|
||||
var failures = [];
|
||||
if (action === 'copy') {
|
||||
dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: runtimeState.sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId });
|
||||
recordFileTreeActionStatus('copy-requested', { documentId: targetDocumentId });
|
||||
if (copy) {
|
||||
dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: rowIds || [], targetDocumentId: targetParentId });
|
||||
recordFileTreeActionStatus('copy-requested', { documentId: targetParentId, batchId: batchId });
|
||||
return true;
|
||||
}
|
||||
for (var i = 0; i < plan.docRows.length; i += 1) {
|
||||
var docRow = plan.docRows[i];
|
||||
var documentId = fileTreeRowDocumentId(docRow);
|
||||
if (!documentId || documentId === targetDocumentId) continue;
|
||||
var movableRows = plan.docRows.concat(plan.folderRows);
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
movableRows = movableRows.concat(plan.fileAssetRows, plan.mindmapRows, plan.tableRows);
|
||||
}
|
||||
for (var i = 0; i < movableRows.length; i += 1) {
|
||||
var sourceRow = movableRows[i];
|
||||
var sourceId = fileTreeMoveSourceId(sourceRow);
|
||||
if (!sourceId || sourceId === targetParentId) continue;
|
||||
try {
|
||||
await dispatchTreeCommand(targetRow || docRow, {
|
||||
await dispatchTreeCommand(targetRow || sourceRow, {
|
||||
action: 'move',
|
||||
batchId: batchId,
|
||||
workspaceId: workspaceId,
|
||||
documentId: documentId,
|
||||
parentId: targetDocumentId,
|
||||
sortOrder: fileTreeChildCount(targetDocumentId) + i
|
||||
documentId: sourceId,
|
||||
parentId: targetParentId,
|
||||
sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(documentId + ': ' + (error && error.message ? error.message : '移动页面失败'));
|
||||
failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败'));
|
||||
}
|
||||
}
|
||||
if (currentSourceKind() !== 'local_folder') {
|
||||
var assetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean);
|
||||
if (assetIds.length > 0) {
|
||||
try {
|
||||
await postSidebarFileTreeJson('/api/media/batch', {
|
||||
action: 'move',
|
||||
assetIds: assetIds,
|
||||
targetDocumentId: targetDocumentId
|
||||
targetDocumentId: targetParentId
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(assetIds.join(',') + ': ' + (error && error.message ? error.message : '移动附件失败'));
|
||||
}
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'move', failed: failures.length, count: movableRows.length } }));
|
||||
if (failures.length > 0) {
|
||||
recordFileTreeActionStatus('failed', { documentId: targetDocumentId, fallback: 'alert' });
|
||||
recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId });
|
||||
window.alert('部分对象移动失败:' + failures.join(';'));
|
||||
return false;
|
||||
}
|
||||
runtimeState.sidebarFileTreeClipboard = null;
|
||||
recordFileTreeActionStatus('applied', { documentId: targetDocumentId });
|
||||
recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pasteSidebarFileTreeClipboard(trigger) {
|
||||
if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false;
|
||||
var targetRow = trigger instanceof HTMLElement ? trigger : null;
|
||||
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
|
||||
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
|
||||
}
|
||||
var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId();
|
||||
var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
|
||||
recordFileTreeAction('paste', {
|
||||
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
|
||||
documentId: targetDocumentId,
|
||||
sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds,
|
||||
clipboardAction: runtimeState.sidebarFileTreeClipboard.action
|
||||
});
|
||||
var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' });
|
||||
if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null;
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function deleteSelectedSidebarFileTreeRows(trigger) {
|
||||
var rows = selectedSidebarFileTreeRows();
|
||||
var plan = buildSidebarFileTreeDeletePlan(rows);
|
||||
var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
|
||||
if (total === 0) return false;
|
||||
if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false;
|
||||
recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total });
|
||||
recordFileTreeActionStatus('pending', { count: total });
|
||||
var batchId = nextFileTreeOperationBatchId('bulk-delete');
|
||||
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-batch-id', batchId);
|
||||
recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total, batchId: batchId });
|
||||
recordFileTreeActionStatus('pending', { count: total, batchId: batchId });
|
||||
var failures = [];
|
||||
for (var i = 0; i < plan.docRows.length; i += 1) {
|
||||
var docRow = plan.docRows[i];
|
||||
@@ -1418,6 +1586,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
try {
|
||||
await dispatchTreeCommand(trigger || docRow, {
|
||||
action: 'archive',
|
||||
batchId: batchId,
|
||||
workspaceId: resolveWorkspaceId(docRow),
|
||||
documentId: documentId
|
||||
});
|
||||
@@ -1432,6 +1601,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
try {
|
||||
await dispatchTreeCommand(trigger || folderRow, {
|
||||
action: 'archive',
|
||||
batchId: batchId,
|
||||
workspaceId: resolveWorkspaceId(folderRow),
|
||||
documentId: folderId
|
||||
});
|
||||
@@ -1446,6 +1616,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
for (var lf = 0; lf < fileAssetIds.length; lf += 1) {
|
||||
await dispatchTreeCommand(trigger || plan.fileAssetRows[lf], {
|
||||
action: 'archive',
|
||||
batchId: batchId,
|
||||
workspaceId: resolveWorkspaceId(plan.fileAssetRows[lf]),
|
||||
documentId: fileAssetIds[lf]
|
||||
});
|
||||
@@ -1455,7 +1626,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
}
|
||||
fileAssetIds.forEach(removeFileTreeAssetRow);
|
||||
} catch (error) {
|
||||
failures = failures.concat(fileAssetIds);
|
||||
failures = failures.concat(fileAssetIds.map(function(assetId) {
|
||||
return assetId + ': ' + (error && error.message ? error.message : '删除附件失败');
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (var m = 0; m < plan.mindmapRows.length; m += 1) {
|
||||
@@ -1465,6 +1638,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
await dispatchTreeCommand(trigger || mindmapRow, {
|
||||
action: 'archive',
|
||||
batchId: batchId,
|
||||
workspaceId: resolveWorkspaceId(mindmapRow),
|
||||
documentId: mindmapId
|
||||
});
|
||||
@@ -1485,6 +1659,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
await dispatchTreeCommand(trigger || tableRow, {
|
||||
action: 'archive',
|
||||
batchId: batchId,
|
||||
workspaceId: resolveWorkspaceId(tableRow),
|
||||
documentId: tableId
|
||||
});
|
||||
@@ -1502,13 +1677,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
sidebarFileTreeSelection.focusedRowId = null;
|
||||
syncSidebarFileTreeSelection();
|
||||
if (failures.length > 0) {
|
||||
recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert' });
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: failures.length, count: total } }));
|
||||
recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert', batchId: batchId });
|
||||
window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : ''));
|
||||
return false;
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
|
||||
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal' });
|
||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: 0, count: total } }));
|
||||
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal', batchId: batchId });
|
||||
if (currentSourceKind() !== 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1565,6 +1742,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
postSidebarFileTreeJson,
|
||||
fileTreeRowsByRowIds,
|
||||
fileTreeChildCount,
|
||||
moveSidebarFileTreeRows,
|
||||
pasteSidebarFileTreeClipboard,
|
||||
deleteSelectedSidebarFileTreeRows,
|
||||
};
|
||||
|
||||
@@ -45,25 +45,15 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
}
|
||||
|
||||
function readGlobalShowHeadingNumbers() {
|
||||
try {
|
||||
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : '';
|
||||
return raw === 'true' || raw === '1';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(currentPageOptions().showHeadingNumbers);
|
||||
}
|
||||
|
||||
function writeGlobalShowHeadingNumbers(value) {
|
||||
try {
|
||||
if (window.localStorage) {
|
||||
window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false');
|
||||
}
|
||||
} catch (_) {}
|
||||
document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value)));
|
||||
void persistPageOptionsPatch({ showHeadingNumbers: Boolean(value) });
|
||||
}
|
||||
|
||||
function effectiveShowHeadingNumbers(options) {
|
||||
return readGlobalShowHeadingNumbers();
|
||||
return Boolean(options && options.showHeadingNumbers);
|
||||
}
|
||||
|
||||
function pageOptionIsSupported(name) {
|
||||
@@ -161,7 +151,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
|
||||
function applyPageOptionsToShell() {
|
||||
var options = currentPageOptions();
|
||||
var globalShowHeadingNumbers = readGlobalShowHeadingNumbers();
|
||||
var globalShowHeadingNumbers = Boolean(options.showHeadingNumbers);
|
||||
var showHeadingNumbers = effectiveShowHeadingNumbers(options);
|
||||
var shell = document.querySelector('.document-shell');
|
||||
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
@@ -623,21 +613,27 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
try {
|
||||
var response = await fetch('/api/documents/options', {
|
||||
method: 'POST',
|
||||
var response = await fetch('/api/ui/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
...currentWorkspaceSourcePayload(),
|
||||
options: nextOptions,
|
||||
commandName: 'page.layout.updateOptions'
|
||||
updates: nextOptions
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_settings_save_failed_' + response.status);
|
||||
}
|
||||
var savedOptions = payload && payload.result && payload.result.pageOptions && typeof payload.result.pageOptions === 'object'
|
||||
? Object.assign(defaultPageOptions(), payload.result.pageOptions)
|
||||
: nextOptions;
|
||||
pageUiState.pageOptions = Object.assign({}, savedOptions);
|
||||
nextOptions = Object.assign({}, pageUiState.pageOptions);
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
|
||||
if (script) {
|
||||
try {
|
||||
|
||||
@@ -54,6 +54,8 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
||||
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
if (treeView === 'filetree') targetUrl.searchParams.set('treeView', 'filetree');
|
||||
var fileTreeScope = String(options && options.fileTreeScope || '').trim();
|
||||
if (fileTreeScope) targetUrl.searchParams.set('fileTreeScope', fileTreeScope);
|
||||
copyWorkspaceSourceParams(targetUrl);
|
||||
var url = targetUrl.pathname + targetUrl.search;
|
||||
if (mnoteNavigationInFlight === url) return;
|
||||
|
||||
@@ -18,11 +18,29 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
shortMindmapFileName,
|
||||
syncSidebarFileTreeSelection,
|
||||
} = dependencies;
|
||||
var fileTreeLazyChildrenCache = new Map();
|
||||
var fileTreeExpandedRelativePaths = new Set();
|
||||
var fileTreeViewState = {
|
||||
rootUri: '',
|
||||
scope: '',
|
||||
expandedParents: new Set(),
|
||||
selectedRowIds: new Set(),
|
||||
focusedRowId: '',
|
||||
activeRowId: '',
|
||||
scrollTop: 0,
|
||||
rowsByParent: new Map(),
|
||||
loadedParents: new Set(),
|
||||
loadingParents: new Map(),
|
||||
dirtyParents: new Set(),
|
||||
staleParents: new Set(),
|
||||
revisionByParent: new Map(),
|
||||
requestGeneration: 0,
|
||||
latestGenerationByParent: new Map()
|
||||
};
|
||||
var fileTreeState = fileTreeViewState;
|
||||
var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;
|
||||
var fileTreeLazyCacheRootUri = '';
|
||||
var fileTreeExpansionStorageRootUri = '';
|
||||
var fileTreeExpansionRestoreTimer = 0;
|
||||
var fileTreeCommandBatchRefreshParents = new Map();
|
||||
var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1';
|
||||
|
||||
function updateTitleEverywhere(documentId, title) {
|
||||
@@ -70,6 +88,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
function isFileTreePageRow(row) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
if (row.getAttribute('data-asset-id')) return false;
|
||||
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
|
||||
if (documentId.indexOf('local-md:') === 0) return true;
|
||||
var rowKind = row.getAttribute('data-row-kind') || '';
|
||||
return rowKind === 'document' || rowKind === 'doc' || rowKind === 'index' || rowKind === 'markdown';
|
||||
}
|
||||
@@ -295,6 +315,134 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
function parentRelativePathForPath(relativePath) {
|
||||
var normalized = String(relativePath || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!normalized || normalized === '.') return '';
|
||||
var index = normalized.lastIndexOf('/');
|
||||
return index > 0 ? normalized.slice(0, index) : '';
|
||||
}
|
||||
|
||||
function addCommandRefreshParent(parents, value) {
|
||||
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
if (normalized === '.') normalized = '';
|
||||
parents.add(normalized);
|
||||
}
|
||||
|
||||
function addAffectedParentsFromCommandResult(parents, result) {
|
||||
var affectedParents = Array.isArray(result && result.affectedParents)
|
||||
? result.affectedParents
|
||||
: Array.isArray(result && result.execution && result.execution.affectedParents)
|
||||
? result.execution.affectedParents
|
||||
: null;
|
||||
if (!affectedParents) return false;
|
||||
var before = parents.size;
|
||||
affectedParents.forEach(function(parent) {
|
||||
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
||||
});
|
||||
return parents.size > before;
|
||||
}
|
||||
|
||||
function collectFileTreeRefreshParentsForCommand(result, body) {
|
||||
var parents = new Set();
|
||||
if (!addAffectedParentsFromCommandResult(parents, result)) {
|
||||
document.documentElement.setAttribute('data-mnote-filetree-command-refresh-fallback', 'legacy-path-fields');
|
||||
var values = [
|
||||
result && (result.parentRelativePath || result.parent_relative_path),
|
||||
result && result.execution && (result.execution.parentRelativePath || result.execution.parent_relative_path),
|
||||
result && (result.relativePath || result.relative_path),
|
||||
result && result.execution && (result.execution.relativePath || result.execution.relative_path),
|
||||
result && (result.previousRelativePath || result.previous_relative_path),
|
||||
result && result.execution && (result.execution.previousRelativePath || result.execution.previous_relative_path),
|
||||
body && (body.parentRelativePath || body.parent_relative_path)
|
||||
];
|
||||
values.forEach(function(value) {
|
||||
var normalized = String(value || '').trim();
|
||||
if (!normalized) return;
|
||||
if (normalized.indexOf('/') >= 0 || /\.[^/]+$/.test(normalized)) addCommandRefreshParent(parents, parentRelativePathForPath(normalized));
|
||||
else addCommandRefreshParent(parents, normalized);
|
||||
});
|
||||
}
|
||||
if (!parents.size) addCommandRefreshParent(parents, currentFileTreeScope());
|
||||
return parents;
|
||||
}
|
||||
|
||||
function fileTreeRefreshParentsForCommand(result, body) {
|
||||
var parents = collectFileTreeRefreshParentsForCommand(result, body);
|
||||
return Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
||||
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
||||
setTreeLiveApplyError(error && error.message ? error.message : '文件树局部刷新失败');
|
||||
return false;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function queueFileTreeBatchRefresh(batchId, result, body) {
|
||||
var normalizedBatchId = String(batchId || '').trim();
|
||||
if (!normalizedBatchId) return false;
|
||||
var batchParents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId);
|
||||
if (!batchParents) {
|
||||
batchParents = new Set();
|
||||
fileTreeCommandBatchRefreshParents.set(normalizedBatchId, batchParents);
|
||||
}
|
||||
collectFileTreeRefreshParentsForCommand(result, body).forEach(function(parent) {
|
||||
batchParents.add(parent);
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-pending', normalizedBatchId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushFileTreeBatchRefresh(batchId) {
|
||||
var normalizedBatchId = String(batchId || '').trim();
|
||||
if (!normalizedBatchId) return false;
|
||||
var parents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId);
|
||||
fileTreeCommandBatchRefreshParents.delete(normalizedBatchId);
|
||||
if (!parents || !parents.size) return false;
|
||||
document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-applied', normalizedBatchId);
|
||||
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
||||
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
||||
setTreeLiveApplyError(error && error.message ? error.message : '文件树批量刷新失败');
|
||||
return false;
|
||||
});
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyLocalFolderWatchBatch(payload) {
|
||||
var batch = payload && (payload.payload || payload);
|
||||
var affectedParents = Array.isArray(batch && batch.affectedParents)
|
||||
? batch.affectedParents
|
||||
: Array.isArray(batch && batch.affected_parents)
|
||||
? batch.affected_parents
|
||||
: [];
|
||||
if (!affectedParents.length) {
|
||||
setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents');
|
||||
return false;
|
||||
}
|
||||
var parents = new Set();
|
||||
affectedParents.forEach(function(parent) {
|
||||
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
|
||||
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
||||
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
||||
setTreeLiveApplyError(error && error.message ? error.message : '文件树 watch batch 刷新失败');
|
||||
return false;
|
||||
});
|
||||
})).then(function() {
|
||||
markLocalFolderWatchApplied('watch_batch');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshLocalFolderAfterCommand(action, result, body) {
|
||||
if (!localCommandNeedsProjectionRefresh(action, result)) return;
|
||||
if (body && body.batchId) {
|
||||
queueFileTreeBatchRefresh(body.batchId, result, body);
|
||||
return;
|
||||
}
|
||||
void fileTreeRefreshParentsForCommand(result, body);
|
||||
}
|
||||
|
||||
function sortOrderFromDelta(data) {
|
||||
var raw = data && (data.sortOrder ?? data.sort_order);
|
||||
var value = Number(raw);
|
||||
@@ -440,6 +588,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return String(item && (item.relativePath || item.rootRelativePath) || '').trim();
|
||||
}
|
||||
|
||||
function normalizeFileTreeRelativePath(value) {
|
||||
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
return normalized === '.' ? '' : normalized;
|
||||
}
|
||||
|
||||
function fileTreeRowByRelativePath(relativePath) {
|
||||
var normalized = normalizeFileTreeRelativePath(relativePath);
|
||||
if (!normalized) return null;
|
||||
return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]');
|
||||
}
|
||||
|
||||
function groupRowsByParent(rows) {
|
||||
var runtimeFn = fileTreeRuntimeFunction('groupRowsByParent');
|
||||
if (runtimeFn) return runtimeFn(rows);
|
||||
@@ -454,6 +613,53 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function fileTreeParentKey(rootUri, parentRelativePath) {
|
||||
return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
|
||||
}
|
||||
|
||||
function currentFileTreeParentKey(parentRelativePath) {
|
||||
return fileTreeParentKey(currentRootUri(), parentRelativePath);
|
||||
}
|
||||
|
||||
function projectionParentRelativePath(projection) {
|
||||
var resolved = readProjection(projection);
|
||||
return String(resolved && (resolved.parentRelativePath || resolved.parent_relative_path) || '').trim();
|
||||
}
|
||||
|
||||
function isFileTreeRootProjectionParent(parentRelativePath) {
|
||||
return normalizeFileTreeRelativePath(parentRelativePath) === normalizeFileTreeRelativePath(currentFileTreeScope());
|
||||
}
|
||||
|
||||
function rememberFileTreeProjection(parentRelativePath, rows, projection) {
|
||||
var key = currentFileTreeParentKey(parentRelativePath);
|
||||
fileTreeState.rowsByParent.set(key, rows);
|
||||
fileTreeState.loadedParents.add(key);
|
||||
fileTreeState.dirtyParents.delete(key);
|
||||
fileTreeState.staleParents.delete(key);
|
||||
var resolved = readProjection(projection);
|
||||
var watchRevision = resolved && (resolved.watchRevision || resolved.watch_revision);
|
||||
if (watchRevision) fileTreeState.revisionByParent.set(key, watchRevision);
|
||||
}
|
||||
|
||||
function cachedFileTreeRows(parentRelativePath) {
|
||||
return fileTreeState.rowsByParent.get(currentFileTreeParentKey(parentRelativePath)) || [];
|
||||
}
|
||||
|
||||
function beginFileTreeRequest(key) {
|
||||
fileTreeState.requestGeneration += 1;
|
||||
fileTreeState.latestGenerationByParent.set(key, fileTreeState.requestGeneration);
|
||||
return fileTreeState.requestGeneration;
|
||||
}
|
||||
|
||||
function isLatestFileTreeRequest(key, generation) {
|
||||
return fileTreeState.latestGenerationByParent.get(key) === generation;
|
||||
}
|
||||
|
||||
function markFileTreeParentStale(key) {
|
||||
fileTreeState.staleParents.add(key);
|
||||
fileTreeState.dirtyParents.add(key);
|
||||
}
|
||||
|
||||
function pageTreeChevronSvg() {
|
||||
return '<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>';
|
||||
}
|
||||
@@ -586,7 +792,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
var objectIdentity = fileObjectIdentity(item);
|
||||
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
||||
var iconKind = iconKindOf(item);
|
||||
var cachedChildren = relativePath ? (fileTreeLazyChildrenCache.get(relativePath) || []) : [];
|
||||
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
|
||||
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
||||
? fileTreePageTitle(rawTitle)
|
||||
: normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity);
|
||||
@@ -615,9 +821,19 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
|
||||
function ensureFileTreeLazyCacheScope() {
|
||||
var rootUri = currentRootUri();
|
||||
if (rootUri === fileTreeLazyCacheRootUri) return;
|
||||
var scope = currentFileTreeScope();
|
||||
if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;
|
||||
fileTreeLazyCacheRootUri = rootUri;
|
||||
fileTreeLazyChildrenCache.clear();
|
||||
fileTreeState.rootUri = rootUri;
|
||||
fileTreeState.scope = scope;
|
||||
fileTreeState.rowsByParent.clear();
|
||||
fileTreeState.loadedParents.clear();
|
||||
fileTreeState.loadingParents.clear();
|
||||
fileTreeState.dirtyParents.clear();
|
||||
fileTreeState.staleParents.clear();
|
||||
fileTreeState.revisionByParent.clear();
|
||||
fileTreeState.latestGenerationByParent.clear();
|
||||
fileTreeState.requestGeneration += 1;
|
||||
fileTreeExpandedRelativePaths.clear();
|
||||
loadStoredFileTreeExpansionState(rootUri);
|
||||
}
|
||||
@@ -683,15 +899,80 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
if (changed) persistFileTreeExpansionState();
|
||||
}
|
||||
|
||||
function rememberFileTreeSelectionState() {
|
||||
fileTreeViewState.selectedRowIds.clear();
|
||||
fileTreeViewState.focusedRowId = '';
|
||||
fileTreeViewState.activeRowId = '';
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||
if (!rowId) return;
|
||||
if (row.getAttribute('data-selected') === 'true') fileTreeViewState.selectedRowIds.add(rowId);
|
||||
if (row.getAttribute('data-focused') === 'true') fileTreeViewState.focusedRowId = rowId;
|
||||
if (row.getAttribute('data-active') === 'true') fileTreeViewState.activeRowId = rowId;
|
||||
});
|
||||
}
|
||||
|
||||
function reprojectFileTreeSelectionState() {
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||
row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)));
|
||||
row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId));
|
||||
if (fileTreeViewState.activeRowId) {
|
||||
row.setAttribute('data-active', String(rowId === fileTreeViewState.activeRowId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderFileProjection(projection) {
|
||||
var tree = document.getElementById('sidebar-file-tree-root');
|
||||
if (!tree) return false;
|
||||
ensureFileTreeLazyCacheScope();
|
||||
rememberFileTreeExpansionState();
|
||||
rememberFileTreeSelectionState();
|
||||
var rows = projectionItems(projection);
|
||||
var parentRelativePath = projectionParentRelativePath(projection) || currentFileTreeScope();
|
||||
rememberFileTreeProjection(parentRelativePath, rows, projection);
|
||||
if (!isFileTreeRootProjectionParent(parentRelativePath)) {
|
||||
return patchFileTreeParentChildren(parentRelativePath, rows);
|
||||
}
|
||||
var activeId = currentDocumentId();
|
||||
var activeRowId = currentFileTreeActiveRowId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
|
||||
tree.replaceChildren(template.content.cloneNode(true));
|
||||
reprojectFileTreeSelectionState();
|
||||
scheduleRestorePersistedFileTreeExpansionState();
|
||||
return true;
|
||||
}
|
||||
|
||||
function patchFileTreeParentChildren(parentRelativePath, rows) {
|
||||
var row = fileTreeRowByRelativePath(parentRelativePath);
|
||||
if (!(row instanceof HTMLElement)) return true;
|
||||
var node = row.closest('.tree-node');
|
||||
if (!node) return true;
|
||||
rememberFileTreeSelectionState();
|
||||
var button = row.querySelector('[data-rust-action="toggle"]');
|
||||
var wasExpanded = row.getAttribute('aria-expanded') === 'true';
|
||||
var children = node.querySelector(':scope > .tree-children');
|
||||
if (!children && !wasExpanded && row.getAttribute('data-filetree-children-loaded') !== 'true') {
|
||||
return true;
|
||||
}
|
||||
if (!children) {
|
||||
children = document.createElement('ul');
|
||||
children.className = 'tree-children';
|
||||
node.appendChild(children);
|
||||
}
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = renderFileRows('', groupRowsByParent(rows), currentDocumentId(), currentFileTreeActiveRowId());
|
||||
children.replaceChildren(template.content.cloneNode(true));
|
||||
children.classList.toggle('tree-children--collapsed', !wasExpanded);
|
||||
row.setAttribute('data-filetree-children-loaded', 'true');
|
||||
row.removeAttribute('data-filetree-children-loading');
|
||||
setTreeRowExpanded(row, button, wasExpanded);
|
||||
syncSidebarFileTreeSelection();
|
||||
reprojectFileTreeSelectionState();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -702,6 +983,23 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return renderedPage || renderedFile;
|
||||
}
|
||||
|
||||
function renderLiveSidebarSnapshot(payload) {
|
||||
var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false;
|
||||
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
||||
var hasFileProjection = fileProjection && hasProjectionItems(fileProjection);
|
||||
if (currentFileTreeScope()) {
|
||||
if (!hasFileProjection) return renderedPage;
|
||||
var projectionParent = projectionParentRelativePath(fileProjection);
|
||||
if (isFileTreeRootProjectionParent(projectionParent)) {
|
||||
return renderFileProjection(fileProjection) || renderedPage;
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-filetree-scoped-live-snapshot-ignored', projectionParent || 'root');
|
||||
return true;
|
||||
}
|
||||
var renderedFile = hasFileProjection ? renderFileProjection(fileProjection) : false;
|
||||
return renderedPage || renderedFile;
|
||||
}
|
||||
|
||||
function replaceSidebarTreeFromDocument(nextDocument, rootId) {
|
||||
var runtimeFn = fileTreeRuntimeFunction('replaceSidebarTreeFromDocument');
|
||||
if (runtimeFn) return runtimeFn(nextDocument, rootId);
|
||||
@@ -739,10 +1037,61 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentFileTreeScope() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = String(params.get('fileTreeScope') || '').trim();
|
||||
if (fromUrl) return fromUrl;
|
||||
var root = document.getElementById('sidebar-file-tree-root');
|
||||
return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : '';
|
||||
}
|
||||
|
||||
async function fetchFileTreeProjection(parentRelativePath, options) {
|
||||
options = options || {};
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) return null;
|
||||
var url = new URL(options.childrenOnly ? '/api/tree/projections/file/children' : '/api/tree/projections/file', window.location.origin);
|
||||
var workspaceId = currentWorkspaceId();
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
if (parentRelativePath) url.searchParams.set('parentRelativePath', parentRelativePath);
|
||||
var currentId = currentDocumentId();
|
||||
if (!options.childrenOnly && currentId) url.searchParams.set('rootNodeId', currentId);
|
||||
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_projection_failed_' + response.status);
|
||||
return readProjection(payload && (payload.result || payload));
|
||||
}
|
||||
|
||||
async function refreshFileTreeParent(parentRelativePath) {
|
||||
ensureFileTreeLazyCacheScope();
|
||||
var key = currentFileTreeParentKey(parentRelativePath);
|
||||
fileTreeState.dirtyParents.add(key);
|
||||
if (!isFileTreeRootProjectionParent(parentRelativePath)) {
|
||||
var row = fileTreeRowByRelativePath(parentRelativePath);
|
||||
if (row instanceof HTMLElement && row.getAttribute('aria-expanded') !== 'true') {
|
||||
markFileTreeParentStale(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (fileTreeState.loadingParents.has(key)) {
|
||||
return fileTreeState.loadingParents.get(key).then(function(rows) {
|
||||
fileTreeState.dirtyParents.delete(key);
|
||||
return isFileTreeRootProjectionParent(parentRelativePath)
|
||||
? renderFileProjection({ parentRelativePath: parentRelativePath, items: rows })
|
||||
: patchFileTreeParentChildren(parentRelativePath, rows);
|
||||
});
|
||||
}
|
||||
var projection = await fetchFileTreeProjection(parentRelativePath, { childrenOnly: false });
|
||||
if (!projection) return false;
|
||||
return renderFileProjection(projection);
|
||||
}
|
||||
|
||||
async function refreshLocalFolderSidebarSnapshot() {
|
||||
var workspaceId = currentWorkspaceId();
|
||||
var rootUri = currentRootUri();
|
||||
var currentId = currentDocumentId();
|
||||
var fileTreeScope = currentFileTreeScope();
|
||||
if (!workspaceId || !rootUri) return false;
|
||||
|
||||
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
|
||||
@@ -750,22 +1099,24 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
sidebarUrl.searchParams.set('sourceKind', 'local_folder');
|
||||
sidebarUrl.searchParams.set('rootUri', rootUri);
|
||||
if (currentId) sidebarUrl.searchParams.set('rootNodeId', currentId);
|
||||
var fileUrl = new URL('/api/tree/projections/file', window.location.origin);
|
||||
fileUrl.searchParams.set('workspaceId', workspaceId);
|
||||
fileUrl.searchParams.set('sourceKind', 'local_folder');
|
||||
fileUrl.searchParams.set('rootUri', rootUri);
|
||||
if (currentId) fileUrl.searchParams.set('rootNodeId', currentId);
|
||||
|
||||
var responses = await Promise.all([
|
||||
var responses = await Promise.allSettled([
|
||||
fetch(sidebarUrl.toString(), { headers: { accept: 'application/json' } }),
|
||||
fetch(fileUrl.toString(), { headers: { accept: 'application/json' } })
|
||||
refreshFileTreeParent(fileTreeScope)
|
||||
]);
|
||||
if (!responses[0].ok && !responses[1].ok) return false;
|
||||
var sidebarResponse = responses[0].status === 'fulfilled' ? responses[0].value : null;
|
||||
var renderedFile = responses[1].status === 'fulfilled' ? responses[1].value : false;
|
||||
if ((!sidebarResponse || !sidebarResponse.ok) && !renderedFile) return false;
|
||||
|
||||
var sidebarPayload = responses[0].ok ? await responses[0].json().catch(function() { return null; }) : null;
|
||||
var filePayload = responses[1].ok ? await responses[1].json().catch(function() { return null; }) : null;
|
||||
var renderedPage = sidebarPayload ? renderSidebarSnapshot(sidebarPayload.result || sidebarPayload) : false;
|
||||
var renderedFile = filePayload ? renderFileProjection(filePayload.result || filePayload) : false;
|
||||
var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null;
|
||||
var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null;
|
||||
var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)
|
||||
? renderPageProjection(resolvedSidebarPayload)
|
||||
: false;
|
||||
if (renderedFile && fileTreeScope) {
|
||||
var fileRoot = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileRoot instanceof HTMLElement) fileRoot.setAttribute('data-mnote-filetree-scope', fileTreeScope);
|
||||
document.documentElement.setAttribute('data-mnote-filetree-scope', fileTreeScope);
|
||||
}
|
||||
if (!renderedPage && !renderedFile) return false;
|
||||
syncSidebarFileTreeSelection();
|
||||
schedulePendingLocalFolderRestoreFocus();
|
||||
@@ -783,22 +1134,35 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
scheduleRestorePersistedFileTreeExpansionState();
|
||||
var revision = '';
|
||||
var refreshTimer = 0;
|
||||
var treeLiveEventsActive = function() {
|
||||
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
|
||||
return treeTransport === 'local-folder-events';
|
||||
};
|
||||
var scheduleRefresh = function() {
|
||||
if (treeLiveEventsActive()) return;
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = window.setTimeout(function() {
|
||||
refreshTimer = 0;
|
||||
if (treeLiveEventsActive()) return;
|
||||
var fileTreeScope = currentFileTreeScope();
|
||||
if (fileTreeScope) {
|
||||
markFileTreeParentStale(currentFileTreeParentKey(fileTreeScope));
|
||||
markLocalFolderWatchApplied('scope_stale');
|
||||
document.documentElement.setAttribute('data-mnote-filetree-scope-watch-stale', fileTreeScope);
|
||||
return;
|
||||
}
|
||||
void refreshLocalFolderSidebarSnapshot();
|
||||
}, 180);
|
||||
};
|
||||
var poll = async function() {
|
||||
if (document.hidden) return;
|
||||
// If tree live SSE transport is active for local_folder, skip polling (fallback)
|
||||
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
|
||||
if (treeTransport === 'local-folder-events') return;
|
||||
if (treeLiveEventsActive()) return;
|
||||
var url = new URL('/api/tree/local-folder-watch', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
if (treeLiveEventsActive()) return;
|
||||
var payload = await response.json();
|
||||
var nextRevision = payload && payload.result && typeof payload.result.revision === 'string'
|
||||
? payload.result.revision
|
||||
@@ -881,8 +1245,23 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
persistFileTreeExpansionState();
|
||||
}
|
||||
|
||||
function markExistingFileTreeChildrenLoaded(row, button) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var node = row.closest('.tree-node');
|
||||
if (!node) return false;
|
||||
var children = node.querySelector(':scope > .tree-children');
|
||||
if (!(children instanceof HTMLElement)) return false;
|
||||
row.setAttribute('data-filetree-children-loaded', 'true');
|
||||
children.classList.remove('tree-children--collapsed');
|
||||
setTreeRowExpanded(row, button, true);
|
||||
syncSidebarFileTreeSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderCachedFileTreeChildren(row, button, relativePath) {
|
||||
var cachedRows = fileTreeLazyChildrenCache.get(relativePath) || [];
|
||||
var key = currentFileTreeParentKey(relativePath);
|
||||
if (fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key)) return false;
|
||||
var cachedRows = cachedFileTreeRows(relativePath);
|
||||
if (!cachedRows.length) return false;
|
||||
var node = row.closest('.tree-node');
|
||||
if (!node) return false;
|
||||
@@ -892,7 +1271,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
children.className = 'tree-children';
|
||||
node.appendChild(children);
|
||||
}
|
||||
children.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId());
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId());
|
||||
children.replaceChildren(template.content.cloneNode(true));
|
||||
children.classList.remove('tree-children--collapsed');
|
||||
row.setAttribute('data-filetree-children-loaded', 'true');
|
||||
setTreeRowExpanded(row, button, true);
|
||||
@@ -900,31 +1281,47 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getFileTreeChildren(parentRelativePath) {
|
||||
ensureFileTreeLazyCacheScope();
|
||||
var key = currentFileTreeParentKey(parentRelativePath);
|
||||
if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key) && !fileTreeState.staleParents.has(key)) {
|
||||
return fileTreeState.rowsByParent.get(key) || [];
|
||||
}
|
||||
if (fileTreeState.loadingParents.has(key)) {
|
||||
return fileTreeState.loadingParents.get(key);
|
||||
}
|
||||
var generation = beginFileTreeRequest(key);
|
||||
var promise = fetchFileTreeProjection(parentRelativePath, { childrenOnly: true }).then(function(projection) {
|
||||
if (!isLatestFileTreeRequest(key, generation)) {
|
||||
return fileTreeState.rowsByParent.get(key) || [];
|
||||
}
|
||||
var rows = projectionItems(projection);
|
||||
rememberFileTreeProjection(parentRelativePath, rows, projection);
|
||||
return rows;
|
||||
}).finally(function() {
|
||||
fileTreeState.loadingParents.delete(key);
|
||||
});
|
||||
fileTreeState.loadingParents.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function loadFileTreeChildren(row, button) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
if (row.getAttribute('data-shell-mode') !== 'filetree') return false;
|
||||
if (currentSourceKind() !== 'local_folder') return false;
|
||||
ensureFileTreeLazyCacheScope();
|
||||
var relativePath = localFileTreeRelativePathFromRow(row);
|
||||
if (relativePath && renderCachedFileTreeChildren(row, button, relativePath)) return true;
|
||||
var key = currentFileTreeParentKey(relativePath);
|
||||
var stale = fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key);
|
||||
if (!stale && markExistingFileTreeChildrenLoaded(row, button)) return true;
|
||||
if (!stale && relativePath && renderCachedFileTreeChildren(row, button, relativePath)) return true;
|
||||
if (row.getAttribute('data-filetree-children-loaded') === 'true') return false;
|
||||
if (row.getAttribute('data-filetree-children-loading') === 'true') return true;
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri || !relativePath) return false;
|
||||
setTreeRowExpanded(row, button, true);
|
||||
row.setAttribute('data-filetree-children-loading', 'true');
|
||||
try {
|
||||
var url = new URL('/api/tree/projections/file/children', window.location.origin);
|
||||
var workspaceId = currentWorkspaceId();
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('parentRelativePath', relativePath);
|
||||
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_children_failed_' + response.status);
|
||||
var projection = readProjection(payload && (payload.result || payload));
|
||||
var rows = projectionItems(projection);
|
||||
fileTreeLazyChildrenCache.set(relativePath, rows);
|
||||
var rows = await getFileTreeChildren(relativePath);
|
||||
if (!rows.length) {
|
||||
row.setAttribute('data-filetree-children-loaded', 'true');
|
||||
setTreeRowExpanded(row, button, true);
|
||||
@@ -939,6 +1336,53 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeParentChainForRelativePath(relativePath) {
|
||||
var normalized = normalizeFileTreeRelativePath(relativePath);
|
||||
if (!normalized) return [];
|
||||
var parts = normalized.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
var chain = [];
|
||||
for (var index = 0; index < parts.length; index += 1) {
|
||||
chain.push(parts.slice(0, index + 1).join('/'));
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
async function revealFileTreeResource(input) {
|
||||
input = input || {};
|
||||
var rootUri = String(input.rootUri || currentRootUri() || '').trim();
|
||||
if (!rootUri || rootUri !== currentRootUri()) return false;
|
||||
ensureFileTreeLazyCacheScope();
|
||||
var relativePath = normalizeFileTreeRelativePath(input.relativePath || input.relative_path || '');
|
||||
var rowId = String(input.rowId || input.row_id || '').trim();
|
||||
var parentChain = fileTreeParentChainForRelativePath(relativePath);
|
||||
for (var index = 0; index < parentChain.length; index += 1) {
|
||||
var parentRelativePath = parentChain[index];
|
||||
var parentRow = fileTreeRowByRelativePath(parentRelativePath);
|
||||
if (!(parentRow instanceof HTMLElement)) continue;
|
||||
var button = parentRow.querySelector('[data-rust-action="toggle"]');
|
||||
await getFileTreeChildren(parentRelativePath);
|
||||
renderCachedFileTreeChildren(parentRow, button, parentRelativePath);
|
||||
setTreeRowExpanded(parentRow, button, true);
|
||||
}
|
||||
var targetRow = rowId
|
||||
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]')
|
||||
: null;
|
||||
if (!(targetRow instanceof HTMLElement) && relativePath) {
|
||||
targetRow = fileTreeRowByRelativePath(relativePath);
|
||||
}
|
||||
if (!(targetRow instanceof HTMLElement)) return false;
|
||||
var targetRowId = String(targetRow.getAttribute('data-row-id') || rowId || '').trim();
|
||||
if (targetRowId) {
|
||||
fileTreeViewState.selectedRowIds = new Set([targetRowId]);
|
||||
fileTreeViewState.focusedRowId = targetRowId;
|
||||
fileTreeViewState.activeRowId = targetRowId;
|
||||
}
|
||||
reprojectFileTreeSelectionState();
|
||||
try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toggleChildren(row, button) {
|
||||
var li = row && row.parentElement;
|
||||
var children = li ? li.querySelector(':scope > .tree-children') : null;
|
||||
@@ -962,6 +1406,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
var relativePath = localFileTreeRelativePathFromRow(row);
|
||||
if (!relativePath || !fileTreeExpandedRelativePaths.has(relativePath)) return;
|
||||
var button = row.querySelector('[data-rust-action="toggle"]');
|
||||
if (markExistingFileTreeChildrenLoaded(row, button)) {
|
||||
restored = true;
|
||||
return;
|
||||
}
|
||||
if (row.getAttribute('data-filetree-children-loaded') === 'true') {
|
||||
setTreeRowExpanded(row, button, true);
|
||||
restored = true;
|
||||
@@ -998,7 +1446,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
var result = detail.result || {};
|
||||
if (action === 'create') {
|
||||
applyCreatedDocumentLocally(result, body.parentId || null, body.title || '新页面');
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
refreshLocalFolderAfterCommand(action, result, body);
|
||||
return;
|
||||
}
|
||||
if (action === 'rename' && body.documentId && body.title) {
|
||||
@@ -1012,7 +1460,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
} else {
|
||||
updateTitleEverywhere(body.documentId, body.title);
|
||||
}
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
refreshLocalFolderAfterCommand(action, result, body);
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'rename');
|
||||
return;
|
||||
}
|
||||
@@ -1020,20 +1468,25 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move');
|
||||
}
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
refreshLocalFolderAfterCommand(action, result, body);
|
||||
return;
|
||||
}
|
||||
if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) {
|
||||
if (applyRemoveDocumentDelta({ documentId: body.documentId })) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove');
|
||||
}
|
||||
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
|
||||
refreshLocalFolderAfterCommand(action, result, body);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('tree:local-command-batch-complete', function(event) {
|
||||
var detail = event.detail || {};
|
||||
flushFileTreeBatchRefresh(detail.batchId || detail.batch_id || '');
|
||||
});
|
||||
|
||||
window.addEventListener('tree:snapshot', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
if (renderSidebarSnapshot(payload)) {
|
||||
if (renderLiveSidebarSnapshot(payload)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot');
|
||||
return;
|
||||
}
|
||||
@@ -1061,7 +1514,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题');
|
||||
});
|
||||
}
|
||||
if (renderSidebarSnapshot(payload)) {
|
||||
if (renderLiveSidebarSnapshot(payload)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
return;
|
||||
}
|
||||
@@ -1073,7 +1526,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
|
||||
window.addEventListener('tree:resync', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
if (renderSidebarSnapshot(payload)) {
|
||||
if (renderLiveSidebarSnapshot(payload)) {
|
||||
refreshEditorLocalAttachmentExistence();
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
||||
return;
|
||||
@@ -1081,6 +1534,18 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
|
||||
setTreeLiveApplyError('tree_resync_missing_projection_payload');
|
||||
});
|
||||
|
||||
window.addEventListener('tree:local-folder-watch-batch', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
applyLocalFolderWatchBatch(payload);
|
||||
});
|
||||
|
||||
window.addEventListener('tree:error', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
var code = payload && (payload.code || payload.error || payload.message);
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-error-schema', String(payload && payload.schema || ''));
|
||||
setTreeLiveApplyError(code || 'tree_live_error');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1091,14 +1556,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
commandDocumentTitle,
|
||||
deltaNeedsProjectionRefresh,
|
||||
fileTreePageTitle,
|
||||
flushFileTreeBatchRefresh,
|
||||
installTreeLiveApplyEventListeners,
|
||||
isFileTreePageRow,
|
||||
applyLocalFolderWatchBatch,
|
||||
localCommandNeedsProjectionRefresh,
|
||||
normalizeFileTreePageRenameTitle,
|
||||
objectIdentityAttr,
|
||||
refreshLocalFolderSidebarSnapshot,
|
||||
removeDocumentRowForMode,
|
||||
renderSidebarSnapshot,
|
||||
revealFileTreeResource,
|
||||
restorePersistedFileTreeExpansionState,
|
||||
setTreeLiveApplyError,
|
||||
startLocalFolderSidebarWatch,
|
||||
|
||||
@@ -301,13 +301,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (currentSourceKind() !== 'local_folder') return false;
|
||||
var workspaceId = resolveWorkspaceId(trigger || document.body);
|
||||
var effectiveParentId = String(parentId || '').trim();
|
||||
var title = window.prompt('新建文件夹', '新建文件夹');
|
||||
if (!title || !title.trim()) return false;
|
||||
var result = await dispatchTreeCommand(trigger || document.body, {
|
||||
action: 'create_folder',
|
||||
workspaceId: workspaceId,
|
||||
parentId: effectiveParentId || null,
|
||||
title: title.trim()
|
||||
title: '新建文件夹'
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-filetree-folder-created', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-filetree-folder-created-id', commandDocumentId(result, result.id || ''));
|
||||
@@ -357,6 +355,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
copyWorkspaceSourceParams,
|
||||
cssEscape,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
dispatchTreeCommand,
|
||||
normalizeSidebarTreeMode,
|
||||
@@ -387,12 +386,385 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const startLocalFolderSidebarWatch = (...args) => sidebarTreeLiveApply.startLocalFolderSidebarWatch(...args);
|
||||
const deltaNeedsProjectionRefresh = (...args) => sidebarTreeLiveApply.deltaNeedsProjectionRefresh(...args);
|
||||
const toggleChildren = (...args) => sidebarTreeLiveApply.toggleChildren(...args);
|
||||
const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args);
|
||||
|
||||
function dispatchSidebarEvent(name, detail) {
|
||||
document.documentElement.setAttribute('data-mnote-last-tree-action', name);
|
||||
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
|
||||
}
|
||||
|
||||
function sidebarShortcutWorkspaceId() {
|
||||
return currentWorkspaceId()
|
||||
|| (document.getElementById('sidebar-file-tree-root') && document.getElementById('sidebar-file-tree-root').getAttribute('data-workspace-id') || '')
|
||||
|| (document.getElementById('sidebar-tree-root') && document.getElementById('sidebar-tree-root').getAttribute('data-workspace-id') || '');
|
||||
}
|
||||
|
||||
function sidebarShortcutSourceKind() {
|
||||
return currentSourceKind() || 'workspace';
|
||||
}
|
||||
|
||||
function currentFileTreeScope() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = String(params.get('fileTreeScope') || '').trim();
|
||||
if (fromUrl) return fromUrl;
|
||||
var root = document.getElementById('sidebar-file-tree-root');
|
||||
return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : '';
|
||||
}
|
||||
|
||||
function currentTopbarTitle() {
|
||||
var title = document.querySelector('[data-page-title-current="true"]');
|
||||
return title && title.textContent ? title.textContent.trim() : '无标题';
|
||||
}
|
||||
|
||||
function fileTreeRowTitleForShortcut(row, fallback) {
|
||||
var title = row && row.querySelector ? row.querySelector(':scope > .tree-link > .tree-link-title') : null;
|
||||
return title && title.textContent ? title.textContent.trim() : (fallback || '文件夹');
|
||||
}
|
||||
|
||||
function sidebarShortcutRows() {
|
||||
return Array.from(document.querySelectorAll('.wolai-starred-section [data-mnote-shortcut-kind]'));
|
||||
}
|
||||
|
||||
function shortcutMatches(row, kind, targetId, relativePath, documentId) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
if ((row.getAttribute('data-mnote-shortcut-kind') || '') !== kind) return false;
|
||||
if (documentId && (row.getAttribute('data-mnote-shortcut-document-id') || row.getAttribute('data-document-id') || '') === documentId) return true;
|
||||
if (relativePath && (row.getAttribute('data-mnote-shortcut-relative-path') || '') === relativePath) return true;
|
||||
return Boolean(targetId && (row.getAttribute('data-mnote-shortcut-target-id') || row.getAttribute('data-node-id') || '') === targetId);
|
||||
}
|
||||
|
||||
async function listSidebarShortcuts(workspaceId) {
|
||||
var url = new URL('/api/sidebar/shortcuts', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
var response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store'
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'sidebar_shortcuts_list_failed_' + response.status);
|
||||
return payload && Array.isArray(payload.shortcuts) ? payload.shortcuts : [];
|
||||
}
|
||||
|
||||
function shortcutRecordMatches(shortcut, kind, targetId, relativePath, documentId) {
|
||||
if (!shortcut || shortcut.kind !== kind) return false;
|
||||
var shortcutMetadata = shortcut && shortcut.metadata && typeof shortcut.metadata === 'object' ? shortcut.metadata : {};
|
||||
var shortcutRootUri = String(shortcut.rootUri || shortcut.root_uri || shortcutMetadata.rootUri || shortcutMetadata.root_uri || '').trim();
|
||||
var currentShortcutRootUri = currentRootUri();
|
||||
if (shortcutRootUri && currentShortcutRootUri && shortcutRootUri !== currentShortcutRootUri) return false;
|
||||
if (documentId && String(shortcut.documentId || shortcut.document_id || '') === documentId) return true;
|
||||
if (relativePath && String(shortcut.relativePath || shortcut.relative_path || '') === relativePath) return true;
|
||||
return Boolean(targetId && String(shortcut.targetId || shortcut.target_id || '') === targetId);
|
||||
}
|
||||
|
||||
async function findSidebarShortcut(payload) {
|
||||
var workspaceId = payload.workspaceId || sidebarShortcutWorkspaceId();
|
||||
if (!workspaceId) return null;
|
||||
var shortcuts = await listSidebarShortcuts(workspaceId);
|
||||
return shortcuts.find(function(shortcut) {
|
||||
return shortcutRecordMatches(shortcut, payload.kind, payload.targetId, payload.relativePath, payload.documentId);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function upsertSidebarShortcut(payload) {
|
||||
var response = await fetch('/api/sidebar/shortcuts', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var result = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(result && (result.error || result.message) || 'sidebar_shortcut_upsert_failed_' + response.status);
|
||||
return result && result.shortcut ? result.shortcut : null;
|
||||
}
|
||||
|
||||
async function deleteSidebarShortcut(shortcutId) {
|
||||
if (!shortcutId) return false;
|
||||
var response = await fetch('/api/sidebar/shortcuts/' + encodeURIComponent(shortcutId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
var result = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(result && (result.error || result.message) || 'sidebar_shortcut_delete_failed_' + response.status);
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeSidebarShortcutMenu() {
|
||||
var existing = document.querySelector('[data-testid="mnote-sidebar-shortcut-menu"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
document.querySelectorAll('[data-mnote-shortcut-action="menu"][aria-expanded="true"]').forEach(function(button) {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
async function removeSidebarShortcutByRow(row) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var shortcutId = String(row.getAttribute('data-mnote-shortcut-id') || '').trim();
|
||||
if (!shortcutId) return false;
|
||||
row.setAttribute('data-mnote-shortcut-pending', 'true');
|
||||
try {
|
||||
await deleteSidebarShortcut(shortcutId);
|
||||
row.remove();
|
||||
closeSidebarShortcutMenu();
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'remove');
|
||||
return true;
|
||||
} finally {
|
||||
if (row.isConnected) row.removeAttribute('data-mnote-shortcut-pending');
|
||||
}
|
||||
}
|
||||
|
||||
function openSidebarShortcutMenu(row, trigger) {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
closeSidebarShortcutMenu();
|
||||
if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'true');
|
||||
var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : row.getBoundingClientRect();
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'mnote-tree-context-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('data-testid', 'mnote-sidebar-shortcut-menu');
|
||||
menu.innerHTML = '<button type="button" class="mnote-tree-context-menu__item" role="menuitem" data-mnote-sidebar-shortcut-menu-action="open"><span class="material-symbols-outlined mnote-tree-context-menu__icon" data-icon="login" aria-hidden="true"></span><span class="mnote-tree-context-menu__label">在右侧边栏打开</span></button><div class="mnote-tree-context-menu__separator" role="separator"></div><button type="button" class="mnote-tree-context-menu__item" role="menuitem" data-mnote-sidebar-shortcut-menu-action="copy-link"><span class="material-symbols-outlined mnote-tree-context-menu__icon" data-icon="link" aria-hidden="true"></span><span class="mnote-tree-context-menu__label">复制访问链接</span></button><button type="button" class="mnote-tree-context-menu__item mnote-tree-context-menu__item--danger" role="menuitem" data-mnote-sidebar-shortcut-menu-action="remove"><span class="material-symbols-outlined mnote-tree-context-menu__icon" data-icon="star_off" aria-hidden="true"></span><span class="mnote-tree-context-menu__label">取消星标</span></button>';
|
||||
menu.__mnoteShortcutRow = row;
|
||||
document.body.appendChild(menu);
|
||||
var width = menu.offsetWidth || 220;
|
||||
var left = Math.min(Math.max(8, rect.right - width), Math.max(8, window.innerWidth - width - 8));
|
||||
var top = Math.min(Math.max(8, rect.bottom + 4), Math.max(8, window.innerHeight - (menu.offsetHeight || 120) - 8));
|
||||
menu.style.left = left + 'px';
|
||||
menu.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function removeSidebarShortcutRow(shortcut) {
|
||||
sidebarShortcutRows().forEach(function(row) {
|
||||
if (shortcutMatches(
|
||||
row,
|
||||
shortcut.kind,
|
||||
String(shortcut.targetId || shortcut.target_id || ''),
|
||||
String(shortcut.relativePath || shortcut.relative_path || ''),
|
||||
String(shortcut.documentId || shortcut.document_id || '')
|
||||
)) {
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderSidebarShortcutRow(shortcut) {
|
||||
if (!shortcut) return;
|
||||
var section = document.querySelector('.wolai-starred-section');
|
||||
if (!(section instanceof HTMLElement)) return;
|
||||
removeSidebarShortcutRow(shortcut);
|
||||
var kind = String(shortcut.kind || '').trim();
|
||||
var shortcutId = String(shortcut.id || shortcut.targetId || shortcut.target_id || '').trim();
|
||||
var targetId = String(shortcut.targetId || shortcut.target_id || '').trim();
|
||||
var relativePath = String(shortcut.relativePath || shortcut.relative_path || '').trim();
|
||||
var documentId = String(shortcut.documentId || shortcut.document_id || '').trim();
|
||||
var sourceKind = String(shortcut.sourceKind || shortcut.source_kind || '').trim();
|
||||
var metadata = shortcut.metadata && typeof shortcut.metadata === 'object' ? shortcut.metadata : {};
|
||||
var rootUri = String(shortcut.rootUri || shortcut.root_uri || metadata.rootUri || metadata.root_uri || '').trim();
|
||||
var workspaceId = String(shortcut.workspaceId || shortcut.workspace_id || sidebarShortcutWorkspaceId() || '').trim();
|
||||
var title = String(shortcut.title || (kind === 'folder' ? '文件夹' : '无标题')).trim();
|
||||
var href = '';
|
||||
if (documentId) {
|
||||
var targetUrl = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin);
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
if (sourceKind) targetUrl.searchParams.set('sourceKind', sourceKind);
|
||||
if (rootUri) targetUrl.searchParams.set('rootUri', rootUri);
|
||||
href = targetUrl.pathname + targetUrl.search;
|
||||
}
|
||||
var row = document.createElement(href ? 'a' : 'div');
|
||||
row.className = 'wolai-page-row';
|
||||
if (href) row.setAttribute('href', href);
|
||||
else {
|
||||
row.setAttribute('role', 'button');
|
||||
row.setAttribute('tabindex', '0');
|
||||
}
|
||||
row.setAttribute('data-testid', 'wolai-sidebar-row');
|
||||
row.setAttribute('data-node-id', shortcutId || targetId);
|
||||
row.setAttribute('data-document-id', documentId || shortcutId || targetId);
|
||||
if (shortcutId) row.setAttribute('data-mnote-shortcut-id', shortcutId);
|
||||
if (workspaceId) row.setAttribute('data-workspace-id', workspaceId);
|
||||
row.setAttribute('data-mnote-shortcut-kind', kind);
|
||||
if (sourceKind) row.setAttribute('data-mnote-shortcut-source-kind', sourceKind);
|
||||
row.setAttribute('data-mnote-shortcut-target-id', targetId);
|
||||
if (relativePath) row.setAttribute('data-mnote-shortcut-relative-path', relativePath);
|
||||
if (rootUri) row.setAttribute('data-mnote-shortcut-root-uri', rootUri);
|
||||
if (documentId) row.setAttribute('data-mnote-shortcut-document-id', documentId);
|
||||
row.setAttribute('data-depth', '0');
|
||||
row.setAttribute('data-active', String(documentId && documentId === currentDocumentId()));
|
||||
row.innerHTML = '<span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon"><span class="material-symbols-outlined wolai-row-symbol" data-icon="' + (kind === 'folder' ? 'folder_open' : 'home') + '" aria-hidden="true"></span></span><span class="wolai-row-title">' + escapeHtml(title) + '</span><button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>';
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
async function toggleSidebarShortcut(payload) {
|
||||
var workspaceId = payload.workspaceId || sidebarShortcutWorkspaceId();
|
||||
if (!workspaceId) return false;
|
||||
var normalized = Object.assign({}, payload, { workspaceId: workspaceId });
|
||||
var existing = await findSidebarShortcut(normalized);
|
||||
if (existing && existing.id) {
|
||||
await deleteSidebarShortcut(existing.id);
|
||||
removeSidebarShortcutRow(existing);
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'remove');
|
||||
return true;
|
||||
}
|
||||
var shortcut = await upsertSidebarShortcut(normalized);
|
||||
renderSidebarShortcutRow(shortcut);
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'upsert');
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentPageShortcutPayload() {
|
||||
var documentId = currentDocumentId();
|
||||
var workspaceId = sidebarShortcutWorkspaceId();
|
||||
if (!documentId || !workspaceId) return null;
|
||||
return {
|
||||
workspaceId: workspaceId,
|
||||
kind: 'page',
|
||||
sourceKind: sidebarShortcutSourceKind(),
|
||||
targetId: documentId,
|
||||
documentId: documentId,
|
||||
title: currentTopbarTitle(),
|
||||
icon: 'star',
|
||||
rootUri: currentRootUri(),
|
||||
metadataJson: JSON.stringify({ rootUri: currentRootUri() })
|
||||
};
|
||||
}
|
||||
|
||||
function folderShortcutPayload(detail, trigger) {
|
||||
detail = detail || {};
|
||||
var row = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
|
||||
var rowKind = String(detail.rowKind || (row && row.getAttribute('data-row-kind')) || '').trim();
|
||||
if (rowKind !== 'folder' && rowKind !== 'directory') return null;
|
||||
var relativePath = String(detail.localRelativePath || (row && row.getAttribute('data-local-relative-path')) || '').trim();
|
||||
if (!relativePath) return null;
|
||||
var workspaceId = String(detail.workspaceId || sidebarShortcutWorkspaceId() || '').trim();
|
||||
if (!workspaceId) return null;
|
||||
var rowId = String(detail.rowId || (row && row.getAttribute('data-row-id')) || '').trim();
|
||||
return {
|
||||
workspaceId: workspaceId,
|
||||
kind: 'folder',
|
||||
sourceKind: 'local_folder',
|
||||
targetId: rowId || ('folder:' + relativePath),
|
||||
relativePath: relativePath,
|
||||
title: detail.title || fileTreeRowTitleForShortcut(row, relativePath),
|
||||
icon: 'folder_open',
|
||||
rootUri: currentRootUri(),
|
||||
metadataJson: JSON.stringify({ rootUri: currentRootUri() })
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleCurrentPageSidebarShortcut(trigger) {
|
||||
var payload = currentPageShortcutPayload();
|
||||
if (!payload) return;
|
||||
if (trigger instanceof HTMLElement) trigger.setAttribute('data-mnote-shortcut-pending', 'true');
|
||||
try {
|
||||
await toggleSidebarShortcut(payload);
|
||||
} finally {
|
||||
if (trigger instanceof HTMLElement) trigger.removeAttribute('data-mnote-shortcut-pending');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFolderSidebarShortcut(detail, trigger) {
|
||||
var payload = folderShortcutPayload(detail, trigger);
|
||||
if (!payload) return false;
|
||||
await toggleSidebarShortcut(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureStarredFolderFileTreeHost(workspaceId) {
|
||||
var root = document.getElementById('sidebar-file-tree-root');
|
||||
if (root instanceof HTMLElement) return root;
|
||||
var panel = document.getElementById('wolai-sidebar-file-tree-panel');
|
||||
if (!(panel instanceof HTMLElement)) return null;
|
||||
var section = document.createElement('div');
|
||||
section.className = 'sidebar-tree-section sidebar-file-tree-section';
|
||||
root = document.createElement('div');
|
||||
root.id = 'sidebar-file-tree-root';
|
||||
root.className = 'sidebar-tree';
|
||||
root.setAttribute('data-tree-shell-mode', 'filetree');
|
||||
if (workspaceId) root.setAttribute('data-workspace-id', workspaceId);
|
||||
section.appendChild(root);
|
||||
panel.appendChild(section);
|
||||
return root;
|
||||
}
|
||||
|
||||
function persistStarredFolderScope(workspaceId, rootUri, relativePath) {
|
||||
var targetUrl = new URL(window.location.href);
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
targetUrl.searchParams.set('sourceKind', 'local_folder');
|
||||
targetUrl.searchParams.set('rootUri', rootUri);
|
||||
targetUrl.searchParams.set('treeView', 'filetree');
|
||||
targetUrl.searchParams.set('fileTreeScope', relativePath);
|
||||
window.history.replaceState(window.history.state, '', targetUrl.pathname + targetUrl.search + targetUrl.hash);
|
||||
if (document.body instanceof HTMLElement) {
|
||||
document.body.setAttribute('data-mnote-source-kind', 'local_folder');
|
||||
document.body.setAttribute('data-mnote-root-uri', rootUri);
|
||||
}
|
||||
}
|
||||
|
||||
function readShortcutRootUri(row) {
|
||||
if (!(row instanceof HTMLElement)) return '';
|
||||
return String(row.getAttribute('data-mnote-shortcut-root-uri') || '').trim();
|
||||
}
|
||||
|
||||
async function openStarredFolderShortcut(row) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var relativePath = String(row.getAttribute('data-mnote-shortcut-relative-path') || '').trim();
|
||||
var workspaceId = String(row.getAttribute('data-workspace-id') || '').trim() || sidebarShortcutWorkspaceId();
|
||||
var rootUri = readShortcutRootUri(row);
|
||||
if (!relativePath || !rootUri || !workspaceId) {
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-open-error', !rootUri ? 'missing_root_uri' : 'missing_target');
|
||||
return false;
|
||||
}
|
||||
document.documentElement.removeAttribute('data-mnote-sidebar-shortcut-open-error');
|
||||
var tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"]');
|
||||
if (tab instanceof HTMLElement) switchSidebarTreeTab(tab);
|
||||
var root = ensureStarredFolderFileTreeHost(workspaceId);
|
||||
if (root instanceof HTMLElement) {
|
||||
root.setAttribute('data-workspace-id', workspaceId);
|
||||
root.setAttribute('data-mnote-filetree-scope', relativePath);
|
||||
root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath);
|
||||
}
|
||||
persistStarredFolderScope(workspaceId, rootUri, relativePath);
|
||||
document.documentElement.setAttribute('data-mnote-filetree-scope', relativePath);
|
||||
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
|
||||
sidebarUrl.searchParams.set('workspaceId', workspaceId);
|
||||
sidebarUrl.searchParams.set('sourceKind', 'local_folder');
|
||||
sidebarUrl.searchParams.set('rootUri', rootUri);
|
||||
var url = new URL('/api/tree/projections/file', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('parentRelativePath', relativePath);
|
||||
var sidebarResponse = await fetch(sidebarUrl.toString(), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store'
|
||||
});
|
||||
var sidebarPayload = await sidebarResponse.json().catch(function() { return null; });
|
||||
if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status);
|
||||
var response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store'
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'scoped_filetree_failed_' + response.status);
|
||||
var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {};
|
||||
var fileProjection = payload && (payload.result || payload);
|
||||
var rendered = renderSidebarSnapshot(Object.assign({}, sidebarProjection, {
|
||||
dataset: Object.assign({}, sidebarProjection.dataset || {}, { kernel_file_tree_projection: fileProjection })
|
||||
}));
|
||||
root = document.getElementById('sidebar-file-tree-root');
|
||||
if (root instanceof HTMLElement) {
|
||||
root.setAttribute('data-workspace-id', workspaceId);
|
||||
root.setAttribute('data-mnote-filetree-scope', relativePath);
|
||||
root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
const sidebarFileTreeOpen = createSidebarFileTreeOpenRuntime({
|
||||
copyWorkspaceSourceParams,
|
||||
currentDocumentId,
|
||||
@@ -736,14 +1108,19 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
async function healLegacyOfficeAttachmentParagraphs() {
|
||||
var editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return;
|
||||
var index = await fetchLegacyOfficeAttachmentIndex();
|
||||
var paragraphs = Array.from(editor.querySelectorAll('p'));
|
||||
paragraphs.forEach(function(paragraph) {
|
||||
var candidates = paragraphs.filter(function(paragraph) {
|
||||
if (!(paragraph instanceof HTMLParagraphElement)) return;
|
||||
if (paragraph.querySelector('a, img, video, audio, table, iframe, canvas')) return;
|
||||
if (paragraph.childNodes.length !== 1 || paragraph.firstChild?.nodeType !== Node.TEXT_NODE) return;
|
||||
var fileName = String(paragraph.textContent || '').trim();
|
||||
if (!fileName) return;
|
||||
return Boolean(inferOnlyOfficeFileType(fileName, ''));
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
var index = await fetchLegacyOfficeAttachmentIndex();
|
||||
candidates.forEach(function(paragraph) {
|
||||
var fileName = String(paragraph.textContent || '').trim();
|
||||
var detail = index[fileName];
|
||||
if (!detail) return;
|
||||
var link = document.createElement('a');
|
||||
@@ -1545,6 +1922,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
createPage,
|
||||
cssEscape,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
deleteSingleFileTreeAsset,
|
||||
dispatchSidebarEvent,
|
||||
@@ -1563,6 +1941,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
|
||||
openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args),
|
||||
refreshLocalFolderSidebarSnapshot,
|
||||
removeFileTreeAssetRow,
|
||||
revealFileTreeResource,
|
||||
resolveWorkspaceId,
|
||||
runtimeState: sidebarFileTreeCommandState,
|
||||
selectedSidebarFileTreeSelection: sidebarFileTreeSelection,
|
||||
@@ -1620,6 +2000,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const postSidebarFileTreeJson = (...args) => sidebarFileTreeCommand.postSidebarFileTreeJson(...args);
|
||||
const fileTreeRowsByRowIds = (...args) => sidebarFileTreeCommand.fileTreeRowsByRowIds(...args);
|
||||
const fileTreeChildCount = (...args) => sidebarFileTreeCommand.fileTreeChildCount(...args);
|
||||
const moveSidebarFileTreeRows = (...args) => sidebarFileTreeCommand.moveSidebarFileTreeRows(...args);
|
||||
const pasteSidebarFileTreeClipboard = (...args) => sidebarFileTreeCommand.pasteSidebarFileTreeClipboard(...args);
|
||||
const deleteSelectedSidebarFileTreeRows = (...args) => sidebarFileTreeCommand.deleteSelectedSidebarFileTreeRows(...args);
|
||||
|
||||
@@ -1891,6 +2272,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
buildLocalOnlyOfficeOpenUrl,
|
||||
buildOnlyOfficeOpenPath,
|
||||
buildOnlyOfficeOpenUrl,
|
||||
closestAction,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentWorkspaceSourcePayload,
|
||||
@@ -2274,6 +2656,58 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
|
||||
var pageShortcutTrigger = closestAction(e.target, '[data-mnote-action="toggle-sidebar-shortcut"]');
|
||||
if (pageShortcutTrigger) {
|
||||
e.preventDefault();
|
||||
void toggleCurrentPageSidebarShortcut(pageShortcutTrigger);
|
||||
return;
|
||||
}
|
||||
|
||||
var shortcutMenu = closestAction(e.target, '[data-testid="mnote-sidebar-shortcut-menu"]');
|
||||
var shortcutMenuAction = closestAction(e.target, '[data-mnote-sidebar-shortcut-menu-action]');
|
||||
if (shortcutMenuAction) {
|
||||
e.preventDefault();
|
||||
var action = String(shortcutMenuAction.getAttribute('data-mnote-sidebar-shortcut-menu-action') || '').trim();
|
||||
var shortcutRowFromMenu = shortcutMenu && shortcutMenu.__mnoteShortcutRow instanceof HTMLElement
|
||||
? shortcutMenu.__mnoteShortcutRow
|
||||
: null;
|
||||
if (action === 'remove') {
|
||||
void removeSidebarShortcutByRow(shortcutRowFromMenu);
|
||||
return;
|
||||
}
|
||||
if (action === 'open') {
|
||||
closeSidebarShortcutMenu();
|
||||
if (shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('data-mnote-shortcut-kind') === 'folder') {
|
||||
void openStarredFolderShortcut(shortcutRowFromMenu);
|
||||
} else if (shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('href')) {
|
||||
window.location.assign(shortcutRowFromMenu.getAttribute('href'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'copy-link') {
|
||||
var href = shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('href') || window.location.href;
|
||||
if (navigator.clipboard && href) void navigator.clipboard.writeText(new URL(href, window.location.origin).toString());
|
||||
closeSidebarShortcutMenu();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!shortcutMenu) closeSidebarShortcutMenu();
|
||||
|
||||
var shortcutMenuTrigger = closestAction(e.target, '.wolai-starred-section [data-mnote-shortcut-action="menu"]');
|
||||
if (shortcutMenuTrigger) {
|
||||
e.preventDefault();
|
||||
var shortcutRow = shortcutMenuTrigger.closest('[data-mnote-shortcut-id]');
|
||||
openSidebarShortcutMenu(shortcutRow, shortcutMenuTrigger);
|
||||
return;
|
||||
}
|
||||
|
||||
var folderShortcutRow = closestAction(e.target, '.wolai-starred-section [data-mnote-shortcut-kind="folder"]');
|
||||
if (folderShortcutRow) {
|
||||
e.preventDefault();
|
||||
void openStarredFolderShortcut(folderShortcutRow);
|
||||
return;
|
||||
}
|
||||
|
||||
var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]');
|
||||
if (localFolderTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -2295,6 +2729,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
|
||||
var createFolderTrigger = closestAction(e.target, '[data-mnote-action="create-folder"]');
|
||||
if (createFolderTrigger) {
|
||||
e.preventDefault();
|
||||
if (currentSourceKind() !== 'local_folder') return;
|
||||
var scope = currentFileTreeScope();
|
||||
var parentId = scope ? 'local:folder:' + scope : null;
|
||||
void createFileTreeFolder(createFolderTrigger, parentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var fileTree = document.getElementById('sidebar-file-tree-root');
|
||||
if (fileTree && fileTree.contains(e.target)) {
|
||||
var fileBtn = closestAction(e.target, '[data-rust-action]');
|
||||
@@ -2345,7 +2789,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree', fileTreeScope: currentFileTreeScope() });
|
||||
} else if (assetId) {
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow), openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab' });
|
||||
}
|
||||
@@ -2355,6 +2799,29 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (sidebarPageTree.handlePageTreeClick(e, { closestAction: closestAction })) return;
|
||||
});
|
||||
|
||||
window.addEventListener('tree.sidebarShortcut.toggleFolder', function(event) {
|
||||
var detail = event.detail || {};
|
||||
var row = detail.rowId
|
||||
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.rowId) + '"]')
|
||||
: null;
|
||||
void toggleFolderSidebarShortcut(detail, row);
|
||||
});
|
||||
|
||||
window.addEventListener('tree.filetree.internal-drop', function(event) {
|
||||
var detail = event.detail || {};
|
||||
var rowIds = Array.isArray(detail.rowIds) ? detail.rowIds : [];
|
||||
if (!rowIds.length) return;
|
||||
var targetRow = detail.targetRowId
|
||||
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]')
|
||||
: null;
|
||||
recordFileTreeAction('internal-drop', {
|
||||
rowId: detail.targetRowId || '',
|
||||
sourceRowIds: rowIds,
|
||||
copy: Boolean(detail.copy)
|
||||
});
|
||||
void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) });
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', function(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||
|
||||
@@ -156,7 +156,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
|
||||
|
||||
function copyWorkspaceSourceParams(targetUrl) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
|
||||
['sourceKind', 'rootUri', 'fileTreeScope', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) targetUrl.searchParams.set(name, value);
|
||||
});
|
||||
|
||||
@@ -87,6 +87,18 @@
|
||||
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('watch_batch', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
var revision = payload.revision || payload.cursor || event.lastEventId || null;
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
|
||||
dispatchTreeEvent('tree:local-folder-watch-batch', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('tree_error', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
dispatchTreeEvent('tree:error', { payload: payload, bootstrap: bootstrap });
|
||||
});
|
||||
|
||||
source.addEventListener('block.delta', function(event){
|
||||
var payload = JSON.parse(event.data || '{}');
|
||||
dispatchTreeEvent('tree:block-delta', { payload: payload, bootstrap: bootstrap });
|
||||
|
||||
@@ -174,6 +174,57 @@ impl BufferStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地文件 rename/move 后重绑打开的 Markdown buffer。
|
||||
pub fn rekey_local_folder_markdown(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
previous_relative_path: &str,
|
||||
previous_document_id: &str,
|
||||
next_relative_path: &str,
|
||||
next_document_id: &str,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let previous_path = build_local_folder_workspace_path(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
previous_relative_path,
|
||||
previous_document_id,
|
||||
);
|
||||
let next_path = build_local_folder_workspace_path(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
next_relative_path,
|
||||
next_document_id,
|
||||
);
|
||||
let previous_key = BufferKey::from_workspace_path(&previous_path);
|
||||
let next_key = BufferKey::from_workspace_path(&next_path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
let mut buffer = inner.buffers.remove(&previous_key)?;
|
||||
buffer.workspace_path = next_path;
|
||||
inner.buffers.insert(next_key, buffer.clone());
|
||||
Some(buffer)
|
||||
}
|
||||
|
||||
/// 本地文件 delete/archive/purge 后标记打开的 Markdown buffer 已删除。
|
||||
pub fn mark_local_folder_markdown_deleted(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
document_id: &str,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let path =
|
||||
build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id);
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get_mut(&key) {
|
||||
buf.mark_deleted();
|
||||
Some(buf.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
|
||||
pub fn init_buffer(
|
||||
&self,
|
||||
@@ -449,4 +500,70 @@ mod tests {
|
||||
assert_eq!(buf.file_version.as_deref(), Some("v2"));
|
||||
assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_rekeys_after_local_file_operation_rename() {
|
||||
let store = BufferStore::new();
|
||||
let root_uri = "file:///tmp/mnote-buffer-rekey";
|
||||
let old_path = build_local_folder_workspace_path(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Old.md",
|
||||
"local-md:docs~2FOld.md",
|
||||
);
|
||||
store.init_buffer(&old_path, Some("v1".into()), Some("sha256:old".into()));
|
||||
|
||||
let rekeyed = store
|
||||
.rekey_local_folder_markdown(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Old.md",
|
||||
"local-md:docs~2FOld.md",
|
||||
"docs/New.md",
|
||||
"local-md:docs~2FNew.md",
|
||||
)
|
||||
.expect("buffer should be rekeyed");
|
||||
|
||||
assert_eq!(rekeyed.workspace_path.relative_path, "docs/New.md");
|
||||
assert_eq!(
|
||||
rekeyed
|
||||
.workspace_path
|
||||
.object_identity
|
||||
.document_id
|
||||
.as_deref(),
|
||||
Some("local-md:docs~2FNew.md")
|
||||
);
|
||||
assert!(store.get_by_path(&old_path).is_none());
|
||||
let next_path = build_local_folder_workspace_path(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/New.md",
|
||||
"local-md:docs~2FNew.md",
|
||||
);
|
||||
assert!(store.get_by_path(&next_path).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_marks_deleted_after_local_file_operation_archive() {
|
||||
let store = BufferStore::new();
|
||||
let root_uri = "file:///tmp/mnote-buffer-delete";
|
||||
let path = build_local_folder_workspace_path(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Delete.md",
|
||||
"local-md:docs~2FDelete.md",
|
||||
);
|
||||
store.get_or_create(&path);
|
||||
|
||||
let deleted = store
|
||||
.mark_local_folder_markdown_deleted(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Delete.md",
|
||||
"local-md:docs~2FDelete.md",
|
||||
)
|
||||
.expect("buffer should be marked deleted");
|
||||
|
||||
assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,12 @@ pub async fn update_options(
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let (wired_options, ignored_options, warnings) = filter_wired_page_options(options);
|
||||
let (wired_options, ignored_options, warnings) =
|
||||
if input.effective_source_kind().as_deref() == Some("local_folder") {
|
||||
filter_local_ui_preference_options(options)
|
||||
} else {
|
||||
filter_wired_page_options(options)
|
||||
};
|
||||
page_command(
|
||||
state,
|
||||
context,
|
||||
@@ -235,7 +240,16 @@ async fn page_command(
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::update_local_page_options(&root_uri, &document_id, &options)?
|
||||
crate::routes::ui_preferences::update_page_preferences_from_value(
|
||||
state,
|
||||
context,
|
||||
context.auth.actor_id.trim(),
|
||||
workspace_id.as_deref().unwrap_or_default(),
|
||||
"local_folder",
|
||||
&root_uri,
|
||||
&document_id,
|
||||
&options,
|
||||
)?
|
||||
}
|
||||
_ => {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -443,6 +457,41 @@ fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>)
|
||||
(Value::Object(out), ignored, warnings)
|
||||
}
|
||||
|
||||
fn filter_local_ui_preference_options(options: Value) -> (Value, Vec<String>, Vec<Value>) {
|
||||
let allowed = [
|
||||
"wideLayout",
|
||||
"smallText",
|
||||
"layoutDensity",
|
||||
"pageFont",
|
||||
"showHeadingNumbers",
|
||||
"showToc",
|
||||
"showStructure",
|
||||
"showWordCount",
|
||||
"collapseBacklinks",
|
||||
"hideChildPages",
|
||||
"showBlockRefCount",
|
||||
"hideTitleHeader",
|
||||
];
|
||||
let mut out = serde_json::Map::new();
|
||||
let mut ignored = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
if let Value::Object(map) = options {
|
||||
for (key, value) in map {
|
||||
if allowed.contains(&key.as_str()) {
|
||||
out.insert(key, value);
|
||||
} else {
|
||||
warnings.push(json!({
|
||||
"code": "page_option_not_wired",
|
||||
"field": key.clone(),
|
||||
"message": "页面设置字段尚未接入 SQLite UI 偏好,已忽略"
|
||||
}));
|
||||
ignored.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Object(out), ignored, warnings)
|
||||
}
|
||||
|
||||
fn summarize_blocks(content: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
collect_blocks(content, &mut out);
|
||||
|
||||
@@ -6,8 +6,7 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, update_local_markdown_title, update_local_page_options,
|
||||
write_local_markdown_page_body,
|
||||
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
@@ -66,7 +65,15 @@ fn find_document_buffer_state(
|
||||
relative_path,
|
||||
document_id,
|
||||
);
|
||||
return buffer_store.get_by_path(&ws_path);
|
||||
if let Some(buffer) = buffer_store.get_by_path(&ws_path) {
|
||||
return Some(buffer);
|
||||
}
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.root_uri == root_uri
|
||||
&& buf.workspace_path.relative_path == relative_path
|
||||
&& buf.workspace_path.object_identity.document_id.as_deref()
|
||||
== Some(document_id)
|
||||
});
|
||||
}
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.root_uri == root_uri
|
||||
@@ -1022,7 +1029,16 @@ pub async fn options(
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = update_local_page_options(root_uri, document_id, &body.options)?;
|
||||
let result = crate::routes::ui_preferences::update_page_preferences_from_value(
|
||||
&state,
|
||||
&context,
|
||||
context.auth.actor_id.trim(),
|
||||
body.workspace_id.as_deref().unwrap_or_default(),
|
||||
body.source_kind.as_deref().unwrap_or("local_folder"),
|
||||
root_uri,
|
||||
document_id,
|
||||
&body.options,
|
||||
)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
@@ -1486,7 +1502,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_documents_save_title_and_options_write_to_disk() {
|
||||
async fn local_folder_documents_save_title_and_options_store_ui_preferences_in_sqlite() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-documents-write-{}",
|
||||
std::process::id()
|
||||
@@ -1615,20 +1631,9 @@ mod tests {
|
||||
assert!(markdown.contains("## Saved Heading"));
|
||||
assert!(markdown.contains("Saved body"));
|
||||
|
||||
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
|
||||
.expect("read page options");
|
||||
let options_json: Value = serde_json::from_str(&options).expect("options json");
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["wideLayout"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["showToc"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["hideTitleHeader"],
|
||||
false
|
||||
assert!(
|
||||
!root.join(".mnote").join("page-options.json").exists(),
|
||||
"local folder UI 偏好不应继续写入 .mnote/page-options.json"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -8,11 +8,12 @@ use crate::routes::local_folder_source::{
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
|
||||
load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
attach_sidebar_shortcuts_to_dataset, build_document_panes_bootstrap_json,
|
||||
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
|
||||
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
render_document_title_controller_script, render_editor_island_adapter_script,
|
||||
render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
render_editor_runtime_preload_links, render_local_file_tree_html,
|
||||
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
|
||||
};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::workspace_shell::{
|
||||
@@ -67,6 +68,7 @@ pub(crate) struct RootEntryQuery {
|
||||
workspace_id: Option<String>,
|
||||
source_kind: Option<String>,
|
||||
root_uri: Option<String>,
|
||||
file_tree_scope: Option<String>,
|
||||
tree_view: Option<String>,
|
||||
restore_focus_row_id: Option<String>,
|
||||
}
|
||||
@@ -300,6 +302,11 @@ pub async fn root_entry(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == "filetree");
|
||||
let file_tree_scope = query
|
||||
.file_tree_scope
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let (
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
@@ -318,15 +325,8 @@ pub async fn root_entry(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
||||
let snapshot = if requests_filetree_first {
|
||||
crate::routes::local_folder_source::load_local_folder_file_tree_snapshot_with_reveal(
|
||||
root_uri,
|
||||
requested_page_id.as_deref(),
|
||||
)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
let workspace_id = snapshot
|
||||
let page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let workspace_id = page_tree_snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("id"))
|
||||
@@ -337,8 +337,15 @@ pub async fn root_entry(
|
||||
.to_string();
|
||||
let requested_or_recent_page_id =
|
||||
choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
|
||||
let mut workspace_dataset = page_tree_snapshot.dataset.clone();
|
||||
attach_sidebar_shortcuts_to_dataset(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&snapshot.dataset,
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
"本地文件夹",
|
||||
@@ -352,20 +359,18 @@ pub async fn root_entry(
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html = if requests_filetree_first {
|
||||
String::new()
|
||||
} else {
|
||||
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?
|
||||
};
|
||||
let sidebar_tree_html =
|
||||
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
|
||||
let restore_focus_row_id = query
|
||||
.restore_focus_row_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let file_tree_html = render_local_file_tree_html(
|
||||
let file_tree_html = render_local_file_tree_html_scoped(
|
||||
root_uri,
|
||||
selected_active_page_id.as_deref(),
|
||||
restore_focus_row_id,
|
||||
file_tree_scope,
|
||||
)?;
|
||||
(
|
||||
workspace_id,
|
||||
@@ -399,8 +404,15 @@ pub async fn root_entry(
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("local-folder")
|
||||
.to_string();
|
||||
let mut workspace_dataset = snapshot.dataset.clone();
|
||||
attach_sidebar_shortcuts_to_dataset(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&snapshot.dataset,
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_page_id.as_deref(),
|
||||
&default_workspace_name,
|
||||
@@ -437,6 +449,7 @@ pub async fn root_entry(
|
||||
None,
|
||||
);
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
@@ -489,6 +502,7 @@ pub async fn root_entry(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
file_tree_scope,
|
||||
);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let active_page_id = selected_active_page_id.unwrap_or_default();
|
||||
@@ -593,12 +607,19 @@ pub async fn root_entry(
|
||||
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
|
||||
}
|
||||
};
|
||||
let editor_runtime_preload_links =
|
||||
if body_extra.contains("document-editor-adapter-runtime.js") {
|
||||
render_editor_runtime_preload_links()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{}</title>
|
||||
{}
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
|
||||
@@ -607,6 +628,7 @@ pub async fn root_entry(
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&html_title),
|
||||
editor_runtime_preload_links,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
|
||||
@@ -654,21 +676,25 @@ pub async fn trash_entry(
|
||||
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
|
||||
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
let mut workspace_dataset = json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
});
|
||||
attach_sidebar_shortcuts_to_dataset(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
None,
|
||||
"我的空间",
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection =
|
||||
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let trash_workbench_html = render_local_trash_workbench_html(
|
||||
&workspace_id,
|
||||
@@ -714,6 +740,7 @@ pub async fn trash_entry(
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
@@ -732,6 +759,7 @@ pub async fn trash_entry(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id)
|
||||
.await
|
||||
@@ -2779,6 +2807,9 @@ mod tests {
|
||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains("README.md"));
|
||||
assert!(html.contains(r#"id="sidebar-tree-root""#));
|
||||
assert!(html.contains(r#"data-shell-mode="page""#));
|
||||
assert!(html.contains(r#"data-node-id="local-md:README.md""#));
|
||||
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
|
||||
assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#));
|
||||
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
|
||||
@@ -2832,6 +2863,12 @@ mod tests {
|
||||
assert!(html.contains(
|
||||
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
));
|
||||
assert!(
|
||||
include_str!("../../browser/document-resource-tab-runtime.js")
|
||||
.contains("openResourceInActiveTab")
|
||||
|
||||
@@ -2966,6 +2966,89 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-page-options-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.update_options",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_page_options_local",
|
||||
"runId": "run_page_options_local",
|
||||
"toolCallId": "call_page_options_local",
|
||||
"traceId": "trace_page_options_local",
|
||||
"idempotencyKey": "idem_page_options_local",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"options": {
|
||||
"wideLayout": true,
|
||||
"showHeadingNumbers": true,
|
||||
"hideTitleHeader": false
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
assert_eq!(
|
||||
payload["result"]["commandName"],
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["result"]["pageOptions"]["wideLayout"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["result"]["pageOptions"]["showHeadingNumbers"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["result"]["pageOptions"]["hideTitleHeader"],
|
||||
false
|
||||
);
|
||||
assert!(
|
||||
!root.join(".mnote").join("page-options.json").exists(),
|
||||
"AI 页面设置工具不应继续写入 .mnote/page-options.json"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
|
||||
let response = app()
|
||||
|
||||
@@ -19,6 +19,10 @@ use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelProjectionQuery {
|
||||
@@ -89,31 +93,39 @@ async fn project_projection(
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let snapshot = if projection == KernelProjectionKind::FileTree {
|
||||
if let Some(parent_relative_path) = query
|
||||
let root_uri = root_uri.to_string();
|
||||
let parent_relative_path = query
|
||||
.parent_relative_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
load_local_folder_file_tree_children_snapshot(root_uri, parent_relative_path)?
|
||||
} else if query
|
||||
.map(ToOwned::to_owned);
|
||||
let root_node_id = query
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
.map(ToOwned::to_owned);
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
block_local_folder_projection_for_test();
|
||||
if projection == KernelProjectionKind::FileTree {
|
||||
if let Some(parent_relative_path) = parent_relative_path.as_deref() {
|
||||
load_local_folder_file_tree_children_snapshot(&root_uri, parent_relative_path)
|
||||
} else if root_node_id.is_some() {
|
||||
load_local_folder_file_tree_snapshot_with_reveal(
|
||||
root_uri,
|
||||
query.root_node_id.as_deref(),
|
||||
)?
|
||||
&root_uri,
|
||||
root_node_id.as_deref(),
|
||||
)
|
||||
} else {
|
||||
load_local_folder_file_tree_snapshot(root_uri)?
|
||||
load_local_folder_file_tree_snapshot(&root_uri)
|
||||
}
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
load_local_folder_page_tree_snapshot(&root_uri)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| WebError::internal(format!("本地树 projection 构建任务失败: {error}")))??;
|
||||
return Ok(ok_response(&context, snapshot.projection));
|
||||
}
|
||||
|
||||
@@ -137,6 +149,14 @@ async fn project_projection(
|
||||
Ok(ok_response(&context, snapshot.projection))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn block_local_folder_projection_for_test() {
|
||||
let delay_ms = LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn project_tree_sidebar(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
@@ -245,6 +265,7 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -443,6 +464,70 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_file_projection_does_not_block_leptos_runtime_asset_request() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-kernel-projection-runtime-asset-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
|
||||
let app = app();
|
||||
let projection_app = app.clone();
|
||||
let projection_uri = format!(
|
||||
"/api/tree/projections/file/children?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs"
|
||||
);
|
||||
super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS
|
||||
.store(400, std::sync::atomic::Ordering::SeqCst);
|
||||
struct ResetLocalProjectionBlock;
|
||||
impl Drop for ResetLocalProjectionBlock {
|
||||
fn drop(&mut self) {
|
||||
super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _reset_block = ResetLocalProjectionBlock;
|
||||
let projection_task = tokio::spawn(async move {
|
||||
projection_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(projection_uri)
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("projection request"),
|
||||
)
|
||||
.await
|
||||
.expect("projection response")
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
tokio::task::yield_now().await;
|
||||
let asset_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("asset request"),
|
||||
)
|
||||
.await
|
||||
.expect("asset response");
|
||||
let asset_elapsed = started.elapsed();
|
||||
let projection_response = projection_task.await.expect("projection task");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(asset_response.status(), StatusCode::OK);
|
||||
assert_eq!(projection_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
asset_elapsed < Duration::from_millis(150),
|
||||
"leptos-tiptap runtime asset 不应被本地 FileTree projection 扫描阻塞,实际等待 {asset_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_keep_ok_response_shape() {
|
||||
let response = app()
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::{interval, MissedTickBehavior};
|
||||
use tokio::time::{interval, timeout, MissedTickBehavior};
|
||||
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
@@ -177,18 +177,38 @@ async fn build_tree_live_stream(
|
||||
|
||||
loop {
|
||||
match subscription.receiver.recv().await {
|
||||
Ok(_watcher_payload) => {
|
||||
// Rebuild full snapshot on any filesystem change
|
||||
if let Some(resync_payload) =
|
||||
rebuild_tree_resync_payload(&root_uri, &workspace_id)
|
||||
Ok(watcher_payload) => {
|
||||
let mut watcher_payloads = vec![watcher_payload];
|
||||
loop {
|
||||
match timeout(Duration::from_millis(120), subscription.receiver.recv())
|
||||
.await
|
||||
{
|
||||
Ok(Ok(next_payload)) => watcher_payloads.push(next_payload),
|
||||
Ok(Err(RecvError::Lagged(_))) => continue,
|
||||
Ok(Err(RecvError::Closed)) => return None,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Some(batch_payload) = build_local_folder_watch_batch_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
watcher_payloads,
|
||||
) {
|
||||
return Some((
|
||||
Ok(stream_event("resync", &resync_payload)),
|
||||
Ok(stream_event("watch_batch", &batch_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
));
|
||||
}
|
||||
// Snapshot load failed — continue waiting for next change
|
||||
continue;
|
||||
let error_payload = build_tree_live_error_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
"tree_live_watch_batch_failed",
|
||||
"local folder watcher batch payload missing paths",
|
||||
);
|
||||
return Some((
|
||||
Ok(stream_event("tree_error", &error_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
@@ -276,6 +296,96 @@ fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Val
|
||||
))
|
||||
}
|
||||
|
||||
fn parent_relative_path_for_watch_path(relative_path: &str) -> String {
|
||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||
if normalized.is_empty() || normalized == "." {
|
||||
return String::new();
|
||||
}
|
||||
normalized
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_local_folder_watch_batch_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
watcher_payloads: Vec<Value>,
|
||||
) -> Option<Value> {
|
||||
let revision = local_folder_watch_revision(root_uri).ok()?;
|
||||
let mut changed_paths = Vec::new();
|
||||
let mut affected_parents = Vec::new();
|
||||
let mut event_kinds = Vec::new();
|
||||
let mut seen_paths = std::collections::BTreeSet::new();
|
||||
let mut seen_parents = std::collections::BTreeSet::new();
|
||||
let mut seen_kinds = std::collections::BTreeSet::new();
|
||||
for payload in watcher_payloads {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let event_kind = payload
|
||||
.get("eventKind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown");
|
||||
if seen_paths.insert(relative_path.to_string()) {
|
||||
changed_paths.push(json!({
|
||||
"relativePath": relative_path,
|
||||
"kind": event_kind,
|
||||
}));
|
||||
}
|
||||
if seen_kinds.insert(event_kind.to_string()) {
|
||||
event_kinds.push(event_kind.to_string());
|
||||
}
|
||||
let parent = parent_relative_path_for_watch_path(relative_path);
|
||||
if seen_parents.insert(parent.clone()) {
|
||||
affected_parents.push(json!({
|
||||
"relativePath": parent,
|
||||
"reason": "child-watch",
|
||||
}));
|
||||
}
|
||||
}
|
||||
if changed_paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"schema": "mnote.local_folder_watch_batch.v1",
|
||||
"kind": "watch_batch",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"revision": revision.revision,
|
||||
"watchRevision": revision,
|
||||
"changedPaths": changed_paths,
|
||||
"affectedParents": affected_parents,
|
||||
"eventKinds": event_kinds,
|
||||
"fallbackResync": false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_tree_live_error_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
code: &str,
|
||||
message: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"schema": "mnote.tree_live_error.v1",
|
||||
"kind": "error",
|
||||
"phase": "tree_live_resync",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"code": code,
|
||||
"message": message,
|
||||
"fallbackResync": true,
|
||||
"revision": system_time_ms(SystemTime::now()).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_tree_snapshot_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
@@ -548,4 +658,66 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() {
|
||||
let root = test_root("tree-live-watch-batch");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs/README.md"), "# Initial\n").expect("write initial");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id =
|
||||
local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id");
|
||||
|
||||
let payload = build_local_folder_watch_batch_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
vec![
|
||||
json!({
|
||||
"relativePath": "docs/README.md",
|
||||
"eventKind": "Modify(Data)",
|
||||
}),
|
||||
json!({
|
||||
"relativePath": "docs/New.md",
|
||||
"eventKind": "Create(File)",
|
||||
}),
|
||||
],
|
||||
)
|
||||
.expect("watch batch payload");
|
||||
|
||||
assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1");
|
||||
assert_eq!(payload["kind"], "watch_batch");
|
||||
assert_eq!(payload["fallbackResync"], false);
|
||||
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
|
||||
assert!(
|
||||
payload["affectedParents"]
|
||||
.as_array()
|
||||
.expect("affected parents")
|
||||
.iter()
|
||||
.any(|parent| parent["relativePath"].as_str() == Some("docs")
|
||||
&& parent["reason"].as_str() == Some("child-watch")),
|
||||
"watch batch 应声明 docs affected parent: {payload}"
|
||||
);
|
||||
assert!(payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_live_error_payload_is_structured() {
|
||||
let payload = build_tree_live_error_payload(
|
||||
"file:///test",
|
||||
"local:test",
|
||||
"tree_live_resync_failed",
|
||||
"failed",
|
||||
);
|
||||
|
||||
assert_eq!(payload["schema"], "mnote.tree_live_error.v1");
|
||||
assert_eq!(payload["phase"], "tree_live_resync");
|
||||
assert_eq!(payload["fallbackResync"], true);
|
||||
assert_eq!(payload["code"], "tree_live_resync_failed");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ pub(crate) fn query_local_search_index(
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let normalized_query = normalize_search_text(query);
|
||||
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
|
||||
@@ -294,6 +294,23 @@ fn rebuild_local_search_index(
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn load_or_rebuild_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<LocalSearchIndex, WebError> {
|
||||
match read_local_search_index(root_path) {
|
||||
Ok(Some(index))
|
||||
if index.version == LOCAL_SEARCH_INDEX_VERSION
|
||||
&& index.root_uri == root_uri
|
||||
&& index.workspace_id == workspace_id =>
|
||||
{
|
||||
Ok(index)
|
||||
}
|
||||
Ok(_) | Err(_) => rebuild_local_search_index(root_path, root_uri, workspace_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
|
||||
let index_path = root_path
|
||||
.join(".mnote")
|
||||
@@ -999,4 +1016,74 @@ mod tests {
|
||||
.any(|document| document.path == "README.md"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_query_reads_existing_index_without_rebuilding() {
|
||||
let root = temp_root("mnote-local-search-query-cache");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id = "local-ws-query-cache";
|
||||
let child_path = root.join("docs").join("child.md");
|
||||
fs::write(
|
||||
&child_path,
|
||||
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
|
||||
)
|
||||
.expect("write child");
|
||||
|
||||
let first_projection = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OriginalToken",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("first query");
|
||||
assert_eq!(
|
||||
first_projection["results"].as_array().map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
fs::write(
|
||||
&child_path,
|
||||
"---\ntitle: Child\n---\n# Child\nUnindexedToken body.\n",
|
||||
)
|
||||
.expect("update child without refresh");
|
||||
|
||||
let stale_projection = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"UnindexedToken",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query existing index");
|
||||
assert_eq!(
|
||||
stale_projection["results"].as_array().map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
|
||||
.expect("incremental refresh");
|
||||
let refreshed_projection = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"UnindexedToken",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query refreshed index");
|
||||
assert_eq!(
|
||||
refreshed_projection["results"].as_array().map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn mindmap_object_shell(
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(root_uri, Some(&doc_id), None).unwrap_or_default();
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id.as_deref().unwrap_or("local-folder"),
|
||||
@@ -74,6 +75,7 @@ pub async fn mindmap_object_shell(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
@@ -82,6 +84,7 @@ pub async fn mindmap_object_shell(
|
||||
)
|
||||
} else if let Some(workspace_id) = workspace_id.as_deref() {
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
@@ -109,6 +112,7 @@ pub async fn mindmap_object_shell(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
@@ -268,7 +272,7 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
}
|
||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
|
||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||
const manifest = await manifestResponse.json();
|
||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||
|
||||
@@ -23,16 +23,18 @@ mod query_support;
|
||||
mod resource_trash;
|
||||
mod search;
|
||||
mod session;
|
||||
pub(crate) mod sidebar_shortcuts;
|
||||
mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
pub(crate) mod ui_preferences;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, update_local_page_options, write_local_markdown_page_body,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||
|
||||
@@ -82,6 +84,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/page-aggregate/{document_id}",
|
||||
get(web_shell::page_aggregate),
|
||||
)
|
||||
.route(
|
||||
"/api/ui/preferences/effective",
|
||||
get(ui_preferences::effective_preferences),
|
||||
)
|
||||
.route(
|
||||
"/api/ui/preferences",
|
||||
put(ui_preferences::update_preferences),
|
||||
)
|
||||
.route(
|
||||
"/api/leptos-tiptap-runtime/manifest.json",
|
||||
get(web_shell::leptos_tiptap_manifest),
|
||||
@@ -263,6 +273,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/whoami", get(session::session))
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route(
|
||||
"/api/sidebar/shortcuts",
|
||||
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
||||
)
|
||||
.route(
|
||||
"/api/sidebar/shortcuts/{shortcut_id}",
|
||||
delete(sidebar_shortcuts::delete_shortcut),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy",
|
||||
get(local_folder_source::get_local_access_policy),
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SidebarShortcutListQuery {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SidebarShortcutUpsertRequest {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "target_id")]
|
||||
target_id: String,
|
||||
#[serde(default, alias = "relative_path")]
|
||||
relative_path: Option<String>,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default, alias = "sort_order")]
|
||||
sort_order: Option<i64>,
|
||||
#[serde(default, alias = "metadata_json")]
|
||||
metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_shortcuts(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<SidebarShortcutListQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let workspace_id = query.workspace_id.trim();
|
||||
if workspace_id.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"sidebar_shortcut_workspace_required",
|
||||
"缺少 workspaceId",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let shortcuts = state
|
||||
.control_plane()
|
||||
.list_sidebar_shortcuts(&actor_id, workspace_id)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("读取星标置顶失败: {error}")).with_context(&context)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"workspaceId": workspace_id,
|
||||
"shortcuts": shortcuts.iter().map(shortcut_to_json).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upsert_shortcut(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<SidebarShortcutUpsertRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let workspace_id = request.workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"sidebar_shortcut_workspace_required",
|
||||
"缺少 workspaceId",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let kind = request.kind.trim().to_string();
|
||||
let target_id = request.target_id.trim().to_string();
|
||||
let title = request.title.trim().to_string();
|
||||
let root_uri = request
|
||||
.root_uri
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let metadata_json =
|
||||
normalize_shortcut_metadata_json(request.metadata_json, root_uri.as_deref())?;
|
||||
let shortcut = state
|
||||
.control_plane()
|
||||
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
|
||||
id: request.id,
|
||||
user_id: actor_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
root_uri,
|
||||
kind,
|
||||
source_kind: request
|
||||
.source_kind
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_else(|| "local_folder".to_string()),
|
||||
target_id,
|
||||
relative_path: request
|
||||
.relative_path
|
||||
.map(|value| value.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
document_id: request
|
||||
.document_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
title,
|
||||
icon: request
|
||||
.icon
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
sort_order: request.sort_order.unwrap_or(0),
|
||||
metadata_json,
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"sidebar_shortcut_upsert_failed",
|
||||
format!("写入星标置顶失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
append_shortcut_audit(&state, &actor_id, "sidebar.shortcut.upserted", &shortcut);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"shortcut": shortcut_to_json(&shortcut),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn delete_shortcut(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(shortcut_id): Path<String>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
state
|
||||
.control_plane()
|
||||
.delete_sidebar_shortcut(&actor_id, &shortcut_id)
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"sidebar_shortcut_delete_failed",
|
||||
format!("移除星标置顶失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(actor_id),
|
||||
action: "sidebar.shortcut.removed".to_string(),
|
||||
target_kind: "sidebar_shortcut".to_string(),
|
||||
target_id: Some(shortcut_id.clone()),
|
||||
metadata_json: "{}".to_string(),
|
||||
});
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"removedShortcutId": shortcut_id,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn load_sidebar_shortcut_dataset(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Vec<Value> {
|
||||
let Some(actor_id) = current_actor_id(state, context) else {
|
||||
return Vec::new();
|
||||
};
|
||||
state
|
||||
.control_plane()
|
||||
.list_sidebar_shortcuts_with_global_local(&actor_id, workspace_id)
|
||||
.map(|shortcuts| shortcuts.iter().map(shortcut_to_json).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<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,
|
||||
"sidebar_shortcut_auth_required",
|
||||
"星标置顶需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn shortcut_to_json(shortcut: &SidebarShortcutRecord) -> Value {
|
||||
json!({
|
||||
"id": shortcut.id,
|
||||
"userId": shortcut.user_id,
|
||||
"workspaceId": shortcut.workspace_id,
|
||||
"rootUri": shortcut.root_uri,
|
||||
"kind": shortcut.kind,
|
||||
"sourceKind": shortcut.source_kind,
|
||||
"targetId": shortcut.target_id,
|
||||
"relativePath": shortcut.relative_path,
|
||||
"documentId": shortcut.document_id,
|
||||
"title": shortcut.title,
|
||||
"icon": shortcut.icon,
|
||||
"sortOrder": shortcut.sort_order,
|
||||
"status": shortcut.status,
|
||||
"metadata": serde_json::from_str::<Value>(&shortcut.metadata_json).unwrap_or(Value::Null),
|
||||
"createdAt": shortcut.created_at,
|
||||
"updatedAt": shortcut.updated_at,
|
||||
"revision": shortcut.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_shortcut_metadata_json(
|
||||
metadata_json: Option<String>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let mut metadata = metadata_json
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| serde_json::from_str::<Value>(value))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"sidebar_shortcut_metadata_invalid",
|
||||
format!("星标置顶 metadataJson 必须是 JSON 对象: {error}"),
|
||||
)
|
||||
})?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
if !metadata.is_object() {
|
||||
metadata = json!({});
|
||||
}
|
||||
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.insert("rootUri".to_string(), Value::String(root_uri.to_string()));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&metadata)
|
||||
.map_err(|error| WebError::internal(format!("星标置顶 metadataJson 序列化失败: {error}")))
|
||||
}
|
||||
|
||||
fn append_shortcut_audit(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
action: &str,
|
||||
shortcut: &SidebarShortcutRecord,
|
||||
) {
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(actor_id.to_string()),
|
||||
action: action.to_string(),
|
||||
target_kind: "sidebar_shortcut".to_string(),
|
||||
target_id: Some(shortcut.id.clone()),
|
||||
metadata_json: json!({
|
||||
"workspaceId": shortcut.workspace_id,
|
||||
"kind": shortcut.kind,
|
||||
"targetId": shortcut.target_id,
|
||||
})
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.trim().is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ pub struct TreeCommandEnvelope {
|
||||
pub target_resource_meta: Option<Value>,
|
||||
pub selection: Option<Value>,
|
||||
pub operation: Option<String>,
|
||||
pub batch_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub target_parent_id: Option<String>,
|
||||
@@ -114,6 +115,7 @@ pub struct TreeCommandEnvelopeContext {
|
||||
pub target_resource_meta: Option<Value>,
|
||||
pub selection: Option<Value>,
|
||||
pub operation: Option<String>,
|
||||
pub batch_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TreeCommandEnvelopeContext {
|
||||
@@ -130,6 +132,7 @@ impl TreeCommandEnvelopeContext {
|
||||
target_resource_meta: envelope.target_resource_meta.clone(),
|
||||
selection: envelope.selection.clone(),
|
||||
operation: read_optional_non_empty(envelope.operation.clone()),
|
||||
batch_id: read_optional_non_empty(envelope.batch_id.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,6 +528,25 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let relative_path = item
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("workspacePath"))
|
||||
.and_then(|workspace_path| workspace_path.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("extra"))
|
||||
.and_then(|extra| extra.get("source"))
|
||||
.and_then(|source| source.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let icon_kind = item
|
||||
.get("iconHint")
|
||||
.and_then(Value::as_str)
|
||||
@@ -571,6 +593,7 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
icon_kind,
|
||||
document_id,
|
||||
asset_id,
|
||||
relative_path,
|
||||
object_identity: resource_meta
|
||||
.and_then(|meta| meta.get("objectIdentity"))
|
||||
.and_then(|value| serde_json::to_string(value).ok()),
|
||||
@@ -2092,6 +2115,79 @@ async fn resolve_tree_create_workspace_id(
|
||||
})
|
||||
}
|
||||
|
||||
fn operation_resource_relative_path(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn operation_resource_document_id(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|path| path.starts_with("local-md:"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn apply_local_file_operation_participants(
|
||||
buffer_store: &crate::document_buffer_store::BufferStore,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
action: &str,
|
||||
execution: &Value,
|
||||
) {
|
||||
let previous_relative_path = operation_resource_relative_path(execution, "previousResource");
|
||||
let previous_document_id = operation_resource_document_id(execution, "previousResource");
|
||||
let next_relative_path = operation_resource_relative_path(execution, "resource");
|
||||
let next_document_id = operation_resource_document_id(execution, "resource");
|
||||
match action {
|
||||
"rename" | "move" => {
|
||||
if let (
|
||||
Some(previous_relative_path),
|
||||
Some(previous_document_id),
|
||||
Some(next_relative_path),
|
||||
Some(next_document_id),
|
||||
) = (
|
||||
previous_relative_path.as_deref(),
|
||||
previous_document_id.as_deref(),
|
||||
next_relative_path.as_deref(),
|
||||
next_document_id.as_deref(),
|
||||
) {
|
||||
let _ = buffer_store.rekey_local_folder_markdown(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
previous_relative_path,
|
||||
previous_document_id,
|
||||
next_relative_path,
|
||||
next_document_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
"delete" | "archive" | "trash" | "purge" => {
|
||||
if let (Some(previous_relative_path), Some(previous_document_id)) = (
|
||||
previous_relative_path.as_deref(),
|
||||
previous_document_id.as_deref(),
|
||||
) {
|
||||
let _ = buffer_store.mark_local_folder_markdown_deleted(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
previous_relative_path,
|
||||
previous_document_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn tree_command(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -2292,10 +2388,18 @@ pub async fn tree_command(
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "tree_local_executor")
|
||||
})?;
|
||||
let local_workspace_id = local_workspace_id_from_root_uri(root_uri)?;
|
||||
apply_local_file_operation_participants(
|
||||
&state.buffer_store,
|
||||
&local_workspace_id,
|
||||
root_uri,
|
||||
action,
|
||||
&execution,
|
||||
);
|
||||
return Ok(json_response(
|
||||
&context,
|
||||
json!({
|
||||
"workspaceId": local_workspace_id_from_root_uri(root_uri)?,
|
||||
"workspaceId": local_workspace_id,
|
||||
"action": action,
|
||||
"documentId": execution
|
||||
.get("documentId")
|
||||
@@ -2304,6 +2408,12 @@ pub async fn tree_command(
|
||||
"parentId": requested_parent_id,
|
||||
"title": requested_title,
|
||||
"sortOrder": requested_sort_order,
|
||||
"affectedParents": execution.get("affectedParents").cloned().unwrap_or(Value::Null),
|
||||
"revealTarget": execution.get("revealTarget").cloned().unwrap_or(Value::Null),
|
||||
"selectTarget": execution.get("selectTarget").cloned().unwrap_or(Value::Null),
|
||||
"operationId": execution.get("operationId").cloned().unwrap_or(Value::Null),
|
||||
"batchId": envelope_context.batch_id.clone(),
|
||||
"schema": execution.get("schema").cloned().unwrap_or(Value::Null),
|
||||
"updatedAt": Value::Null,
|
||||
"execution": execution,
|
||||
"artifacts": Value::Null,
|
||||
@@ -3999,6 +4109,7 @@ mod tests {
|
||||
"rowIds": ["doc:page_child"]
|
||||
})),
|
||||
operation: Some("tree.node.rename".into()),
|
||||
batch_id: None,
|
||||
};
|
||||
|
||||
let rename_wire = create_command_wire(
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::page_aggregate::{PageAggregate, PageOptions};
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::{
|
||||
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
|
||||
pub(crate) const SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER: &str = "external_local_folder";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UiPreferencesQuery {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UiPreferencesUpdateRequest {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: String,
|
||||
#[serde(default)]
|
||||
updates: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PagePreferenceScope {
|
||||
workspace_id: String,
|
||||
source_kind: String,
|
||||
source_family: String,
|
||||
document_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EffectivePagePreferences {
|
||||
scope: PagePreferenceScope,
|
||||
page_options: PageOptions,
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn effective_preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<UiPreferencesQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let effective = resolve_effective_page_preferences(
|
||||
&state,
|
||||
&actor_id,
|
||||
&query.workspace_id,
|
||||
&query.source_kind,
|
||||
&query.root_uri,
|
||||
&query.document_id,
|
||||
PageOptions::default(),
|
||||
)?;
|
||||
Ok(Json(effective_preferences_payload(effective)))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<UiPreferencesUpdateRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let result = update_page_preferences_from_value(
|
||||
&state,
|
||||
&context,
|
||||
&actor_id,
|
||||
&request.workspace_id,
|
||||
&request.source_kind,
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
&Value::Object(request.updates.into_iter().collect()),
|
||||
)?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": result,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn update_page_preferences_from_value(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
updates: &Value,
|
||||
) -> Result<Value, WebError> {
|
||||
ensure_actor_user(state, actor_id)?;
|
||||
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
|
||||
let update_map = updates.as_object().ok_or_else(|| {
|
||||
WebError::bad_request_code("ui_preference_updates_invalid", "updates 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
for (key, value) in update_map {
|
||||
let Some((scope_kind, scope_id)) = preference_scope_for_key(key, &scope) else {
|
||||
continue;
|
||||
};
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: actor_id.to_string(),
|
||||
workspace_id: preference_workspace_for_scope(&scope.workspace_id, &scope_kind),
|
||||
source_kind: preference_source_kind_for_scope(&scope.source_kind, &scope_kind),
|
||||
scope_kind,
|
||||
scope_id,
|
||||
key: key.trim().to_string(),
|
||||
value_json: value.to_string(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"ui_preference_write_failed",
|
||||
format!("写入 UI 偏好失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
}
|
||||
let effective = resolve_effective_page_preferences(
|
||||
state,
|
||||
actor_id,
|
||||
workspace_id,
|
||||
source_kind,
|
||||
root_uri,
|
||||
document_id,
|
||||
PageOptions::default(),
|
||||
)?;
|
||||
Ok(effective_preferences_payload(effective)["result"].clone())
|
||||
}
|
||||
|
||||
pub(crate) fn source_family_for_page(
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
if source_kind.map(str::trim) != Some("local_folder") {
|
||||
return Ok("workspace".to_string());
|
||||
}
|
||||
let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string());
|
||||
};
|
||||
if local_root_has_workspace_manifest(root_uri)? {
|
||||
Ok(SOURCE_FAMILY_MY_SPACE.to_string())
|
||||
} else {
|
||||
Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_effective_page_preferences(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
aggregate: &mut PageAggregate,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<(), WebError> {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if actor_id.is_empty() || actor_id == "anonymous" {
|
||||
return Ok(());
|
||||
}
|
||||
let effective = resolve_effective_page_preferences(
|
||||
state,
|
||||
actor_id,
|
||||
&aggregate.identity.workspace_id,
|
||||
source_kind.unwrap_or_default(),
|
||||
root_uri.unwrap_or_default(),
|
||||
&aggregate.identity.document_id,
|
||||
aggregate.layout.page_options.clone(),
|
||||
)?;
|
||||
aggregate.layout.page_options = effective.page_options;
|
||||
aggregate.layout_options = serde_json::to_value(&aggregate.layout.page_options)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_effective_page_preferences(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
base_options: PageOptions,
|
||||
) -> Result<EffectivePagePreferences, WebError> {
|
||||
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
|
||||
let preferences = state
|
||||
.control_plane()
|
||||
.list_user_ui_preferences(
|
||||
actor_id,
|
||||
Some(&scope.workspace_id),
|
||||
Some(&scope.source_kind),
|
||||
)
|
||||
.map_err(|error| WebError::internal(format!("SQLite UI 偏好读取失败: {error}")))?;
|
||||
let mut page_options = base_options;
|
||||
if scope.source_family == SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER {
|
||||
page_options.hide_title_header = true;
|
||||
}
|
||||
let mut sources = BTreeMap::new();
|
||||
apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?;
|
||||
Ok(EffectivePagePreferences {
|
||||
scope,
|
||||
page_options,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_preference_records(
|
||||
page_options: &mut PageOptions,
|
||||
sources: &mut BTreeMap<String, String>,
|
||||
scope: &PagePreferenceScope,
|
||||
preferences: &[UserUiPreferenceRecord],
|
||||
) -> Result<(), WebError> {
|
||||
for scope_kind in ["global", "source_family", "workspace", "document"] {
|
||||
for preference in preferences
|
||||
.iter()
|
||||
.filter(|preference| preference.scope_kind.trim() == scope_kind)
|
||||
{
|
||||
let scope_matches = match scope_kind {
|
||||
"global" => preference.scope_id.trim() == "default",
|
||||
"source_family" => preference.scope_id.trim() == scope.source_family,
|
||||
"workspace" => preference.scope_id.trim() == scope.workspace_id,
|
||||
"document" => preference.scope_id.trim() == scope.document_id,
|
||||
_ => false,
|
||||
};
|
||||
if !scope_matches {
|
||||
continue;
|
||||
}
|
||||
let value = serde_json::from_str::<Value>(&preference.value_json).map_err(|error| {
|
||||
WebError::internal(format!(
|
||||
"SQLite UI 偏好 JSON 无效 {}: {error}",
|
||||
preference.key
|
||||
))
|
||||
})?;
|
||||
if apply_page_option_value(page_options, &preference.key, &value) {
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn page_preference_scope(
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
) -> Result<PagePreferenceScope, WebError> {
|
||||
let source_kind = source_kind.trim();
|
||||
let source_kind = if source_kind.is_empty() {
|
||||
"convex_workspace"
|
||||
} else {
|
||||
source_kind
|
||||
};
|
||||
let workspace_id = workspace_id.trim().to_string().if_empty_else(|| {
|
||||
if source_kind == "local_folder" {
|
||||
local_workspace_id_from_root_uri(root_uri).unwrap_or_else(|_| "local-folder".into())
|
||||
} else {
|
||||
"default".into()
|
||||
}
|
||||
});
|
||||
Ok(PagePreferenceScope {
|
||||
workspace_id,
|
||||
source_kind: source_kind.to_string(),
|
||||
source_family: source_family_for_page(Some(source_kind), Some(root_uri))?,
|
||||
document_id: document_id.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> {
|
||||
match key.trim() {
|
||||
"hideTitleHeader" | "hide_title_header" => {
|
||||
Some(("source_family".to_string(), scope.source_family.clone()))
|
||||
}
|
||||
"showHeadingNumbers" | "show_heading_numbers" | "showWordCount" | "show_word_count" => {
|
||||
Some(("global".to_string(), "default".to_string()))
|
||||
}
|
||||
"wideLayout"
|
||||
| "wide_layout"
|
||||
| "smallText"
|
||||
| "small_text"
|
||||
| "layoutDensity"
|
||||
| "layout_density"
|
||||
| "pageFont"
|
||||
| "page_font"
|
||||
| "showToc"
|
||||
| "show_toc"
|
||||
| "showStructure"
|
||||
| "show_structure"
|
||||
| "collapseBacklinks"
|
||||
| "collapse_backlinks"
|
||||
| "hideChildPages"
|
||||
| "hide_child_pages"
|
||||
| "showBlockRefCount"
|
||||
| "show_block_ref_count" => Some(("workspace".to_string(), scope.workspace_id.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
Some(workspace_id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
Some(source_kind.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({}));
|
||||
let sources = effective
|
||||
.sources
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, Value::String(value)))
|
||||
.collect::<Map<String, Value>>();
|
||||
json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": {
|
||||
"scope": {
|
||||
"sourceFamily": effective.scope.source_family,
|
||||
"workspaceId": effective.scope.workspace_id,
|
||||
"sourceKind": effective.scope.source_kind,
|
||||
"documentId": effective.scope.document_id,
|
||||
},
|
||||
"pageOptions": page_options,
|
||||
"sources": Value::Object(sources),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<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,
|
||||
"ui_preference_auth_required",
|
||||
"UI 偏好需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> {
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(actor_id.to_string()),
|
||||
email: None,
|
||||
username: actor_id.to_string(),
|
||||
display_name: actor_id.to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| WebError::internal(format!("SQLite 用户初始化失败: {error}")))
|
||||
}
|
||||
|
||||
fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value) -> bool {
|
||||
match key {
|
||||
"hideTitleHeader" | "hide_title_header" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.hide_title_header = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showHeadingNumbers" | "show_heading_numbers" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_heading_numbers = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"wideLayout" | "wide_layout" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.wide_layout = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"smallText" | "small_text" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.small_text = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"layoutDensity" | "layout_density" => {
|
||||
if let Some(value) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
options.layout_density = value.to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"pageFont" | "page_font" => {
|
||||
if let Some(value) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
options.page_font = value.to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showToc" | "show_toc" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_toc = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showStructure" | "show_structure" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_structure = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showWordCount" | "show_word_count" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_word_count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"collapseBacklinks" | "collapse_backlinks" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.collapse_backlinks = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"hideChildPages" | "hide_child_pages" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.hide_child_pages = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showBlockRefCount" | "show_block_ref_count" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_block_ref_count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.trim().is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::routes::documents::{
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
@@ -35,7 +36,7 @@ use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
|
||||
@@ -43,6 +44,10 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentShellQuery {
|
||||
@@ -50,6 +55,7 @@ pub struct DocumentShellQuery {
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub tree_view: Option<String>,
|
||||
pub file_tree_scope: Option<String>,
|
||||
pub secondary_document_id: Option<String>,
|
||||
pub secondary_source_kind: Option<String>,
|
||||
pub secondary_root_uri: Option<String>,
|
||||
@@ -129,6 +135,7 @@ pub async fn document_page_shell(
|
||||
};
|
||||
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let mut workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
@@ -148,11 +155,17 @@ pub async fn document_page_shell(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == "filetree");
|
||||
let file_tree_scope = query
|
||||
.file_tree_scope
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
|
||||
let root_uri = query.root_uri.as_deref().unwrap_or_default();
|
||||
(
|
||||
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
render_local_file_tree_html(root_uri, Some(&document_id), None).unwrap_or_default(),
|
||||
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -179,6 +192,7 @@ pub async fn document_page_shell(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
file_tree_scope,
|
||||
);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let page_subtree_json =
|
||||
@@ -245,6 +259,7 @@ pub async fn document_page_shell(
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{}</title>
|
||||
{}
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
|
||||
@@ -261,6 +276,7 @@ pub async fn document_page_shell(
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(title),
|
||||
render_editor_runtime_preload_links(),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&document_id),
|
||||
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
|
||||
@@ -646,6 +662,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
||||
}
|
||||
|
||||
pub(crate) fn render_editor_runtime_preload_links() -> &'static str {
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">
|
||||
<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
}
|
||||
|
||||
fn runtime_asset_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../spikes/leptos-tiptap-spike/generated/island")
|
||||
@@ -678,6 +699,10 @@ fn runtime_asset_content_type(asset_path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_asset_cache_control() -> &'static str {
|
||||
"public, max-age=3600, stale-while-revalidate=86400"
|
||||
}
|
||||
|
||||
pub async fn editor_image_placeholder_asset() -> Response {
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
|
||||
<rect width="640" height="360" rx="18" fill="#f3f4f6"/>
|
||||
@@ -705,7 +730,7 @@ pub async fn resource_open_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -719,7 +744,7 @@ pub async fn local_upload_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -733,7 +758,7 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -747,7 +772,7 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -761,7 +786,7 @@ pub async fn sidebar_page_settings_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -775,7 +800,7 @@ pub async fn sidebar_shell_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -789,7 +814,7 @@ pub async fn sidebar_workspace_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -803,7 +828,7 @@ pub async fn sidebar_page_tree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -817,7 +842,7 @@ pub async fn sidebar_tree_live_apply_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -831,7 +856,7 @@ pub async fn sidebar_filetree_open_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -845,7 +870,7 @@ pub async fn sidebar_filetree_command_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -859,7 +884,7 @@ pub async fn sidebar_filetree_upload_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -873,7 +898,7 @@ pub async fn sidebar_attachment_open_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -887,7 +912,7 @@ pub async fn filetree_keyboard_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -901,7 +926,7 @@ pub async fn filetree_dnd_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -915,7 +940,7 @@ pub async fn filetree_context_menu_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -929,7 +954,7 @@ pub async fn filetree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -943,7 +968,7 @@ pub async fn filetree_selection_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -957,7 +982,7 @@ pub async fn tree_live_controller_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -971,7 +996,7 @@ pub async fn tree_shell_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -985,7 +1010,7 @@ pub async fn tree_shell_render_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -999,7 +1024,7 @@ pub async fn tree_shell_page_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1013,7 +1038,7 @@ pub async fn tree_shell_state_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1027,7 +1052,7 @@ pub async fn tree_shell_icons_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1041,7 +1066,7 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1055,7 +1080,7 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1069,7 +1094,7 @@ pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1083,7 +1108,7 @@ pub async fn tree_shell_picker_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1097,7 +1122,7 @@ pub async fn tree_shell_dom_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1112,7 +1137,7 @@ pub async fn document_conflict_panel_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1126,7 +1151,7 @@ pub async fn document_pane_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1140,7 +1165,7 @@ pub async fn document_mindmap_host_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1154,7 +1179,7 @@ pub async fn document_resource_tab_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1168,7 +1193,7 @@ pub async fn document_session_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1182,7 +1207,7 @@ pub async fn document_slash_position_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1196,7 +1221,7 @@ pub async fn document_tiptap_conversion_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1210,7 +1235,7 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1228,6 +1253,10 @@ pub async fn leptos_tiptap_manifest() -> Response {
|
||||
});
|
||||
let mut response = Json(manifest).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(runtime_asset_cache_control()),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -1251,7 +1280,7 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
|
||||
header::CONTENT_TYPE,
|
||||
runtime_asset_content_type(&asset_path),
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
|
||||
@@ -1312,7 +1341,24 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(state, context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
let root_uri_for_build = root_uri.to_string();
|
||||
let root_uri_for_preferences = root_uri_for_build.clone();
|
||||
let document_id = document_id.to_string();
|
||||
let mut aggregate = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
block_local_page_aggregate_for_test();
|
||||
resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")))??;
|
||||
super::ui_preferences::apply_effective_page_preferences(
|
||||
state,
|
||||
context,
|
||||
&mut aggregate,
|
||||
source_kind,
|
||||
Some(root_uri_for_preferences.as_str()),
|
||||
)?;
|
||||
return Ok(aggregate);
|
||||
}
|
||||
|
||||
let meta = load_document_meta_result(
|
||||
@@ -1398,6 +1444,7 @@ pub(crate) fn escape_script_json(value: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) async fn load_workspace_shell_projection(
|
||||
state: Option<&AppState>,
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
@@ -1412,7 +1459,7 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
query: None,
|
||||
max_results: None,
|
||||
};
|
||||
let dataset = match load_projection_snapshot(config, context, &spec).await {
|
||||
let mut dataset = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => snapshot.dataset,
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
let documents = active_document_id
|
||||
@@ -1444,6 +1491,9 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
"degraded_reason": "projection_unavailable"
|
||||
}),
|
||||
};
|
||||
if let Some(state) = state {
|
||||
attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset);
|
||||
}
|
||||
|
||||
build_workspace_shell_projection(
|
||||
&dataset,
|
||||
@@ -1453,6 +1503,25 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn attach_sidebar_shortcuts_to_dataset(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
dataset: &mut Value,
|
||||
) {
|
||||
let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset(
|
||||
state,
|
||||
context,
|
||||
workspace_id,
|
||||
);
|
||||
if shortcuts.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(object) = dataset.as_object_mut() {
|
||||
object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts));
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载侧栏页面树 HTML(SSR)
|
||||
///
|
||||
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
||||
@@ -1605,7 +1674,23 @@ pub(crate) fn render_local_file_tree_html(
|
||||
active_document_id: Option<&str>,
|
||||
active_row_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?;
|
||||
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_file_tree_html_scoped(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
active_row_id: Option<&str>,
|
||||
file_tree_scope: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = if let Some(scope) = file_tree_scope
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
load_local_folder_file_tree_children_snapshot(root_uri, scope)?
|
||||
} else {
|
||||
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
|
||||
};
|
||||
let rows =
|
||||
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
|
||||
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
@@ -1613,14 +1698,23 @@ pub(crate) fn render_local_file_tree_html(
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn block_local_page_aggregate_for_test() {
|
||||
let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput};
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
|
||||
@@ -1917,6 +2011,12 @@ mod tests {
|
||||
assert!(html.contains("data-mnote-resource-tab-host"));
|
||||
assert!(runtime.contains("openPrimaryMindmap"));
|
||||
assert!(runtime.contains("openResourceInActiveTab"));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
));
|
||||
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
|
||||
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
|
||||
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
|
||||
@@ -2033,6 +2133,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_browser_runtime_assets_are_cacheable() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cache_control = response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("cache-control");
|
||||
assert_ne!(cache_control, "no-store");
|
||||
assert!(
|
||||
cache_control.contains("max-age"),
|
||||
"runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leptos_tiptap_runtime_assets_are_cacheable() {
|
||||
let manifest_response = app()
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/manifest.json")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(manifest_response.status(), StatusCode::OK);
|
||||
let manifest_cache_control = manifest_response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("manifest cache-control");
|
||||
assert_ne!(manifest_cache_control, "no-store");
|
||||
assert!(
|
||||
manifest_cache_control.contains("max-age"),
|
||||
"leptos-tiptap manifest 应允许浏览器缓存,避免每次重新发现 runtime 入口"
|
||||
);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cache_control = response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("cache-control");
|
||||
assert_ne!(cache_control, "no-store");
|
||||
assert!(
|
||||
cache_control.contains("max-age"),
|
||||
"leptos-tiptap runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
@@ -2243,6 +2415,190 @@ mod tests {
|
||||
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-aggregate-runtime-asset-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle");
|
||||
std::fs::write(
|
||||
root.join("Cold Start").join("Cold Start.md"),
|
||||
"# Cold Start\n\nEditor cold start target.\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let app = app();
|
||||
let aggregate_app = app.clone();
|
||||
let aggregate_uri = format!(
|
||||
"/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
);
|
||||
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
|
||||
.store(400, std::sync::atomic::Ordering::SeqCst);
|
||||
struct ResetLocalAggregateBlock;
|
||||
impl Drop for ResetLocalAggregateBlock {
|
||||
fn drop(&mut self) {
|
||||
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _reset_block = ResetLocalAggregateBlock;
|
||||
let aggregate_task = tokio::spawn(async move {
|
||||
aggregate_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(aggregate_uri)
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("aggregate request"),
|
||||
)
|
||||
.await
|
||||
.expect("aggregate response")
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
tokio::task::yield_now().await;
|
||||
let asset_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("asset request"),
|
||||
)
|
||||
.await
|
||||
.expect("asset response");
|
||||
let asset_elapsed = started.elapsed();
|
||||
let aggregate_response = aggregate_task.await.expect("aggregate task");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(asset_response.status(), StatusCode::OK);
|
||||
assert_eq!(aggregate_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
asset_elapsed < Duration::from_millis(150),
|
||||
"leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞,实际等待 {asset_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-aggregate-ui-pref-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::write(root.join("README.md"), "# Readme Heading\n正文\n").expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
|
||||
let workspace_id =
|
||||
crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri)
|
||||
.expect("workspace id");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: "user_test".into(),
|
||||
workspace_id: Some(workspace_id),
|
||||
source_kind: Some("local_folder".into()),
|
||||
scope_kind: "source_family".into(),
|
||||
scope_id: "external_local_folder".into(),
|
||||
key: "hideTitleHeader".into(),
|
||||
value_json: "false".into(),
|
||||
})
|
||||
.expect("upsert title header preference");
|
||||
|
||||
let context = request_context("user_test", "user");
|
||||
let aggregate = super::build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
Some("local_folder"),
|
||||
Some(&root_uri),
|
||||
)
|
||||
.await
|
||||
.expect("local page aggregate");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert!(!aggregate.layout.page_options.hide_title_header);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_preferences_api_updates_and_returns_effective_page_options() {
|
||||
let app = app();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/ui/preferences")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"sourceKind": "convex_workspace",
|
||||
"documentId": "doc_1",
|
||||
"updates": {
|
||||
"showHeadingNumbers": true,
|
||||
"layoutDensity": "compact"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true);
|
||||
assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact");
|
||||
assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global");
|
||||
assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
||||
let root =
|
||||
@@ -2469,6 +2825,7 @@ mod tests {
|
||||
let filetree_html =
|
||||
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
|
||||
let workspace_projection = super::load_workspace_shell_projection(
|
||||
None,
|
||||
&config,
|
||||
&context,
|
||||
"ws_demo",
|
||||
|
||||
@@ -88,6 +88,7 @@ pub fn PageLayout(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
});
|
||||
let tree_live_bootstrap = serde_json::json!({
|
||||
@@ -175,8 +176,8 @@ pub fn PageLayout(
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wolai-topbar-actions" aria-label="页面操作">
|
||||
<span class="wolai-public-pill" data-testid="wolai-public-state">"全网公开"</span>
|
||||
<button type="button" class="wolai-icon-button" title="收藏" aria-label="收藏"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
|
||||
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
||||
@@ -199,6 +200,8 @@ pub fn PageLayout(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use leptos::prelude::ElementChild;
|
||||
|
||||
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-runtime.js");
|
||||
const SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-tree-live-apply-runtime.js");
|
||||
@@ -260,6 +263,8 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||
.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command"));
|
||||
@@ -341,6 +346,51 @@ mod tests {
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||
<main>"正文"</main>
|
||||
</super::PageLayout>
|
||||
});
|
||||
|
||||
assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#));
|
||||
assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("inferLocalRootUriFromWorkspaceId"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-scope"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("toggle-sidebar-folder-shortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
|
||||
let scope_start = SIDEBAR_TREE_RUNTIME_JS
|
||||
.find("persistStarredFolderScope(workspaceId, rootUri, relativePath);")
|
||||
.expect("starred folder should persist scope before loading");
|
||||
let sidebar_fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..]
|
||||
.find("fetch(sidebarUrl.toString()")
|
||||
.expect("starred folder should fetch local page projection")
|
||||
+ scope_start;
|
||||
let fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..]
|
||||
.find("fetch(url.toString()")
|
||||
.expect("starred folder should fetch scoped projection")
|
||||
+ scope_start;
|
||||
assert!(
|
||||
sidebar_fetch_start < fetch_start,
|
||||
"星标文件夹请求 scoped filetree 前必须先拉本地页面树投影,避免我的空间旧页面残留"
|
||||
);
|
||||
assert!(
|
||||
scope_start < fetch_start,
|
||||
"星标文件夹应先写入 fileTreeScope,再请求大目录,避免 live refresh 把视图折回根目录"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
|
||||
assert!(
|
||||
@@ -503,9 +553,10 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("renderSidebarSnapshot(sidebarPayload.result || sidebarPayload)"));
|
||||
.contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("renderFileProjection(filePayload.result || filePayload)"));
|
||||
.contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
|
||||
);
|
||||
@@ -565,6 +616,29 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("editorRoot: uploadContext.root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_attachment_open_runtime_receives_closest_action_dependency() {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function closestAction"));
|
||||
let injection_start = SIDEBAR_TREE_RUNTIME_JS
|
||||
.find("const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({")
|
||||
.expect("attachment runtime injection");
|
||||
let injection_end = SIDEBAR_TREE_RUNTIME_JS[injection_start..]
|
||||
.find(" });")
|
||||
.map(|offset| injection_start + offset)
|
||||
.expect("attachment runtime injection end");
|
||||
let injection = &SIDEBAR_TREE_RUNTIME_JS[injection_start..injection_end];
|
||||
assert!(
|
||||
injection.contains("closestAction"),
|
||||
"attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("closestAction = typeof injectedClosestAction === 'function'")
|
||||
|| SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("dependencies.closestAction"),
|
||||
"attachment open runtime 应从 dependencies 读取 closestAction,而不是依赖外层闭包"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_upload_runtime_contains_editor_upload_context_helpers() {
|
||||
const LOCAL_UPLOAD_RUNTIME_JS: &str =
|
||||
@@ -803,8 +877,9 @@ mod tests {
|
||||
"selectSidebarFileTreeDocument",
|
||||
);
|
||||
assert!(
|
||||
select_document_body
|
||||
.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"),
|
||||
select_document_body.contains("activateSidebarFileTreeRow(row, options)")
|
||||
&& SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function activateSidebarFileTreeRow")
|
||||
&& SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"),
|
||||
"document selection 应复用 filetree row selection runtime/fallback"
|
||||
);
|
||||
}
|
||||
@@ -1006,8 +1081,173 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file/children"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("parentRelativePath"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-filetree-children-loaded"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeLazyChildrenCache"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowsByParent: new Map()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadedParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadingParents: new Map()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("dirtyParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeExpandedRelativePaths"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (fileTreeState.loadingParents.has(key))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("return fileTreeState.loadingParents.get(key);"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function markExistingFileTreeChildrenLoaded"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (markExistingFileTreeChildrenLoaded(row, button))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("scheduleRestorePersistedFileTreeExpansionState();"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("return patchFileTreeParentChildren(parentRelativePath, rows);"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)"));
|
||||
let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("function renderFileProjection(projection)")
|
||||
.expect("renderFileProjection");
|
||||
let render_end = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..]
|
||||
.find("function renderSidebarSnapshot(payload)")
|
||||
.map(|offset| render_start + offset)
|
||||
.expect("renderFileProjection end");
|
||||
let render_body = &SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..render_end];
|
||||
assert!(!render_body.contains("tree.innerHTML ="));
|
||||
assert!(render_body.contains("tree.replaceChildren"));
|
||||
let patch_branch = render_body
|
||||
.find("patchFileTreeParentChildren(parentRelativePath, rows)")
|
||||
.expect("non-root parent patch");
|
||||
let root_replace = render_body
|
||||
.find("tree.replaceChildren")
|
||||
.expect("root replacement");
|
||||
assert!(
|
||||
patch_branch < root_replace,
|
||||
"非 scope parent projection 必须先局部 patch,不能替换整棵 filetree"
|
||||
);
|
||||
let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("async function loadFileTreeChildren(row, button)")
|
||||
.expect("lazy children loader");
|
||||
let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||
.find("setTreeRowExpanded(row, button, true);")
|
||||
.expect("lazy loading should mark the requested folder expanded before fetch")
|
||||
+ load_start;
|
||||
let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||
.find("getFileTreeChildren(relativePath)")
|
||||
.expect("lazy children datasource call")
|
||||
+ load_start;
|
||||
assert!(
|
||||
optimistic_expand < fetch_start,
|
||||
"慢目录加载期间必须先保存 expanded 状态,否则 refresh/create-page 会把刚点开的文件夹折叠"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_discards_stale_generation_results() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("staleParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("requestGeneration: 0"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function beginFileTreeRequest"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function isLatestFileTreeRequest"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (!isLatestFileTreeRequest(key, generation))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.staleParents.add(key)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.requestGeneration += 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_prefers_command_affected_parents() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function addAffectedParentsFromCommandResult"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("Array.isArray(result && result.affectedParents)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("result && result.execution && result.execution.affectedParents"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("addAffectedParentsFromCommandResult(parents, result)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-filetree-command-refresh-fallback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_batches_local_command_refresh() {
|
||||
assert!(
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function nextFileTreeOperationBatchId")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("removeFileTreeAssetRow"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("removeFileTreeAssetRow,"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("batchId: batchId"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("tree:local-command-batch-complete"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function queueFileTreeBatchRefresh"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function flushFileTreeBatchRefresh"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-filetree-batch-refresh-pending"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-filetree-batch-refresh-applied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_handles_watch_batch_and_structured_error() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("watch_batch"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:local-folder-watch-batch"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-local-folder-watch-batch-applied"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_has_view_state_namespace() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("expandedParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("selectedRowIds: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("focusedRowId: ''"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("activeRowId: ''"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeState = fileTreeViewState;")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
||||
"if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function rememberFileTreeSelectionState")
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function reprojectFileTreeSelectionState")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("fileTreeViewState.selectedRowIds.add(rowId)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
||||
"row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_can_reveal_unloaded_resource_path() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("async function revealFileTreeResource")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function fileTreeParentChainForRelativePath"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("await getFileTreeChildren(parentRelativePath)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("fileTreeViewState.selectedRowIds = new Set([targetRowId])"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("revealFileTreeResource,"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args);"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("revealFileTreeResource({"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("decodeLocalEncodedPath(id.slice('local-md:'.length))"));
|
||||
assert!(
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function localMarkdownBundleParentPath")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("visibleFileTreeRowByRelativePath(bundleParentPath)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1522,6 +1522,18 @@ body {
|
||||
color: var(--atelier-text);
|
||||
}
|
||||
|
||||
.wolai-section-add + .wolai-section-add {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.wolai-section-add .material-symbols-outlined {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wolai-page-row {
|
||||
min-height: 30px;
|
||||
gap: 7px;
|
||||
@@ -1560,6 +1572,39 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wolai-row-more {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #8C8983;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-row:hover .wolai-row-more,
|
||||
.wolai-row-more:focus-visible,
|
||||
.wolai-row-more[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wolai-row-more:hover {
|
||||
background: #E7E4E0;
|
||||
color: #4B4945;
|
||||
}
|
||||
|
||||
.wolai-row-more .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-tree-section {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct FileTreeRenderRow {
|
||||
pub icon_kind: String,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub object_identity: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
@@ -90,7 +91,7 @@ fn render_filetree_row(
|
||||
};
|
||||
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -101,6 +102,7 @@ fn render_filetree_row(
|
||||
document_id = escape_html(command_document_id),
|
||||
owner_document_id = escape_html(owner_document_id),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()),
|
||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
toggle_html = toggle_html,
|
||||
@@ -176,6 +178,7 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
relative_path: None,
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||
),
|
||||
@@ -193,6 +196,7 @@ mod tests {
|
||||
icon_kind: "mindmap".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("mind_1".into()),
|
||||
relative_path: Some("assets/思维导图.json".into()),
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
|
||||
),
|
||||
@@ -210,6 +214,7 @@ mod tests {
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-local-relative-path=\"assets/思维导图.json\""));
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
@@ -234,6 +239,7 @@ mod tests {
|
||||
icon_kind: "folder".into(),
|
||||
document_id: None,
|
||||
asset_id: None,
|
||||
relative_path: Some("docs".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
@@ -249,6 +255,7 @@ mod tests {
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("local-md:docs~2FREADME.md".into()),
|
||||
asset_id: None,
|
||||
relative_path: Some("docs/README.md".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
@@ -256,8 +263,34 @@ mod tests {
|
||||
});
|
||||
|
||||
assert!(html.contains("data-row-id=\"local:folder:docs\""));
|
||||
assert!(html.contains("data-local-relative-path=\"docs\""));
|
||||
assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\""));
|
||||
assert!(!html.contains("README.md"));
|
||||
assert!(!html.contains("tree-children--collapsed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_ssr_rows_include_local_relative_path() {
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows: vec![FileTreeRenderRow {
|
||||
row_id: "local:folder:design/03-rust-web".into(),
|
||||
row_kind: "folder".into(),
|
||||
node_id: "local:node:design/03-rust-web".into(),
|
||||
parent_node_id: None,
|
||||
title: "03-rust-web".into(),
|
||||
depth: 1,
|
||||
expandable: true,
|
||||
expanded: false,
|
||||
icon_kind: "folder".into(),
|
||||
document_id: None,
|
||||
asset_id: None,
|
||||
relative_path: Some("design/03-rust-web".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
}],
|
||||
});
|
||||
|
||||
assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#));
|
||||
assert!(html.contains(r#"<button type="button" class="tree-link""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
relative_path: None,
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
@@ -148,6 +149,7 @@ mod tests {
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("asset_1".into()),
|
||||
relative_path: Some("asset_1".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,14 @@ pub struct WorkspaceShellItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub icon: Option<String>,
|
||||
pub shortcut_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub href: String,
|
||||
pub depth: u32,
|
||||
@@ -88,6 +96,21 @@ pub fn build_workspace_shell_projection(
|
||||
})
|
||||
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
let starred_page_ids = starred_items
|
||||
.iter()
|
||||
.map(|item| item.id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for shortcut in sidebar_shortcuts(dataset) {
|
||||
if let Some(item) = shortcut_to_item(
|
||||
shortcut,
|
||||
workspace_id,
|
||||
active_page_id.as_deref(),
|
||||
&documents,
|
||||
&starred_page_ids,
|
||||
) {
|
||||
starred_items.push(item);
|
||||
}
|
||||
}
|
||||
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||
|
||||
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
|
||||
@@ -214,6 +237,14 @@ fn document_to_item(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
shortcut_id: None,
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
kind: Some("page".to_string()),
|
||||
target_id: Some(id.to_string()),
|
||||
relative_path: None,
|
||||
document_id: Some(id.to_string()),
|
||||
parent_id,
|
||||
href: format!("/documents/{id}?workspaceId={workspace_id}"),
|
||||
depth,
|
||||
@@ -221,6 +252,207 @@ fn document_to_item(
|
||||
})
|
||||
}
|
||||
|
||||
fn sidebar_shortcuts(dataset: &Value) -> Vec<&Value> {
|
||||
dataset
|
||||
.get("sidebar_shortcuts")
|
||||
.or_else(|| dataset.get("sidebarShortcuts"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn shortcut_to_item(
|
||||
shortcut: &Value,
|
||||
workspace_id: &str,
|
||||
active_page_id: Option<&str>,
|
||||
documents: &[Value],
|
||||
starred_page_ids: &std::collections::BTreeSet<String>,
|
||||
) -> Option<WorkspaceShellItem> {
|
||||
let shortcut_workspace_id = shortcut
|
||||
.get("workspace_id")
|
||||
.or_else(|| shortcut.get("workspaceId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(workspace_id);
|
||||
let source_kind = shortcut
|
||||
.get("source_kind")
|
||||
.or_else(|| shortcut.get("sourceKind"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("workspace");
|
||||
if shortcut_workspace_id != workspace_id && source_kind != "local_folder" {
|
||||
return None;
|
||||
}
|
||||
let kind = shortcut
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let target_id = shortcut
|
||||
.get("target_id")
|
||||
.or_else(|| shortcut.get("targetId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
if kind == "page" {
|
||||
let document_id = shortcut
|
||||
.get("document_id")
|
||||
.or_else(|| shortcut.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(target_id);
|
||||
if starred_page_ids.contains(document_id) {
|
||||
return None;
|
||||
}
|
||||
let root_uri = shortcut_root_uri(shortcut);
|
||||
if let Some(document) = documents.iter().find(|document| {
|
||||
document.get("id").and_then(Value::as_str).map(str::trim) == Some(document_id)
|
||||
}) {
|
||||
let mut item = document_to_item(document, workspace_id, active_page_id)?;
|
||||
let shortcut_id = shortcut
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(document_id)
|
||||
.to_string();
|
||||
item.id = shortcut_id.clone();
|
||||
item.shortcut_id = Some(shortcut_id);
|
||||
item.kind = Some("page".to_string());
|
||||
item.workspace_id = Some(shortcut_workspace_id.to_string());
|
||||
item.source_kind = Some(source_kind.to_string());
|
||||
item.root_uri = root_uri.clone();
|
||||
item.href = shortcut_document_href(
|
||||
document_id,
|
||||
shortcut_workspace_id,
|
||||
source_kind,
|
||||
root_uri.as_deref(),
|
||||
);
|
||||
item.target_id = Some(target_id.to_string());
|
||||
item.document_id = Some(document_id.to_string());
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
let title = shortcut
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if kind == "folder" {
|
||||
"文件夹"
|
||||
} else {
|
||||
"无标题"
|
||||
}
|
||||
});
|
||||
let relative_path = shortcut
|
||||
.get("relative_path")
|
||||
.or_else(|| shortcut.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let document_id = shortcut
|
||||
.get("document_id")
|
||||
.or_else(|| shortcut.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let root_uri = shortcut_root_uri(shortcut);
|
||||
let shortcut_id = shortcut
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(target_id)
|
||||
.to_string();
|
||||
Some(WorkspaceShellItem {
|
||||
id: shortcut_id.clone(),
|
||||
title: title.to_string(),
|
||||
icon: shortcut
|
||||
.get("icon")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| (kind == "folder").then(|| "folder_open".to_string())),
|
||||
shortcut_id: Some(shortcut_id),
|
||||
workspace_id: Some(shortcut_workspace_id.to_string()),
|
||||
source_kind: Some(source_kind.to_string()),
|
||||
root_uri: root_uri.clone(),
|
||||
kind: Some(kind.to_string()),
|
||||
target_id: Some(target_id.to_string()),
|
||||
relative_path,
|
||||
document_id: document_id.clone(),
|
||||
parent_id: None,
|
||||
href: document_id
|
||||
.as_ref()
|
||||
.map(|id| {
|
||||
shortcut_document_href(id, shortcut_workspace_id, source_kind, root_uri.as_deref())
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
depth: 0,
|
||||
active: kind == "page"
|
||||
&& active_page_id.is_some_and(|active_id| document_id.as_deref() == Some(active_id)),
|
||||
})
|
||||
}
|
||||
|
||||
fn shortcut_root_uri(shortcut: &Value) -> Option<String> {
|
||||
shortcut
|
||||
.get("rootUri")
|
||||
.or_else(|| shortcut.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
shortcut
|
||||
.get("metadata")
|
||||
.and_then(|metadata| metadata.get("rootUri").or_else(|| metadata.get("root_uri")))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn shortcut_document_href(
|
||||
document_id: &str,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: Option<&str>,
|
||||
) -> String {
|
||||
let mut href = format!(
|
||||
"/documents/{}?workspaceId={}",
|
||||
document_id,
|
||||
encode_query_component(workspace_id)
|
||||
);
|
||||
if !source_kind.trim().is_empty() && source_kind != "workspace" {
|
||||
href.push_str("&sourceKind=");
|
||||
href.push_str(&encode_query_component(source_kind));
|
||||
}
|
||||
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
href.push_str("&rootUri=");
|
||||
href.push_str(&encode_query_component(root_uri));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn encode_query_component(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.as_bytes() {
|
||||
match *byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(*byte as char)
|
||||
}
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
|
||||
for item in items {
|
||||
item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
|
||||
@@ -336,7 +568,7 @@ mod tests {
|
||||
Some("doc_root"),
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
||||
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
|
||||
assert!(html.contains("data-node-id=\"doc_root\""));
|
||||
@@ -346,12 +578,52 @@ mod tests {
|
||||
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
|
||||
assert!(html.contains("data-mnote-action=\"create-page\""));
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-create-folder\""));
|
||||
assert!(html.contains("data-mnote-action=\"create-folder\""));
|
||||
assert!(html.contains("wolai-section-add--folder"));
|
||||
assert!(html.contains("data-icon=\"folder_open\""));
|
||||
assert!(!html.contains(">create_new_folder</span>"));
|
||||
assert!(html.contains("class=\"material-symbols-outlined"));
|
||||
assert!(html.contains("data-icon=\"home\""));
|
||||
assert!(html.contains("data-icon=\"star\""));
|
||||
assert!(html.contains("data-icon=\"delete\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_renders_sqlite_folder_shortcut_attrs() {
|
||||
let dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [],
|
||||
"sidebarShortcuts": [{
|
||||
"id": "shortcut_design",
|
||||
"workspaceId": "local-ws:demo",
|
||||
"kind": "folder",
|
||||
"sourceKind": "local_folder",
|
||||
"targetId": "folder:design",
|
||||
"relativePath": "design",
|
||||
"title": "design",
|
||||
"icon": "folder_open",
|
||||
"metadata": {
|
||||
"rootUri": "file:///tmp/mnote-demo"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let projection =
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
||||
|
||||
assert_eq!(projection.starred_items.len(), 1);
|
||||
assert!(html.contains(r#"data-mnote-shortcut-kind="folder""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-id="shortcut_design""#));
|
||||
assert!(html.contains(r#"data-workspace-id="local-ws:demo""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-target-id="folder:design""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-relative-path="design""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-root-uri="file:///tmp/mnote-demo""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-action="menu""#));
|
||||
assert!(html.contains(r#"role="button""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_uses_single_tabbed_tree_host() {
|
||||
let dataset = json!({
|
||||
@@ -371,6 +643,7 @@ mod tests {
|
||||
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
|
||||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#));
|
||||
@@ -401,6 +674,7 @@ mod tests {
|
||||
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
|
||||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||||
Some("filetree"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(html.contains(
|
||||
@@ -413,6 +687,26 @@ mod tests {
|
||||
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_marks_filetree_scope() {
|
||||
let dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": []
|
||||
});
|
||||
let projection =
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(
|
||||
&projection,
|
||||
None,
|
||||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||||
Some("filetree"),
|
||||
Some("design"),
|
||||
);
|
||||
|
||||
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
|
||||
assert!(html.contains(r#"data-mnote-filetree-scope="design""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_outputs_empty_state() {
|
||||
let dataset = json!({
|
||||
@@ -421,7 +715,7 @@ mod tests {
|
||||
});
|
||||
let projection =
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
||||
|
||||
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||
assert!(!html.contains("暂无页面"));
|
||||
@@ -442,7 +736,7 @@ mod tests {
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
let degraded_html =
|
||||
render_workspace_shell_sidebar_html(°raded_projection, None, None, None);
|
||||
render_workspace_shell_sidebar_html(°raded_projection, None, None, None, None);
|
||||
|
||||
assert!(degraded_projection.degraded);
|
||||
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
|
||||
@@ -456,7 +750,7 @@ mod tests {
|
||||
});
|
||||
let dev_projection =
|
||||
build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None);
|
||||
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None, None);
|
||||
|
||||
assert!(dev_projection.dev_fixture);
|
||||
assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
|
||||
@@ -469,6 +763,7 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
sidebar_tree_html: Option<&str>,
|
||||
file_tree_html: Option<&str>,
|
||||
initial_tree_mode: Option<&str>,
|
||||
file_tree_scope: Option<&str>,
|
||||
) -> String {
|
||||
let starred_rows = if projection.starred_items.is_empty() {
|
||||
String::new()
|
||||
@@ -500,8 +795,18 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|html| {
|
||||
let file_tree_scope_attr = file_tree_scope
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
format!(
|
||||
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div>"#,
|
||||
r#" data-mnote-filetree-scope="{}""#,
|
||||
escape_html(value)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
|
||||
escape_html(&projection.workspace_id),
|
||||
)
|
||||
})
|
||||
@@ -594,10 +899,11 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
);
|
||||
|
||||
format!(
|
||||
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button><button type="button" class="wolai-section-add wolai-section-add--folder" data-testid="wolai-sidebar-create-folder" data-mnote-action="create-folder" data-workspace-id="{}" title="新建文件夹" aria-label="新建文件夹"><span class="material-symbols-outlined" data-icon="folder_open" aria-hidden="true"></span></button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
render_symbol("star", "wolai-section-icon"),
|
||||
render_symbol("folder_open", "wolai-folder-icon"),
|
||||
escape_html(&projection.workspace_id),
|
||||
escape_html(&projection.workspace_id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -613,20 +919,105 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
|
||||
.as_deref()
|
||||
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
|
||||
.unwrap_or_default();
|
||||
let workspace_attr = item
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(|workspace_id| format!(r#" data-workspace-id="{}""#, escape_html(workspace_id)))
|
||||
.unwrap_or_default();
|
||||
let shortcut_id_attr = item
|
||||
.shortcut_id
|
||||
.as_deref()
|
||||
.map(|shortcut_id| format!(r#" data-mnote-shortcut-id="{}""#, escape_html(shortcut_id)))
|
||||
.unwrap_or_default();
|
||||
let source_kind_attr = item
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(|source_kind| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-source-kind="{}""#,
|
||||
escape_html(source_kind)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let root_uri_attr = item
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(|root_uri| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-root-uri="{}""#,
|
||||
escape_html(root_uri)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let aria_current = if item.active {
|
||||
r#" aria-current="page""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let kind_attr = item
|
||||
.kind
|
||||
.as_deref()
|
||||
.map(|kind| format!(r#" data-mnote-shortcut-kind="{}""#, escape_html(kind)))
|
||||
.unwrap_or_default();
|
||||
let target_attr = item
|
||||
.target_id
|
||||
.as_deref()
|
||||
.map(|target_id| {
|
||||
format!(
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
|
||||
escape_html(&item.href),
|
||||
escape_html(&item.id),
|
||||
escape_html(&item.id),
|
||||
item.depth,
|
||||
item.active,
|
||||
r#" data-mnote-shortcut-target-id="{}""#,
|
||||
escape_html(target_id)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let relative_attr = item
|
||||
.relative_path
|
||||
.as_deref()
|
||||
.map(|relative_path| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-relative-path="{}""#,
|
||||
escape_html(relative_path)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let document_attr = item
|
||||
.document_id
|
||||
.as_deref()
|
||||
.map(|document_id| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-document-id="{}""#,
|
||||
escape_html(document_id)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let row_body = format!(
|
||||
r#"<span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span>"#,
|
||||
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
|
||||
escape_html(&item.title),
|
||||
);
|
||||
let shortcut_action = item
|
||||
.shortcut_id
|
||||
.as_deref()
|
||||
.map(|_| {
|
||||
r#"<button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>"#
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if item.href.trim().is_empty() {
|
||||
return format!(
|
||||
r#"<div class="wolai-page-row{active_class}" role="button" tabindex="0" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</div>"#,
|
||||
escape_html(&item.id),
|
||||
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
|
||||
item.depth,
|
||||
item.active,
|
||||
);
|
||||
}
|
||||
format!(
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</a>"#,
|
||||
escape_html(&item.href),
|
||||
escape_html(&item.id),
|
||||
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
|
||||
item.depth,
|
||||
item.active,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ async function readRuntimePageOptions(page) {
|
||||
async function waitForOptionsResponse(page, documentId, mutate) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
|
||||
if (!response.url().includes("/api/ui/preferences") || response.request().method() !== "PUT") {
|
||||
return false;
|
||||
}
|
||||
const payload = response.request().postDataJSON();
|
||||
@@ -103,8 +103,8 @@ async function waitForOptionsResponse(page, documentId, mutate) {
|
||||
await mutate();
|
||||
const response = await responsePromise;
|
||||
const payload = await response.json();
|
||||
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
|
||||
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
|
||||
assert.equal(payload?.owner, "mnote-web", "页面设置写入必须由 mnote-web 持有");
|
||||
assert(payload?.result?.pageOptions, "页面设置写入必须返回 SQLite 合并后的 pageOptions");
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ async function openPageSettingsDialog(page) {
|
||||
async function waitForOptionsResponse(page, documentId, mutate) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
async (response) => {
|
||||
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
|
||||
if (!response.url().includes("/api/ui/preferences") || response.request().method() !== "PUT") {
|
||||
return false;
|
||||
}
|
||||
const payload = response.request().postDataJSON();
|
||||
@@ -185,8 +185,8 @@ async function waitForOptionsResponse(page, documentId, mutate) {
|
||||
await mutate();
|
||||
const response = await responsePromise;
|
||||
const payload = await response.json();
|
||||
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
|
||||
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
|
||||
assert.equal(payload?.owner, "mnote-web", "页面设置写入必须由 mnote-web 持有");
|
||||
assert(payload?.result?.pageOptions, "页面设置写入必须返回 SQLite 合并后的 pageOptions");
|
||||
return response.status();
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +286,8 @@ async function run() {
|
||||
request.url().includes("/api/tree/commands") ||
|
||||
request.url().includes("/api/documents/title") ||
|
||||
request.url().includes("/api/documents/save") ||
|
||||
request.url().includes("/api/documents/options")
|
||||
request.url().includes("/api/documents/options") ||
|
||||
request.url().includes("/api/ui/preferences")
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
@@ -298,7 +299,6 @@ async function run() {
|
||||
|
||||
try {
|
||||
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => window.localStorage.removeItem("mnote.global.showHeadingNumbers"));
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Frontmatter Title");
|
||||
const dialogs = [];
|
||||
@@ -393,21 +393,14 @@ async function run() {
|
||||
if (!optionsResponse.ok) throw new Error(`options_failed_${optionsResponse.status}`);
|
||||
}, { rootUri: fileUrl(root) });
|
||||
assert(fs.readFileSync(path.join(root, "README.md"), "utf8").includes("Browser saved body"), "浏览器保存应写回 Markdown 正文");
|
||||
assert(fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存应写入 .mnote/page-options.json");
|
||||
assert(!fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存不应继续写入 .mnote/page-options.json");
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Browser Saved Title");
|
||||
await waitForText(page, "Browser saved body");
|
||||
const globallyDisabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
|
||||
window.getComputedStyle(node, "::before").content
|
||||
);
|
||||
assert(globallyDisabledHeadingBefore === "none", "全局关闭时,即使页面设置显式开启 showHeadingNumbers,也不应显示标题自动编号");
|
||||
await page.evaluate(() => window.localStorage.setItem("mnote.global.showHeadingNumbers", "true"));
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Browser Saved Title");
|
||||
const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
|
||||
window.getComputedStyle(node, "::before").content
|
||||
);
|
||||
assert(enabledHeadingBefore !== "none", "全局开启后应显示标题自动编号");
|
||||
assert(enabledHeadingBefore !== "none", "SQLite 偏好开启后应显示标题自动编号");
|
||||
await page.goto(documentUrl(root, "local-md:docs~2Fblocks.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Complex Title");
|
||||
await waitForText(page, "Bullet item");
|
||||
|
||||
@@ -193,8 +193,10 @@ async function main() {
|
||||
);
|
||||
assert(markdown.includes("# 正文标题"), "正文 H1 应写回 markdown");
|
||||
assert(markdown.includes("正文已保存"), "正文段落应写回 markdown");
|
||||
const options = fs.readFileSync(path.join(managedRoot, ".mnote", "page-options.json"), "utf8");
|
||||
assert(options.includes("showToc"), "页面设置应写入 .mnote/page-options.json");
|
||||
assert(
|
||||
!fs.existsSync(path.join(managedRoot, ".mnote", "page-options.json")),
|
||||
"页面设置不应继续写入 .mnote/page-options.json",
|
||||
);
|
||||
|
||||
console.log("task167 local markdown title/body/options no-convex smoke passed");
|
||||
} finally {
|
||||
|
||||
@@ -81,11 +81,21 @@ async function main() {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const treeCommands = [];
|
||||
const treeCommandResponses = [];
|
||||
const unexpectedDialogs = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/tree/commands")) return;
|
||||
const body = request.postDataJSON?.() || null;
|
||||
treeCommands.push({ method: request.method(), body });
|
||||
});
|
||||
page.on("response", async (response) => {
|
||||
if (!response.url().includes("/api/tree/commands")) return;
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch (_) {}
|
||||
treeCommandResponses.push({ status: response.status(), body });
|
||||
});
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
@@ -95,6 +105,14 @@ async function main() {
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const docsRow = page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="docs"]').first();
|
||||
if (await docsRow.isVisible({ timeout: UI_TIMEOUT_MS }).catch(() => false)) {
|
||||
const expanded = await docsRow.getAttribute("aria-expanded").catch(() => null);
|
||||
if (expanded !== "true") {
|
||||
await docsRow.locator('[data-rust-action="toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
const selectors = assets.map((asset) => `#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`);
|
||||
await page.locator(selectors[0]).waitFor({
|
||||
state: "visible",
|
||||
@@ -137,8 +155,16 @@ async function main() {
|
||||
await page.locator('.mnote-tree-context-menu__item[data-action="delete-trash"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const confirmText = await dialogPromise;
|
||||
assert.match(confirmText, /2 个附件/, `右键 bulk delete 确认文案应包含附件数量: ${confirmText}`);
|
||||
page.on("dialog", async (dialog) => {
|
||||
unexpectedDialogs.push(dialog.message());
|
||||
await dialog.accept().catch(() => undefined);
|
||||
});
|
||||
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, {
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.documentElement.getAttribute("data-mnote-filetree-last-action-status");
|
||||
return document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true"
|
||||
|| status === "failed";
|
||||
}, null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const actionState = await page.evaluate(() => ({
|
||||
@@ -146,8 +172,11 @@ async function main() {
|
||||
status: document.documentElement.getAttribute("data-mnote-filetree-last-action-status"),
|
||||
rowId: document.documentElement.getAttribute("data-mnote-filetree-last-action-row-id"),
|
||||
applied: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied"),
|
||||
batchId: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-batch-id"),
|
||||
batchRefresh: document.documentElement.getAttribute("data-mnote-filetree-batch-refresh-applied"),
|
||||
}));
|
||||
assert.equal(actionState.action, "bulk-delete", `bulk delete 应记录 action 名: ${JSON.stringify(actionState)}`);
|
||||
assert.deepEqual(unexpectedDialogs, [], `bulk delete 不应出现失败 alert: ${JSON.stringify({ unexpectedDialogs, treeCommands, treeCommandResponses })}`);
|
||||
assert.equal(actionState.status, "archived", `bulk delete 应记录 undo/archive 状态: ${JSON.stringify(actionState)}`);
|
||||
assert.equal(actionState.rowId, rowIds[1], `bulk delete 应记录触发目标 row: ${JSON.stringify(actionState)}`);
|
||||
for (const asset of assets) {
|
||||
@@ -159,6 +188,13 @@ async function main() {
|
||||
}
|
||||
const archiveCommands = treeCommands.filter((entry) => entry.body && entry.body.action === "archive");
|
||||
assert.equal(archiveCommands.length, 2, `bulk delete 应发出两个 archive tree command: ${JSON.stringify(treeCommands)}`);
|
||||
assert(actionState.batchId, `bulk delete 应生成 batch id: ${JSON.stringify(actionState)}`);
|
||||
assert.equal(actionState.batchRefresh, actionState.batchId, `bulk delete 应按 batch 完成后统一刷新: ${JSON.stringify(actionState)}`);
|
||||
assert.deepEqual(
|
||||
Array.from(new Set(archiveCommands.map((entry) => entry.body.batchId))).sort(),
|
||||
[actionState.batchId],
|
||||
`archive command 应共享同一个 batchId: ${JSON.stringify(archiveCommands)}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
archiveCommands.map((entry) => entry.body.documentId).sort(),
|
||||
assets.map((asset) => asset.assetId).sort(),
|
||||
|
||||
@@ -850,22 +850,17 @@ async function main() {
|
||||
await hideTitleCheckbox.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const hideTitleCheckedBefore = await hideTitleCheckbox.isChecked();
|
||||
assert.equal(hideTitleCheckedBefore, false, "托管工作区(我的空间)默认不隐藏文件标题");
|
||||
await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS });
|
||||
const optionsPath = path.join(root, ".mnote", "page-options.json");
|
||||
const optionsHidden = await waitForFileContent(
|
||||
optionsPath,
|
||||
(content) => {
|
||||
try {
|
||||
const payload = JSON.parse(content);
|
||||
return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
|
||||
return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false;
|
||||
},
|
||||
UI_TIMEOUT_MS,
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
assert(optionsHidden.ok, `隐藏标题应持久化 hideTitleHeader=true,实际: ${optionsHidden.content}`);
|
||||
await page.waitForTimeout(500);
|
||||
assert(!fs.existsSync(optionsPath), "隐藏标题不应继续写入 .mnote/page-options.json");
|
||||
const titleHeaderHiddenAfterCheck = await page.evaluate(() => {
|
||||
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
|
||||
return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false;
|
||||
@@ -873,20 +868,15 @@ async function main() {
|
||||
assert(titleHeaderHiddenAfterCheck, "勾选隐藏标题后标题应隐藏");
|
||||
|
||||
await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS });
|
||||
const optionsShown = await waitForFileContent(
|
||||
optionsPath,
|
||||
(content) => {
|
||||
try {
|
||||
const payload = JSON.parse(content);
|
||||
return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
|
||||
return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
|
||||
},
|
||||
UI_TIMEOUT_MS,
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
assert(optionsShown.ok, `显示标题应持久化 hideTitleHeader=false,实际: ${optionsShown.content}`);
|
||||
await page.waitForTimeout(500);
|
||||
assert(!fs.existsSync(optionsPath), "显示标题不应继续写入 .mnote/page-options.json");
|
||||
const titleHeaderVisibleAfterToggle = await page.evaluate(() => {
|
||||
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
|
||||
return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task492-sidebar-starred-shortcuts-smoke";
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function fileUrlToPath(value) {
|
||||
const url = new URL(value);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
||||
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
|
||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const browserDiagnostics = [];
|
||||
page.on("console", (message) => {
|
||||
browserDiagnostics.push(`console:${message.type()}:${message.text()}`);
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
browserDiagnostics.push(`pageerror:${error.message}`);
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/projections/file")) {
|
||||
browserDiagnostics.push(`request:${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
||||
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
name: actorId,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signUp",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const rootUri = new URL(page.url()).searchParams.get("rootUri")
|
||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
||||
assert(rootUri, "应进入 local_folder workspace");
|
||||
const rootPath = fileUrlToPath(rootUri);
|
||||
fs.mkdirSync(path.join(rootPath, "design"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "other"), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "design", "Brief.md"), "# Brief\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "other", "Other.md"), "# Other\n", "utf8");
|
||||
|
||||
const activeDocumentId = "local-md:Home.md";
|
||||
const url = new URL(page.url());
|
||||
url.pathname = `/documents/${encodeURIComponent(activeDocumentId)}`;
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
assert.equal(await page.locator('[data-testid="wolai-public-state"]').isVisible(), false, "未公开页面不应显示全网公开");
|
||||
await page.locator('[data-mnote-action="toggle-sidebar-shortcut"][data-mnote-shortcut-kind="page"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="page"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
}).catch(async (error) => {
|
||||
const debug = await page.evaluate(() => ({
|
||||
href: location.href,
|
||||
action: document.documentElement.getAttribute("data-mnote-sidebar-shortcut-last-action"),
|
||||
workspaceId: document.getElementById("sidebar-file-tree-root")?.getAttribute("data-workspace-id") || "",
|
||||
rootUri: document.body.getAttribute("data-mnote-root-uri") || "",
|
||||
topbar: document.querySelector(".wolai-topbar-actions")?.innerHTML || "",
|
||||
}));
|
||||
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}; browser=${browserDiagnostics.slice(-8).join(" | ")}`);
|
||||
});
|
||||
|
||||
const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first();
|
||||
await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS });
|
||||
const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
|
||||
await designShortcut.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const cloudSpaceUrl = new URL(baseUrl);
|
||||
cloudSpaceUrl.searchParams.set("workspaceId", "default");
|
||||
await page.goto(cloudSpaceUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const cloudDesignShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
|
||||
await cloudDesignShortcut.waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(
|
||||
await cloudDesignShortcut.getAttribute("data-mnote-shortcut-root-uri"),
|
||||
rootUri,
|
||||
"回到我的空间后,本地文件夹星标应携带自己的 rootUri",
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const panel = document.querySelector("#wolai-sidebar-page-tree-panel, [data-mnote-sidebar-tree-panel='page']");
|
||||
let root = document.querySelector("#sidebar-tree-root");
|
||||
if (!(root instanceof HTMLElement) && panel instanceof HTMLElement) {
|
||||
root = document.createElement("div");
|
||||
root.id = "sidebar-tree-root";
|
||||
root.className = "sidebar-tree";
|
||||
root.setAttribute("data-tree-shell-mode", "page");
|
||||
panel.appendChild(root);
|
||||
}
|
||||
if (!(root instanceof HTMLElement)) throw new Error("无法注入旧页面树 root");
|
||||
root.innerHTML = [
|
||||
'<ul class="tree-root" role="tree">',
|
||||
'<li class="tree-node" data-node-id="stale-my-space-page">',
|
||||
'<div class="tree-row" role="treeitem" data-shell-mode="page" data-node-id="stale-my-space-page" data-active="true">',
|
||||
'<button type="button" class="tree-link" data-rust-action="open" data-node-id="stale-my-space-page">',
|
||||
'<span class="tree-link-title">我的空间旧页面</span>',
|
||||
"</button>",
|
||||
"</div>",
|
||||
"</li>",
|
||||
"</ul>",
|
||||
].join("");
|
||||
});
|
||||
|
||||
await cloudDesignShortcut.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const scopedExplorerUrl = new URL(page.url());
|
||||
assert.equal(scopedExplorerUrl.searchParams.get("sourceKind"), "local_folder", "点击星标文件夹后应切到本地文件夹 source");
|
||||
assert.equal(scopedExplorerUrl.searchParams.get("rootUri"), rootUri, "点击星标文件夹后 URL 应保留显式 rootUri");
|
||||
assert.equal(scopedExplorerUrl.searchParams.get("fileTreeScope"), "design", "点击星标文件夹后 URL 应进入 design scope");
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.getAttribute("data-mnote-sidebar-shortcut-open-error") || ""),
|
||||
"",
|
||||
"星标文件夹打开不应依赖 workspaceId 反推 rootUri",
|
||||
);
|
||||
const scopedText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert.match(scopedText, /Brief/);
|
||||
assert.doesNotMatch(scopedText, /Other/);
|
||||
const scopedPageTreeState = await page.evaluate(() => {
|
||||
const root = document.querySelector("#sidebar-tree-root");
|
||||
return {
|
||||
text: root?.innerText || "",
|
||||
rowCount: root?.querySelectorAll('.tree-row[data-shell-mode="page"]').length || 0,
|
||||
staleRowCount: root?.querySelectorAll('.tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').length || 0,
|
||||
activeRows: Array.from(root?.querySelectorAll('.tree-row[data-shell-mode="page"][data-active="true"]') || []).map((row) => ({
|
||||
nodeId: row.getAttribute("data-node-id") || "",
|
||||
title: row.querySelector(".tree-link-title")?.textContent?.trim() || "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
assert.equal(
|
||||
scopedPageTreeState.staleRowCount,
|
||||
0,
|
||||
`打开星标本地文件夹后不应保留原我的空间页面树: ${JSON.stringify(scopedPageTreeState)}`,
|
||||
);
|
||||
assert(
|
||||
scopedPageTreeState.rowCount > 0 && /Brief/.test(scopedPageTreeState.text),
|
||||
`打开星标本地文件夹后应显示本地 Markdown 页面树投影: ${JSON.stringify(scopedPageTreeState)}`,
|
||||
);
|
||||
|
||||
await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Brief.md"] .tree-link').first().click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
||||
const scopedDocumentUrl = page.url();
|
||||
const scopedDocumentResponse = await context.request.fetch(scopedDocumentUrl);
|
||||
const scopedDocumentHtml = await scopedDocumentResponse.text();
|
||||
assert(scopedDocumentHtml.includes('data-mnote-filetree-scope="design"'), "SSR document shell 应输出 filetree scope");
|
||||
assert(scopedDocumentHtml.includes("Brief.md"), "SSR document shell 应输出 scoped 文件树内容");
|
||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const documentScopedText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert.match(documentScopedText, /Brief/, `打开 scoped md 后应保留 design scope: url=${page.url()} text=${documentScopedText} browser=${browserDiagnostics.slice(-12).join(" | ")}`);
|
||||
assert.doesNotMatch(documentScopedText, /Other/, `打开 scoped md 后不应回到根目录: url=${page.url()} text=${documentScopedText} browser=${browserDiagnostics.slice(-12).join(" | ")}`);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const reloadedDesignShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first();
|
||||
await reloadedDesignShortcut.waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await reloadedDesignShortcut.hover({ timeout: UI_TIMEOUT_MS });
|
||||
await reloadedDesignShortcut.locator('[data-mnote-shortcut-action="menu"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-sidebar-shortcut-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByRole("menuitem", { name: "取消星标" }).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').waitFor({
|
||||
state: "detached",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(
|
||||
await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').count(),
|
||||
0,
|
||||
"三点菜单取消星标后刷新不应恢复该文件夹星标",
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, task: TASK, baseUrl }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill("SIGINT");
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
if (server.exitCode == null) {
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1_000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, baseUrl, pathname, init = {}) {
|
||||
const response = await requestContext.fetch(`${baseUrl}${pathname}`, {
|
||||
method: init.method || "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
},
|
||||
data: init.data,
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
assert(
|
||||
response.ok(),
|
||||
`${init.method || "GET"} ${pathname} failed ${response.status()}: ${JSON.stringify(payload)}`,
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadAggregate(requestContext, baseUrl, documentId, rootUri) {
|
||||
const url = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, baseUrl);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
const payload = await requestJson(requestContext, baseUrl, `${url.pathname}${url.search}`);
|
||||
return payload.result || payload;
|
||||
}
|
||||
|
||||
async function effectivePreferences(requestContext, baseUrl, documentId, rootUri) {
|
||||
const url = new URL("/api/ui/preferences/effective", baseUrl);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("documentId", documentId);
|
||||
const payload = await requestJson(requestContext, baseUrl, `${url.pathname}${url.search}`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-ui-pref-smoke-"));
|
||||
const policyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-ui-pref-policy-"));
|
||||
const policyFile = path.join(policyRoot, "access-policy.json");
|
||||
const actorId = `ui-pref-smoke-${process.pid}-${Date.now()}`;
|
||||
const otherActorId = `${actorId}-other`;
|
||||
const documentId = "local-md:README.md";
|
||||
const rootUri = `file://${root}`;
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# README\n\n正文\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
policyFile,
|
||||
JSON.stringify({
|
||||
grants: [
|
||||
{
|
||||
userId: actorId,
|
||||
rootUri,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_LOCAL_ACCESS_POLICY_FILE: policyFile,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const firstContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": actorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const secondContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": actorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const otherContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": otherActorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
||||
|
||||
const initialAggregate = await loadAggregate(firstContext.request, baseUrl, documentId, rootUri);
|
||||
assert.equal(
|
||||
initialAggregate.layout.pageOptions.hideTitleHeader,
|
||||
true,
|
||||
"普通外部 local folder 默认隐藏本地 Markdown 标题",
|
||||
);
|
||||
|
||||
await requestJson(firstContext.request, baseUrl, "/api/ui/preferences", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
updates: {
|
||||
hideTitleHeader: false,
|
||||
showHeadingNumbers: true,
|
||||
wideLayout: true,
|
||||
pageFont: "song",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert(
|
||||
!fs.existsSync(path.join(root, ".mnote", "page-options.json")),
|
||||
"SQLite 偏好写入不应生成 .mnote/page-options.json",
|
||||
);
|
||||
|
||||
const sameUserEffective = await effectivePreferences(secondContext.request, baseUrl, documentId, rootUri);
|
||||
assert.equal(sameUserEffective.pageOptions.hideTitleHeader, false, "同用户第二浏览器应读到隐藏标题偏好");
|
||||
assert.equal(sameUserEffective.pageOptions.showHeadingNumbers, true, "同用户第二浏览器应读到标题编号偏好");
|
||||
assert.equal(sameUserEffective.pageOptions.wideLayout, true, "同用户第二浏览器应读到页面宽度偏好");
|
||||
assert.equal(sameUserEffective.pageOptions.pageFont, "song", "同用户第二浏览器应读到页面字体偏好");
|
||||
|
||||
const otherUserEffective = await effectivePreferences(otherContext.request, baseUrl, documentId, rootUri);
|
||||
assert.equal(otherUserEffective.pageOptions.hideTitleHeader, true, "不同用户不应串读 source family 偏好");
|
||||
assert.equal(otherUserEffective.pageOptions.showHeadingNumbers, false, "不同用户不应串读 global 偏好");
|
||||
|
||||
console.log("task493 page settings sqlite preferences smoke passed");
|
||||
} finally {
|
||||
await firstContext.close().catch(() => {});
|
||||
await secondContext.close().catch(() => {});
|
||||
await otherContext.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
server.kill("SIGINT");
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(policyRoot, { recursive: true, force: true });
|
||||
if (server.exitCode == null) {
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task494-filetree-lazy-loading-dedup-smoke";
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function fileUrlToPath(value) {
|
||||
const url = new URL(value);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
|
||||
function localMarkdownDocumentPath(documentId) {
|
||||
const encoded = String(documentId || "").replace(/^local-md:/, "");
|
||||
return decodeURIComponent(encoded.replace(/~/g, "%"));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
||||
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
|
||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const browserDiagnostics = [];
|
||||
let targetChildrenRequests = 0;
|
||||
let staleScopeChildrenRequests = 0;
|
||||
let scopedRootProjectionRequests = 0;
|
||||
let workspaceRootProjectionRequests = 0;
|
||||
let forceChangingWatchRevision = false;
|
||||
let localWatchRevision = 0;
|
||||
|
||||
page.on("console", (message) => {
|
||||
browserDiagnostics.push(`console:${message.type()}:${message.text()}`);
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
browserDiagnostics.push(`pageerror:${error.message}`);
|
||||
});
|
||||
await page.route("**/api/tree/projections/file**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const parentRelativePath = url.searchParams.get("parentRelativePath") || "";
|
||||
if (url.pathname.endsWith("/api/tree/projections/file") && parentRelativePath === "design") {
|
||||
scopedRootProjectionRequests += 1;
|
||||
}
|
||||
if (url.pathname.endsWith("/api/tree/projections/file") && !parentRelativePath) {
|
||||
workspaceRootProjectionRequests += 1;
|
||||
}
|
||||
if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/03-rust-web") {
|
||||
targetChildrenRequests += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
if (url.pathname.endsWith("/api/tree/projections/file/children") && parentRelativePath === "design/slow-scope") {
|
||||
staleScopeChildrenRequests += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await page.route("**/api/tree/local-folder-watch**", async (route) => {
|
||||
if (!forceChangingWatchRevision) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
localWatchRevision += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
revision: `forced-watch-${localWatchRevision}`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
||||
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
name: actorId,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signUp",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const initialUrl = new URL(page.url());
|
||||
const workspaceId = initialUrl.searchParams.get("workspaceId") || "";
|
||||
const rootUri = initialUrl.searchParams.get("rootUri")
|
||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
||||
assert(rootUri, "应进入 local_folder workspace");
|
||||
const rootPath = fileUrlToPath(rootUri);
|
||||
fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "done"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "process"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "design", "03-rust-web", "reference"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "design", "reveal-parent", "child"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "design", "slow-scope", "child"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "docs", "target"), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "design", "Overview.md"), "Plain paragraph without office attachment.\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "plan.md"), "# Plan\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "design", "reveal-parent", "child", "note.md"), "# Reveal\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "design", "slow-scope", "child", "note.md"), "# Slow child\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "docs", "target", "note.md"), "# Docs child\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "docs", "opened-rename.md"), "# Opened rename\n", "utf8");
|
||||
|
||||
const fileTreeUrl = new URL(baseUrl);
|
||||
if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId);
|
||||
fileTreeUrl.searchParams.set("sourceKind", "local_folder");
|
||||
fileTreeUrl.searchParams.set("rootUri", rootUri);
|
||||
fileTreeUrl.searchParams.set("treeView", "filetree");
|
||||
await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForTimeout(300);
|
||||
const firstCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || "");
|
||||
const firstCreatedRelativePath = localMarkdownDocumentPath(firstCreatedDocumentId);
|
||||
const firstCreatedParentPath = firstCreatedRelativePath.split("/").slice(0, -1).join("/");
|
||||
|
||||
await page.getByRole("button", { name: "新建页面" }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => {
|
||||
const documentId = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() || "");
|
||||
return url.pathname.startsWith("/documents/") && documentId !== firstCreatedDocumentId;
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForTimeout(500);
|
||||
const secondCreatedDocumentId = decodeURIComponent(new URL(page.url()).pathname.split("/").filter(Boolean).pop() || "");
|
||||
const secondCreatedRelativePath = localMarkdownDocumentPath(secondCreatedDocumentId);
|
||||
const secondCreatedParentPath = secondCreatedRelativePath.split("/").slice(0, -1).join("/");
|
||||
const createExpansionState = await page.evaluate(({ firstParent, secondParent }) => {
|
||||
const readRow = (relativePath) => {
|
||||
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(relativePath)}"]`);
|
||||
return row
|
||||
? {
|
||||
relativePath,
|
||||
expanded: row.getAttribute("aria-expanded"),
|
||||
selected: row.getAttribute("data-selected"),
|
||||
active: row.getAttribute("data-active"),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
return {
|
||||
first: readRow(firstParent),
|
||||
second: readRow(secondParent),
|
||||
firstMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(firstParent + "/" + firstParent.split("/").pop() + ".md")}"]`)),
|
||||
secondMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(secondParent + "/" + secondParent.split("/").pop() + ".md")}"]`)),
|
||||
};
|
||||
}, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath });
|
||||
assert.equal(
|
||||
createExpansionState.first?.expanded,
|
||||
"false",
|
||||
`连续新建页面不应把上一个页面包目录自动展开: ${JSON.stringify(createExpansionState)}`,
|
||||
);
|
||||
assert.equal(
|
||||
createExpansionState.second?.expanded,
|
||||
"false",
|
||||
`连续新建页面不应为了选中内部 md 而展开当前页面包目录: ${JSON.stringify(createExpansionState)}`,
|
||||
);
|
||||
assert.equal(createExpansionState.firstMarkdownVisible, false, `上一个页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`);
|
||||
assert.equal(createExpansionState.secondMarkdownVisible, false, `当前页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`);
|
||||
assert.equal(createExpansionState.second?.active, "true", `当前页面包目录应承接 active 状态: ${JSON.stringify(createExpansionState)}`);
|
||||
|
||||
const scopedUrl = new URL(baseUrl);
|
||||
if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId);
|
||||
scopedUrl.searchParams.set("sourceKind", "local_folder");
|
||||
scopedUrl.searchParams.set("rootUri", rootUri);
|
||||
scopedUrl.searchParams.set("treeView", "filetree");
|
||||
scopedUrl.searchParams.set("fileTreeScope", "design");
|
||||
await page.goto(scopedUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const scopedRootRequestsAfterInitialLoad = scopedRootProjectionRequests;
|
||||
const workspaceRootRequestsAfterInitialLoad = workspaceRootProjectionRequests;
|
||||
await page.evaluate(() => {
|
||||
window.__mnoteScopedFileOpenNoReloadMarker = "kept";
|
||||
document.documentElement.setAttribute("data-mnote-scoped-file-open-no-reload-marker", "kept");
|
||||
});
|
||||
await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Overview.md"] .tree-link').first().click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
if (typeof window.__mnoteEnhanceEditorAttachmentLinks === "function") {
|
||||
window.__mnoteEnhanceEditorAttachmentLinks();
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
const scopedOpenMarker = await page.evaluate(() => window.__mnoteScopedFileOpenNoReloadMarker || "");
|
||||
assert.equal(scopedOpenMarker, "kept", "scoped 文件树打开 md 应走 pane 内导航,不应整页 reload 重建侧栏");
|
||||
assert.equal(
|
||||
scopedRootProjectionRequests,
|
||||
scopedRootRequestsAfterInitialLoad,
|
||||
"scoped 文件树打开已可见 md 不应重新请求 scope 根 projection",
|
||||
);
|
||||
assert.equal(
|
||||
workspaceRootProjectionRequests,
|
||||
workspaceRootRequestsAfterInitialLoad,
|
||||
"普通 Markdown 打开不应为了 legacy office 附件兼容重拉 workspace root projection",
|
||||
);
|
||||
const scopedRootRequestsBeforeRootSnapshot = scopedRootProjectionRequests;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent("tree:snapshot", {
|
||||
detail: {
|
||||
payload: {
|
||||
dataset: {
|
||||
kernel_file_tree_projection: {
|
||||
parentRelativePath: "",
|
||||
items: [
|
||||
{
|
||||
rowId: "local:folder:root-probe",
|
||||
nodeId: "local:folder:root-probe",
|
||||
title: "root-probe",
|
||||
rowKind: "folder",
|
||||
resourceMeta: {
|
||||
workspacePath: { relativePath: "root-probe" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
await page.waitForTimeout(350);
|
||||
assert.equal(
|
||||
scopedRootProjectionRequests,
|
||||
scopedRootRequestsBeforeRootSnapshot,
|
||||
"scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection",
|
||||
);
|
||||
const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests;
|
||||
forceChangingWatchRevision = true;
|
||||
await page.waitForTimeout(1600);
|
||||
forceChangingWatchRevision = false;
|
||||
assert.equal(
|
||||
scopedRootProjectionRequests,
|
||||
scopedRootRequestsBeforeCoarseWatch,
|
||||
"scoped 文件树收到 coarse local-folder-watch revision 不应重拉 scope 根 projection",
|
||||
);
|
||||
|
||||
const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]';
|
||||
const childSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path^="design/03-rust-web/"]';
|
||||
await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(targetChildrenRequests, 0, "scoped 首屏不应预加载 design/03-rust-web children");
|
||||
|
||||
await page.locator(rowSelector).evaluate((row) => {
|
||||
const toggle = row.querySelector('[data-rust-action="toggle"]');
|
||||
for (let index = 0; index < 5; index += 1) toggle.click();
|
||||
});
|
||||
await page.waitForFunction((selector) => {
|
||||
const row = document.querySelector(selector);
|
||||
return row && row.getAttribute("aria-expanded") === "true" && row.getAttribute("data-filetree-children-loaded") === "true";
|
||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(targetChildrenRequests, 1, "同一路径快速重复展开最多只能产生一个 children 请求");
|
||||
|
||||
const firstExpandRequests = targetChildrenRequests;
|
||||
await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((selector) => {
|
||||
const row = document.querySelector(selector);
|
||||
return row && row.getAttribute("aria-expanded") === "false";
|
||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
||||
const expandStartedAt = Date.now();
|
||||
await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((selector) => {
|
||||
const row = document.querySelector(selector);
|
||||
const child = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]');
|
||||
return row && child && row.getAttribute("aria-expanded") === "true";
|
||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
||||
const cachedExpandMs = Date.now() - expandStartedAt;
|
||||
assert.equal(targetChildrenRequests, firstExpandRequests, "收起后再次展开应命中 cache,不应再次请求 children");
|
||||
|
||||
await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => {
|
||||
row.setAttribute("data-selected", "true");
|
||||
row.setAttribute("data-focused", "true");
|
||||
row.setAttribute("data-active", "true");
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent("tree:local-command", {
|
||||
detail: {
|
||||
body: {
|
||||
action: "rename",
|
||||
documentId: "local-md:design~2F03-rust-web~2Fplan.md",
|
||||
title: "plan",
|
||||
parentRelativePath: "design/03-rust-web",
|
||||
},
|
||||
result: {
|
||||
parentRelativePath: "design/03-rust-web",
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
await page.waitForFunction((selector) => {
|
||||
const row = document.querySelector(selector);
|
||||
const done = document.querySelector('[data-local-relative-path="design/03-rust-web/done"]');
|
||||
const process = document.querySelector('[data-local-relative-path="design/03-rust-web/process"]');
|
||||
return row && done && process && row.getAttribute("aria-expanded") === "true";
|
||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(targetChildrenRequests, firstExpandRequests, "局部 parent refresh 不应绕过 cache 触发 children 重复请求");
|
||||
const selectedAfterRefresh = await page.locator(`${childSelector}[data-local-relative-path="design/03-rust-web/done"]`).evaluate((row) => ({
|
||||
selected: row.getAttribute("data-selected"),
|
||||
focused: row.getAttribute("data-focused"),
|
||||
active: row.getAttribute("data-active"),
|
||||
}));
|
||||
assert.deepEqual(selectedAfterRefresh, {
|
||||
selected: "true",
|
||||
focused: "true",
|
||||
active: "true",
|
||||
}, "局部 parent refresh 后应按 logical state 复投影 selection/focus/active");
|
||||
|
||||
fs.writeFileSync(path.join(rootPath, "design", "03-rust-web", "watch-created.md"), "# Watch\n", "utf8");
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", {
|
||||
detail: {
|
||||
payload: {
|
||||
schema: "mnote.local_folder_watch_batch.v1",
|
||||
revision: "smoke-watch-1",
|
||||
affectedParents: [{ relativePath: "design/03-rust-web", reason: "child-watch" }],
|
||||
changedPaths: [{ relativePath: "design/03-rust-web/watch-created.md", kind: "Create(File)" }],
|
||||
eventKinds: ["Create(File)"],
|
||||
fallbackResync: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
await page.waitForFunction((selector) => {
|
||||
const row = document.querySelector(selector);
|
||||
const created = document.querySelector('[data-local-relative-path="design/03-rust-web/watch-created.md"]');
|
||||
return row && created && row.getAttribute("aria-expanded") === "true";
|
||||
}, rowSelector, { timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(
|
||||
await page.locator(rowSelector).getAttribute("aria-expanded"),
|
||||
"true",
|
||||
"watch batch 局部刷新后已展开 parent 不应折叠",
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
|
||||
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
|
||||
}));
|
||||
});
|
||||
const revealTargetSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/reveal-parent/child/note.md"]';
|
||||
await page.waitForFunction((selector) => {
|
||||
const row = document.querySelector(selector);
|
||||
return row
|
||||
&& row.getAttribute("data-selected") === "true"
|
||||
&& row.getAttribute("data-focused") === "true"
|
||||
&& row.getAttribute("data-active") === "true";
|
||||
}, revealTargetSelector, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const slowScopeSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/slow-scope"]';
|
||||
await page.locator(slowScopeSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`${slowScopeSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
const docsUrl = new URL(scopedUrl.toString());
|
||||
docsUrl.searchParams.set("fileTreeScope", "docs");
|
||||
await page.goto(docsUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="docs"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(staleScopeChildrenRequests, 1, "慢目录切 scope 前应触发一次 children 请求");
|
||||
const staleRowsInDocsScope = await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path^="design/slow-scope/"]').count();
|
||||
assert.equal(staleRowsInDocsScope, 0, "慢请求返回后不得把旧 design scope children patch 到 docs scope");
|
||||
|
||||
const rootText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert.match(rootText, /target/);
|
||||
|
||||
const openedDocumentId = "local-md:docs~2Fopened-rename.md";
|
||||
const renamedDocumentId = "local-md:docs~2Fopened-renamed.md";
|
||||
const documentUrl = new URL(`${baseUrl}/documents/${encodeURIComponent(openedDocumentId)}`);
|
||||
if (workspaceId) documentUrl.searchParams.set("workspaceId", workspaceId);
|
||||
documentUrl.searchParams.set("sourceKind", "local_folder");
|
||||
documentUrl.searchParams.set("rootUri", rootUri);
|
||||
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const renameResponse = await context.request.fetch(`${baseUrl}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "rename",
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId: openedDocumentId,
|
||||
title: "opened-renamed",
|
||||
},
|
||||
});
|
||||
assert(renameResponse.ok(), `opened markdown rename command failed: ${renameResponse.status()} ${await renameResponse.text()}`);
|
||||
const bufferStateUrl = new URL(`${baseUrl}/api/documents/buffer-state`);
|
||||
bufferStateUrl.searchParams.set("documentId", renamedDocumentId);
|
||||
if (workspaceId) bufferStateUrl.searchParams.set("workspaceId", workspaceId);
|
||||
bufferStateUrl.searchParams.set("sourceKind", "local_folder");
|
||||
bufferStateUrl.searchParams.set("rootUri", rootUri);
|
||||
bufferStateUrl.searchParams.set("relativePath", "docs/opened-renamed.md");
|
||||
const bufferStateResponse = await context.request.fetch(bufferStateUrl.toString());
|
||||
assert(bufferStateResponse.ok(), `opened markdown rename should rekey buffer: ${bufferStateResponse.status()} ${await bufferStateResponse.text()}`);
|
||||
const bufferStatePayload = await bufferStateResponse.json();
|
||||
assert.equal(bufferStatePayload.result.documentId, renamedDocumentId, "opened markdown rename 后 buffer key 应更新到新 documentId");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl,
|
||||
targetChildrenRequests,
|
||||
staleScopeChildrenRequests,
|
||||
cachedExpandMs,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill("SIGINT");
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
if (server.exitCode == null) {
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
const RUNTIME_PATH = path.join(
|
||||
REPO_ROOT,
|
||||
"rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js",
|
||||
);
|
||||
|
||||
function loadRuntimeFactory() {
|
||||
const source = fs
|
||||
.readFileSync(RUNTIME_PATH, "utf8")
|
||||
.replace(
|
||||
"export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {",
|
||||
"const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {",
|
||||
);
|
||||
const context = {
|
||||
console,
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, init) {
|
||||
this.type = type;
|
||||
this.detail = init && init.detail;
|
||||
}
|
||||
},
|
||||
HTMLElement: class HTMLElement {},
|
||||
navigator: {
|
||||
clipboard: {
|
||||
writeText: async (text) => {
|
||||
context.__clipboardText = text;
|
||||
},
|
||||
},
|
||||
},
|
||||
document: {
|
||||
body: {},
|
||||
documentElement: {
|
||||
attrs: {},
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = String(value);
|
||||
},
|
||||
},
|
||||
createElement() {
|
||||
return {};
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
window: {
|
||||
alert() {},
|
||||
confirm() {
|
||||
return true;
|
||||
},
|
||||
dispatchEvent() {},
|
||||
},
|
||||
URL,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(
|
||||
`${source}\nglobalThis.__factory = createSidebarFileTreeCommandRuntime;`,
|
||||
context,
|
||||
{ filename: RUNTIME_PATH },
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
async function runCopyId(runtime, context, detail) {
|
||||
context.__clipboardText = "";
|
||||
runtime.handleTreeContextMenuAction("copy-id", detail, null);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
return context.__clipboardText;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const context = loadRuntimeFactory();
|
||||
const runtime = context.__factory({
|
||||
currentRootUri: () => "file:///mnt/Data1T/mnote",
|
||||
currentSourceKind: () => "local_folder",
|
||||
cssEscape: (value) => String(value).replace(/"/g, '\\"'),
|
||||
fileTreeRuntimeFunction: () => null,
|
||||
localFilePathFromAssetId: (assetId) =>
|
||||
String(assetId || "").replace(/^local-file:/, ""),
|
||||
resolveWorkspaceId: () => "local-folder",
|
||||
runtimeState: { activeTreeContextMenu: null },
|
||||
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await runCopyId(runtime, context, {
|
||||
contextKind: "filetree",
|
||||
rowKind: "folder",
|
||||
documentId: "local-dir:docs",
|
||||
rowId: "local:folder:docs",
|
||||
title: "docs",
|
||||
}),
|
||||
"/mnt/Data1T/mnote/docs",
|
||||
"复制本地文件夹页面 ID 应得到绝对路径",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await runCopyId(runtime, context, {
|
||||
contextKind: "filetree",
|
||||
rowKind: "markdown",
|
||||
documentId: "local-md:docs~2FPage.md",
|
||||
rowId: "local:markdown:docs~2FPage.md",
|
||||
title: "Page",
|
||||
}),
|
||||
"/mnt/Data1T/mnote/docs/Page.md",
|
||||
"复制本地 Markdown 页面 ID 应得到绝对路径",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await runCopyId(runtime, context, {
|
||||
contextKind: "filetree",
|
||||
rowKind: "asset",
|
||||
assetId: "local-file:assets~2Freport.pdf",
|
||||
rowId: "local:asset:assets~2Freport.pdf",
|
||||
title: "report.pdf",
|
||||
}),
|
||||
"/mnt/Data1T/mnote/assets/report.pdf",
|
||||
"复制本地资源 ID 应得到绝对路径",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
copied: [
|
||||
"/mnt/Data1T/mnote/docs",
|
||||
"/mnt/Data1T/mnote/docs/Page.md",
|
||||
"/mnt/Data1T/mnote/assets/report.pdf",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task496-editor-open-parallel-runtime-aggregate-smoke";
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function fileUrlToPath(value) {
|
||||
const url = new URL(value);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
||||
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
|
||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const requestTimes = {
|
||||
manifest: 0,
|
||||
pageAggregate: 0,
|
||||
};
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.requestIdleCallback = () => 0;
|
||||
});
|
||||
await page.route("**/api/leptos-tiptap-runtime/manifest.json", async (route) => {
|
||||
if (!requestTimes.manifest) requestTimes.manifest = Date.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await route.continue();
|
||||
});
|
||||
await page.route("**/api/page-aggregate/**", async (route) => {
|
||||
if (!requestTimes.pageAggregate) requestTimes.pageAggregate = Date.now();
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
||||
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
name: actorId,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signUp",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const rootUri = new URL(page.url()).searchParams.get("rootUri")
|
||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
||||
assert(rootUri, "应进入 local_folder workspace");
|
||||
const rootPath = fileUrlToPath(rootUri);
|
||||
fs.writeFileSync(path.join(rootPath, "Target.md"), "# Target\n\nEditor cold open target.\n", "utf8");
|
||||
|
||||
const fileTreeUrl = new URL(baseUrl);
|
||||
const workspaceId = new URL(page.url()).searchParams.get("workspaceId") || "";
|
||||
if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId);
|
||||
fileTreeUrl.searchParams.set("sourceKind", "local_folder");
|
||||
fileTreeUrl.searchParams.set("rootUri", rootUri);
|
||||
fileTreeUrl.searchParams.set("treeView", "filetree");
|
||||
await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path="Target.md"] .tree-link').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
assert(requestTimes.manifest > 0, "应在打开文档时加载 editor runtime manifest");
|
||||
assert(requestTimes.pageAggregate > 0, "应在打开文档时请求 Page Aggregate");
|
||||
const aggregateDelayMs = requestTimes.pageAggregate - requestTimes.manifest;
|
||||
assert(
|
||||
aggregateDelayMs < 500,
|
||||
`Page Aggregate fetch 应与 editor runtime load 并行启动,实际晚于 manifest ${aggregateDelayMs}ms`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
task: TASK,
|
||||
aggregateDelayMs,
|
||||
requestTimes,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill("SIGINT");
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
if (server.exitCode == null) {
|
||||
setTimeout(() => server.kill("SIGKILL"), 2000).unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user