fix local folder resource lifecycle

This commit is contained in:
lix-2026
2026-05-24 01:49:51 +08:00
parent 5f97800489
commit 97953101a2
16 changed files with 1392 additions and 154 deletions
+71 -10
View File
@@ -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#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}"#,
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#"<script id="__MNOTE_PAGE_AGGREGATE__""#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -10201,12 +10201,12 @@ fn main() {}
})
.expect("page row");
// 有 H1 且无 frontmatter title 时,标题来自 H1
assert_eq!(row["title"].as_str(), Some("Body Heading"));
// 本地 Markdown 标题来自文件名,正文 H1 只作为正文内容
assert_eq!(row["title"].as_str(), Some("File Name"));
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md")
.expect("aggregate");
assert_eq!(aggregate.title, "Body Heading");
assert_eq!(aggregate.title, "File Name");
let _ = std::fs::remove_dir_all(&root);
}
@@ -11386,12 +11386,11 @@ fn main() {}
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("load page tree");
let items = snapshot.projection["items"].as_array().expect("items");
// 找到 Root 页面节点和 Child 页面节点。
// 标题优先级:H1"Body Root" / "Body Child")高于文件名。
// 找到 Root 页面节点和 Child 页面节点。标题来自文件名,不来自正文 H1。
let root_node = items
.iter()
.find(|item| {
item["title"].as_str() == Some("Body Root")
item["title"].as_str() == Some("Root")
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
})
.expect("Root page node");
@@ -11399,7 +11398,7 @@ fn main() {}
let child_node = items
.iter()
.find(|item| {
item["title"].as_str() == Some("Body Child")
item["title"].as_str() == Some("Child")
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
})
.expect("Child page node");
@@ -79,61 +79,17 @@ struct MarkdownInlineStyles {
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (frontmatter, body) = split_frontmatter(markdown);
let (_frontmatter, body) = split_frontmatter(markdown);
let body_owned = body.to_string();
// 标题优先级:frontmatter title > 正文第一条 H1 > 文件名
let title = parse_frontmatter_title(frontmatter.as_deref())
.or_else(|| find_first_h1(&body_owned))
.unwrap_or_else(|| file_stem_title(file_name));
// 本地 Markdown 的树标题和页头标题统一来自文件名
// frontmatter title 与正文 H1 都保留为正文/元数据内容,不作为资源标题真相。
let title = file_stem_title(file_name);
ParsedLocalMarkdownPage {
title,
body: body_owned,
}
}
/// 从 frontmatter 中提取简单 `title:` 字段。
fn parse_frontmatter_title(frontmatter: Option<&str>) -> Option<String> {
let text = frontmatter?;
for line in text.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("title:") {
let raw = value.trim();
if raw.is_empty() {
continue;
}
let unquoted = if (raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\''))
{
&raw[1..raw.len() - 1]
} else {
raw
};
let title = unquoted.trim().to_string();
if !title.is_empty() {
return Some(title);
}
}
}
None
}
/// 通过 AST 找正文第一条 H1,避免把代码块里的 `# ...` 误判为标题。
fn find_first_h1(body: &str) -> Option<String> {
let arena = Arena::new();
let root = parse_document(&arena, body, &markdown_options());
for node in root.descendants() {
let value = node.data.borrow();
if let NodeValue::Heading(heading) = &value.value {
if heading.level != 1 {
continue;
}
let title = collect_plain_text(node).trim().to_string();
return (!title.is_empty()).then_some(title);
}
}
None
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
}
@@ -920,65 +876,35 @@ mod tests {
assert!(!d2_inline.is_empty(), "非空数据单元格不应为空");
}
// 标题优先级:frontmatter title > H1 > 文件名
// 本地 Markdown 标题统一来自文件名,frontmatter title H1 都只保留为正文/元数据
#[test]
fn parse_markdown_title_uses_frontmatter_title() {
fn parse_markdown_title_uses_filename_over_frontmatter_and_h1() {
let parsed = parse_markdown_page(
"---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n",
"file.md",
"File Name.md",
);
assert_eq!(parsed.title, "Frontmatter Title");
assert_eq!(parsed.title, "File Name");
}
#[test]
fn parse_markdown_title_uses_first_h1_when_no_frontmatter_title() {
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "file.md");
assert_eq!(parsed.title, "H1 Title");
fn parse_markdown_title_uses_filename_when_h1_exists() {
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "File Name.md");
assert_eq!(parsed.title, "File Name");
}
#[test]
fn parse_markdown_title_falls_back_to_filename() {
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
let parsed = parse_markdown_page("plain text\nmore text\n", "My File.md");
assert_eq!(parsed.title, "My File");
}
#[test]
fn parse_markdown_title_prefers_frontmatter_over_h1() {
let parsed = parse_markdown_page("---\ntitle: FM Title\n---\n# H1 here\n", "file.md");
assert_eq!(parsed.title, "FM Title");
}
#[test]
fn parse_markdown_title_handles_quoted_double() {
let parsed = parse_markdown_page("---\ntitle: \"Quoted Double\"\n---\nbody\n", "file.md");
assert_eq!(parsed.title, "Quoted Double");
}
#[test]
fn parse_markdown_title_handles_quoted_single() {
let parsed = parse_markdown_page("---\ntitle: 'Single Quoted'\n---\nbody\n", "file.md");
assert_eq!(parsed.title, "Single Quoted");
}
#[test]
fn parse_markdown_title_uses_h1_when_frontmatter_has_no_title() {
let parsed = parse_markdown_page("---\nother: value\n---\n# From H1\n", "file.md");
assert_eq!(parsed.title, "From H1");
}
#[test]
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
fn parse_markdown_title_ignores_frontmatter_without_title() {
let parsed = parse_markdown_page("---\ntags: foo\n---\nplain text\n", "NoH1.md");
assert_eq!(parsed.title, "NoH1");
}
#[test]
fn parse_markdown_title_handles_atx_closing_markers() {
let parsed = parse_markdown_page("# Heading With Closing #\n", "file.md");
assert_eq!(parsed.title, "Heading With Closing");
}
#[test]
fn parse_markdown_title_ignores_hash_inside_code_block() {
let parsed = parse_markdown_page("```md\n# Not A Title\n```\nplain text\n", "Fallback.md");
+12 -9
View File
@@ -222,6 +222,8 @@ pub async fn document_page_shell(
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
primary_hide_title_header={is_local_folder}
secondary_hide_title_header={secondary_source_kind == Some("local_folder")}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
/>
});
@@ -653,7 +655,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID);
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes) || panesBootstrap.panes.length === 0) return;
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes)) return;
const paneRouteConfig = {
primary: {
@@ -803,7 +805,6 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const paneRuntimes = panesBootstrap.panes.map(buildPaneRuntime).filter(Boolean);
if (!paneRuntimes.length) return;
const loadRuntime = async () => {
if (window.__mnoteLeptosTiptapRuntimePromise) {
@@ -4298,9 +4299,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
});
}, { once: true });
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
console.error('mnote multi-pane editor mount failed', error);
});
if (paneRuntimes.length) {
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
console.error('mnote multi-pane editor mount failed', error);
});
}
})();
</script>"#
}
@@ -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"));