Improve local filetree view state and sidebar performance
This commit is contained in:
@@ -174,6 +174,57 @@ impl BufferStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地文件 rename/move 后重绑打开的 Markdown buffer。
|
||||
pub fn rekey_local_folder_markdown(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
previous_relative_path: &str,
|
||||
previous_document_id: &str,
|
||||
next_relative_path: &str,
|
||||
next_document_id: &str,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let previous_path = build_local_folder_workspace_path(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
previous_relative_path,
|
||||
previous_document_id,
|
||||
);
|
||||
let next_path = build_local_folder_workspace_path(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
next_relative_path,
|
||||
next_document_id,
|
||||
);
|
||||
let previous_key = BufferKey::from_workspace_path(&previous_path);
|
||||
let next_key = BufferKey::from_workspace_path(&next_path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
let mut buffer = inner.buffers.remove(&previous_key)?;
|
||||
buffer.workspace_path = next_path;
|
||||
inner.buffers.insert(next_key, buffer.clone());
|
||||
Some(buffer)
|
||||
}
|
||||
|
||||
/// 本地文件 delete/archive/purge 后标记打开的 Markdown buffer 已删除。
|
||||
pub fn mark_local_folder_markdown_deleted(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
document_id: &str,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let path =
|
||||
build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id);
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get_mut(&key) {
|
||||
buf.mark_deleted();
|
||||
Some(buf.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
|
||||
pub fn init_buffer(
|
||||
&self,
|
||||
@@ -449,4 +500,70 @@ mod tests {
|
||||
assert_eq!(buf.file_version.as_deref(), Some("v2"));
|
||||
assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_rekeys_after_local_file_operation_rename() {
|
||||
let store = BufferStore::new();
|
||||
let root_uri = "file:///tmp/mnote-buffer-rekey";
|
||||
let old_path = build_local_folder_workspace_path(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Old.md",
|
||||
"local-md:docs~2FOld.md",
|
||||
);
|
||||
store.init_buffer(&old_path, Some("v1".into()), Some("sha256:old".into()));
|
||||
|
||||
let rekeyed = store
|
||||
.rekey_local_folder_markdown(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Old.md",
|
||||
"local-md:docs~2FOld.md",
|
||||
"docs/New.md",
|
||||
"local-md:docs~2FNew.md",
|
||||
)
|
||||
.expect("buffer should be rekeyed");
|
||||
|
||||
assert_eq!(rekeyed.workspace_path.relative_path, "docs/New.md");
|
||||
assert_eq!(
|
||||
rekeyed
|
||||
.workspace_path
|
||||
.object_identity
|
||||
.document_id
|
||||
.as_deref(),
|
||||
Some("local-md:docs~2FNew.md")
|
||||
);
|
||||
assert!(store.get_by_path(&old_path).is_none());
|
||||
let next_path = build_local_folder_workspace_path(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/New.md",
|
||||
"local-md:docs~2FNew.md",
|
||||
);
|
||||
assert!(store.get_by_path(&next_path).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_marks_deleted_after_local_file_operation_archive() {
|
||||
let store = BufferStore::new();
|
||||
let root_uri = "file:///tmp/mnote-buffer-delete";
|
||||
let path = build_local_folder_workspace_path(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Delete.md",
|
||||
"local-md:docs~2FDelete.md",
|
||||
);
|
||||
store.get_or_create(&path);
|
||||
|
||||
let deleted = store
|
||||
.mark_local_folder_markdown_deleted(
|
||||
"local:test",
|
||||
root_uri,
|
||||
"docs/Delete.md",
|
||||
"local-md:docs~2FDelete.md",
|
||||
)
|
||||
.expect("buffer should be marked deleted");
|
||||
|
||||
assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,12 @@ pub async fn update_options(
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let (wired_options, ignored_options, warnings) = filter_wired_page_options(options);
|
||||
let (wired_options, ignored_options, warnings) =
|
||||
if input.effective_source_kind().as_deref() == Some("local_folder") {
|
||||
filter_local_ui_preference_options(options)
|
||||
} else {
|
||||
filter_wired_page_options(options)
|
||||
};
|
||||
page_command(
|
||||
state,
|
||||
context,
|
||||
@@ -235,7 +240,16 @@ async fn page_command(
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::update_local_page_options(&root_uri, &document_id, &options)?
|
||||
crate::routes::ui_preferences::update_page_preferences_from_value(
|
||||
state,
|
||||
context,
|
||||
context.auth.actor_id.trim(),
|
||||
workspace_id.as_deref().unwrap_or_default(),
|
||||
"local_folder",
|
||||
&root_uri,
|
||||
&document_id,
|
||||
&options,
|
||||
)?
|
||||
}
|
||||
_ => {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -443,6 +457,41 @@ fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>)
|
||||
(Value::Object(out), ignored, warnings)
|
||||
}
|
||||
|
||||
fn filter_local_ui_preference_options(options: Value) -> (Value, Vec<String>, Vec<Value>) {
|
||||
let allowed = [
|
||||
"wideLayout",
|
||||
"smallText",
|
||||
"layoutDensity",
|
||||
"pageFont",
|
||||
"showHeadingNumbers",
|
||||
"showToc",
|
||||
"showStructure",
|
||||
"showWordCount",
|
||||
"collapseBacklinks",
|
||||
"hideChildPages",
|
||||
"showBlockRefCount",
|
||||
"hideTitleHeader",
|
||||
];
|
||||
let mut out = serde_json::Map::new();
|
||||
let mut ignored = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
if let Value::Object(map) = options {
|
||||
for (key, value) in map {
|
||||
if allowed.contains(&key.as_str()) {
|
||||
out.insert(key, value);
|
||||
} else {
|
||||
warnings.push(json!({
|
||||
"code": "page_option_not_wired",
|
||||
"field": key.clone(),
|
||||
"message": "页面设置字段尚未接入 SQLite UI 偏好,已忽略"
|
||||
}));
|
||||
ignored.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Object(out), ignored, warnings)
|
||||
}
|
||||
|
||||
fn summarize_blocks(content: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
collect_blocks(content, &mut out);
|
||||
|
||||
@@ -6,8 +6,7 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, update_local_markdown_title, update_local_page_options,
|
||||
write_local_markdown_page_body,
|
||||
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
@@ -66,7 +65,15 @@ fn find_document_buffer_state(
|
||||
relative_path,
|
||||
document_id,
|
||||
);
|
||||
return buffer_store.get_by_path(&ws_path);
|
||||
if let Some(buffer) = buffer_store.get_by_path(&ws_path) {
|
||||
return Some(buffer);
|
||||
}
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.root_uri == root_uri
|
||||
&& buf.workspace_path.relative_path == relative_path
|
||||
&& buf.workspace_path.object_identity.document_id.as_deref()
|
||||
== Some(document_id)
|
||||
});
|
||||
}
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.root_uri == root_uri
|
||||
@@ -1022,7 +1029,16 @@ pub async fn options(
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = update_local_page_options(root_uri, document_id, &body.options)?;
|
||||
let result = crate::routes::ui_preferences::update_page_preferences_from_value(
|
||||
&state,
|
||||
&context,
|
||||
context.auth.actor_id.trim(),
|
||||
body.workspace_id.as_deref().unwrap_or_default(),
|
||||
body.source_kind.as_deref().unwrap_or("local_folder"),
|
||||
root_uri,
|
||||
document_id,
|
||||
&body.options,
|
||||
)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
@@ -1486,7 +1502,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_documents_save_title_and_options_write_to_disk() {
|
||||
async fn local_folder_documents_save_title_and_options_store_ui_preferences_in_sqlite() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-documents-write-{}",
|
||||
std::process::id()
|
||||
@@ -1615,20 +1631,9 @@ mod tests {
|
||||
assert!(markdown.contains("## Saved Heading"));
|
||||
assert!(markdown.contains("Saved body"));
|
||||
|
||||
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
|
||||
.expect("read page options");
|
||||
let options_json: Value = serde_json::from_str(&options).expect("options json");
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["wideLayout"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["showToc"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
options_json["pages"][&renamed_document_id]["hideTitleHeader"],
|
||||
false
|
||||
assert!(
|
||||
!root.join(".mnote").join("page-options.json").exists(),
|
||||
"local folder UI 偏好不应继续写入 .mnote/page-options.json"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -8,11 +8,12 @@ use crate::routes::local_folder_source::{
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
|
||||
load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
attach_sidebar_shortcuts_to_dataset, build_document_panes_bootstrap_json,
|
||||
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
|
||||
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
render_document_title_controller_script, render_editor_island_adapter_script,
|
||||
render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
render_editor_runtime_preload_links, render_local_file_tree_html,
|
||||
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
|
||||
};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::workspace_shell::{
|
||||
@@ -67,6 +68,7 @@ pub(crate) struct RootEntryQuery {
|
||||
workspace_id: Option<String>,
|
||||
source_kind: Option<String>,
|
||||
root_uri: Option<String>,
|
||||
file_tree_scope: Option<String>,
|
||||
tree_view: Option<String>,
|
||||
restore_focus_row_id: Option<String>,
|
||||
}
|
||||
@@ -300,6 +302,11 @@ pub async fn root_entry(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == "filetree");
|
||||
let file_tree_scope = query
|
||||
.file_tree_scope
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let (
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
@@ -318,15 +325,8 @@ pub async fn root_entry(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
||||
let snapshot = if requests_filetree_first {
|
||||
crate::routes::local_folder_source::load_local_folder_file_tree_snapshot_with_reveal(
|
||||
root_uri,
|
||||
requested_page_id.as_deref(),
|
||||
)?
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
let workspace_id = snapshot
|
||||
let page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let workspace_id = page_tree_snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("id"))
|
||||
@@ -337,8 +337,15 @@ pub async fn root_entry(
|
||||
.to_string();
|
||||
let requested_or_recent_page_id =
|
||||
choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
|
||||
let mut workspace_dataset = page_tree_snapshot.dataset.clone();
|
||||
attach_sidebar_shortcuts_to_dataset(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&snapshot.dataset,
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
"本地文件夹",
|
||||
@@ -352,20 +359,18 @@ pub async fn root_entry(
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html = if requests_filetree_first {
|
||||
String::new()
|
||||
} else {
|
||||
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?
|
||||
};
|
||||
let sidebar_tree_html =
|
||||
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
|
||||
let restore_focus_row_id = query
|
||||
.restore_focus_row_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let file_tree_html = render_local_file_tree_html(
|
||||
let file_tree_html = render_local_file_tree_html_scoped(
|
||||
root_uri,
|
||||
selected_active_page_id.as_deref(),
|
||||
restore_focus_row_id,
|
||||
file_tree_scope,
|
||||
)?;
|
||||
(
|
||||
workspace_id,
|
||||
@@ -399,8 +404,15 @@ pub async fn root_entry(
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("local-folder")
|
||||
.to_string();
|
||||
let mut workspace_dataset = snapshot.dataset.clone();
|
||||
attach_sidebar_shortcuts_to_dataset(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&snapshot.dataset,
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_page_id.as_deref(),
|
||||
&default_workspace_name,
|
||||
@@ -437,6 +449,7 @@ pub async fn root_entry(
|
||||
None,
|
||||
);
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
@@ -489,6 +502,7 @@ pub async fn root_entry(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
file_tree_scope,
|
||||
);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let active_page_id = selected_active_page_id.unwrap_or_default();
|
||||
@@ -593,12 +607,19 @@ pub async fn root_entry(
|
||||
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
|
||||
}
|
||||
};
|
||||
let editor_runtime_preload_links =
|
||||
if body_extra.contains("document-editor-adapter-runtime.js") {
|
||||
render_editor_runtime_preload_links()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{}</title>
|
||||
{}
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
|
||||
@@ -607,6 +628,7 @@ pub async fn root_entry(
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&html_title),
|
||||
editor_runtime_preload_links,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
|
||||
@@ -654,21 +676,25 @@ pub async fn trash_entry(
|
||||
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
|
||||
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
let mut workspace_dataset = json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
});
|
||||
attach_sidebar_shortcuts_to_dataset(
|
||||
&state,
|
||||
&context,
|
||||
&workspace_id,
|
||||
None,
|
||||
"我的空间",
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection =
|
||||
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let trash_workbench_html = render_local_trash_workbench_html(
|
||||
&workspace_id,
|
||||
@@ -714,6 +740,7 @@ pub async fn trash_entry(
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
@@ -732,6 +759,7 @@ pub async fn trash_entry(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id)
|
||||
.await
|
||||
@@ -2779,6 +2807,9 @@ mod tests {
|
||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains("README.md"));
|
||||
assert!(html.contains(r#"id="sidebar-tree-root""#));
|
||||
assert!(html.contains(r#"data-shell-mode="page""#));
|
||||
assert!(html.contains(r#"data-node-id="local-md:README.md""#));
|
||||
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
|
||||
assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#));
|
||||
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
|
||||
@@ -2832,6 +2863,12 @@ mod tests {
|
||||
assert!(html.contains(
|
||||
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
));
|
||||
assert!(
|
||||
include_str!("../../browser/document-resource-tab-runtime.js")
|
||||
.contains("openResourceInActiveTab")
|
||||
|
||||
@@ -2966,6 +2966,89 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-page-options-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.update_options",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_page_options_local",
|
||||
"runId": "run_page_options_local",
|
||||
"toolCallId": "call_page_options_local",
|
||||
"traceId": "trace_page_options_local",
|
||||
"idempotencyKey": "idem_page_options_local",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"options": {
|
||||
"wideLayout": true,
|
||||
"showHeadingNumbers": true,
|
||||
"hideTitleHeader": false
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
assert_eq!(
|
||||
payload["result"]["commandName"],
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["result"]["pageOptions"]["wideLayout"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["result"]["pageOptions"]["showHeadingNumbers"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["result"]["pageOptions"]["hideTitleHeader"],
|
||||
false
|
||||
);
|
||||
assert!(
|
||||
!root.join(".mnote").join("page-options.json").exists(),
|
||||
"AI 页面设置工具不应继续写入 .mnote/page-options.json"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
|
||||
let response = app()
|
||||
|
||||
@@ -19,6 +19,10 @@ use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelProjectionQuery {
|
||||
@@ -89,31 +93,39 @@ async fn project_projection(
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let snapshot = if projection == KernelProjectionKind::FileTree {
|
||||
if let Some(parent_relative_path) = query
|
||||
.parent_relative_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
load_local_folder_file_tree_children_snapshot(root_uri, parent_relative_path)?
|
||||
} else if query
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
load_local_folder_file_tree_snapshot_with_reveal(
|
||||
root_uri,
|
||||
query.root_node_id.as_deref(),
|
||||
)?
|
||||
let root_uri = root_uri.to_string();
|
||||
let parent_relative_path = query
|
||||
.parent_relative_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let root_node_id = query
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
block_local_folder_projection_for_test();
|
||||
if projection == KernelProjectionKind::FileTree {
|
||||
if let Some(parent_relative_path) = parent_relative_path.as_deref() {
|
||||
load_local_folder_file_tree_children_snapshot(&root_uri, parent_relative_path)
|
||||
} else if root_node_id.is_some() {
|
||||
load_local_folder_file_tree_snapshot_with_reveal(
|
||||
&root_uri,
|
||||
root_node_id.as_deref(),
|
||||
)
|
||||
} else {
|
||||
load_local_folder_file_tree_snapshot(&root_uri)
|
||||
}
|
||||
} else {
|
||||
load_local_folder_file_tree_snapshot(root_uri)?
|
||||
load_local_folder_page_tree_snapshot(&root_uri)
|
||||
}
|
||||
} else {
|
||||
load_local_folder_page_tree_snapshot(root_uri)?
|
||||
};
|
||||
})
|
||||
.await
|
||||
.map_err(|error| WebError::internal(format!("本地树 projection 构建任务失败: {error}")))??;
|
||||
return Ok(ok_response(&context, snapshot.projection));
|
||||
}
|
||||
|
||||
@@ -137,6 +149,14 @@ async fn project_projection(
|
||||
Ok(ok_response(&context, snapshot.projection))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn block_local_folder_projection_for_test() {
|
||||
let delay_ms = LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn project_tree_sidebar(
|
||||
state: State<AppState>,
|
||||
context: Extension<RequestContext>,
|
||||
@@ -245,6 +265,7 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -443,6 +464,70 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_file_projection_does_not_block_leptos_runtime_asset_request() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-kernel-projection-runtime-asset-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
|
||||
let app = app();
|
||||
let projection_app = app.clone();
|
||||
let projection_uri = format!(
|
||||
"/api/tree/projections/file/children?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs"
|
||||
);
|
||||
super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS
|
||||
.store(400, std::sync::atomic::Ordering::SeqCst);
|
||||
struct ResetLocalProjectionBlock;
|
||||
impl Drop for ResetLocalProjectionBlock {
|
||||
fn drop(&mut self) {
|
||||
super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _reset_block = ResetLocalProjectionBlock;
|
||||
let projection_task = tokio::spawn(async move {
|
||||
projection_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(projection_uri)
|
||||
.header("x-mnote-actor-id", "dev-user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("projection request"),
|
||||
)
|
||||
.await
|
||||
.expect("projection response")
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
tokio::task::yield_now().await;
|
||||
let asset_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("asset request"),
|
||||
)
|
||||
.await
|
||||
.expect("asset response");
|
||||
let asset_elapsed = started.elapsed();
|
||||
let projection_response = projection_task.await.expect("projection task");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(asset_response.status(), StatusCode::OK);
|
||||
assert_eq!(projection_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
asset_elapsed < Duration::from_millis(150),
|
||||
"leptos-tiptap runtime asset 不应被本地 FileTree projection 扫描阻塞,实际等待 {asset_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_keep_ok_response_shape() {
|
||||
let response = app()
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::{interval, MissedTickBehavior};
|
||||
use tokio::time::{interval, timeout, MissedTickBehavior};
|
||||
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
@@ -177,18 +177,38 @@ async fn build_tree_live_stream(
|
||||
|
||||
loop {
|
||||
match subscription.receiver.recv().await {
|
||||
Ok(_watcher_payload) => {
|
||||
// Rebuild full snapshot on any filesystem change
|
||||
if let Some(resync_payload) =
|
||||
rebuild_tree_resync_payload(&root_uri, &workspace_id)
|
||||
{
|
||||
Ok(watcher_payload) => {
|
||||
let mut watcher_payloads = vec![watcher_payload];
|
||||
loop {
|
||||
match timeout(Duration::from_millis(120), subscription.receiver.recv())
|
||||
.await
|
||||
{
|
||||
Ok(Ok(next_payload)) => watcher_payloads.push(next_payload),
|
||||
Ok(Err(RecvError::Lagged(_))) => continue,
|
||||
Ok(Err(RecvError::Closed)) => return None,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Some(batch_payload) = build_local_folder_watch_batch_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
watcher_payloads,
|
||||
) {
|
||||
return Some((
|
||||
Ok(stream_event("resync", &resync_payload)),
|
||||
Ok(stream_event("watch_batch", &batch_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
));
|
||||
}
|
||||
// Snapshot load failed — continue waiting for next change
|
||||
continue;
|
||||
let error_payload = build_tree_live_error_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
"tree_live_watch_batch_failed",
|
||||
"local folder watcher batch payload missing paths",
|
||||
);
|
||||
return Some((
|
||||
Ok(stream_event("tree_error", &error_payload)),
|
||||
(None, subscription, root_uri, workspace_id),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
@@ -276,6 +296,96 @@ fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Val
|
||||
))
|
||||
}
|
||||
|
||||
fn parent_relative_path_for_watch_path(relative_path: &str) -> String {
|
||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||
if normalized.is_empty() || normalized == "." {
|
||||
return String::new();
|
||||
}
|
||||
normalized
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_local_folder_watch_batch_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
watcher_payloads: Vec<Value>,
|
||||
) -> Option<Value> {
|
||||
let revision = local_folder_watch_revision(root_uri).ok()?;
|
||||
let mut changed_paths = Vec::new();
|
||||
let mut affected_parents = Vec::new();
|
||||
let mut event_kinds = Vec::new();
|
||||
let mut seen_paths = std::collections::BTreeSet::new();
|
||||
let mut seen_parents = std::collections::BTreeSet::new();
|
||||
let mut seen_kinds = std::collections::BTreeSet::new();
|
||||
for payload in watcher_payloads {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let event_kind = payload
|
||||
.get("eventKind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown");
|
||||
if seen_paths.insert(relative_path.to_string()) {
|
||||
changed_paths.push(json!({
|
||||
"relativePath": relative_path,
|
||||
"kind": event_kind,
|
||||
}));
|
||||
}
|
||||
if seen_kinds.insert(event_kind.to_string()) {
|
||||
event_kinds.push(event_kind.to_string());
|
||||
}
|
||||
let parent = parent_relative_path_for_watch_path(relative_path);
|
||||
if seen_parents.insert(parent.clone()) {
|
||||
affected_parents.push(json!({
|
||||
"relativePath": parent,
|
||||
"reason": "child-watch",
|
||||
}));
|
||||
}
|
||||
}
|
||||
if changed_paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"schema": "mnote.local_folder_watch_batch.v1",
|
||||
"kind": "watch_batch",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"revision": revision.revision,
|
||||
"watchRevision": revision,
|
||||
"changedPaths": changed_paths,
|
||||
"affectedParents": affected_parents,
|
||||
"eventKinds": event_kinds,
|
||||
"fallbackResync": false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_tree_live_error_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
code: &str,
|
||||
message: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"schema": "mnote.tree_live_error.v1",
|
||||
"kind": "error",
|
||||
"phase": "tree_live_resync",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"code": code,
|
||||
"message": message,
|
||||
"fallbackResync": true,
|
||||
"revision": system_time_ms(SystemTime::now()).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_tree_snapshot_payload(
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
@@ -548,4 +658,66 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() {
|
||||
let root = test_root("tree-live-watch-batch");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs/README.md"), "# Initial\n").expect("write initial");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id =
|
||||
local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id");
|
||||
|
||||
let payload = build_local_folder_watch_batch_payload(
|
||||
&root_uri,
|
||||
&workspace_id,
|
||||
vec![
|
||||
json!({
|
||||
"relativePath": "docs/README.md",
|
||||
"eventKind": "Modify(Data)",
|
||||
}),
|
||||
json!({
|
||||
"relativePath": "docs/New.md",
|
||||
"eventKind": "Create(File)",
|
||||
}),
|
||||
],
|
||||
)
|
||||
.expect("watch batch payload");
|
||||
|
||||
assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1");
|
||||
assert_eq!(payload["kind"], "watch_batch");
|
||||
assert_eq!(payload["fallbackResync"], false);
|
||||
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
|
||||
assert!(
|
||||
payload["affectedParents"]
|
||||
.as_array()
|
||||
.expect("affected parents")
|
||||
.iter()
|
||||
.any(|parent| parent["relativePath"].as_str() == Some("docs")
|
||||
&& parent["reason"].as_str() == Some("child-watch")),
|
||||
"watch batch 应声明 docs affected parent: {payload}"
|
||||
);
|
||||
assert!(payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_live_error_payload_is_structured() {
|
||||
let payload = build_tree_live_error_payload(
|
||||
"file:///test",
|
||||
"local:test",
|
||||
"tree_live_resync_failed",
|
||||
"failed",
|
||||
);
|
||||
|
||||
assert_eq!(payload["schema"], "mnote.tree_live_error.v1");
|
||||
assert_eq!(payload["phase"], "tree_live_resync");
|
||||
assert_eq!(payload["fallbackResync"], true);
|
||||
assert_eq!(payload["code"], "tree_live_resync_failed");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ pub(crate) fn query_local_search_index(
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let normalized_query = normalize_search_text(query);
|
||||
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
|
||||
@@ -294,6 +294,23 @@ fn rebuild_local_search_index(
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn load_or_rebuild_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<LocalSearchIndex, WebError> {
|
||||
match read_local_search_index(root_path) {
|
||||
Ok(Some(index))
|
||||
if index.version == LOCAL_SEARCH_INDEX_VERSION
|
||||
&& index.root_uri == root_uri
|
||||
&& index.workspace_id == workspace_id =>
|
||||
{
|
||||
Ok(index)
|
||||
}
|
||||
Ok(_) | Err(_) => rebuild_local_search_index(root_path, root_uri, workspace_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
|
||||
let index_path = root_path
|
||||
.join(".mnote")
|
||||
@@ -999,4 +1016,74 @@ mod tests {
|
||||
.any(|document| document.path == "README.md"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_query_reads_existing_index_without_rebuilding() {
|
||||
let root = temp_root("mnote-local-search-query-cache");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id = "local-ws-query-cache";
|
||||
let child_path = root.join("docs").join("child.md");
|
||||
fs::write(
|
||||
&child_path,
|
||||
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
|
||||
)
|
||||
.expect("write child");
|
||||
|
||||
let first_projection = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OriginalToken",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("first query");
|
||||
assert_eq!(
|
||||
first_projection["results"].as_array().map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
fs::write(
|
||||
&child_path,
|
||||
"---\ntitle: Child\n---\n# Child\nUnindexedToken body.\n",
|
||||
)
|
||||
.expect("update child without refresh");
|
||||
|
||||
let stale_projection = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"UnindexedToken",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query existing index");
|
||||
assert_eq!(
|
||||
stale_projection["results"].as_array().map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
|
||||
.expect("incremental refresh");
|
||||
let refreshed_projection = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"UnindexedToken",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query refreshed index");
|
||||
assert_eq!(
|
||||
refreshed_projection["results"].as_array().map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn mindmap_object_shell(
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(root_uri, Some(&doc_id), None).unwrap_or_default();
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id.as_deref().unwrap_or("local-folder"),
|
||||
@@ -74,6 +75,7 @@ pub async fn mindmap_object_shell(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
@@ -82,6 +84,7 @@ pub async fn mindmap_object_shell(
|
||||
)
|
||||
} else if let Some(workspace_id) = workspace_id.as_deref() {
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
@@ -109,6 +112,7 @@ pub async fn mindmap_object_shell(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
@@ -268,7 +272,7 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
}
|
||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
|
||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||
const manifest = await manifestResponse.json();
|
||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||
|
||||
@@ -23,16 +23,18 @@ mod query_support;
|
||||
mod resource_trash;
|
||||
mod search;
|
||||
mod session;
|
||||
pub(crate) mod sidebar_shortcuts;
|
||||
mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
pub(crate) mod ui_preferences;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, update_local_page_options, write_local_markdown_page_body,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||
|
||||
@@ -82,6 +84,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/page-aggregate/{document_id}",
|
||||
get(web_shell::page_aggregate),
|
||||
)
|
||||
.route(
|
||||
"/api/ui/preferences/effective",
|
||||
get(ui_preferences::effective_preferences),
|
||||
)
|
||||
.route(
|
||||
"/api/ui/preferences",
|
||||
put(ui_preferences::update_preferences),
|
||||
)
|
||||
.route(
|
||||
"/api/leptos-tiptap-runtime/manifest.json",
|
||||
get(web_shell::leptos_tiptap_manifest),
|
||||
@@ -263,6 +273,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/whoami", get(session::session))
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route(
|
||||
"/api/sidebar/shortcuts",
|
||||
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
||||
)
|
||||
.route(
|
||||
"/api/sidebar/shortcuts/{shortcut_id}",
|
||||
delete(sidebar_shortcuts::delete_shortcut),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy",
|
||||
get(local_folder_source::get_local_access_policy),
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SidebarShortcutListQuery {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SidebarShortcutUpsertRequest {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "target_id")]
|
||||
target_id: String,
|
||||
#[serde(default, alias = "relative_path")]
|
||||
relative_path: Option<String>,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default, alias = "sort_order")]
|
||||
sort_order: Option<i64>,
|
||||
#[serde(default, alias = "metadata_json")]
|
||||
metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_shortcuts(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<SidebarShortcutListQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let workspace_id = query.workspace_id.trim();
|
||||
if workspace_id.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"sidebar_shortcut_workspace_required",
|
||||
"缺少 workspaceId",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let shortcuts = state
|
||||
.control_plane()
|
||||
.list_sidebar_shortcuts(&actor_id, workspace_id)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("读取星标置顶失败: {error}")).with_context(&context)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"workspaceId": workspace_id,
|
||||
"shortcuts": shortcuts.iter().map(shortcut_to_json).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upsert_shortcut(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<SidebarShortcutUpsertRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let workspace_id = request.workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"sidebar_shortcut_workspace_required",
|
||||
"缺少 workspaceId",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let kind = request.kind.trim().to_string();
|
||||
let target_id = request.target_id.trim().to_string();
|
||||
let title = request.title.trim().to_string();
|
||||
let root_uri = request
|
||||
.root_uri
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let metadata_json =
|
||||
normalize_shortcut_metadata_json(request.metadata_json, root_uri.as_deref())?;
|
||||
let shortcut = state
|
||||
.control_plane()
|
||||
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
|
||||
id: request.id,
|
||||
user_id: actor_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
root_uri,
|
||||
kind,
|
||||
source_kind: request
|
||||
.source_kind
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_else(|| "local_folder".to_string()),
|
||||
target_id,
|
||||
relative_path: request
|
||||
.relative_path
|
||||
.map(|value| value.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
document_id: request
|
||||
.document_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
title,
|
||||
icon: request
|
||||
.icon
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
sort_order: request.sort_order.unwrap_or(0),
|
||||
metadata_json,
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"sidebar_shortcut_upsert_failed",
|
||||
format!("写入星标置顶失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
append_shortcut_audit(&state, &actor_id, "sidebar.shortcut.upserted", &shortcut);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"shortcut": shortcut_to_json(&shortcut),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn delete_shortcut(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(shortcut_id): Path<String>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
state
|
||||
.control_plane()
|
||||
.delete_sidebar_shortcut(&actor_id, &shortcut_id)
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"sidebar_shortcut_delete_failed",
|
||||
format!("移除星标置顶失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(actor_id),
|
||||
action: "sidebar.shortcut.removed".to_string(),
|
||||
target_kind: "sidebar_shortcut".to_string(),
|
||||
target_id: Some(shortcut_id.clone()),
|
||||
metadata_json: "{}".to_string(),
|
||||
});
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"removedShortcutId": shortcut_id,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn load_sidebar_shortcut_dataset(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Vec<Value> {
|
||||
let Some(actor_id) = current_actor_id(state, context) else {
|
||||
return Vec::new();
|
||||
};
|
||||
state
|
||||
.control_plane()
|
||||
.list_sidebar_shortcuts_with_global_local(&actor_id, workspace_id)
|
||||
.map(|shortcuts| shortcuts.iter().map(shortcut_to_json).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
||||
current_actor_id(state, context)
|
||||
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"sidebar_shortcut_auth_required",
|
||||
"星标置顶需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn shortcut_to_json(shortcut: &SidebarShortcutRecord) -> Value {
|
||||
json!({
|
||||
"id": shortcut.id,
|
||||
"userId": shortcut.user_id,
|
||||
"workspaceId": shortcut.workspace_id,
|
||||
"rootUri": shortcut.root_uri,
|
||||
"kind": shortcut.kind,
|
||||
"sourceKind": shortcut.source_kind,
|
||||
"targetId": shortcut.target_id,
|
||||
"relativePath": shortcut.relative_path,
|
||||
"documentId": shortcut.document_id,
|
||||
"title": shortcut.title,
|
||||
"icon": shortcut.icon,
|
||||
"sortOrder": shortcut.sort_order,
|
||||
"status": shortcut.status,
|
||||
"metadata": serde_json::from_str::<Value>(&shortcut.metadata_json).unwrap_or(Value::Null),
|
||||
"createdAt": shortcut.created_at,
|
||||
"updatedAt": shortcut.updated_at,
|
||||
"revision": shortcut.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_shortcut_metadata_json(
|
||||
metadata_json: Option<String>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let mut metadata = metadata_json
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| serde_json::from_str::<Value>(value))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"sidebar_shortcut_metadata_invalid",
|
||||
format!("星标置顶 metadataJson 必须是 JSON 对象: {error}"),
|
||||
)
|
||||
})?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
if !metadata.is_object() {
|
||||
metadata = json!({});
|
||||
}
|
||||
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.insert("rootUri".to_string(), Value::String(root_uri.to_string()));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&metadata)
|
||||
.map_err(|error| WebError::internal(format!("星标置顶 metadataJson 序列化失败: {error}")))
|
||||
}
|
||||
|
||||
fn append_shortcut_audit(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
action: &str,
|
||||
shortcut: &SidebarShortcutRecord,
|
||||
) {
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(actor_id.to_string()),
|
||||
action: action.to_string(),
|
||||
target_kind: "sidebar_shortcut".to_string(),
|
||||
target_id: Some(shortcut.id.clone()),
|
||||
metadata_json: json!({
|
||||
"workspaceId": shortcut.workspace_id,
|
||||
"kind": shortcut.kind,
|
||||
"targetId": shortcut.target_id,
|
||||
})
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.trim().is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ pub struct TreeCommandEnvelope {
|
||||
pub target_resource_meta: Option<Value>,
|
||||
pub selection: Option<Value>,
|
||||
pub operation: Option<String>,
|
||||
pub batch_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub target_parent_id: Option<String>,
|
||||
@@ -114,6 +115,7 @@ pub struct TreeCommandEnvelopeContext {
|
||||
pub target_resource_meta: Option<Value>,
|
||||
pub selection: Option<Value>,
|
||||
pub operation: Option<String>,
|
||||
pub batch_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TreeCommandEnvelopeContext {
|
||||
@@ -130,6 +132,7 @@ impl TreeCommandEnvelopeContext {
|
||||
target_resource_meta: envelope.target_resource_meta.clone(),
|
||||
selection: envelope.selection.clone(),
|
||||
operation: read_optional_non_empty(envelope.operation.clone()),
|
||||
batch_id: read_optional_non_empty(envelope.batch_id.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,6 +528,25 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let relative_path = item
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("workspacePath"))
|
||||
.and_then(|workspace_path| workspace_path.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("extra"))
|
||||
.and_then(|extra| extra.get("source"))
|
||||
.and_then(|source| source.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let icon_kind = item
|
||||
.get("iconHint")
|
||||
.and_then(Value::as_str)
|
||||
@@ -571,6 +593,7 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
icon_kind,
|
||||
document_id,
|
||||
asset_id,
|
||||
relative_path,
|
||||
object_identity: resource_meta
|
||||
.and_then(|meta| meta.get("objectIdentity"))
|
||||
.and_then(|value| serde_json::to_string(value).ok()),
|
||||
@@ -2092,6 +2115,79 @@ async fn resolve_tree_create_workspace_id(
|
||||
})
|
||||
}
|
||||
|
||||
fn operation_resource_relative_path(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn operation_resource_document_id(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|path| path.starts_with("local-md:"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn apply_local_file_operation_participants(
|
||||
buffer_store: &crate::document_buffer_store::BufferStore,
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
action: &str,
|
||||
execution: &Value,
|
||||
) {
|
||||
let previous_relative_path = operation_resource_relative_path(execution, "previousResource");
|
||||
let previous_document_id = operation_resource_document_id(execution, "previousResource");
|
||||
let next_relative_path = operation_resource_relative_path(execution, "resource");
|
||||
let next_document_id = operation_resource_document_id(execution, "resource");
|
||||
match action {
|
||||
"rename" | "move" => {
|
||||
if let (
|
||||
Some(previous_relative_path),
|
||||
Some(previous_document_id),
|
||||
Some(next_relative_path),
|
||||
Some(next_document_id),
|
||||
) = (
|
||||
previous_relative_path.as_deref(),
|
||||
previous_document_id.as_deref(),
|
||||
next_relative_path.as_deref(),
|
||||
next_document_id.as_deref(),
|
||||
) {
|
||||
let _ = buffer_store.rekey_local_folder_markdown(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
previous_relative_path,
|
||||
previous_document_id,
|
||||
next_relative_path,
|
||||
next_document_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
"delete" | "archive" | "trash" | "purge" => {
|
||||
if let (Some(previous_relative_path), Some(previous_document_id)) = (
|
||||
previous_relative_path.as_deref(),
|
||||
previous_document_id.as_deref(),
|
||||
) {
|
||||
let _ = buffer_store.mark_local_folder_markdown_deleted(
|
||||
workspace_id,
|
||||
root_uri,
|
||||
previous_relative_path,
|
||||
previous_document_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn tree_command(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -2292,10 +2388,18 @@ pub async fn tree_command(
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "tree_local_executor")
|
||||
})?;
|
||||
let local_workspace_id = local_workspace_id_from_root_uri(root_uri)?;
|
||||
apply_local_file_operation_participants(
|
||||
&state.buffer_store,
|
||||
&local_workspace_id,
|
||||
root_uri,
|
||||
action,
|
||||
&execution,
|
||||
);
|
||||
return Ok(json_response(
|
||||
&context,
|
||||
json!({
|
||||
"workspaceId": local_workspace_id_from_root_uri(root_uri)?,
|
||||
"workspaceId": local_workspace_id,
|
||||
"action": action,
|
||||
"documentId": execution
|
||||
.get("documentId")
|
||||
@@ -2304,6 +2408,12 @@ pub async fn tree_command(
|
||||
"parentId": requested_parent_id,
|
||||
"title": requested_title,
|
||||
"sortOrder": requested_sort_order,
|
||||
"affectedParents": execution.get("affectedParents").cloned().unwrap_or(Value::Null),
|
||||
"revealTarget": execution.get("revealTarget").cloned().unwrap_or(Value::Null),
|
||||
"selectTarget": execution.get("selectTarget").cloned().unwrap_or(Value::Null),
|
||||
"operationId": execution.get("operationId").cloned().unwrap_or(Value::Null),
|
||||
"batchId": envelope_context.batch_id.clone(),
|
||||
"schema": execution.get("schema").cloned().unwrap_or(Value::Null),
|
||||
"updatedAt": Value::Null,
|
||||
"execution": execution,
|
||||
"artifacts": Value::Null,
|
||||
@@ -3999,6 +4109,7 @@ mod tests {
|
||||
"rowIds": ["doc:page_child"]
|
||||
})),
|
||||
operation: Some("tree.node.rename".into()),
|
||||
batch_id: None,
|
||||
};
|
||||
|
||||
let rename_wire = create_command_wire(
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::page_aggregate::{PageAggregate, PageOptions};
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::{
|
||||
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
|
||||
pub(crate) const SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER: &str = "external_local_folder";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UiPreferencesQuery {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UiPreferencesUpdateRequest {
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: String,
|
||||
#[serde(default)]
|
||||
updates: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PagePreferenceScope {
|
||||
workspace_id: String,
|
||||
source_kind: String,
|
||||
source_family: String,
|
||||
document_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EffectivePagePreferences {
|
||||
scope: PagePreferenceScope,
|
||||
page_options: PageOptions,
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn effective_preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<UiPreferencesQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let effective = resolve_effective_page_preferences(
|
||||
&state,
|
||||
&actor_id,
|
||||
&query.workspace_id,
|
||||
&query.source_kind,
|
||||
&query.root_uri,
|
||||
&query.document_id,
|
||||
PageOptions::default(),
|
||||
)?;
|
||||
Ok(Json(effective_preferences_payload(effective)))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<UiPreferencesUpdateRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let result = update_page_preferences_from_value(
|
||||
&state,
|
||||
&context,
|
||||
&actor_id,
|
||||
&request.workspace_id,
|
||||
&request.source_kind,
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
&Value::Object(request.updates.into_iter().collect()),
|
||||
)?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": result,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn update_page_preferences_from_value(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
updates: &Value,
|
||||
) -> Result<Value, WebError> {
|
||||
ensure_actor_user(state, actor_id)?;
|
||||
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
|
||||
let update_map = updates.as_object().ok_or_else(|| {
|
||||
WebError::bad_request_code("ui_preference_updates_invalid", "updates 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
for (key, value) in update_map {
|
||||
let Some((scope_kind, scope_id)) = preference_scope_for_key(key, &scope) else {
|
||||
continue;
|
||||
};
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: actor_id.to_string(),
|
||||
workspace_id: preference_workspace_for_scope(&scope.workspace_id, &scope_kind),
|
||||
source_kind: preference_source_kind_for_scope(&scope.source_kind, &scope_kind),
|
||||
scope_kind,
|
||||
scope_id,
|
||||
key: key.trim().to_string(),
|
||||
value_json: value.to_string(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"ui_preference_write_failed",
|
||||
format!("写入 UI 偏好失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
}
|
||||
let effective = resolve_effective_page_preferences(
|
||||
state,
|
||||
actor_id,
|
||||
workspace_id,
|
||||
source_kind,
|
||||
root_uri,
|
||||
document_id,
|
||||
PageOptions::default(),
|
||||
)?;
|
||||
Ok(effective_preferences_payload(effective)["result"].clone())
|
||||
}
|
||||
|
||||
pub(crate) fn source_family_for_page(
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
if source_kind.map(str::trim) != Some("local_folder") {
|
||||
return Ok("workspace".to_string());
|
||||
}
|
||||
let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string());
|
||||
};
|
||||
if local_root_has_workspace_manifest(root_uri)? {
|
||||
Ok(SOURCE_FAMILY_MY_SPACE.to_string())
|
||||
} else {
|
||||
Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_effective_page_preferences(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
aggregate: &mut PageAggregate,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<(), WebError> {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if actor_id.is_empty() || actor_id == "anonymous" {
|
||||
return Ok(());
|
||||
}
|
||||
let effective = resolve_effective_page_preferences(
|
||||
state,
|
||||
actor_id,
|
||||
&aggregate.identity.workspace_id,
|
||||
source_kind.unwrap_or_default(),
|
||||
root_uri.unwrap_or_default(),
|
||||
&aggregate.identity.document_id,
|
||||
aggregate.layout.page_options.clone(),
|
||||
)?;
|
||||
aggregate.layout.page_options = effective.page_options;
|
||||
aggregate.layout_options = serde_json::to_value(&aggregate.layout.page_options)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_effective_page_preferences(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
base_options: PageOptions,
|
||||
) -> Result<EffectivePagePreferences, WebError> {
|
||||
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
|
||||
let preferences = state
|
||||
.control_plane()
|
||||
.list_user_ui_preferences(
|
||||
actor_id,
|
||||
Some(&scope.workspace_id),
|
||||
Some(&scope.source_kind),
|
||||
)
|
||||
.map_err(|error| WebError::internal(format!("SQLite UI 偏好读取失败: {error}")))?;
|
||||
let mut page_options = base_options;
|
||||
if scope.source_family == SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER {
|
||||
page_options.hide_title_header = true;
|
||||
}
|
||||
let mut sources = BTreeMap::new();
|
||||
apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?;
|
||||
Ok(EffectivePagePreferences {
|
||||
scope,
|
||||
page_options,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_preference_records(
|
||||
page_options: &mut PageOptions,
|
||||
sources: &mut BTreeMap<String, String>,
|
||||
scope: &PagePreferenceScope,
|
||||
preferences: &[UserUiPreferenceRecord],
|
||||
) -> Result<(), WebError> {
|
||||
for scope_kind in ["global", "source_family", "workspace", "document"] {
|
||||
for preference in preferences
|
||||
.iter()
|
||||
.filter(|preference| preference.scope_kind.trim() == scope_kind)
|
||||
{
|
||||
let scope_matches = match scope_kind {
|
||||
"global" => preference.scope_id.trim() == "default",
|
||||
"source_family" => preference.scope_id.trim() == scope.source_family,
|
||||
"workspace" => preference.scope_id.trim() == scope.workspace_id,
|
||||
"document" => preference.scope_id.trim() == scope.document_id,
|
||||
_ => false,
|
||||
};
|
||||
if !scope_matches {
|
||||
continue;
|
||||
}
|
||||
let value = serde_json::from_str::<Value>(&preference.value_json).map_err(|error| {
|
||||
WebError::internal(format!(
|
||||
"SQLite UI 偏好 JSON 无效 {}: {error}",
|
||||
preference.key
|
||||
))
|
||||
})?;
|
||||
if apply_page_option_value(page_options, &preference.key, &value) {
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn page_preference_scope(
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
) -> Result<PagePreferenceScope, WebError> {
|
||||
let source_kind = source_kind.trim();
|
||||
let source_kind = if source_kind.is_empty() {
|
||||
"convex_workspace"
|
||||
} else {
|
||||
source_kind
|
||||
};
|
||||
let workspace_id = workspace_id.trim().to_string().if_empty_else(|| {
|
||||
if source_kind == "local_folder" {
|
||||
local_workspace_id_from_root_uri(root_uri).unwrap_or_else(|_| "local-folder".into())
|
||||
} else {
|
||||
"default".into()
|
||||
}
|
||||
});
|
||||
Ok(PagePreferenceScope {
|
||||
workspace_id,
|
||||
source_kind: source_kind.to_string(),
|
||||
source_family: source_family_for_page(Some(source_kind), Some(root_uri))?,
|
||||
document_id: document_id.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> {
|
||||
match key.trim() {
|
||||
"hideTitleHeader" | "hide_title_header" => {
|
||||
Some(("source_family".to_string(), scope.source_family.clone()))
|
||||
}
|
||||
"showHeadingNumbers" | "show_heading_numbers" | "showWordCount" | "show_word_count" => {
|
||||
Some(("global".to_string(), "default".to_string()))
|
||||
}
|
||||
"wideLayout"
|
||||
| "wide_layout"
|
||||
| "smallText"
|
||||
| "small_text"
|
||||
| "layoutDensity"
|
||||
| "layout_density"
|
||||
| "pageFont"
|
||||
| "page_font"
|
||||
| "showToc"
|
||||
| "show_toc"
|
||||
| "showStructure"
|
||||
| "show_structure"
|
||||
| "collapseBacklinks"
|
||||
| "collapse_backlinks"
|
||||
| "hideChildPages"
|
||||
| "hide_child_pages"
|
||||
| "showBlockRefCount"
|
||||
| "show_block_ref_count" => Some(("workspace".to_string(), scope.workspace_id.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
Some(workspace_id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
Some(source_kind.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({}));
|
||||
let sources = effective
|
||||
.sources
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, Value::String(value)))
|
||||
.collect::<Map<String, Value>>();
|
||||
json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": {
|
||||
"scope": {
|
||||
"sourceFamily": effective.scope.source_family,
|
||||
"workspaceId": effective.scope.workspace_id,
|
||||
"sourceKind": effective.scope.source_kind,
|
||||
"documentId": effective.scope.document_id,
|
||||
},
|
||||
"pageOptions": page_options,
|
||||
"sources": Value::Object(sources),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
||||
current_actor_id(state, context)
|
||||
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"ui_preference_auth_required",
|
||||
"UI 偏好需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> {
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(actor_id.to_string()),
|
||||
email: None,
|
||||
username: actor_id.to_string(),
|
||||
display_name: actor_id.to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| WebError::internal(format!("SQLite 用户初始化失败: {error}")))
|
||||
}
|
||||
|
||||
fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value) -> bool {
|
||||
match key {
|
||||
"hideTitleHeader" | "hide_title_header" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.hide_title_header = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showHeadingNumbers" | "show_heading_numbers" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_heading_numbers = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"wideLayout" | "wide_layout" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.wide_layout = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"smallText" | "small_text" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.small_text = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"layoutDensity" | "layout_density" => {
|
||||
if let Some(value) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
options.layout_density = value.to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"pageFont" | "page_font" => {
|
||||
if let Some(value) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
options.page_font = value.to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showToc" | "show_toc" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_toc = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showStructure" | "show_structure" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_structure = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showWordCount" | "show_word_count" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_word_count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"collapseBacklinks" | "collapse_backlinks" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.collapse_backlinks = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"hideChildPages" | "hide_child_pages" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.hide_child_pages = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"showBlockRefCount" | "show_block_ref_count" => {
|
||||
if let Some(value) = value.as_bool() {
|
||||
options.show_block_ref_count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.trim().is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::routes::documents::{
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot,
|
||||
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
@@ -35,7 +36,7 @@ use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
|
||||
@@ -43,6 +44,10 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentShellQuery {
|
||||
@@ -50,6 +55,7 @@ pub struct DocumentShellQuery {
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub tree_view: Option<String>,
|
||||
pub file_tree_scope: Option<String>,
|
||||
pub secondary_document_id: Option<String>,
|
||||
pub secondary_source_kind: Option<String>,
|
||||
pub secondary_root_uri: Option<String>,
|
||||
@@ -129,6 +135,7 @@ pub async fn document_page_shell(
|
||||
};
|
||||
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let mut workspace_projection = load_workspace_shell_projection(
|
||||
Some(&state),
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
@@ -148,11 +155,17 @@ pub async fn document_page_shell(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == "filetree");
|
||||
let file_tree_scope = query
|
||||
.file_tree_scope
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
|
||||
let root_uri = query.root_uri.as_deref().unwrap_or_default();
|
||||
(
|
||||
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
render_local_file_tree_html(root_uri, Some(&document_id), None).unwrap_or_default(),
|
||||
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -179,6 +192,7 @@ pub async fn document_page_shell(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
file_tree_scope,
|
||||
);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let page_subtree_json =
|
||||
@@ -245,6 +259,7 @@ pub async fn document_page_shell(
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{}</title>
|
||||
{}
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
|
||||
@@ -261,6 +276,7 @@ pub async fn document_page_shell(
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(title),
|
||||
render_editor_runtime_preload_links(),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&document_id),
|
||||
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
|
||||
@@ -646,6 +662,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
||||
}
|
||||
|
||||
pub(crate) fn render_editor_runtime_preload_links() -> &'static str {
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">
|
||||
<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
}
|
||||
|
||||
fn runtime_asset_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../spikes/leptos-tiptap-spike/generated/island")
|
||||
@@ -678,6 +699,10 @@ fn runtime_asset_content_type(asset_path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_asset_cache_control() -> &'static str {
|
||||
"public, max-age=3600, stale-while-revalidate=86400"
|
||||
}
|
||||
|
||||
pub async fn editor_image_placeholder_asset() -> Response {
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
|
||||
<rect width="640" height="360" rx="18" fill="#f3f4f6"/>
|
||||
@@ -705,7 +730,7 @@ pub async fn resource_open_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -719,7 +744,7 @@ pub async fn local_upload_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -733,7 +758,7 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -747,7 +772,7 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -761,7 +786,7 @@ pub async fn sidebar_page_settings_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -775,7 +800,7 @@ pub async fn sidebar_shell_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -789,7 +814,7 @@ pub async fn sidebar_workspace_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -803,7 +828,7 @@ pub async fn sidebar_page_tree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -817,7 +842,7 @@ pub async fn sidebar_tree_live_apply_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -831,7 +856,7 @@ pub async fn sidebar_filetree_open_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -845,7 +870,7 @@ pub async fn sidebar_filetree_command_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -859,7 +884,7 @@ pub async fn sidebar_filetree_upload_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -873,7 +898,7 @@ pub async fn sidebar_attachment_open_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -887,7 +912,7 @@ pub async fn filetree_keyboard_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -901,7 +926,7 @@ pub async fn filetree_dnd_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -915,7 +940,7 @@ pub async fn filetree_context_menu_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -929,7 +954,7 @@ pub async fn filetree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -943,7 +968,7 @@ pub async fn filetree_selection_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -957,7 +982,7 @@ pub async fn tree_live_controller_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -971,7 +996,7 @@ pub async fn tree_shell_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -985,7 +1010,7 @@ pub async fn tree_shell_render_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -999,7 +1024,7 @@ pub async fn tree_shell_page_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1013,7 +1038,7 @@ pub async fn tree_shell_state_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1027,7 +1052,7 @@ pub async fn tree_shell_icons_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1041,7 +1066,7 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1055,7 +1080,7 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1069,7 +1094,7 @@ pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1083,7 +1108,7 @@ pub async fn tree_shell_picker_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1097,7 +1122,7 @@ pub async fn tree_shell_dom_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1112,7 +1137,7 @@ pub async fn document_conflict_panel_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1126,7 +1151,7 @@ pub async fn document_pane_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1140,7 +1165,7 @@ pub async fn document_mindmap_host_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1154,7 +1179,7 @@ pub async fn document_resource_tab_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1168,7 +1193,7 @@ pub async fn document_session_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1182,7 +1207,7 @@ pub async fn document_slash_position_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1196,7 +1221,7 @@ pub async fn document_tiptap_conversion_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1210,7 +1235,7 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
@@ -1228,6 +1253,10 @@ pub async fn leptos_tiptap_manifest() -> Response {
|
||||
});
|
||||
let mut response = Json(manifest).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(runtime_asset_cache_control()),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -1251,7 +1280,7 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
|
||||
header::CONTENT_TYPE,
|
||||
runtime_asset_content_type(&asset_path),
|
||||
)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
|
||||
@@ -1312,7 +1341,24 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
})?;
|
||||
ensure_local_workspace_read_access_with_state(state, context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
let root_uri_for_build = root_uri.to_string();
|
||||
let root_uri_for_preferences = root_uri_for_build.clone();
|
||||
let document_id = document_id.to_string();
|
||||
let mut aggregate = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
block_local_page_aggregate_for_test();
|
||||
resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")))??;
|
||||
super::ui_preferences::apply_effective_page_preferences(
|
||||
state,
|
||||
context,
|
||||
&mut aggregate,
|
||||
source_kind,
|
||||
Some(root_uri_for_preferences.as_str()),
|
||||
)?;
|
||||
return Ok(aggregate);
|
||||
}
|
||||
|
||||
let meta = load_document_meta_result(
|
||||
@@ -1398,6 +1444,7 @@ pub(crate) fn escape_script_json(value: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) async fn load_workspace_shell_projection(
|
||||
state: Option<&AppState>,
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
@@ -1412,7 +1459,7 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
query: None,
|
||||
max_results: None,
|
||||
};
|
||||
let dataset = match load_projection_snapshot(config, context, &spec).await {
|
||||
let mut dataset = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => snapshot.dataset,
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
let documents = active_document_id
|
||||
@@ -1444,6 +1491,9 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
"degraded_reason": "projection_unavailable"
|
||||
}),
|
||||
};
|
||||
if let Some(state) = state {
|
||||
attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset);
|
||||
}
|
||||
|
||||
build_workspace_shell_projection(
|
||||
&dataset,
|
||||
@@ -1453,6 +1503,25 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn attach_sidebar_shortcuts_to_dataset(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
dataset: &mut Value,
|
||||
) {
|
||||
let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset(
|
||||
state,
|
||||
context,
|
||||
workspace_id,
|
||||
);
|
||||
if shortcuts.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(object) = dataset.as_object_mut() {
|
||||
object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts));
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载侧栏页面树 HTML(SSR)
|
||||
///
|
||||
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
||||
@@ -1605,7 +1674,23 @@ pub(crate) fn render_local_file_tree_html(
|
||||
active_document_id: Option<&str>,
|
||||
active_row_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?;
|
||||
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_file_tree_html_scoped(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
active_row_id: Option<&str>,
|
||||
file_tree_scope: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = if let Some(scope) = file_tree_scope
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
load_local_folder_file_tree_children_snapshot(root_uri, scope)?
|
||||
} else {
|
||||
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
|
||||
};
|
||||
let rows =
|
||||
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
|
||||
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
@@ -1613,14 +1698,23 @@ pub(crate) fn render_local_file_tree_html(
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn block_local_page_aggregate_for_test() {
|
||||
let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput};
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
|
||||
@@ -1917,6 +2011,12 @@ mod tests {
|
||||
assert!(html.contains("data-mnote-resource-tab-host"));
|
||||
assert!(runtime.contains("openPrimaryMindmap"));
|
||||
assert!(runtime.contains("openResourceInActiveTab"));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
));
|
||||
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
|
||||
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
|
||||
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
|
||||
@@ -2033,6 +2133,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_browser_runtime_assets_are_cacheable() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cache_control = response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("cache-control");
|
||||
assert_ne!(cache_control, "no-store");
|
||||
assert!(
|
||||
cache_control.contains("max-age"),
|
||||
"runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leptos_tiptap_runtime_assets_are_cacheable() {
|
||||
let manifest_response = app()
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/manifest.json")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(manifest_response.status(), StatusCode::OK);
|
||||
let manifest_cache_control = manifest_response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("manifest cache-control");
|
||||
assert_ne!(manifest_cache_control, "no-store");
|
||||
assert!(
|
||||
manifest_cache_control.contains("max-age"),
|
||||
"leptos-tiptap manifest 应允许浏览器缓存,避免每次重新发现 runtime 入口"
|
||||
);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cache_control = response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("cache-control");
|
||||
assert_ne!(cache_control, "no-store");
|
||||
assert!(
|
||||
cache_control.contains("max-age"),
|
||||
"leptos-tiptap runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
@@ -2243,6 +2415,190 @@ mod tests {
|
||||
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-aggregate-runtime-asset-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle");
|
||||
std::fs::write(
|
||||
root.join("Cold Start").join("Cold Start.md"),
|
||||
"# Cold Start\n\nEditor cold start target.\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let app = app();
|
||||
let aggregate_app = app.clone();
|
||||
let aggregate_uri = format!(
|
||||
"/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
);
|
||||
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
|
||||
.store(400, std::sync::atomic::Ordering::SeqCst);
|
||||
struct ResetLocalAggregateBlock;
|
||||
impl Drop for ResetLocalAggregateBlock {
|
||||
fn drop(&mut self) {
|
||||
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _reset_block = ResetLocalAggregateBlock;
|
||||
let aggregate_task = tokio::spawn(async move {
|
||||
aggregate_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(aggregate_uri)
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("aggregate request"),
|
||||
)
|
||||
.await
|
||||
.expect("aggregate response")
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
tokio::task::yield_now().await;
|
||||
let asset_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
|
||||
.body(Body::empty())
|
||||
.expect("asset request"),
|
||||
)
|
||||
.await
|
||||
.expect("asset response");
|
||||
let asset_elapsed = started.elapsed();
|
||||
let aggregate_response = aggregate_task.await.expect("aggregate task");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(asset_response.status(), StatusCode::OK);
|
||||
assert_eq!(aggregate_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
asset_elapsed < Duration::from_millis(150),
|
||||
"leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞,实际等待 {asset_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-aggregate-ui-pref-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::write(root.join("README.md"), "# Readme Heading\n正文\n").expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
|
||||
let workspace_id =
|
||||
crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri)
|
||||
.expect("workspace id");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||||
id: None,
|
||||
user_id: "user_test".into(),
|
||||
workspace_id: Some(workspace_id),
|
||||
source_kind: Some("local_folder".into()),
|
||||
scope_kind: "source_family".into(),
|
||||
scope_id: "external_local_folder".into(),
|
||||
key: "hideTitleHeader".into(),
|
||||
value_json: "false".into(),
|
||||
})
|
||||
.expect("upsert title header preference");
|
||||
|
||||
let context = request_context("user_test", "user");
|
||||
let aggregate = super::build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
Some("local_folder"),
|
||||
Some(&root_uri),
|
||||
)
|
||||
.await
|
||||
.expect("local page aggregate");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert!(!aggregate.layout.page_options.hide_title_header);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_preferences_api_updates_and_returns_effective_page_options() {
|
||||
let app = app();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/ui/preferences")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"sourceKind": "convex_workspace",
|
||||
"documentId": "doc_1",
|
||||
"updates": {
|
||||
"showHeadingNumbers": true,
|
||||
"layoutDensity": "compact"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true);
|
||||
assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact");
|
||||
assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global");
|
||||
assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
||||
let root =
|
||||
@@ -2469,6 +2825,7 @@ mod tests {
|
||||
let filetree_html =
|
||||
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
|
||||
let workspace_projection = super::load_workspace_shell_projection(
|
||||
None,
|
||||
&config,
|
||||
&context,
|
||||
"ws_demo",
|
||||
|
||||
@@ -88,6 +88,7 @@ pub fn PageLayout(
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
});
|
||||
let tree_live_bootstrap = serde_json::json!({
|
||||
@@ -175,8 +176,8 @@ pub fn PageLayout(
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wolai-topbar-actions" aria-label="页面操作">
|
||||
<span class="wolai-public-pill" data-testid="wolai-public-state">"全网公开"</span>
|
||||
<button type="button" class="wolai-icon-button" title="收藏" aria-label="收藏"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
|
||||
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
||||
@@ -199,6 +200,8 @@ pub fn PageLayout(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use leptos::prelude::ElementChild;
|
||||
|
||||
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-runtime.js");
|
||||
const SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-tree-live-apply-runtime.js");
|
||||
@@ -260,6 +263,8 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||
.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command"));
|
||||
@@ -341,6 +346,51 @@ mod tests {
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||
<main>"正文"</main>
|
||||
</super::PageLayout>
|
||||
});
|
||||
|
||||
assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#));
|
||||
assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("inferLocalRootUriFromWorkspaceId"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-scope"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("toggle-sidebar-folder-shortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
|
||||
let scope_start = SIDEBAR_TREE_RUNTIME_JS
|
||||
.find("persistStarredFolderScope(workspaceId, rootUri, relativePath);")
|
||||
.expect("starred folder should persist scope before loading");
|
||||
let sidebar_fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..]
|
||||
.find("fetch(sidebarUrl.toString()")
|
||||
.expect("starred folder should fetch local page projection")
|
||||
+ scope_start;
|
||||
let fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..]
|
||||
.find("fetch(url.toString()")
|
||||
.expect("starred folder should fetch scoped projection")
|
||||
+ scope_start;
|
||||
assert!(
|
||||
sidebar_fetch_start < fetch_start,
|
||||
"星标文件夹请求 scoped filetree 前必须先拉本地页面树投影,避免我的空间旧页面残留"
|
||||
);
|
||||
assert!(
|
||||
scope_start < fetch_start,
|
||||
"星标文件夹应先写入 fileTreeScope,再请求大目录,避免 live refresh 把视图折回根目录"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
|
||||
assert!(
|
||||
@@ -503,9 +553,10 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("renderSidebarSnapshot(sidebarPayload.result || sidebarPayload)"));
|
||||
.contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("renderFileProjection(filePayload.result || filePayload)"));
|
||||
.contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
|
||||
);
|
||||
@@ -565,6 +616,29 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("editorRoot: uploadContext.root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_attachment_open_runtime_receives_closest_action_dependency() {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function closestAction"));
|
||||
let injection_start = SIDEBAR_TREE_RUNTIME_JS
|
||||
.find("const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({")
|
||||
.expect("attachment runtime injection");
|
||||
let injection_end = SIDEBAR_TREE_RUNTIME_JS[injection_start..]
|
||||
.find(" });")
|
||||
.map(|offset| injection_start + offset)
|
||||
.expect("attachment runtime injection end");
|
||||
let injection = &SIDEBAR_TREE_RUNTIME_JS[injection_start..injection_end];
|
||||
assert!(
|
||||
injection.contains("closestAction"),
|
||||
"attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("closestAction = typeof injectedClosestAction === 'function'")
|
||||
|| SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("dependencies.closestAction"),
|
||||
"attachment open runtime 应从 dependencies 读取 closestAction,而不是依赖外层闭包"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_upload_runtime_contains_editor_upload_context_helpers() {
|
||||
const LOCAL_UPLOAD_RUNTIME_JS: &str =
|
||||
@@ -803,8 +877,9 @@ mod tests {
|
||||
"selectSidebarFileTreeDocument",
|
||||
);
|
||||
assert!(
|
||||
select_document_body
|
||||
.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"),
|
||||
select_document_body.contains("activateSidebarFileTreeRow(row, options)")
|
||||
&& SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function activateSidebarFileTreeRow")
|
||||
&& SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"),
|
||||
"document selection 应复用 filetree row selection runtime/fallback"
|
||||
);
|
||||
}
|
||||
@@ -1006,8 +1081,173 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file/children"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("parentRelativePath"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-filetree-children-loaded"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeLazyChildrenCache"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowsByParent: new Map()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadedParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadingParents: new Map()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("dirtyParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeExpandedRelativePaths"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (fileTreeState.loadingParents.has(key))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("return fileTreeState.loadingParents.get(key);"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function markExistingFileTreeChildrenLoaded"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (markExistingFileTreeChildrenLoaded(row, button))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("scheduleRestorePersistedFileTreeExpansionState();"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("return patchFileTreeParentChildren(parentRelativePath, rows);"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)"));
|
||||
let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("function renderFileProjection(projection)")
|
||||
.expect("renderFileProjection");
|
||||
let render_end = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..]
|
||||
.find("function renderSidebarSnapshot(payload)")
|
||||
.map(|offset| render_start + offset)
|
||||
.expect("renderFileProjection end");
|
||||
let render_body = &SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..render_end];
|
||||
assert!(!render_body.contains("tree.innerHTML ="));
|
||||
assert!(render_body.contains("tree.replaceChildren"));
|
||||
let patch_branch = render_body
|
||||
.find("patchFileTreeParentChildren(parentRelativePath, rows)")
|
||||
.expect("non-root parent patch");
|
||||
let root_replace = render_body
|
||||
.find("tree.replaceChildren")
|
||||
.expect("root replacement");
|
||||
assert!(
|
||||
patch_branch < root_replace,
|
||||
"非 scope parent projection 必须先局部 patch,不能替换整棵 filetree"
|
||||
);
|
||||
let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("async function loadFileTreeChildren(row, button)")
|
||||
.expect("lazy children loader");
|
||||
let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||
.find("setTreeRowExpanded(row, button, true);")
|
||||
.expect("lazy loading should mark the requested folder expanded before fetch")
|
||||
+ load_start;
|
||||
let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||
.find("getFileTreeChildren(relativePath)")
|
||||
.expect("lazy children datasource call")
|
||||
+ load_start;
|
||||
assert!(
|
||||
optimistic_expand < fetch_start,
|
||||
"慢目录加载期间必须先保存 expanded 状态,否则 refresh/create-page 会把刚点开的文件夹折叠"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_discards_stale_generation_results() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("staleParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("requestGeneration: 0"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function beginFileTreeRequest"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function isLatestFileTreeRequest"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("if (!isLatestFileTreeRequest(key, generation))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.staleParents.add(key)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.requestGeneration += 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_prefers_command_affected_parents() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function addAffectedParentsFromCommandResult"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("Array.isArray(result && result.affectedParents)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("result && result.execution && result.execution.affectedParents"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("addAffectedParentsFromCommandResult(parents, result)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-filetree-command-refresh-fallback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_batches_local_command_refresh() {
|
||||
assert!(
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function nextFileTreeOperationBatchId")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("removeFileTreeAssetRow"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("removeFileTreeAssetRow,"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("batchId: batchId"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("tree:local-command-batch-complete"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function queueFileTreeBatchRefresh"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function flushFileTreeBatchRefresh"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-filetree-batch-refresh-pending"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-filetree-batch-refresh-applied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_handles_watch_batch_and_structured_error() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("watch_batch"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:local-folder-watch-batch"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("data-mnote-local-folder-watch-batch-applied"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_has_view_state_namespace() {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("expandedParents: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("selectedRowIds: new Set()"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("focusedRowId: ''"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("activeRowId: ''"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeState = fileTreeViewState;")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
||||
"if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function rememberFileTreeSelectionState")
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function reprojectFileTreeSelectionState")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("fileTreeViewState.selectedRowIds.add(rowId)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
||||
"row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_can_reveal_unloaded_resource_path() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("async function revealFileTreeResource")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("function fileTreeParentChainForRelativePath"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("await getFileTreeChildren(parentRelativePath)"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("fileTreeViewState.selectedRowIds = new Set([targetRowId])"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("revealFileTreeResource,"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args);"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("revealFileTreeResource({"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("decodeLocalEncodedPath(id.slice('local-md:'.length))"));
|
||||
assert!(
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function localMarkdownBundleParentPath")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("visibleFileTreeRowByRelativePath(bundleParentPath)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1522,6 +1522,18 @@ body {
|
||||
color: var(--atelier-text);
|
||||
}
|
||||
|
||||
.wolai-section-add + .wolai-section-add {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.wolai-section-add .material-symbols-outlined {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wolai-page-row {
|
||||
min-height: 30px;
|
||||
gap: 7px;
|
||||
@@ -1560,6 +1572,39 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wolai-row-more {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #8C8983;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-row:hover .wolai-row-more,
|
||||
.wolai-row-more:focus-visible,
|
||||
.wolai-row-more[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wolai-row-more:hover {
|
||||
background: #E7E4E0;
|
||||
color: #4B4945;
|
||||
}
|
||||
|
||||
.wolai-row-more .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-tree-section {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct FileTreeRenderRow {
|
||||
pub icon_kind: String,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub object_identity: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
@@ -90,7 +91,7 @@ fn render_filetree_row(
|
||||
};
|
||||
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -101,6 +102,7 @@ fn render_filetree_row(
|
||||
document_id = escape_html(command_document_id),
|
||||
owner_document_id = escape_html(owner_document_id),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()),
|
||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
toggle_html = toggle_html,
|
||||
@@ -176,6 +178,7 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
relative_path: None,
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||
),
|
||||
@@ -193,6 +196,7 @@ mod tests {
|
||||
icon_kind: "mindmap".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("mind_1".into()),
|
||||
relative_path: Some("assets/思维导图.json".into()),
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
|
||||
),
|
||||
@@ -210,6 +214,7 @@ mod tests {
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-local-relative-path=\"assets/思维导图.json\""));
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
@@ -234,6 +239,7 @@ mod tests {
|
||||
icon_kind: "folder".into(),
|
||||
document_id: None,
|
||||
asset_id: None,
|
||||
relative_path: Some("docs".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
@@ -249,6 +255,7 @@ mod tests {
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("local-md:docs~2FREADME.md".into()),
|
||||
asset_id: None,
|
||||
relative_path: Some("docs/README.md".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
@@ -256,8 +263,34 @@ mod tests {
|
||||
});
|
||||
|
||||
assert!(html.contains("data-row-id=\"local:folder:docs\""));
|
||||
assert!(html.contains("data-local-relative-path=\"docs\""));
|
||||
assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\""));
|
||||
assert!(!html.contains("README.md"));
|
||||
assert!(!html.contains("tree-children--collapsed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_ssr_rows_include_local_relative_path() {
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows: vec![FileTreeRenderRow {
|
||||
row_id: "local:folder:design/03-rust-web".into(),
|
||||
row_kind: "folder".into(),
|
||||
node_id: "local:node:design/03-rust-web".into(),
|
||||
parent_node_id: None,
|
||||
title: "03-rust-web".into(),
|
||||
depth: 1,
|
||||
expandable: true,
|
||||
expanded: false,
|
||||
icon_kind: "folder".into(),
|
||||
document_id: None,
|
||||
asset_id: None,
|
||||
relative_path: Some("design/03-rust-web".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
}],
|
||||
});
|
||||
|
||||
assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#));
|
||||
assert!(html.contains(r#"<button type="button" class="tree-link""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
relative_path: None,
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
@@ -148,6 +149,7 @@ mod tests {
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("asset_1".into()),
|
||||
relative_path: Some("asset_1".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,14 @@ pub struct WorkspaceShellItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub icon: Option<String>,
|
||||
pub shortcut_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub href: String,
|
||||
pub depth: u32,
|
||||
@@ -88,6 +96,21 @@ pub fn build_workspace_shell_projection(
|
||||
})
|
||||
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
let starred_page_ids = starred_items
|
||||
.iter()
|
||||
.map(|item| item.id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for shortcut in sidebar_shortcuts(dataset) {
|
||||
if let Some(item) = shortcut_to_item(
|
||||
shortcut,
|
||||
workspace_id,
|
||||
active_page_id.as_deref(),
|
||||
&documents,
|
||||
&starred_page_ids,
|
||||
) {
|
||||
starred_items.push(item);
|
||||
}
|
||||
}
|
||||
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||
|
||||
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
|
||||
@@ -214,6 +237,14 @@ fn document_to_item(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
shortcut_id: None,
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
kind: Some("page".to_string()),
|
||||
target_id: Some(id.to_string()),
|
||||
relative_path: None,
|
||||
document_id: Some(id.to_string()),
|
||||
parent_id,
|
||||
href: format!("/documents/{id}?workspaceId={workspace_id}"),
|
||||
depth,
|
||||
@@ -221,6 +252,207 @@ fn document_to_item(
|
||||
})
|
||||
}
|
||||
|
||||
fn sidebar_shortcuts(dataset: &Value) -> Vec<&Value> {
|
||||
dataset
|
||||
.get("sidebar_shortcuts")
|
||||
.or_else(|| dataset.get("sidebarShortcuts"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn shortcut_to_item(
|
||||
shortcut: &Value,
|
||||
workspace_id: &str,
|
||||
active_page_id: Option<&str>,
|
||||
documents: &[Value],
|
||||
starred_page_ids: &std::collections::BTreeSet<String>,
|
||||
) -> Option<WorkspaceShellItem> {
|
||||
let shortcut_workspace_id = shortcut
|
||||
.get("workspace_id")
|
||||
.or_else(|| shortcut.get("workspaceId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(workspace_id);
|
||||
let source_kind = shortcut
|
||||
.get("source_kind")
|
||||
.or_else(|| shortcut.get("sourceKind"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("workspace");
|
||||
if shortcut_workspace_id != workspace_id && source_kind != "local_folder" {
|
||||
return None;
|
||||
}
|
||||
let kind = shortcut
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let target_id = shortcut
|
||||
.get("target_id")
|
||||
.or_else(|| shortcut.get("targetId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
if kind == "page" {
|
||||
let document_id = shortcut
|
||||
.get("document_id")
|
||||
.or_else(|| shortcut.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(target_id);
|
||||
if starred_page_ids.contains(document_id) {
|
||||
return None;
|
||||
}
|
||||
let root_uri = shortcut_root_uri(shortcut);
|
||||
if let Some(document) = documents.iter().find(|document| {
|
||||
document.get("id").and_then(Value::as_str).map(str::trim) == Some(document_id)
|
||||
}) {
|
||||
let mut item = document_to_item(document, workspace_id, active_page_id)?;
|
||||
let shortcut_id = shortcut
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(document_id)
|
||||
.to_string();
|
||||
item.id = shortcut_id.clone();
|
||||
item.shortcut_id = Some(shortcut_id);
|
||||
item.kind = Some("page".to_string());
|
||||
item.workspace_id = Some(shortcut_workspace_id.to_string());
|
||||
item.source_kind = Some(source_kind.to_string());
|
||||
item.root_uri = root_uri.clone();
|
||||
item.href = shortcut_document_href(
|
||||
document_id,
|
||||
shortcut_workspace_id,
|
||||
source_kind,
|
||||
root_uri.as_deref(),
|
||||
);
|
||||
item.target_id = Some(target_id.to_string());
|
||||
item.document_id = Some(document_id.to_string());
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
let title = shortcut
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if kind == "folder" {
|
||||
"文件夹"
|
||||
} else {
|
||||
"无标题"
|
||||
}
|
||||
});
|
||||
let relative_path = shortcut
|
||||
.get("relative_path")
|
||||
.or_else(|| shortcut.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let document_id = shortcut
|
||||
.get("document_id")
|
||||
.or_else(|| shortcut.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let root_uri = shortcut_root_uri(shortcut);
|
||||
let shortcut_id = shortcut
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(target_id)
|
||||
.to_string();
|
||||
Some(WorkspaceShellItem {
|
||||
id: shortcut_id.clone(),
|
||||
title: title.to_string(),
|
||||
icon: shortcut
|
||||
.get("icon")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| (kind == "folder").then(|| "folder_open".to_string())),
|
||||
shortcut_id: Some(shortcut_id),
|
||||
workspace_id: Some(shortcut_workspace_id.to_string()),
|
||||
source_kind: Some(source_kind.to_string()),
|
||||
root_uri: root_uri.clone(),
|
||||
kind: Some(kind.to_string()),
|
||||
target_id: Some(target_id.to_string()),
|
||||
relative_path,
|
||||
document_id: document_id.clone(),
|
||||
parent_id: None,
|
||||
href: document_id
|
||||
.as_ref()
|
||||
.map(|id| {
|
||||
shortcut_document_href(id, shortcut_workspace_id, source_kind, root_uri.as_deref())
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
depth: 0,
|
||||
active: kind == "page"
|
||||
&& active_page_id.is_some_and(|active_id| document_id.as_deref() == Some(active_id)),
|
||||
})
|
||||
}
|
||||
|
||||
fn shortcut_root_uri(shortcut: &Value) -> Option<String> {
|
||||
shortcut
|
||||
.get("rootUri")
|
||||
.or_else(|| shortcut.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
shortcut
|
||||
.get("metadata")
|
||||
.and_then(|metadata| metadata.get("rootUri").or_else(|| metadata.get("root_uri")))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn shortcut_document_href(
|
||||
document_id: &str,
|
||||
workspace_id: &str,
|
||||
source_kind: &str,
|
||||
root_uri: Option<&str>,
|
||||
) -> String {
|
||||
let mut href = format!(
|
||||
"/documents/{}?workspaceId={}",
|
||||
document_id,
|
||||
encode_query_component(workspace_id)
|
||||
);
|
||||
if !source_kind.trim().is_empty() && source_kind != "workspace" {
|
||||
href.push_str("&sourceKind=");
|
||||
href.push_str(&encode_query_component(source_kind));
|
||||
}
|
||||
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
href.push_str("&rootUri=");
|
||||
href.push_str(&encode_query_component(root_uri));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn encode_query_component(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.as_bytes() {
|
||||
match *byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(*byte as char)
|
||||
}
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
|
||||
for item in items {
|
||||
item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
|
||||
@@ -336,7 +568,7 @@ mod tests {
|
||||
Some("doc_root"),
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
||||
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
|
||||
assert!(html.contains("data-node-id=\"doc_root\""));
|
||||
@@ -346,12 +578,52 @@ mod tests {
|
||||
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
|
||||
assert!(html.contains("data-mnote-action=\"create-page\""));
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-create-folder\""));
|
||||
assert!(html.contains("data-mnote-action=\"create-folder\""));
|
||||
assert!(html.contains("wolai-section-add--folder"));
|
||||
assert!(html.contains("data-icon=\"folder_open\""));
|
||||
assert!(!html.contains(">create_new_folder</span>"));
|
||||
assert!(html.contains("class=\"material-symbols-outlined"));
|
||||
assert!(html.contains("data-icon=\"home\""));
|
||||
assert!(html.contains("data-icon=\"star\""));
|
||||
assert!(html.contains("data-icon=\"delete\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_renders_sqlite_folder_shortcut_attrs() {
|
||||
let dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [],
|
||||
"sidebarShortcuts": [{
|
||||
"id": "shortcut_design",
|
||||
"workspaceId": "local-ws:demo",
|
||||
"kind": "folder",
|
||||
"sourceKind": "local_folder",
|
||||
"targetId": "folder:design",
|
||||
"relativePath": "design",
|
||||
"title": "design",
|
||||
"icon": "folder_open",
|
||||
"metadata": {
|
||||
"rootUri": "file:///tmp/mnote-demo"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let projection =
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
||||
|
||||
assert_eq!(projection.starred_items.len(), 1);
|
||||
assert!(html.contains(r#"data-mnote-shortcut-kind="folder""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-id="shortcut_design""#));
|
||||
assert!(html.contains(r#"data-workspace-id="local-ws:demo""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-target-id="folder:design""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-relative-path="design""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-root-uri="file:///tmp/mnote-demo""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-action="menu""#));
|
||||
assert!(html.contains(r#"role="button""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_uses_single_tabbed_tree_host() {
|
||||
let dataset = json!({
|
||||
@@ -371,6 +643,7 @@ mod tests {
|
||||
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
|
||||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#));
|
||||
@@ -401,6 +674,7 @@ mod tests {
|
||||
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
|
||||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||||
Some("filetree"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(html.contains(
|
||||
@@ -413,6 +687,26 @@ mod tests {
|
||||
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_marks_filetree_scope() {
|
||||
let dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": []
|
||||
});
|
||||
let projection =
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(
|
||||
&projection,
|
||||
None,
|
||||
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
|
||||
Some("filetree"),
|
||||
Some("design"),
|
||||
);
|
||||
|
||||
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
|
||||
assert!(html.contains(r#"data-mnote-filetree-scope="design""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_outputs_empty_state() {
|
||||
let dataset = json!({
|
||||
@@ -421,7 +715,7 @@ mod tests {
|
||||
});
|
||||
let projection =
|
||||
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
|
||||
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
|
||||
|
||||
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||
assert!(!html.contains("暂无页面"));
|
||||
@@ -442,7 +736,7 @@ mod tests {
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
let degraded_html =
|
||||
render_workspace_shell_sidebar_html(°raded_projection, None, None, None);
|
||||
render_workspace_shell_sidebar_html(°raded_projection, None, None, None, None);
|
||||
|
||||
assert!(degraded_projection.degraded);
|
||||
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
|
||||
@@ -456,7 +750,7 @@ mod tests {
|
||||
});
|
||||
let dev_projection =
|
||||
build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None);
|
||||
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None, None);
|
||||
|
||||
assert!(dev_projection.dev_fixture);
|
||||
assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
|
||||
@@ -469,6 +763,7 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
sidebar_tree_html: Option<&str>,
|
||||
file_tree_html: Option<&str>,
|
||||
initial_tree_mode: Option<&str>,
|
||||
file_tree_scope: Option<&str>,
|
||||
) -> String {
|
||||
let starred_rows = if projection.starred_items.is_empty() {
|
||||
String::new()
|
||||
@@ -500,8 +795,18 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|html| {
|
||||
let file_tree_scope_attr = file_tree_scope
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
format!(
|
||||
r#" data-mnote-filetree-scope="{}""#,
|
||||
escape_html(value)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div>"#,
|
||||
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
|
||||
escape_html(&projection.workspace_id),
|
||||
)
|
||||
})
|
||||
@@ -594,10 +899,11 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
);
|
||||
|
||||
format!(
|
||||
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button><button type="button" class="wolai-section-add wolai-section-add--folder" data-testid="wolai-sidebar-create-folder" data-mnote-action="create-folder" data-workspace-id="{}" title="新建文件夹" aria-label="新建文件夹"><span class="material-symbols-outlined" data-icon="folder_open" aria-hidden="true"></span></button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
render_symbol("star", "wolai-section-icon"),
|
||||
render_symbol("folder_open", "wolai-folder-icon"),
|
||||
escape_html(&projection.workspace_id),
|
||||
escape_html(&projection.workspace_id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -613,20 +919,105 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
|
||||
.as_deref()
|
||||
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
|
||||
.unwrap_or_default();
|
||||
let workspace_attr = item
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(|workspace_id| format!(r#" data-workspace-id="{}""#, escape_html(workspace_id)))
|
||||
.unwrap_or_default();
|
||||
let shortcut_id_attr = item
|
||||
.shortcut_id
|
||||
.as_deref()
|
||||
.map(|shortcut_id| format!(r#" data-mnote-shortcut-id="{}""#, escape_html(shortcut_id)))
|
||||
.unwrap_or_default();
|
||||
let source_kind_attr = item
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(|source_kind| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-source-kind="{}""#,
|
||||
escape_html(source_kind)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let root_uri_attr = item
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(|root_uri| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-root-uri="{}""#,
|
||||
escape_html(root_uri)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let aria_current = if item.active {
|
||||
r#" aria-current="page""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
|
||||
escape_html(&item.href),
|
||||
escape_html(&item.id),
|
||||
escape_html(&item.id),
|
||||
item.depth,
|
||||
item.active,
|
||||
let kind_attr = item
|
||||
.kind
|
||||
.as_deref()
|
||||
.map(|kind| format!(r#" data-mnote-shortcut-kind="{}""#, escape_html(kind)))
|
||||
.unwrap_or_default();
|
||||
let target_attr = item
|
||||
.target_id
|
||||
.as_deref()
|
||||
.map(|target_id| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-target-id="{}""#,
|
||||
escape_html(target_id)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let relative_attr = item
|
||||
.relative_path
|
||||
.as_deref()
|
||||
.map(|relative_path| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-relative-path="{}""#,
|
||||
escape_html(relative_path)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let document_attr = item
|
||||
.document_id
|
||||
.as_deref()
|
||||
.map(|document_id| {
|
||||
format!(
|
||||
r#" data-mnote-shortcut-document-id="{}""#,
|
||||
escape_html(document_id)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let row_body = format!(
|
||||
r#"<span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span>"#,
|
||||
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
|
||||
escape_html(&item.title),
|
||||
);
|
||||
let shortcut_action = item
|
||||
.shortcut_id
|
||||
.as_deref()
|
||||
.map(|_| {
|
||||
r#"<button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>"#
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if item.href.trim().is_empty() {
|
||||
return format!(
|
||||
r#"<div class="wolai-page-row{active_class}" role="button" tabindex="0" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</div>"#,
|
||||
escape_html(&item.id),
|
||||
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
|
||||
item.depth,
|
||||
item.active,
|
||||
);
|
||||
}
|
||||
format!(
|
||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</a>"#,
|
||||
escape_html(&item.href),
|
||||
escape_html(&item.id),
|
||||
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
|
||||
item.depth,
|
||||
item.active,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user