# 4-48 [done] 本地文件夹 Markdown 资源生命周期合同 v1

> 日期：2026-05-24
>
> Owner：Tree Domain / Local Folder / Editor Mainline
>
> 关联 bug：`bugs/04-tree-domain/done/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 document，local 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 刷新仍保留。
- [x] 覆盖 Backspace / Delete 删除一个附件不破坏相邻附件。
- [x] 覆盖刚进入 local folder 直接打开 PDF / Markdown 附件进入 active tab。
- [x] 覆盖 body 不因文件树滚动到底产生额外空白。
- [x] 覆盖本地 Markdown 文件名标题来源。

### 5.2 上传链路

- [x] 前端上传 detail 增加明确 `uploadIntent`。
- [x] 后端 `/api/local-folder/assets/upload` 解析并校验 intent。
- [x] `editor.markdown.attach` 写入 `markdown_page_resource_directory()`。
- [x] `filetree.folder.drop` 写入 `targetRelativePath` 指向的目录。
- [x] 上传响应返回 intent、rootRelativePath、markdownRelativePath 和 ownerDocumentId，供 smoke 断言。

### 5.3 删除和缺失资源

- [x] 主编辑区附件删除 command 只删除 Markdown 链接并保存。
- [x] 手柄删除复用同一 command，不做纯 DOM 隐藏。
- [x] Parser / serializer 保留 broken link。
- [x] watcher 收到真实文件删除后刷新存在性状态，不改正文。
- [x] 缺失资源点击进入 missing / error 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 打开，不抢占当前页面正文。
- [x] active row / selected row 保持在被打开资源。

### 5.5 标题和页面设置

- [x] 修正本地 Markdown 标题解析测试命名和断言。
- [x] File Tree / Page Tree 标题默认来自文件名。
- [x] 正文 H1 保留在正文，不参与标题推导。
- [x] 本地 Markdown 页头标题默认隐藏。
- [x] 页面设置新增“隐藏本地 Markdown 文件标题”的可切换 UI。
- [x] 设置刷新后持久生效。
- [x] 重命名标题时走文件重命名和 bundle 目录同步。

### 5.6 滚动边界

- [x] Workbench 根容器固定视口高度。
- [x] Sidebar / File Tree 和文档面板作为内部滚动容器，body 不承担工作台滚动。
- [x] resize handle wheel 不穿透到 body。
- [x] 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

- [x] `4-49` 中的用户可见症状均有对应 smoke。
- [x] 默认删除行为不级联删除真实附件。
- [x] 文件树真实删除不再导致正文链接刷新后消失。
- [x] 本地资源默认在主编辑区 tab 打开。
- [x] 本地 Markdown 标题来源和隐藏默认值经过用户确认并落地。
- [x] 相关 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` 相关单测通过。
- 2026-05-24 追加：`task479` 新增 Check 7，覆盖 Backspace 与手柄删除单个附件引用；删除后只改 Markdown 链接，不删除真实附件文件，相邻附件刷新后仍保持可点击附件块。
- 2026-05-24 追加：来源菜单的旧“云空间”入口按当前 local-first MVP 语义改为“我的空间”，默认回到受管 `my-space` 本地根；新增 `mnoteHome=1` 防止被最近普通本地目录自动重定向接管。`task441-local-folder-cloud-switch-smoke.js` 已按当前语义通过。
- 2026-05-24 追加：上传链路已改为显式 `uploadIntent` 合同。主编辑区上传发送并返回 `editor.markdown.attach`，写入 page resource directory；File Tree folder drop 发送并返回 `filetree.folder.drop`，写入 `targetRelativePath` 指向目录。`task479` 已断言响应中的 `uploadIntent`、`rootRelativePath`、`markdownRelativePath`、`ownerDocumentId`。
- 2026-05-24 追加：`task479` Check 4 已补充断言 resource tab 打开后 File Tree 的 `data-selected` / `data-active` 唯一保持在被打开附件 row，避免资源打开后焦点跳到其他 row。
- 2026-05-24 追加：`task479` Check 3 已补齐缺失真实附件后的点击行为断言。根因是 resource tab 已创建并标记 error 后仍返回 `false`，外层附件点击逻辑因此执行 `window.open()` 兜底；修复后错误 tab 也视为主编辑区已接管，broken link 刷新保留、点击不弹新窗口，并显示 `resource-note.md` 的 resource tab error state。
- 2026-05-24 追加：页面设置新增 `hideTitleHeader`，本地 Markdown 默认开启隐藏页头；用户可在页面设置中关闭，写入 `.mnote/page-options.json`，刷新后由 Page Aggregate 恢复。`task479` Check 6 已断言默认勾选、切换后页头立即显示、刷新后保持显示。
- 2026-05-24 追加：Reasonix 只读审计指出真实附件删除 watcher 只覆盖 Markdown / office / mindmap，PDF / 图片等资源删除不会广播；现已扩展本地 watcher 资源路径过滤并排除 `.mnote` 元数据目录，新增 `/api/local-folder/files/stat` 供编辑器附件增强逻辑刷新存在性状态。`task479` Check 3 已验证删除真实附件后不刷新页面即标记 `data-mnote-attachment-missing="true"`，且 Markdown 正文未被改写。
- 2026-05-24 追加：标题重命名 bundle 同步已有 `update_local_markdown_title()` → `rename_local_markdown_page()` / `rename_nested_bundle_markdown_page()` 路径和 `local_document_title_save_renames_nested_bundle_directory_and_markdown` 单测覆盖；本轮复跑通过。
- 2026-05-24 追加：滚动边界补齐显式工程证据：`.mnote-shell` 固定 `height: 100vh; overflow: hidden`，内部滚动容器使用 `overscroll-behavior: contain`，resize handle 增加 `wheel` 拦截和 `overscroll-behavior: none`。`task479` Check 5 继续验证 body scrollTop 不变化。
