From 97953101a23c378cb20a23495cd98d68d7d577e1 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sun, 24 May 2026 01:49:51 +0800 Subject: [PATCH] fix local folder resource lifecycle --- ...ree-drag-upload-target-parent-folder-v1.md | 324 ++++++++- ...markdown-resource-lifecycle-contract-v1.md | 186 ++++++ rust/crates/mnote-web/src/routes/gateway.rs | 81 ++- .../src/routes/local_folder_source.rs | 13 +- .../src/routes/local_markdown_parser.rs | 100 +-- rust/crates/mnote-web/src/routes/web_shell.rs | 21 +- .../mnote-web/src/ssr/pages/document.rs | 15 +- rust/crates/mnote-web/src/ssr/pages/home.rs | 49 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 7 +- rust/spikes/leptos-tiptap-spike/Cargo.toml | 2 +- .../mnote-leptos-tiptap-spike-island.d.ts | 2 +- .../mnote-leptos-tiptap-spike-island.js | 30 +- .../mnote-leptos-tiptap-spike-island_bg.wasm | Bin 2075594 -> 2089334 bytes ...te-leptos-tiptap-spike-island_bg.wasm.d.ts | 2 +- rust/spikes/leptos-tiptap-spike/src/lib.rs | 93 +++ ...older-markdown-resource-lifecycle-smoke.js | 621 ++++++++++++++++++ 16 files changed, 1392 insertions(+), 154 deletions(-) create mode 100644 design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md create mode 100644 scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js 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()} > -
+