diff --git a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md
index a8081209..edf6be57 100644
--- a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md
+++ b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md
@@ -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 / 持久化尚未实现;当前只落地默认隐藏。
diff --git a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md b/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md
new file mode 100644
index 00000000..2bfb843f
--- /dev/null
+++ b/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md
@@ -0,0 +1,186 @@
+# 4-48 [process] 本地文件夹 Markdown 资源生命周期合同 v1
+
+> 日期:2026-05-24
+>
+> Owner:Tree 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 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 刷新仍保留。
+- [ ] 覆盖 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` 相关单测通过。
diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs
index 91225e48..c28de799 100644
--- a/rust/crates/mnote-web/src/routes/gateway.rs
+++ b/rust/crates/mnote-web/src/routes/gateway.rs
@@ -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#"
+ {}"#,
+ 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#""#
}
@@ -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"));
diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs
index b50f64d9..72c72727 100644
--- a/rust/crates/mnote-web/src/ssr/pages/document.rs
+++ b/rust/crates/mnote-web/src/ssr/pages/document.rs
@@ -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()}
>
-