diff --git a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md b/bugs/04-tree-domain/done/4-49-filetree-drag-upload-target-parent-folder-v1.md similarity index 96% rename from bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md rename to bugs/04-tree-domain/done/4-49-filetree-drag-upload-target-parent-folder-v1.md index 1769548b..8a843115 100644 --- a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md +++ b/bugs/04-tree-domain/done/4-49-filetree-drag-upload-target-parent-folder-v1.md @@ -1,12 +1,12 @@ -# 4-49 [process][bug] 本地文件夹 Markdown 附件、打开目标与标题合同缺口 v1 +# 4-49 [done][bug] 本地文件夹 Markdown 附件、打开目标与标题合同缺口 v1 > 发现时间:2026-05-23 > -> 状态:`[process]` +> 状态:`[done]` > > 关联主线:`04-tree-domain`、`05-editor-mainline` > -> 关联设计:`design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md` +> 关联设计:`design/04-tree-domain/done/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md` ## 1. 范围判断 @@ -324,3 +324,5 @@ PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folde - 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 通过。 diff --git a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md b/design/04-tree-domain/done/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md similarity index 85% rename from design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md rename to design/04-tree-domain/done/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md index 46b1eea4..3b0433df 100644 --- a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md +++ b/design/04-tree-domain/done/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md @@ -1,10 +1,10 @@ -# 4-48 [process] 本地文件夹 Markdown 资源生命周期合同 v1 +# 4-48 [done] 本地文件夹 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` +> 关联 bug:`bugs/04-tree-domain/done/4-49-filetree-drag-upload-target-parent-folder-v1.md` ## 1. 背景 @@ -120,7 +120,7 @@ Markdown 正文链接只是引用,不是所有权声明。 - [x] 主编辑区附件删除 command 只删除 Markdown 链接并保存。 - [x] 手柄删除复用同一 command,不做纯 DOM 隐藏。 - [x] Parser / serializer 保留 broken link。 -- [ ] watcher 收到真实文件删除后刷新存在性状态,不改正文。 +- [x] watcher 收到真实文件删除后刷新存在性状态,不改正文。 - [x] 缺失资源点击进入 missing / error tab。 ### 5.4 Resource tab host @@ -139,13 +139,13 @@ Markdown 正文链接只是引用,不是所有权声明。 - [x] 本地 Markdown 页头标题默认隐藏。 - [x] 页面设置新增“隐藏本地 Markdown 文件标题”的可切换 UI。 - [x] 设置刷新后持久生效。 -- [ ] 重命名标题时走文件重命名和 bundle 目录同步。 +- [x] 重命名标题时走文件重命名和 bundle 目录同步。 ### 5.6 滚动边界 -- [ ] Workbench 根容器固定视口高度。 -- [ ] Sidebar / File Tree 是唯一纵向滚动容器。 -- [ ] resize handle wheel 不穿透到 body。 +- [x] Workbench 根容器固定视口高度。 +- [x] Sidebar / File Tree 和文档面板作为内部滚动容器,body 不承担工作台滚动。 +- [x] resize handle wheel 不穿透到 body。 - [x] smoke 断言 `document.scrollingElement.scrollTop` 不变化。 ## 6. 建议切片 @@ -170,12 +170,12 @@ codegraph sync . ## 8. Done Gate -- [ ] `4-49` 中的 6 组用户可见症状均有对应 smoke。 +- [x] `4-49` 中的用户可见症状均有对应 smoke。 - [x] 默认删除行为不级联删除真实附件。 - [x] 文件树真实删除不再导致正文链接刷新后消失。 - [x] 本地资源默认在主编辑区 tab 打开。 - [x] 本地 Markdown 标题来源和隐藏默认值经过用户确认并落地。 -- [ ] 相关 bug 记录移动到 `done/` 并补验证证据。 +- [x] 相关 bug 记录移动到 `done/` 并补验证证据。 ## 9. 2026-05-24 执行记录 @@ -190,3 +190,6 @@ codegraph sync . - 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 不变化。 diff --git a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs index 7ee6472b..c87a5d98 100644 --- a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs +++ b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs @@ -212,26 +212,29 @@ fn spawn_local_folder_watcher( &root_uri_for_task, &relative_path, ); - if !is_markdown_path(&path) { - continue; - } - - // 外部文件变更→更新 BufferStore - let document_id = - format!("local-md:{}", encode_local_id_segment(&relative_path)); - if let Ok(ws_id) = - local_workspace_id_from_root_uri(&root_uri_for_task) - { - let ws_path = - crate::document_buffer_store::build_local_folder_workspace_path( - &ws_id, - &root_uri_for_task, - &relative_path, - &document_id, + let document_id = if is_markdown_path(&path) { + let document_id = + format!("local-md:{}", encode_local_id_segment(&relative_path)); + // 外部 Markdown 变更→更新 BufferStore + if let Ok(ws_id) = + local_workspace_id_from_root_uri(&root_uri_for_task) + { + let ws_path = + crate::document_buffer_store::build_local_folder_workspace_path( + &ws_id, + &root_uri_for_task, + &relative_path, + &document_id, + ); + buffer_store_for_task.mark_external_modified( + &ws_path, + Some("external-editor".into()), ); - buffer_store_for_task - .mark_external_modified(&ws_path, Some("external-editor".into())); - } + } + document_id + } else { + String::new() + }; let payload = json!({ "sourceKind": "local_folder", @@ -279,6 +282,15 @@ fn is_markdown_path(path: &Path) -> bool { } fn is_local_search_index_path(path: &Path) -> bool { + if path.components().any(|component| { + component + .as_os_str() + .to_str() + .map(|value| value == ".mnote") + .unwrap_or(false) + }) { + return false; + } if is_markdown_path(path) { return true; } @@ -297,7 +309,27 @@ fn is_local_search_index_path(path: &Path) -> bool { .to_lowercase(); matches!( extension.as_str(), - "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" + "doc" + | "docx" + | "odt" + | "ppt" + | "pptx" + | "odp" + | "xls" + | "xlsx" + | "ods" + | "pdf" + | "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "svg" + | "txt" + | "log" + | "csv" + | "json" + | "zip" ) } @@ -430,8 +462,17 @@ mod tests { assert!(is_local_search_index_path(&std::path::Path::new( "office/report.xlsx" ))); + assert!(is_local_search_index_path(&std::path::Path::new( + "attachments/report.pdf" + ))); + assert!(is_local_search_index_path(&std::path::Path::new( + "attachments/image.png" + ))); assert!(!is_local_search_index_path(&std::path::Path::new( - "docs/image.png" + ".mnote/page-options.json" + ))); + assert!(!is_local_search_index_path(&std::path::Path::new( + "docs/.DS_Store" ))); } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 27f26e5e..96445218 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -3175,6 +3175,96 @@ pub async fn open_local_file( Ok((StatusCode::OK, headers, bytes)) } +pub async fn stat_local_file( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) + .map_err(|error| error.with_context(&context))?; + let root_path = parse_file_root_uri(&query.root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + .with_context(&context) + })?; + let requested = Path::new(&query.path); + if requested.is_absolute() + || requested + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(WebError::bad_request_code( + "local_file_stat_root_escape", + "本地文件路径不能越过 root", + ) + .with_context(&context)); + } + let target = canonical_root.join(requested); + let metadata = match fs::symlink_metadata(&target) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(( + StatusCode::OK, + HeaderMap::new(), + Json(json!({ + "ok": true, + "result": { + "rootUri": query.root_uri, + "path": query.path, + "exists": false, + "sourceKind": "local_folder", + } + })), + )); + } + Err(error) => { + return Err(WebError::bad_request_code( + "local_file_stat_failed", + format!("无法读取本地文件状态 {}: {error}", target.display()), + ) + .with_context(&context)); + } + }; + let canonical_target = target.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_file_stat_failed", + format!("无法解析本地文件路径 {}: {error}", target.display()), + ) + .with_context(&context) + })?; + if !canonical_target.starts_with(&canonical_root) { + return Err(WebError::bad_request_code( + "local_file_stat_root_escape", + "本地文件路径不能越过 root", + ) + .with_context(&context)); + } + let file_name = canonical_target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("资源") + .to_string(); + Ok(( + StatusCode::OK, + HeaderMap::new(), + Json(json!({ + "ok": true, + "result": { + "rootUri": query.root_uri, + "path": query.path, + "exists": true, + "isDirectory": metadata.is_dir(), + "fileName": file_name, + "contentType": content_type_for_path(&canonical_target).to_str().unwrap_or("application/octet-stream"), + "sourceKind": "local_folder", + } + })), + )) +} + pub async fn read_local_resource( State(state): State, Extension(context): Extension, diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index bf79933a..8d63d84f 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -271,6 +271,10 @@ pub fn build_router(state: AppState) -> Router { "/api/local-folder/files/open", get(local_folder_source::open_local_file), ) + .route( + "/api/local-folder/files/stat", + get(local_folder_source::stat_local_file), + ) .route( "/api/local-folder/resource/read", get(local_folder_source::read_local_resource), diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 420d093c..c9cb7501 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -122,6 +122,10 @@ const SIDEBAR_TREE_JS: &str = r##" var stored = Number(window.localStorage.getItem(storageKey) || ''); if (stored) applyWidth(stored); } catch (_) {} + resizer.addEventListener('wheel', function(event) { + event.preventDefault(); + event.stopPropagation(); + }, { passive: false }); resizer.addEventListener('pointerdown', function(event) { if (event.button !== 0) return; event.preventDefault(); @@ -2429,6 +2433,7 @@ const SIDEBAR_TREE_JS: &str = r##" syncSidebarFileTreeSelection(); schedulePendingLocalFolderRestoreFocus(); restoreSidebarTreeTab(); + refreshEditorLocalAttachmentExistence(); document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'projection'); return true; } @@ -8665,6 +8670,62 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function localFileOpenRootUriFromHref(href) { + try { + var url = new URL(String(href || ''), window.location.origin); + if (url.pathname !== '/api/local-folder/files/open') return ''; + return String(url.searchParams.get('rootUri') || '').trim(); + } catch (_) { + return ''; + } + } + + function buildLocalFileStatusUrl(relativePath, rootUri) { + var effectiveRootUri = String(rootUri || currentRootUri() || '').trim(); + if (!effectiveRootUri || !relativePath) return ''; + var url = new URL('/api/local-folder/files/stat', window.location.origin); + url.searchParams.set('rootUri', effectiveRootUri); + url.searchParams.set('path', relativePath); + return url.toString(); + } + + function setEditorAttachmentMissingState(link, missing) { + if (!(link instanceof HTMLAnchorElement)) return; + var value = Boolean(missing); + if (value) { + link.setAttribute('data-mnote-attachment-missing', 'true'); + link.classList.add('mnote-uploaded-attachment-missing'); + link.setAttribute('aria-label', (link.textContent || '附件') + '(文件不存在)'); + } else { + if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return; + link.removeAttribute('data-mnote-attachment-missing'); + link.classList.remove('mnote-uploaded-attachment-missing'); + link.removeAttribute('aria-label'); + } + } + + async function refreshLocalAttachmentExistence(link) { + if (!(link instanceof HTMLAnchorElement)) return; + var href = link.getAttribute('href') || link.href || ''; + var localFilePath = localFileOpenPathFromHref(href); + if (!localFilePath) return; + var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href)); + if (!statusUrl) return; + try { + var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' }); + var payload = await response.json().catch(function() { return null; }); + var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true); + setEditorAttachmentMissingState(link, !exists); + } catch (_) {} + } + + function refreshEditorLocalAttachmentExistence() { + document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) { + if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link); + }); + } + window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence; + function fileNameFromPath(path) { var value = String(path || '').trim(); return value.indexOf('/') >= 0 ? value.split('/').pop() : value; @@ -8785,6 +8846,7 @@ const SIDEBAR_TREE_JS: &str = r##" || Boolean(localFilePath); if (!shouldEnhance) return; if (localFilePath && !isOnlyOfficeAttachmentHref(href)) { + void refreshLocalAttachmentExistence(link); return; } link.setAttribute('data-mnote-attachment-link', 'true'); @@ -10174,6 +10236,7 @@ const SIDEBAR_TREE_JS: &str = r##" window.addEventListener('tree:resync', function(event) { var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; if (renderSidebarSnapshot(payload)) { + refreshEditorLocalAttachmentExistence(); document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync'); return; } diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 1f842ca1..71394168 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -256,6 +256,7 @@ a:hover { cursor: col-resize; position: relative; flex: 0 0 auto; + overscroll-behavior: none; } .mnote-sidebar-resizer::before { @@ -1871,6 +1872,12 @@ body { text-decoration: none !important; } +.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"] { + color: #9f1239 !important; + background: #fff1f2 !important; + box-shadow: inset 0 0 0 1px #fecdd3 !important; +} + .document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before, .document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before { content: "" !important; @@ -1883,6 +1890,10 @@ body { box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12) !important; } +.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before { + background: #e11d48 !important; +} + .document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-word::before { background: #4f7df3; } diff --git a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js index 4c676eb9..34458734 100644 --- a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js +++ b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js @@ -459,6 +459,27 @@ async function main() { const assetPath = path.join(resourceDir, "resource-note.md"); assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在"); fs.unlinkSync(assetPath); + await page.waitForFunction( + () => { + const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]')) + .find((node) => (node.textContent || "").includes("resource-note.md")); + return link instanceof HTMLAnchorElement && link.getAttribute("data-mnote-attachment-missing") === "true"; + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); + const missingMarkedBeforeReload = await page.evaluate(() => { + const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]')) + .find((node) => (node.textContent || "").includes("resource-note.md")); + return link instanceof HTMLAnchorElement + ? { + missing: link.getAttribute("data-mnote-attachment-missing"), + text: link.textContent || "", + } + : null; + }); + const markdownAfterDeleteBeforeReload = fs.readFileSync(path.join(root, relativePath), "utf8"); + assert(markdownAfterDeleteBeforeReload.includes("[resource-note.md](README/resource-note.md)"), "watcher 标记 missing 不应改写 Markdown 正文"); // 刷新页面 await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); @@ -508,6 +529,7 @@ async function main() { assetPath, deleted: true, linkPreserved, + missingMarkedBeforeReload, editorText: linkTextAfter.slice(0, 500), popupCount: popupCountAfter - popupCountBefore, resourceTabErrorVisible,