19 KiB
4-49 [process][bug] 本地文件夹 Markdown 附件、打开目标与标题合同缺口 v1
发现时间:2026-05-23
状态:
[process]关联主线:
04-tree-domain、05-editor-mainline关联设计:
design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md
1. 范围判断
这条记录不是单个拖拽 bug,而是本地文件夹 MVP 在 Markdown 正文引用、真实文件资源、File Tree projection、主编辑区 object tab 之间的合同缺口。
已按 Sidex / VSCode 行为确认的边界:
- 主编辑区删除 Markdown 附件块 / 链接,默认只删除正文里的引用,不删除真实附件文件。
- 文件树右键删除真实附件,默认只删除真实文件,不自动扫描并改写所有 Markdown 正文。
- 删除真实文件后,正文中的链接应保留为 broken reference;编辑区需要增强展示和点击失败处理,而不是静默清除链接。
- 只有显式危险动作才可以同时删除引用和真实文件,例如后续可设计“删除引用并移到回收站”,且必须做确认和反向引用检查。
2. 用户可见症状
2.1 主编辑区拖入 / 上传附件后落盘目录不符合预期
复现描述:
- 在“我的空间”或本地文件夹中打开一个 Markdown 页面。
- 向主编辑区拖入文件或上传文件。
- 主编辑区能显示附件,点击也能打开。
- 但文件落在主文件夹同级位置,预期应落在当前 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()。
- 文件树外部 drop 到 folder row:通过
- 既有
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/,目录不存在时创建。
- bundle 形态:
- 前端上传时不要把主编辑区 drop 误标成 folder target;有
targetRelativePath时才走 folder upload。 - smoke 需要同时覆盖“拖到文件树文件夹”和“拖到主编辑区”两个入口,避免互相掩盖。
验收标准:
- 主编辑区上传附件后,文件树中该附件位于当前 Markdown 页面资源目录下。
- 文件树 folder row drop 仍写入目标 folder 下,不回退到当前页面资源目录。
- 新增或扩展 smoke 断言上传响应中的
rootRelativePath、正文中的相对链接、File Tree row 父子位置一致。
2.2 删除真实附件后,正文链接生命周期不稳定
复现描述:
- 主编辑区中已有附件链接。
- 在文件树附件 row 上右键真实删除该附件。
- 主编辑区中的链接仍在,点击会显示附件不存在。
- 刷新后,正文里的链接消失。
当前判断:
- 链接仍在是合理行为;真实文件删除不应自动改写 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 主编辑区删除附件引用的序列化错误
复现描述:
- 主编辑区有两个附件。
- 用 Backspace / Delete 删除后面的附件,前面的附件会变成普通文字。
- 删除前面的附件,后面的附件不受影响。
- 手柄中的删除看起来只是在 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
复现描述:
- 用户打开本地文件夹。
- 想回到“我的空间”,点击左上角下拉中的“云空间”(旧文案)。
- 页面变成空白,整体效果是回不到我的空间。
当前判断:
- 该问题曾由
bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md修复。 - 如果现在仍能复现,应按回归处理:重点检查
lastCloudWorkspaceId是否又被写成 syntheticdefault,以及切回 cloud 时 URL 是否残留rootUri。 - 2026-05-24 复测发现新阶段根因不同:当前 local-first MVP 默认不启动 Convex,旧“云空间”按钮仍跳到
sourceKind=convex_workspace会稳定进入convex_unavailable;同时local-ws:*曾可能被当作 last cloud workspace 复用。
建议修复:
- 先补回归 smoke 或复用
scripts/task441-local-folder-cloud-switch-smoke.js。 openLocalFolderRoot()进入本地前必须记住真实 cloud workspace id,不能从document.body粗粒度推导。switchToCloudWorkspace()必须清理rootUri/ secondary local source 参数,只携带真实 cloud workspace id。- 当前 MVP 修复口径:来源菜单入口文案改为“我的空间”,默认返回受管
my-space本地根,而不是进入已退出默认 dev 主链的 Convex compat。 - 返回“我的空间”时使用
/?treeView=filetree&mnoteHome=1,显式绕过最近普通本地目录自动打开逻辑;lastCloudWorkspaceId继续过滤default和local-ws:*。
验收标准:
- 我的空间有页面 -> 打开普通 local folder -> 点击“我的空间” -> 默认
my-space文件树恢复。 - URL 不含普通 local folder 的
rootUri,mnote.workspace.lastCloudWorkspaceId不得为default或local-ws:*。
2.5 刚打开本地文件夹时,直接打开附件只能新窗口打开
复现描述:
- 用户刚打开本地文件夹。
- 未先打开任何 Markdown 文件。
- 直接打开任意附件,都会在新窗口打开。
- 只有先打开一个
.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. 建议实施顺序
- 先补 smoke 红灯:
- 主编辑区上传落盘目录。
- 删除真实附件后 broken link 保留。
- 主编辑区删除附件引用不影响相邻附件。
- 刚进入 local folder 直接打开附件 / PDF 进入 tab。
- 文件树滚动到底无 body 空白。
- 本地 Markdown 文件名标题来源。
- 修复资源生命周期合同:
- 上传 intent 分流。
- broken reference 保留和标记。
- editor attachment delete command 持久化。
- 修复 local folder object tab host:
- 没有 active document 时也能打开 resource tab。
- PDF / md / 普通附件默认 active tab。
- 修复 workspace source switch 回归,如果 smoke 确认可复现。
- 修复 layout scroll 边界。
- 修正本地 Markdown 标题来源和隐藏标题设置。
4. 已确认与待确认
- 已确认:本地 Markdown 的 File Tree / 页头标题默认来自文件名,用来统一“我的空间”和“本地文件夹”的行为;正文 H1 永远只作为正文内容。
- 已确认:
隐藏本地 Markdown 文件标题默认开启,避免本地 Markdown 页头标题与正文 H1 重复。 - 已确认:Frontmatter
title暂不覆盖页头标题。Frontmattertitle指 Markdown 文件开头形如--- title: Some Title ---的元数据标题;MVP 阶段 File Tree / 页头标题继续以文件名为准。
5. 验证命令建议
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
后续应新增本 bug 专属 smoke,例如:
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
6. 2026-05-24 执行记录
已修复 / 已验证:
- 2.1 主编辑区上传附件落盘到当前 Markdown 页面资源目录:
task479通过。 - 2.2 删除真实附件后刷新,正文 broken link 保留;点击缺失附件进入主编辑区 resource tab error state,不弹新窗口:
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通过。
关键根因:
- 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 只能依赖新窗口兜底。
本轮修复:
- 显式 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,只是不挂载正文编辑器。 task479Check 4 改为使用独立 asset-only local folder,确保真实覆盖“无 active Markdown document”。
仍在 process:
- 2.1 上传 intent 已显式化:前端主编辑区上传发送
editor.markdown.attach,File Tree folder drop 发送filetree.folder.drop;后端/api/local-folder/assets/upload解析并校验 intent,不再靠documentId/targetRelativePath隐式分流;task479已断言两个入口的响应字段。 - 2.3 Backspace / Delete 删除相邻附件已补浏览器级 smoke:
task479Check 7 通过。Backspace 与手柄删除都只删除 Markdown 链接,不删除真实附件文件,相邻附件刷新后仍保持可点击附件块。 - 2.4 切回“我的空间”已按当前 local-first MVP 语义修复并复测:旧“云空间”入口不再进入
convex_workspace,改为“我的空间”,通过mnoteHome=1回默认my-space,避免最近普通本地目录和旧 Convex compat 接管;task441-local-folder-cloud-switch-smoke.js通过。 - 2.5 / 2.7 resource tab 打开目标已补 active/selected row 验证:
task479Check 4 断言刚进入 asset-only local folder 点击report-a.pdf后,主编辑区 resource tab 打开且 File Tree 唯一 selected/active row 保持在该附件 row。 - 2.2 missing tab 点击根因已收口:本地 Markdown 附件缺失时,
openTiptapResourceTab()读取/api/local-folder/resource/read失败,openResourceInActiveTab()已创建 resource tab 并调用markResourceTabError(),但此前仍返回false;外层openEditorAttachmentDetail()因opened=false执行window.open()fallback,导致弹出新窗口。修复为错误 tab 已渲染时返回true,表示主编辑区已接管打开意图。 - 2.8 页面设置中“隐藏本地 Markdown 文件标题”的可切换 UI / 持久化已实现:新增
hideTitleHeaderpage option,本地 Markdown 默认开启;关闭后页头立即显示,刷新后从.mnote/page-options.json恢复。task479Check 6 通过。