fix local folder resource lifecycle

This commit is contained in:
lix-2026
2026-05-24 01:49:51 +08:00
parent 5f97800489
commit 97953101a2
16 changed files with 1392 additions and 154 deletions
@@ -1,38 +1,320 @@
# 4-49 [process][bug] 文件树拖拽上传文件落到同级而非目标文件夹下 v1
# 4-49 [process][bug] 本地文件夹 Markdown 附件、打开目标与标题合同缺口 v1
> 发现时间:2026-05-23
>
> 状态:`[process]`
>
> 关联主线:`04-tree-domain`
> 关联主线:`04-tree-domain`、`05-editor-mainline`
>
> 关联设计:`design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md`
## 1. 用户可见症状
## 1. 范围判断
原始记录:
这条记录不是单个拖拽 bug,而是本地文件夹 MVP 在 `Markdown 正文引用``真实文件资源``File Tree projection``主编辑区 object tab` 之间的合同缺口。
```text
我的空间:向文件树中拖动文件上传文件,能正常在主编辑 md 中显示,点击也能正常打开,但是见 /mnt/Data1T/mnote/tmp/image copy 65.png,文件是和主文件夹同级(应该在我的文件夹下级)。
已按 Sidex / VSCode 行为确认的边界:
- 主编辑区删除 Markdown 附件块 / 链接,默认只删除正文里的引用,不删除真实附件文件。
- 文件树右键删除真实附件,默认只删除真实文件,不自动扫描并改写所有 Markdown 正文。
- 删除真实文件后,正文中的链接应保留为 broken reference;编辑区需要增强展示和点击失败处理,而不是静默清除链接。
- 只有显式危险动作才可以同时删除引用和真实文件,例如后续可设计“删除引用并移到回收站”,且必须做确认和反向引用检查。
## 2. 用户可见症状
### 2.1 主编辑区拖入 / 上传附件后落盘目录不符合预期
复现描述:
1. 在“我的空间”或本地文件夹中打开一个 Markdown 页面。
2. 向主编辑区拖入文件或上传文件。
3. 主编辑区能显示附件,点击也能打开。
4. 但文件落在主文件夹同级位置,预期应落在当前 Markdown 页面资源目录下。
截图:`/mnt/Data1T/mnote/tmp/image copy 65.png`
当前判断:
- 代码中已经有两条上传链路:
- 文件树外部 drop 到 folder row:通过 `targetRelativePath``write_local_folder_file_upload()`
- 主编辑区上传 / drop:通过 `documentId``write_local_markdown_asset()`,目标应为 `markdown_page_resource_directory()`
- 既有 `4-46` 已把“文件树拖到文件夹 row 上传到该文件夹”标记为完成,因此当前缺口更可能在“主编辑区 Markdown 上传目标目录”和“本地 page bundle 目录推导”上,而不是单纯 filetree folder drop。
建议修复:
- 明确区分两类 intent
- `filetree.folder.drop`:文件进入用户指定的文件树目录。
- `editor.markdown.attach`:文件进入当前 Markdown 页面的资源目录。
-`editor.markdown.attach`,后端只允许写入当前 Markdown 的 page resource directory
- bundle 形态:`Page/Page.md` 的附件写入 `Page/`
- flat 形态:`Page.md` 的附件写入 `Page/`,目录不存在时创建。
- 前端上传时不要把主编辑区 drop 误标成 folder target;有 `targetRelativePath` 时才走 folder upload。
- smoke 需要同时覆盖“拖到文件树文件夹”和“拖到主编辑区”两个入口,避免互相掩盖。
验收标准:
- 主编辑区上传附件后,文件树中该附件位于当前 Markdown 页面资源目录下。
- 文件树 folder row drop 仍写入目标 folder 下,不回退到当前页面资源目录。
- 新增或扩展 smoke 断言上传响应中的 `rootRelativePath`、正文中的相对链接、File Tree row 父子位置一致。
### 2.2 删除真实附件后,正文链接生命周期不稳定
复现描述:
1. 主编辑区中已有附件链接。
2. 在文件树附件 row 上右键真实删除该附件。
3. 主编辑区中的链接仍在,点击会显示附件不存在。
4. 刷新后,正文里的链接消失。
当前判断:
- 链接仍在是合理行为;真实文件删除不应自动改写 Markdown 正文。
- 刷新后链接消失是不合理行为,说明 Markdown 解析 / 回写 / 附件索引重建时把 broken local asset link 当成无效增强块过滤掉了。
- 点击显示附件不存在也可以保留,但应在 tab 内或编辑器内给出明确缺失状态,不应走新窗口兜底。
建议修复:
- Markdown parser / serializer 必须保留 broken link 的原始文本。
- 编辑器附件增强逻辑识别目标文件不存在时,保留链接并标记 `data-mnote-attachment-missing="true"` 或等价状态。
- 点击 missing 附件时打开资源 tab 的 missing state,显示原路径和“文件不存在/已删除”,不删除正文。
- 文件树真实删除后通过 watcher / event 让当前编辑器刷新附件存在性状态,但不改正文内容。
验收标准:
- 文件树删除附件后,不刷新页面时正文链接仍可见且标记为缺失。
- 刷新后正文链接仍存在,样式仍是 broken reference。
- 点击缺失附件不会在新窗口打开,也不会静默失败。
### 2.3 主编辑区删除附件引用的序列化错误
复现描述:
1. 主编辑区有两个附件。
2. 用 Backspace / Delete 删除后面的附件,前面的附件会变成普通文字。
3. 删除前面的附件,后面的附件不受影响。
4. 手柄中的删除看起来只是在 UI 上消失,刷新后又回来。
截图:`/mnt/Data1T/mnote/tmp/image copy 75.png`
当前判断:
- Backspace / Delete 默认只删除正文引用,这个产品语义正确。
- “前一个附件变成文字”是编辑器 transaction 或 Markdown serializer 范围错误。
- “手柄删除刷新后回来”是 UI 删除没有写回真实 `.md`,或写回后 parser 又按旧结构恢复。
建议修复:
- 把附件块视为一个可删除的 editor node / mark atom;删除一个附件时只移除对应链接范围,不影响相邻附件。
- 手柄删除复用同一条删除引用 command,不走纯 DOM 隐藏。
- 保存前后的 Markdown 文本做最小断言:删除第二个附件只删除第二条链接,第一条仍保持链接语法和增强属性来源。
验收标准:
- 删除前一个、后一个、中间一个附件,剩余附件都仍是可点击附件块。
- 手柄删除后刷新页面,被删引用不再回来。
- 真实附件文件仍保留在磁盘,除非用户选择未来的显式危险动作。
### 2.4 切回“云空间”进入空白 workspace
复现描述:
1. 用户打开本地文件夹。
2. 想回到“我的空间”,点击左上角下拉中的“云空间”。
3. 页面变成空白,整体效果是回不到我的空间。
当前判断:
- 该问题曾由 `bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md` 修复。
- 如果现在仍能复现,应按回归处理:重点检查 `lastCloudWorkspaceId` 是否又被写成 synthetic `default`,以及切回 cloud 时 URL 是否残留 `rootUri`
建议修复:
- 先补回归 smoke 或复用 `scripts/task441-local-folder-cloud-switch-smoke.js`
- `openLocalFolderRoot()` 进入本地前必须记住真实 cloud workspace id,不能从 `document.body` 粗粒度推导。
- `switchToCloudWorkspace()` 必须清理 `rootUri` / secondary local source 参数,只携带真实 cloud workspace id。
验收标准:
- cloud 有页面 -> 打开 local folder -> 点击云空间 -> 原 cloud 页面树和文件树恢复。
- URL 不含 `rootUri``mnote.workspace.lastCloudWorkspaceId` 不得为 `default`
### 2.5 刚打开本地文件夹时,直接打开附件只能新窗口打开
复现描述:
1. 用户刚打开本地文件夹。
2. 未先打开任何 Markdown 文件。
3. 直接打开任意附件,都会在新窗口打开。
4. 只有先打开一个 `.md` 文件后,附件才能在 new tab / 主编辑区 tab 中打开。
当前判断:
- 这是主编辑区 object tab host 的 landing state 缺口。
- 本地文件夹模式下,即使当前没有 active Markdown document,也应该有 workspace-level editor host 可承载 resource tab。
- 附件打开不应依赖 `currentDocumentId()` 是否存在;文件树 row 已经携带 `rootUri + rootRelativePath / assetId`,足够形成 `resource:file:{rootUri}:{path}` identity。
建议修复:
- 本地文件夹入口首屏初始化 editor host,即使没有当前文档,也显示空白 / welcome landing tab 容器。
- 文件树附件 open intent 使用 `objectIdentity=resource:file:{rootUri}:{rootRelativePath}`,不要回退成 `window.open()`
- `openConvexAssetFromFileTree()` / local file open 分支按 `sourceKind=local_folder` 分流到 active tab;只有 Shift/Ctrl/Meta 或显式“新窗口打开”才新窗口。
验收标准:
- 刚进入 local folder,直接点击 pdf / md / 图片 / 普通附件,均在主编辑区 tab 打开。
- 未打开任何 `.md` 时也不需要新窗口兜底。
- 文件树 row active / selected 保持在被打开附件,不跳到第一个文件。
### 2.6 文件树分隔线或滚动到底后出现底部空白
复现描述:
在文件树的左右拖动调整线处直接滚动页面,或文件树滚动到最下方后继续滚动,会导致下方出现空白。
截图:`/mnt/Data1T/mnote/tmp/image copy 66.png`
当前判断:
- 这更像 workbench layout / scroll container 边界问题,不是资源语义问题。
- 可能是 body / main workbench 和 sidebar tree 同时可滚动,滚轮事件穿透到页面根容器后把整个页面滚到工作台高度之外。
建议修复:
- Workbench 根容器固定 `height: 100vh`,主页面禁止 body 纵向滚动。
- Sidebar / File Tree 自己作为唯一滚动容器,`overscroll-behavior: contain`
- 分隔线 hover / drag 区域拦截 wheel,避免滚动事件穿透到 body。
验收标准:
- 文件树滚到最底部后继续滚动,工作台底部不出现额外空白。
- 在左右 resize handle 上滚轮,不移动页面根滚动位置。
- smoke 记录 `document.scrollingElement.scrollTop === 0` 或等价断言。
### 2.7 本地文件夹 PDF 只能新窗口打开
复现描述:
本地文件夹中的 PDF 文件只能在新窗口打开;我的空间中上传的 PDF 可以在 new tab 中打开。
当前判断:
- 与 2.5 同属本地资源 tab host 缺口。
- PDF 是普通 resource preview tab,不应因为来源是 local folder 就退化到浏览器新窗口。
建议修复:
- 统一本地文件打开目标:默认 active tab,显式 modifier / 菜单动作才 new window。
- PDF tab 使用与 cloud asset 相同的 preview host,只是数据源为 `/api/local-folder/files/open?rootUri=...&path=...`
- 缺失文件显示 missing state,不回退到新窗口。
验收标准:
- local folder PDF 默认在主编辑区 tab 预览。
- cloud PDF 与 local PDF 的 tab chrome、关闭、active row 行为一致。
### 2.8 本地 Markdown 标题来源与标题隐藏设置不清晰
复现描述:
本地文件夹 `.md` 标题解析有问题:第一行带 `#` 时,文件标题重复显示,截图见:
- `/mnt/Data1T/mnote/tmp/image copy 67.png`
- `/mnt/Data1T/mnote/tmp/image copy 69.png`
- `/mnt/Data1T/mnote/tmp/image copy 72.png`
- `/mnt/Data1T/mnote/tmp/image copy 73.png`
用户期望:
- 文件树和页面标题更接近文件名,不要把正文第一行 H1 当成文件标题造成重复。
- 页面设置新增全局设置:隐藏本地文件夹 Markdown 文件的文件标题。
当前判断:
- 当前代码中已有测试 `local_markdown_page_title_comes_from_file_name_not_body_heading`,但断言和注释实际倾向“有 H1 时标题来自 H1”。这与当前用户期望冲突,需要作为产品口径重新确认并修正测试命名 / 行为。
- 对普通本地 Markdown 来说,文件名是更稳定的资源 identity;正文 H1 应保留为正文内容,不建议继续作为文件树标题真源。
建议修复:
- 本地文件夹模式下,File Tree / Page Tree 默认标题来自文件名 stem。
- 本地 Markdown 页头标题也默认来自文件名,以统一“我的空间”和“本地文件夹”的行为。
- Frontmatter `title` 是 Markdown 文件开头 `--- title: ... ---` 这类元数据标题;它是否覆盖页头标题需要单独决定。建议 MVP 先不覆盖 File Tree 文件名,只作为正文 metadata 或页面内显示候选。
- 正文 H1 不参与文件名 / tree row title 推导。
- 页面设置新增 workspace-level 或 user-level 偏好:`隐藏本地 Markdown 文件标题`
- 默认开启:编辑器不额外显示 MNote 页头标题,只显示 Markdown 正文。
- 关闭:显示 MNote 页头标题,但该标题来自文件名,不能与正文 H1 混淆。
- 修改标题时应执行文件重命名;校验非法字符、同级重名和 bundle 目录同步。
验收标准:
- `File Name.md` 且正文第一行 `# Body Heading` 时,文件树显示 `File Name.md`,正文仍显示 `# Body Heading`
- 不出现页头标题和正文 H1 的重复标题误导。
- 页面设置切换“隐藏本地 Markdown 文件标题”后刷新仍生效。
- 重命名合法文件时,文件名和 File Tree row 同步更新;非法字符 / 同级重名在输入框内提示。
## 3. 建议实施顺序
1. 先补 smoke 红灯:
- 主编辑区上传落盘目录。
- 删除真实附件后 broken link 保留。
- 主编辑区删除附件引用不影响相邻附件。
- 刚进入 local folder 直接打开附件 / PDF 进入 tab。
- 文件树滚动到底无 body 空白。
- 本地 Markdown 文件名标题来源。
2. 修复资源生命周期合同:
- 上传 intent 分流。
- broken reference 保留和标记。
- editor attachment delete command 持久化。
3. 修复 local folder object tab host
- 没有 active document 时也能打开 resource tab。
- PDF / md / 普通附件默认 active tab。
4. 修复 workspace source switch 回归,如果 smoke 确认可复现。
5. 修复 layout scroll 边界。
6. 修正本地 Markdown 标题来源和隐藏标题设置。
## 4. 已确认与待确认
- 已确认:本地 Markdown 的 File Tree / 页头标题默认来自文件名,用来统一“我的空间”和“本地文件夹”的行为;正文 H1 永远只作为正文内容。
- 已确认:`隐藏本地 Markdown 文件标题` 默认开启,避免本地 Markdown 页头标题与正文 H1 重复。
- 已确认:Frontmatter `title` 暂不覆盖页头标题。Frontmatter `title` 指 Markdown 文件开头形如 `--- title: Some Title ---` 的元数据标题;MVP 阶段 File Tree / 页头标题继续以文件名为准。
## 5. 验证命令建议
```bash
cargo check --manifest-path rust/Cargo.toml -p mnote-web
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown -- --test-threads=1
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task459-local-markdown-attachment-tab-smoke.js
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task441-local-folder-cloud-switch-smoke.js
```
## 2. 初步判断
后续应新增本 bug 专属 smoke,例如:
这是文件树拖拽上传目标解析问题,不是附件点击打开问题:
```bash
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
```
- 上传后的附件块能正常插入主编辑器。
- 点击附件能正常打开。
- 异常点是资源文件在文件树中的落点:实际落到主文件夹同级,而不是拖拽目标文件夹下级。
## 6. 2026-05-24 执行记录
## 3. 待排查方向
已修复 / 已验证:
- 文件树拖拽上传时的 target row / parent folder 是否正确写入 upload preflight
- `local_folder/assets/upload` 或 upload target plan 是否丢失目标目录
- 页面文件夹 / 当前页面资源归属和文件树手动目标目录之间是否存在优先级覆盖
- 2.1 主编辑区上传附件落盘到当前 Markdown 页面资源目录:`task479` 通过
- 2.2 删除真实附件后刷新,正文 broken link 保留:`task479` 通过
- 2.5 刚进入 local folder、无 active Markdown document 时,点击 PDF 附件进入主编辑区 resource tab`task479` 通过,`popupCount=0`
- 2.7 本地 Markdown 附件 / PDF 默认 active tab`task459``task479` 通过。
- 2.8 本地 Markdown 标题来源:File Tree / 页头标题来自文件名,正文 H1 保留在正文;`local_markdown` 单测与 `task479` 通过。
## 4. 验收建议
关键根因:
后续修复时应补 smoke
- local folder 根入口曾复用全局 `mnote_recent_page_id`。切换到另一个 root 时,旧 recent page 可能指向当前 root 不存在的 `.md`,导致 aggregate 构建失败后进入没有 resource tab host 的 workspace fallback。
- 无 active document 的 local folder 首屏缺少 workspace-level 主编辑区 resource tab host,导致附件 row 只能依赖新窗口兜底。
- 在文件树中创建一个目标文件夹。
- 将附件拖拽到该文件夹行。
- 断言附件在文件树中位于目标文件夹下级
- 断言主编辑器附件块仍能点击在 tab 打开
本轮修复:
- 显式 local folder 根入口不再使用跨 root 的 recent page cookie;无有效 page 时保持无 active page landing state
- HomePage 无 active page 分支渲染主编辑区 tab strip、空 page panel 和 resource tab host
- editor island adapter 允许 `panes=[]` 时仍注册 `__mnoteDocumentPaneRuntime.openResourceInActiveTab`,只是不挂载正文编辑器。
- `task479` Check 4 改为使用独立 asset-only local folder,确保真实覆盖“无 active Markdown document”。
仍在 process
- 2.3 Backspace / Delete 删除相邻附件的浏览器级 smoke 尚未补齐;手柄删除保存链路已有代码修复,但仍需要真实浏览器回归。
- 2.4 切回云空间回归本轮未复测,应继续用 `task441-local-folder-cloud-switch-smoke.js` 覆盖。
- 页面设置中“隐藏本地 Markdown 文件标题”的可切换 UI / 持久化尚未实现;当前只落地默认隐藏。
@@ -0,0 +1,186 @@
# 4-48 [process] 本地文件夹 Markdown 资源生命周期合同 v1
> 日期:2026-05-24
>
> OwnerTree Domain / Local Folder / Editor Mainline
>
> 关联 bug`bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md`
## 1. 背景
本地文件夹 MVP 已经具备 Markdown 打开、附件上传、File Tree projection、资源 tab 打开和回收站能力,但当前多个缺陷说明系统还缺少一份面向本地 Markdown 的资源生命周期合同。
核心问题不是“附件点击坏了”这类单点 UI,而是四类对象的边界没有在本地文件夹场景完全收口:
- Markdown 正文里的链接文本。
- 磁盘上的真实附件文件。
- File Tree 中的 resource row。
- 主编辑区中的 object tab / preview tab。
## 2. 目标
- 固定本地 Markdown 上传、删除、缺失、打开、标题来源的产品和技术合同。
- 保持 Markdown 纯文本语义:正文链接不隐式拥有真实文件。
- 让本地资源默认进入主编辑区 tab,而不是依赖新窗口兜底。
-`4-49` 的多症状 bug 提供实施顺序和验收边界。
## 3. 非目标
- 不实现完整跨文档反向引用索引。
- 不自动删除所有 broken link。
- 不把 File Tree UI 提升为资源真源。
- 不重写完整 workbench tab 系统。
- 不改变 cloud / Convex 资源合同,除非为了保持 open target 行为一致。
## 4. 合同定义
### 4.1 上传 intent
上传入口必须先归类为明确 intent:
- `editor.markdown.attach`:用户在主编辑区上传或拖入文件。目标是当前 Markdown 页面的 resource directory。
- `filetree.folder.drop`:用户把外部文件拖到 File Tree 的 folder row。目标是该 folder 的真实目录。
- `filetree.resource.import`:用户未来通过菜单导入资源。目标由菜单上下文决定。
规则:
- `editor.markdown.attach` 不应携带 `targetRelativePath`,除非产品明确支持“把正文引用指向任意文件树目录”。
- `filetree.folder.drop` 必须携带 `targetRelativePath`
- 后端根据 intent 选择 `write_local_markdown_asset()``write_local_folder_file_upload()`,不能只靠 `documentId` 是否为空隐式决定。
### 4.2 Markdown 引用与真实文件
Markdown 正文链接只是引用,不是所有权声明。
- 删除正文附件块:只删除链接文本。
- 删除文件树附件:只删除真实文件。
- 真实文件缺失:正文链接保留为 broken reference。
- 自动级联删除真实文件:禁止作为默认行为。
后续可选动作:
- “删除引用并移到回收站”:显式危险动作,需要确认。
- “清理所有失效引用”:批量工具,需要预览 diff。
- “重新定位文件”:把 broken link 指向新路径。
### 4.3 缺失资源展示
缺失资源不应静默消失。
- Parser 必须保留原始 Markdown 链接。
- Editor enhancer 标记缺失状态。
- 点击缺失资源打开 missing tab 或 inline missing panel。
- missing state 显示 root、原相对路径、最后一次检测状态。
### 4.4 Object tab 打开目标
本地资源默认打开到主编辑区 active tab。
- `resource:file:{rootUri}:{rootRelativePath}` 是本地普通文件的最小 object identity。
- 即使没有当前 Markdown documentlocal folder workspace 也必须有 editor host landing state。
- PDF、Markdown 附件、图片、普通文件都走同一套 resource tab host。
- Shift / Ctrl / Meta 或显式菜单才使用 new window。
### 4.5 本地 Markdown 标题来源
已确认 MVP 口径:
- File Tree / Page Tree 标题默认来自文件名 stem。
- 页头标题默认来自文件名 stem,用来统一“我的空间”和“本地文件夹”的行为。
- 正文 H1 只作为正文内容,不推导 tree row title。
- 用户可在页面设置中隐藏本地 Markdown 页头标题,默认开启,避免与正文 H1 重复。
已确认:
- Frontmatter `title` 暂不覆盖 File Tree / Page Tree / 页头标题。Frontmatter `title` 指 Markdown 文件开头形如 `--- title: Some Title ---` 的元数据标题;MVP 阶段它只作为文件元数据保留,不参与本地 Markdown 标题真源。
## 5. 执行清单
### 5.1 Smoke 红灯
- [x] 新增 `task479-local-folder-markdown-resource-lifecycle-smoke.js`
- [x] 覆盖主编辑区上传后落盘到 page resource directory。
- [x] 覆盖 filetree folder drop 后落盘到目标 folder。
- [x] 覆盖删除真实文件后 broken link 刷新仍保留。
- [ ] 覆盖 Backspace / Delete 删除一个附件不破坏相邻附件。
- [x] 覆盖刚进入 local folder 直接打开 PDF / Markdown 附件进入 active tab。
- [x] 覆盖 body 不因文件树滚动到底产生额外空白。
- [x] 覆盖本地 Markdown 文件名标题来源。
### 5.2 上传链路
- [ ] 前端上传 detail 增加明确 `uploadIntent`
- [ ] 后端 `/api/local-folder/assets/upload` 解析并校验 intent。
- [ ] `editor.markdown.attach` 写入 `markdown_page_resource_directory()`
- [ ] `filetree.folder.drop` 写入 `targetRelativePath` 指向的目录。
- [ ] 上传响应返回 intent、rootRelativePath、markdownRelativePath 和 ownerDocumentId,供 smoke 断言。
### 5.3 删除和缺失资源
- [ ] 主编辑区附件删除 command 只删除 Markdown 链接并保存。
- [ ] 手柄删除复用同一 command,不做纯 DOM 隐藏。
- [ ] Parser / serializer 保留 broken link。
- [ ] watcher 收到真实文件删除后刷新存在性状态,不改正文。
- [ ] 缺失资源点击进入 missing tab。
### 5.4 Resource tab host
- [x] local folder 首屏无 active document 时也渲染 editor host。
- [x] filetree resource open 默认 active tab。
- [x] PDF 使用与 cloud asset 一致的 preview chrome。
- [x] 本地 Markdown 附件作为 resource tab 打开,不抢占当前页面正文。
- [ ] active row / selected row 保持在被打开资源。
### 5.5 标题和页面设置
- [x] 修正本地 Markdown 标题解析测试命名和断言。
- [x] File Tree / Page Tree 标题默认来自文件名。
- [x] 正文 H1 保留在正文,不参与标题推导。
- [x] 本地 Markdown 页头标题默认隐藏。
- [ ] 页面设置新增“隐藏本地 Markdown 文件标题”的可切换 UI。
- [ ] 设置刷新后持久生效。
- [ ] 重命名标题时走文件重命名和 bundle 目录同步。
### 5.6 滚动边界
- [ ] Workbench 根容器固定视口高度。
- [ ] Sidebar / File Tree 是唯一纵向滚动容器。
- [ ] resize handle wheel 不穿透到 body。
- [ ] smoke 断言 `document.scrollingElement.scrollTop` 不变化。
## 6. 建议切片
1. Smoke + 合同观测字段。
2. 上传 intent 分流。
3. 删除引用和 broken link 保留。
4. 本地 resource tab host。
5. PDF / Markdown 附件 tab 一致性。
6. 标题来源和隐藏标题设置。
7. 滚动边界。
## 7. 验收命令
```bash
cargo check --manifest-path rust/Cargo.toml -p mnote-web
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown -- --test-threads=1
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task459-local-markdown-attachment-tab-smoke.js
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
codegraph sync .
```
## 8. Done Gate
- [ ] `4-49` 中的 6 组用户可见症状均有对应 smoke。
- [ ] 默认删除行为不级联删除真实附件。
- [x] 文件树真实删除不再导致正文链接刷新后消失。
- [x] 本地资源默认在主编辑区 tab 打开。
- [x] 本地 Markdown 标题来源和隐藏默认值经过用户确认并落地。
- [ ] 相关 bug 记录移动到 `done/` 并补验证证据。
## 9. 2026-05-24 执行记录
- `task479-local-folder-markdown-resource-lifecycle-smoke.js` 已覆盖上传目录、filetree folder drop、broken link 保留、无 active document 附件 active tab、文件树滚动边界、文件名标题来源。
- 修复 local folder root entry 不再复用跨 root 的 `mnote_recent_page_id`,避免 asset-only 工作区误进入无效 active page fallback。
- 修复无 active document 的 local folder 首屏,补齐 workspace-level 主编辑区 resource tab host 和空 panes runtime。
- 本地 Markdown 标题已收口为文件名来源;Frontmatter title 暂不覆盖标题真源;本地 Markdown 页头默认隐藏。
- 验证:`task479` 全部通过,`task459-local-markdown-attachment-tab-smoke.js` 通过;Rust `root_entry_``local_markdown``document_shell_renders` 相关单测通过。
+71 -10
View File
@@ -322,12 +322,8 @@ pub async fn root_entry(
.filter(|value| !value.is_empty())
.unwrap_or("local-folder")
.to_string();
let requested_or_recent_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
None,
None,
);
let requested_or_recent_page_id =
choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
let workspace_projection = build_workspace_shell_projection(
&snapshot.dataset,
&workspace_id,
@@ -336,7 +332,7 @@ pub async fn root_entry(
);
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
None,
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
@@ -494,7 +490,24 @@ pub async fn root_entry(
})
};
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
("MNOTE".to_string(), render_workspace_entry(), String::new())
let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
let panes_bootstrap_json = serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": false,
"secondaryInvalid": false,
"panes": [],
}))
.unwrap_or_else(|_| "{}".to_string());
format!(
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}"#,
escape_script_json(&panes_bootstrap_json),
render_editor_island_adapter_script(),
)
} else {
String::new()
};
("MNOTE".to_string(), render_workspace_entry(), body_extra)
} else {
match build_page_aggregate_snapshot(
&state,
@@ -2505,8 +2518,11 @@ mod tests {
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(!html.contains("初始化的新页面"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
let _ = std::fs::remove_dir_all(&base);
}
@@ -2719,6 +2735,7 @@ mod tests {
.uri(format!(
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
@@ -2743,6 +2760,50 @@ mod tests {
assert!(!html.contains(r#"href="/tree"#));
}
#[tokio::test]
async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() {
let root = temp_root("mnote-root-local-folder-no-active-page");
std::fs::create_dir_all(root.join("attachments")).expect("create attachments");
std::fs::write(root.join("attachments").join("report-a.pdf"), b"%PDF-1.4\n")
.expect("write pdf");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init local workspace");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!(
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="workspace""#));
assert!(html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains("openResourceInActiveTab"));
assert!(!html.contains(r#"<script id="__MNOTE_PAGE_AGGREGATE__""#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -10201,12 +10201,12 @@ fn main() {}
})
.expect("page row");
// 有 H1 且无 frontmatter title 时,标题来自 H1
assert_eq!(row["title"].as_str(), Some("Body Heading"));
// 本地 Markdown 标题来自文件名,正文 H1 只作为正文内容
assert_eq!(row["title"].as_str(), Some("File Name"));
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md")
.expect("aggregate");
assert_eq!(aggregate.title, "Body Heading");
assert_eq!(aggregate.title, "File Name");
let _ = std::fs::remove_dir_all(&root);
}
@@ -11386,12 +11386,11 @@ fn main() {}
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("load page tree");
let items = snapshot.projection["items"].as_array().expect("items");
// 找到 Root 页面节点和 Child 页面节点。
// 标题优先级:H1"Body Root" / "Body Child")高于文件名。
// 找到 Root 页面节点和 Child 页面节点。标题来自文件名,不来自正文 H1。
let root_node = items
.iter()
.find(|item| {
item["title"].as_str() == Some("Body Root")
item["title"].as_str() == Some("Root")
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
})
.expect("Root page node");
@@ -11399,7 +11398,7 @@ fn main() {}
let child_node = items
.iter()
.find(|item| {
item["title"].as_str() == Some("Body Child")
item["title"].as_str() == Some("Child")
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
})
.expect("Child page node");
@@ -79,61 +79,17 @@ struct MarkdownInlineStyles {
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (frontmatter, body) = split_frontmatter(markdown);
let (_frontmatter, body) = split_frontmatter(markdown);
let body_owned = body.to_string();
// 标题优先级:frontmatter title > 正文第一条 H1 > 文件名
let title = parse_frontmatter_title(frontmatter.as_deref())
.or_else(|| find_first_h1(&body_owned))
.unwrap_or_else(|| file_stem_title(file_name));
// 本地 Markdown 的树标题和页头标题统一来自文件名
// frontmatter title 与正文 H1 都保留为正文/元数据内容,不作为资源标题真相。
let title = file_stem_title(file_name);
ParsedLocalMarkdownPage {
title,
body: body_owned,
}
}
/// 从 frontmatter 中提取简单 `title:` 字段。
fn parse_frontmatter_title(frontmatter: Option<&str>) -> Option<String> {
let text = frontmatter?;
for line in text.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("title:") {
let raw = value.trim();
if raw.is_empty() {
continue;
}
let unquoted = if (raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\''))
{
&raw[1..raw.len() - 1]
} else {
raw
};
let title = unquoted.trim().to_string();
if !title.is_empty() {
return Some(title);
}
}
}
None
}
/// 通过 AST 找正文第一条 H1,避免把代码块里的 `# ...` 误判为标题。
fn find_first_h1(body: &str) -> Option<String> {
let arena = Arena::new();
let root = parse_document(&arena, body, &markdown_options());
for node in root.descendants() {
let value = node.data.borrow();
if let NodeValue::Heading(heading) = &value.value {
if heading.level != 1 {
continue;
}
let title = collect_plain_text(node).trim().to_string();
return (!title.is_empty()).then_some(title);
}
}
None
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
}
@@ -920,65 +876,35 @@ mod tests {
assert!(!d2_inline.is_empty(), "非空数据单元格不应为空");
}
// 标题优先级:frontmatter title > H1 > 文件名
// 本地 Markdown 标题统一来自文件名,frontmatter title H1 都只保留为正文/元数据
#[test]
fn parse_markdown_title_uses_frontmatter_title() {
fn parse_markdown_title_uses_filename_over_frontmatter_and_h1() {
let parsed = parse_markdown_page(
"---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n",
"file.md",
"File Name.md",
);
assert_eq!(parsed.title, "Frontmatter Title");
assert_eq!(parsed.title, "File Name");
}
#[test]
fn parse_markdown_title_uses_first_h1_when_no_frontmatter_title() {
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "file.md");
assert_eq!(parsed.title, "H1 Title");
fn parse_markdown_title_uses_filename_when_h1_exists() {
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "File Name.md");
assert_eq!(parsed.title, "File Name");
}
#[test]
fn parse_markdown_title_falls_back_to_filename() {
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
let parsed = parse_markdown_page("plain text\nmore text\n", "My File.md");
assert_eq!(parsed.title, "My File");
}
#[test]
fn parse_markdown_title_prefers_frontmatter_over_h1() {
let parsed = parse_markdown_page("---\ntitle: FM Title\n---\n# H1 here\n", "file.md");
assert_eq!(parsed.title, "FM Title");
}
#[test]
fn parse_markdown_title_handles_quoted_double() {
let parsed = parse_markdown_page("---\ntitle: \"Quoted Double\"\n---\nbody\n", "file.md");
assert_eq!(parsed.title, "Quoted Double");
}
#[test]
fn parse_markdown_title_handles_quoted_single() {
let parsed = parse_markdown_page("---\ntitle: 'Single Quoted'\n---\nbody\n", "file.md");
assert_eq!(parsed.title, "Single Quoted");
}
#[test]
fn parse_markdown_title_uses_h1_when_frontmatter_has_no_title() {
let parsed = parse_markdown_page("---\nother: value\n---\n# From H1\n", "file.md");
assert_eq!(parsed.title, "From H1");
}
#[test]
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
fn parse_markdown_title_ignores_frontmatter_without_title() {
let parsed = parse_markdown_page("---\ntags: foo\n---\nplain text\n", "NoH1.md");
assert_eq!(parsed.title, "NoH1");
}
#[test]
fn parse_markdown_title_handles_atx_closing_markers() {
let parsed = parse_markdown_page("# Heading With Closing #\n", "file.md");
assert_eq!(parsed.title, "Heading With Closing");
}
#[test]
fn parse_markdown_title_ignores_hash_inside_code_block() {
let parsed = parse_markdown_page("```md\n# Not A Title\n```\nplain text\n", "Fallback.md");
@@ -222,6 +222,8 @@ pub async fn document_page_shell(
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
primary_hide_title_header={is_local_folder}
secondary_hide_title_header={secondary_source_kind == Some("local_folder")}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
/>
});
@@ -653,7 +655,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID);
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes) || panesBootstrap.panes.length === 0) return;
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes)) return;
const paneRouteConfig = {
primary: {
@@ -803,7 +805,6 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const paneRuntimes = panesBootstrap.panes.map(buildPaneRuntime).filter(Boolean);
if (!paneRuntimes.length) return;
const loadRuntime = async () => {
if (window.__mnoteLeptosTiptapRuntimePromise) {
@@ -4298,9 +4299,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
});
}, { once: true });
if (paneRuntimes.length) {
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
console.error('mnote multi-pane editor mount failed', error);
});
}
})();
</script>"#
}
@@ -5282,8 +5285,8 @@ mod tests {
payload["result"]["identity"]["documentId"],
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
);
// H1 "Local Heading" 优先于文件名标题 "Local Aggregate"
assert_eq!(payload["result"]["head"]["title"], "Local Heading");
// 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
assert_eq!(payload["result"]["body"]["revision"], 0);
assert!(payload["result"]["body"]["content"]
@@ -5423,8 +5426,8 @@ mod tests {
assert!(html.contains("localFolderEventRegistry"));
assert!(html
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(html.contains("mayAffectMissingDocument"));
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
assert!(html.contains("shouldSuppressLocalFolderSelfChange"));
assert!(html.contains("kind.includes('Modify(Name')"));
assert!(html.contains("targetSession.views.size === 0"));
assert!(html.contains("session.views.size === 0"));
assert!(html.contains("targetSession.saving"));
@@ -21,6 +21,7 @@ pub struct DocumentPaneViewModel {
pub has_page_subtree: bool,
pub primary_legacy_ids: bool,
pub visible: bool,
pub hide_title_header: bool,
}
#[component]
@@ -76,7 +77,11 @@ fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
data-page-font={model.page_font.clone()}
data-page-show-heading-numbers={model.page_show_heading_numbers.to_string()}
>
<header class="document-shell-header">
<header
class="document-shell-header"
data-page-title-hidden={model.hide_title_header.to_string()}
hidden={model.hide_title_header}
>
<div class="document-page-icon" aria-hidden="true">
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
</div>
@@ -177,6 +182,12 @@ pub fn DocumentPage(
/// 右侧页面选项 JSON(可选)
#[prop(optional)]
secondary_page_options_json: String,
/// 是否隐藏主文档页头标题
#[prop(optional)]
primary_hide_title_header: bool,
/// 是否隐藏右侧文档页头标题
#[prop(optional)]
secondary_hide_title_header: bool,
/// 是否显示管理员授权入口
#[prop(optional)]
show_admin_access_policy: bool,
@@ -260,6 +271,7 @@ pub fn DocumentPage(
has_page_subtree,
primary_legacy_ids: true,
visible: true,
hide_title_header: primary_hide_title_header,
};
let secondary_model = DocumentPaneViewModel {
pane_role: "secondary",
@@ -278,6 +290,7 @@ pub fn DocumentPage(
has_page_subtree: secondary_has_page_subtree,
primary_legacy_ids: false,
visible: secondary_visible,
hide_title_header: secondary_hide_title_header,
};
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
+48 -1
View File
@@ -61,7 +61,54 @@ pub fn HomePage(
</main>
}.into_any()
} else {
view! {}.into_any()
view! {
<main
class="document-workspace mnote-workspace-empty-editor-host"
data-testid="mnote-document-workspace"
data-has-secondary-pane="false"
data-workspace-id={workspace_id.clone().unwrap_or_default()}
>
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host" data-pane-role="primary">
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="primary" role="tablist" aria-label="主编辑区标签页">
<button
type="button"
class="mnote-main-tab is-active"
data-mnote-main-tab="page"
data-mnote-tab-kind="page"
data-pane-role="primary"
data-workspace-id={workspace_id.clone().unwrap_or_default()}
role="tab"
aria-selected="true"
tabindex="0"
>
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
<span class="mnote-main-tab-title">"文件夹"</span>
</button>
</div>
<div class="mnote-main-tab-panels">
<div data-mnote-page-tab-panel="true" data-pane-role="primary">
<section
class="document-pane mnote-workspace-empty-pane"
data-document-pane="true"
data-pane-role="primary"
data-pane-workspace-id={workspace_id.clone().unwrap_or_default()}
data-pane-visible="true"
aria-label="主文档"
></section>
</div>
<section
class="mnote-resource-tab-host"
data-mnote-resource-tab-host="true"
data-pane-role="primary"
data-testid="mnote-resource-tab-host"
hidden=true
>
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="primary"></div>
</section>
</div>
</section>
</main>
}.into_any()
}}
</PageLayout>
}
@@ -3138,8 +3138,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function shouldOpenLocalResourceInNewWindow(fileName) {
var ext = attachmentExtensionFromFileName(fileName);
return ext === 'pdf';
return false;
}
function isLocalUploadedAsset(asset) {
@@ -10923,9 +10922,9 @@ mod tests {
}
#[test]
fn sidebar_runtime_opens_local_pdf_assets_in_browser_tab_by_default() {
fn sidebar_runtime_opens_local_pdf_assets_in_active_resource_tab_by_default() {
assert!(SIDEBAR_TREE_JS.contains("function shouldOpenLocalResourceInNewWindow(fileName)"));
assert!(SIDEBAR_TREE_JS.contains("return ext === 'pdf';"));
assert!(SIDEBAR_TREE_JS.contains("return false;"));
assert!(SIDEBAR_TREE_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);"));
assert!(SIDEBAR_TREE_JS
.contains("if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({"));
+1 -1
View File
@@ -26,6 +26,6 @@ wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MessageEvent", "MouseEvent", "Node", "Range", "RequestInit", "RequestMode", "Response", "Selection", "Storage", "Window"] }
[dependencies.leptos-tiptap]
path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap"
path = "../../../reference-code/leptos-tiptap"
default-features = false
features = ["component", "full"]
@@ -68,7 +68,7 @@ export interface InitOutput {
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
@@ -608,6 +608,14 @@ function __wbg_get_imports() {
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_innerHeight_72e7bb88c4b9ede8: function() { return handleError(function (arg0) {
const ret = arg0.innerHeight;
return ret;
}, arguments); },
__wbg_innerWidth_c7446907ab672e41: function() { return handleError(function (arg0) {
const ret = arg0.innerWidth;
return ret;
}, arguments); },
__wbg_insertBefore_38c7d835a2dcac23: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.insertBefore(arg1, arg2);
return ret;
@@ -1223,42 +1231,42 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1129, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1128, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1181, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 957, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 956, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1079, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1078, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1129, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1128, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1081, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1080, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1096, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1095, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1132, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1131, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
return ret;
},
@@ -1318,8 +1326,8 @@ function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
@@ -21,7 +21,7 @@ export const intounderlyingsource_cancel: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
@@ -6363,11 +6363,23 @@ fn apply_html_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
next_html: String,
document_id: ReadSignal<Option<String>>,
workspace_id: ReadSignal<Option<String>>,
dirty_count: ReadSignal<u32>,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
hovered_block: ReadSignal<Option<HoveredBlockState>>,
editor_focused: ReadSignal<bool>,
slash_open: ReadSignal<bool>,
turn_into_open: ReadSignal<bool>,
color_menu_open: ReadSignal<bool>,
more_menu_open: ReadSignal<bool>,
revision: ReadSignal<Option<i64>>,
conflict_detection_key: ReadSignal<Option<String>>,
read_only: ReadSignal<bool>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
@@ -6379,6 +6391,39 @@ fn apply_html_update(
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
dispatch_change_event(&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
match persist_document_state(
persisted_identity,
&title.get_untracked(),
@@ -8487,11 +8532,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
editor,
&persisted_identity,
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
set_command_feedback,
"已通过块手柄拖拽重排",
);
@@ -9268,11 +9325,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
editor,
&runtime_persisted_identity(document_id, workspace_id),
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
set_command_feedback,
"已通过块手柄拖拽重排",
);
@@ -10423,11 +10492,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
editor,
&runtime_persisted_identity(document_id, workspace_id),
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
set_command_feedback,
format!("已复制 {}", duplicate_feedback_label.clone()),
);
@@ -10456,11 +10537,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
editor,
&runtime_persisted_identity(document_id, workspace_id),
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
set_command_feedback,
format!("已删除 {}", delete_feedback_label.clone()),
);
@@ -0,0 +1,621 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function localFolderHomeUrl(root) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, taskId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:${taskId}`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function activatePrimaryPageTab(page) {
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
if (await pageTab.count()) {
await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadAttachmentViaPrimarySlash(page, fileName, markdownContent) {
await activatePrimaryPageTab(page);
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page
.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]')
.first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles({
name: fileName,
mimeType: "text/markdown",
buffer: Buffer.from(markdownContent, "utf8"),
});
await page.waitForFunction(
(name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement && (editor.textContent || "").includes(name);
},
fileName,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForFileExists(filePath, timeoutMs) {
const startedAt = Date.now();
while (!fs.existsSync(filePath) && Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return fs.existsSync(filePath);
}
// ─── main ────────────────────────────────────────────────────────────────────
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task479-lifecycle-"));
const relativePath = "README.md";
const resourceDirName = "README";
const resourceDir = path.join(root, resourceDirName);
fs.mkdirSync(resourceDir, { recursive: true });
writeWorkspaceManifest(root, "user_real", "task479");
// 主正文:README.md,引用一个已存在的附件 resource-note.md
fs.writeFileSync(
path.join(root, relativePath),
[
"---",
"title: Lifecycle Smoke",
"---",
"",
"# Lifecycle Smoke",
"",
"正文内容不变。",
"",
"[resource-note.md](" + resourceDirName + "/resource-note.md)",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(path.join(root, resourceDirName, "resource-note.md"), "# Resource Note\n\n已有资源正文\n", "utf8");
// 为 Check 6 准备一个带 H1 的文件(文件名不含空格,避免空格编码干扰测试)
fs.writeFileSync(path.join(root, "MyNotes.md"), "# Body Heading\n\n正文内容\n", "utf8");
// 为 Check 5 准备足够的文件树条目来产生可滚动区域
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
for (let i = 0; i < 30; i++) {
fs.writeFileSync(path.join(root, "docs", `doc-${String(i).padStart(2, "0")}.md`), `# Doc ${i}\n`, "utf8");
}
// 为 Check 5 准备的附件 PDF
fs.writeFileSync(path.join(resourceDir, "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 report-a\n", "utf8"));
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1360, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const popups = [];
page.on("popup", (popup) => popups.push(popup));
const screenshots = [];
const checks = {};
let overallOk = true;
try {
// ── Login ──
await quickLogin(page);
// ──────────────────────────────────────────────────────────────────────────
// Check 1: 主编辑区上传 / drop 附件后,附件应进入当前 Markdown 页面的资源目录
// README/uploaded-one.md 不是 root 同级)
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "editor-upload-to-page-resource-dir";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 通过主编辑区斜杠上传上传一个 MD 文件
await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
// 等待后端 write_local_markdown_asset() 落盘
const correctPath = path.join(resourceDir, "uploaded-one.md");
const wrongPath = path.join(root, "uploaded-one.md");
const landedInResourceDir = await waitForFileExists(correctPath, UI_TIMEOUT_MS);
const landedInRoot = fs.existsSync(wrongPath);
assert(
landedInResourceDir,
`上传文件应落在 page resource directory (${correctPath})。`
+ ` landedInRoot=${landedInRoot} correctPath=${correctPath}`,
);
checks[checkId] = {
ok: true,
message: `上传文件落在资源目录 ${correctPath}`,
landedInResourceDir,
landedInRoot,
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `主编辑区上传落盘目录检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 2: 文件树 folder row 外部 drop 到目标文件夹时,文件应进入该目标 folder
// 而不是当前 Markdown 页面的资源目录
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "filetree-folder-drop-to-target";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
// 等待文件树就绪
const docsFolderRow = page.locator(
'#sidebar-file-tree-root .tree-row[data-row-id="local:folder:docs"]',
).first();
await docsFolderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const droppedFileName = "drag-dropped-note.md";
const droppedContent = "# Drag Dropped\n\n从外部拖入\n";
// 使用 dispatchEvent 模拟外部文件拖入 docs 文件夹
await docsFolderRow.dispatchEvent("dragover", {
dataTransfer: await page.evaluateHandle(
({ fileName, fileContent }) => {
const dt = new DataTransfer();
dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
return dt;
},
{ fileName: droppedFileName, fileContent: droppedContent },
),
});
await docsFolderRow.dispatchEvent("drop", {
dataTransfer: await page.evaluateHandle(
({ fileName, fileContent }) => {
const dt = new DataTransfer();
dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
return dt;
},
{ fileName: droppedFileName, fileContent: droppedContent },
),
});
// 等待文件落盘
const droppedPath = path.join(root, "docs", droppedFileName);
const exists = await waitForFileExists(droppedPath, UI_TIMEOUT_MS);
assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`);
// 确保没有出现在 root 或 README/ 下
const wrongPath = path.join(root, droppedFileName);
const wrongResourcePath = path.join(resourceDir, droppedFileName);
assert(
!fs.existsSync(wrongPath) && !fs.existsSync(wrongResourcePath),
`拖入 docs 文件夹的文件不应落在 root (${wrongPath}) 或 resourceDir (${wrongResourcePath}) 下`,
);
checks[checkId] = {
ok: true,
message: `外部拖入文件进入目标文件夹 docs/`,
droppedPath,
exists,
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `文件树 folder drop 检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 3: 删除真实附件文件后,刷新页面,正文中的 Markdown 链接仍应可见
// 如果当前实现失败,先记录当前 failure,但断言应表达预期行为
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "broken-link-after-real-file-delete";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 先确认正文中有附件链接
const linkTextBefore = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement ? editor.textContent || "" : "";
});
assert(
linkTextBefore.includes("resource-note.md"),
`初始正文应包含附件链接 resource-note.md,实际内容: ${linkTextBefore.slice(0, 500)}`,
);
// 从磁盘删除真实附件文件
const assetPath = path.join(resourceDir, "resource-note.md");
assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在");
fs.unlinkSync(assetPath);
// 刷新页面
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 给异步解析和渲染留时间
await page.waitForTimeout(1000);
// 检查正文是否仍包含附件链接文本
const linkTextAfter = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement ? editor.textContent || "" : "";
});
// 预期行为:链接应保留
const linkPreserved = linkTextAfter.includes("resource-note.md");
checks[checkId] = {
ok: linkPreserved,
message: linkPreserved
? "删除真实附件后刷新,正文链接仍保留"
: "删除真实附件后刷新,正文链接消失(当前实现未保留 broken link",
assetPath,
deleted: true,
linkPreserved,
editorText: linkTextAfter.slice(0, 500),
};
if (!linkPreserved) {
overallOk = false;
}
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 4: 刚进入 local folder、未先打开任何 Markdown 文件时,
// 点击 PDF / Markdown 附件 row 应在主编辑区 tab 打开,不应产生 popup。
// 若当前 UI 无法直接进入这种状态,记录原因并把该 check 标记为 skipped
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "direct-attach-open-without-active-md";
try {
// 使用独立 asset-only 工作区,确保 local folder 首屏没有 active Markdown document。
const assetOnlyRoot = path.join(root, "asset-only-workspace");
fs.mkdirSync(path.join(assetOnlyRoot, "attachments"), { recursive: true });
writeWorkspaceManifest(assetOnlyRoot, "user_real", "task479-asset-only");
fs.writeFileSync(path.join(assetOnlyRoot, "attachments", "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 asset-only report-a\n", "utf8"));
const homeUrl = localFolderHomeUrl(assetOnlyRoot);
await page.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
// 等待文件树就绪
const fileTreeRoot = page.locator("#sidebar-file-tree-root").first();
await fileTreeRoot.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hasResourceHost = await page.locator('[data-mnote-resource-tab-host][data-pane-role="primary"]')
.first()
.count()
.then((count) => count > 0)
.catch(() => false);
const resourceFolderRow = page
.locator('#sidebar-file-tree-root .tree-row[data-node-id="local:node:attachments"], #sidebar-file-tree-root .tree-row[data-row-id="local:folder:attachments"]')
.first();
if (await resourceFolderRow.isVisible().catch(() => false)) {
const expanded = await resourceFolderRow.getAttribute("aria-expanded").catch(() => null);
const resourceFolderToggle = resourceFolderRow.locator('[data-testid="filetree-toggle"]').first();
if (expanded !== "true" && await resourceFolderToggle.isVisible().catch(() => false)) {
await resourceFolderToggle.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
// 找到附件 row (data-testid="filetree-asset-row" 或 data-row-kind="asset"/"attachment")
const pdfAssetRow = page.locator('[data-testid="filetree-asset-row"]', {
hasText: "report-a.pdf",
}).first();
await pdfAssetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hasPdfRow = await pdfAssetRow.isVisible().catch(() => false);
assert(hasResourceHost, "无 active Markdown document 时应渲染主编辑区 resource tab host");
assert(hasPdfRow, "无 active Markdown document 时应渲染 report-a.pdf 附件 row");
const popupCountBefore = popups.length;
await pdfAssetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(2000);
const popupCountAfter = popups.length;
const resourceTabVisible = await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])')
.isVisible().catch(() => false);
const resourceTabTitle = await page
.locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "report-a.pdf" })
.first()
.textContent({ timeout: UI_TIMEOUT_MS })
.catch(() => "");
assert(
popupCountAfter === popupCountBefore,
`点击附件 row 不应产生新浏览器窗口/标签页,popup 数:${popupCountBefore}${popupCountAfter}`,
);
assert(resourceTabVisible, "点击附件 row 后应显示主编辑区 resource tab host");
assert((resourceTabTitle || "").includes("report-a.pdf"), `资源 tab 标题应包含 report-a.pdf,实际: ${resourceTabTitle}`);
checks[checkId] = {
ok: true,
message: "无 active Markdown document 时,点击附件 row 后主编辑区 resource tab 打开且不产生 popup",
popupCount: popupCountAfter - popupCountBefore,
resourceTabVisible,
resourceTabTitle,
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `直接打开附件检查异常: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 5: 文件树滚动到底和 resize handle 上滚轮后,
// document.scrollingElement.scrollTop 应保持 0 或不产生底部空白
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "filetree-scroll-no-body-scroll";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator("#sidebar-file-tree-root").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 先让文件树出现
await page.waitForTimeout(500);
// 获取滚动前的 body scrollTop
const scrollBefore = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
// 尝试让文件树滚动到底:在文件树上用鼠标滚轮
const fileTree = page.locator("#sidebar-file-tree-root").first();
await fileTree.hover({ timeout: UI_TIMEOUT_MS });
// 多次在文件树上滚轮模拟快速滚到底
for (let i = 0; i < 30; i++) {
await page.mouse.wheel(0, 120);
await new Promise((r) => setTimeout(r, 30));
}
// 再在文件树区域一次性大量滚轮
for (let i = 0; i < 50; i++) {
await page.mouse.wheel(0, 300);
await new Promise((r) => setTimeout(r, 20));
}
await page.waitForTimeout(500);
const scrollAfter = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
// 预期:body scrollTop 不应被文件树滚轮事件改变
const bodyNotScrolled = scrollAfter <= scrollBefore;
checks[checkId] = {
ok: bodyNotScrolled,
message: bodyNotScrolled
? "文件树内滚轮没有导致 body 滚动"
: "文件树滚轮导致 body 产生了滚动(出现底部空白)",
scrollBefore,
scrollAfter,
delta: scrollAfter - scrollBefore,
};
if (!bodyNotScrolled) {
overallOk = false;
}
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `文件树滚动 body 空白检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 6: 本地 Markdown 标题:File Name.md 且正文第一行 # Body Heading 时,
// File Tree 显示 "File Name.md",正文仍显示 "Body Heading"
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "markdown-title-from-filename-not-h1";
try {
const fileName = "MyNotes.md";
const fileRelPath = fileName;
// 确认文件已被写入
assert(fs.existsSync(path.join(root, fileName)), "File Name.md 应存在");
// 导航到该文件
await page.goto(documentUrl(root, fileRelPath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 等待文件树就绪
await page.locator("#sidebar-file-tree-root").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForTimeout(500);
// 从文件树获知 MyNotes.md 的标题
const treeTitle = await page.evaluate(() => {
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="local:markdown:"]');
for (const row of rows) {
const text = row.querySelector(".tree-link-title")?.textContent?.trim() || row.textContent?.trim() || "";
if (text.includes("MyNotes")) return text;
}
return null;
});
// 获取正文第一行 / 标题
const bodyHeading = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) return null;
// 尝试获取第一个 h1
const h1 = editor.querySelector("h1");
if (h1 instanceof HTMLElement) return h1.textContent?.trim() || "";
// fallback: textContent 第一行
return (editor.textContent || "").split("\n").map((l) => l.trim()).filter(Boolean)[0] || "";
});
// 预期:文件树显示 MyNotes.md(文件名),正文显示 Body HeadingH1 内容)
const treeShowsFileName = treeTitle && treeTitle.includes("MyNotes.md");
const bodyShowsHeading = bodyHeading && bodyHeading.includes("Body Heading");
checks[checkId] = {
ok: !!(treeShowsFileName && bodyShowsHeading),
message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`,
treeTitle,
bodyHeading,
treeShowsFileName,
bodyShowsHeading,
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `标题来源检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ── 截图 ──
{
const shotTimestamp = Date.now();
const shotDir = path.join(root, `screenshots-${shotTimestamp}`);
fs.mkdirSync(shotDir, { recursive: true });
await page.screenshot({ path: path.join(shotDir, "final-state.png"), fullPage: false });
screenshots.push(path.join(shotDir, "final-state.png"));
}
// ── 结果 ──
const result = {
ok: overallOk,
root,
checks,
screenshots,
popups: popups.length,
};
console.log(JSON.stringify(result, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
// 清理临时目录,保留 result JSON 被 console.log 输出即可
// 如果用户需要查看留下的文件,可设置 KEEP_TEMP=1
if (!process.env.KEEP_TEMP) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* ignore cleanup errors */ }
}
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});