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

> 发现时间：2026-05-23
>
> 状态：`[done]`
>
> 关联主线：`04-tree-domain`、`05-editor-mainline`
>
> 关联设计：`design/04-tree-domain/done/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：通过 `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`。
- 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 刚打开本地文件夹时，直接打开附件只能新窗口打开

复现描述：

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
```

后续应新增本 bug 专属 smoke，例如：

```bash
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`，只是不挂载正文编辑器。
- `task479` Check 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：`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 通过。
- 2.2 watcher 缺失状态刷新已收口：新增本地文件 `stat` 端点，编辑器附件增强逻辑在 watcher / projection 刷新后重新检查本地链接存在性；删除真实附件后不刷新页面即标记 `data-mnote-attachment-missing="true"`，并保持 Markdown 正文不变。`task479` Check 3 通过。
- 2.6 滚动边界已补齐显式 wheel 拦截：Workbench 根容器固定视口高度，内部滚动容器隔离滚动，resize handle 的 wheel 事件不再穿透到 body。`task479` Check 5 通过。

### 6.1 2026-05-24 追加：拖到 Markdown 行的外部文件落盘修复

- 复现范围已进一步确认：问题不是普通 folder row，而是把外部文件拖到 file tree 的 `.md` 行时出现。
- 根因：`layout.rs` 里 file tree 外部 drop 只在 folder/directory row 才传入目录路径，`.md` 行会被序列化成 `targetRelativePath = null/空值`，后续上传分流又把“字段存在”当成 `filetree.folder.drop`，最终把文件落到 root。
- 修复：新增 `fileTreeRowLocalUploadTargetRelativePath()`，`.md` 行改为解析为“该 md 所在目录”；外部 drop 只在有真实目录路径时才走 folder upload，不再回退写 root。
- 回归：`scripts/task163-local-folder-unified-tree-browser-smoke.js` 增加断言，确保拖到 Markdown 行不会落到 root。
- 验证：用户已手动确认该 bug 修复完成。
