complete local markdown resource lifecycle contract

This commit is contained in:
lix-2026
2026-05-24 03:22:03 +08:00
parent 6df74fb4e0
commit 36eac664f2
8 changed files with 269 additions and 33 deletions
@@ -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 通过。
@@ -1,10 +1,10 @@
# 4-48 [process] 本地文件夹 Markdown 资源生命周期合同 v1
# 4-48 [done] 本地文件夹 Markdown 资源生命周期合同 v1
> 日期:2026-05-24
>
> OwnerTree 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 / mindmapPDF / 图片等资源删除不会广播;现已扩展本地 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 不变化。
@@ -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"
)));
}
@@ -3175,6 +3175,96 @@ pub async fn open_local_file(
Ok((StatusCode::OK, headers, bytes))
}
pub async fn stat_local_file(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFileOpenQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
+4
View File
@@ -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),
@@ -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;
}
+11
View File
@@ -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;
}
@@ -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,