Files
mnote/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md
T

19 KiB
Raw Blame History

4-49 [process][bug] 本地文件夹 Markdown 附件、打开目标与标题合同缺口 v1

发现时间:2026-05-23

状态:[process]

关联主线:04-tree-domain05-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 主编辑区拖入 / 上传附件后落盘目录不符合预期

复现描述:

  1. 在“我的空间”或本地文件夹中打开一个 Markdown 页面。
  2. 向主编辑区拖入文件或上传文件。
  3. 主编辑区能显示附件,点击也能打开。
  4. 但文件落在主文件夹同级位置,预期应落在当前 Markdown 页面资源目录下。

截图:/mnt/Data1T/mnote/tmp/image copy 65.png

当前判断:

  • 代码中已经有两条上传链路:
    • 文件树外部 drop 到 folder row:通过 targetRelativePathwrite_local_folder_file_upload()
    • 主编辑区上传 / drop:通过 documentIdwrite_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
  • 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 继续过滤 defaultlocal-ws:*

验收标准:

  • 我的空间有页面 -> 打开普通 local folder -> 点击“我的空间” -> 默认 my-space 文件树恢复。
  • URL 不含普通 local folder 的 rootUrimnote.workspace.lastCloudWorkspaceId 不得为 defaultlocal-ws:*

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. 验证命令建议

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 tabtask479 通过,popupCount=0
  • 2.7 本地 Markdown 附件 / PDF 默认 active tabtask459task479 通过。
  • 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,只是不挂载正文编辑器。
  • task479 Check 4 改为使用独立 asset-only local folder,确保真实覆盖“无 active Markdown document”。

仍在 process

  • 2.1 上传 intent 已显式化:前端主编辑区上传发送 editor.markdown.attachFile Tree folder drop 发送 filetree.folder.drop;后端 /api/local-folder/assets/upload 解析并校验 intent,不再靠 documentId / targetRelativePath 隐式分流;task479 已断言两个入口的响应字段。
  • 2.3 Backspace / Delete 删除相邻附件已补浏览器级 smoke:task479 Check 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 验证:task479 Check 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 / 持久化已实现:新增 hideTitleHeader page option,本地 Markdown 默认开启;关闭后页头立即显示,刷新后从 .mnote/page-options.json 恢复。task479 Check 6 通过。