Implement Rust web sidebar title and tree interactions

This commit is contained in:
lix-2026
2026-04-30 05:46:36 +08:00
parent 559c5ce652
commit 3cc090ba5e
35 changed files with 3029 additions and 424 deletions
+167 -52
View File
@@ -2,7 +2,8 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::{
execute_runtime_query_against_data, resolve_effective_workspace_id,
execute_runtime_query_against_data, execute_runtime_query_via_convex,
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::ssr::pages::search::SearchPage;
@@ -68,18 +69,30 @@ pub async fn shell(
.await
.unwrap_or_default();
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
let initial_results = load_search_results(
state.config(),
&context,
workspace_id,
search_query,
None,
20,
)
.await
.unwrap_or_else(|_| empty_search_projection());
let contract = json!({
"schema": "mnote.search_shell.v1",
"owner": "mnote-web",
"projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"shell": "search",
"workspaceId": workspace_id,
"query": search_query,
"initialResults": {
"queryName": "search.documents",
"results": []
"queryName": "search.documents.query",
"projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![]))
},
"island": {
"kind": "react_search_palette",
"kind": "search_interaction_island",
"mountId": "mnote-search-island",
"runtime": "SearchPaletteHost"
},
@@ -92,6 +105,7 @@ pub async fn shell(
workspace_id={workspace_id.to_string()}
search_query={search_query.to_string()}
sidebar_tree_html={sidebar_tree_html}
initial_results_html={render_initial_results_html(initial_results.get("results").and_then(Value::as_array))}
/>
});
let html = format!(
@@ -122,7 +136,7 @@ pub async fn shell(
}
pub async fn documents(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<SearchDocumentsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -137,49 +151,16 @@ pub async fn documents(
None
};
let result = if normalized_query.is_empty() {
json!({
"enqueueAssetIds": [],
"results": [],
})
} else {
execute_runtime_query_against_data(
&context,
Some(&effective_workspace_id),
RuntimeQueryEnvelopeWire {
name: "search.documents".into(),
payload: json!({
"query": normalized_query,
"workspaceId": effective_workspace_id,
"pageId": page_id,
"limit": body.limit.unwrap_or(30),
"titleOnly": filters.title_only.unwrap_or(false),
"exact": filters.exact.unwrap_or(false),
"includeOcr": filters.include_ocr.unwrap_or(false),
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
}),
},
json!({
"documents": [
{
"id": "doc_1",
"workspaceId": effective_workspace_id,
"title": "Rust Web 搜索结果",
"rawText": "mnote-web search documents transport",
"createdAt": "2026-04-28T00:00:00Z",
"updatedAt": "2026-04-28T00:00:00Z"
}
],
"mindmaps": [],
"tables": [],
"tableRows": [],
"assets": []
}),
)?
};
let result = load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?;
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
@@ -188,10 +169,12 @@ pub async fn documents(
headers,
Json(json!({
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"recent": [],
"meta": {
"owner": "mnote-web",
"queryName": "search.documents",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
},
@@ -199,10 +182,138 @@ pub async fn documents(
))
}
async fn load_search_results(
config: &crate::app::AppConfig,
context: &RequestContext,
workspace_id: &str,
query: &str,
page_id: Option<String>,
limit: u32,
) -> Result<Value, WebError> {
load_search_results_with_filters(
config,
context,
workspace_id,
query,
page_id,
limit,
SearchDocumentsFilters::default(),
)
.await
}
async fn load_search_results_with_filters(
config: &crate::app::AppConfig,
context: &RequestContext,
workspace_id: &str,
query: &str,
page_id: Option<String>,
limit: u32,
filters: SearchDocumentsFilters,
) -> Result<Value, WebError> {
if query.trim().is_empty() {
return Ok(empty_search_projection());
}
let runtime_query = RuntimeQueryEnvelopeWire {
name: "search.documents.query".into(),
payload: json!({
"query": query,
"workspaceId": workspace_id,
"pageId": page_id,
"limit": limit,
"titleOnly": filters.title_only.unwrap_or(false),
"exact": filters.exact.unwrap_or(false),
"includeOcr": filters.include_ocr.unwrap_or(false),
"timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
"timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
"customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
}),
};
match execute_runtime_query_via_convex(config, context, Some(workspace_id), runtime_query.clone()).await {
Ok(value) => Ok(value),
Err(_) => execute_runtime_query_against_data(
context,
Some(workspace_id),
runtime_query,
fallback_search_dataset(workspace_id),
),
}
}
fn empty_search_projection() -> Value {
json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
"results": [],
})
}
fn fallback_search_dataset(workspace_id: &str) -> Value {
json!({
"documents": [
{
"id": "doc_1",
"workspaceId": workspace_id,
"title": "Rust Web 搜索结果",
"rawText": "mnote-web search documents transport",
"createdAt": "2026-04-28T00:00:00Z",
"updatedAt": "2026-04-28T00:00:00Z"
}
],
"mindmaps": [],
"tables": [],
"tableRows": [],
"assets": []
})
}
fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
let Some(results) = results else {
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
};
if results.is_empty() {
return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
}
let items = results
.iter()
.map(|item| {
let title = item
.get("title")
.and_then(Value::as_str)
.unwrap_or("无标题");
let snippet = item
.get("snippet")
.and_then(Value::as_str)
.unwrap_or_default();
let href = item
.get("publicPath")
.and_then(Value::as_str)
.unwrap_or("#");
format!(
r#"<a class="search-result" data-search-result-owner="rust-kernel" href="{}"><strong>{}</strong><span>{}</span></a>"#,
escape_html(href),
escape_html(title),
escape_html(snippet)
)
})
.collect::<Vec<_>>()
.join("");
format!(r#"<div class="search-result-list">{items}</div>"#)
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn stamp_search_headers(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
@@ -275,8 +386,10 @@ mod tests {
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-mnote-shell=\"search\""));
assert!(html.contains("mnote.search_shell.v1"));
assert!(html.contains("react_search_palette"));
assert!(html.contains("search.documents"));
assert!(html.contains("search_interaction_island"));
assert!(html.contains("search.documents.query"));
assert!(html.contains("search-result"));
assert!(html.contains("Rust Web 搜索结果"));
}
#[tokio::test]
@@ -327,6 +440,8 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["meta"]["owner"], "mnote-web");
assert_eq!(payload["meta"]["queryName"], "search.documents");
assert_eq!(payload["meta"]["queryName"], "search.documents.query");
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
assert_eq!(payload["projectionOwner"], "rust-kernel");
}
}