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
+250 -3
View File
@@ -46,6 +46,25 @@ pub struct DocumentSaveRequest {
pub block_count: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentTitleRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub title: String,
pub command_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentOptionsRequest {
pub document_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub options: Value,
pub command_name: Option<String>,
}
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
@@ -484,7 +503,7 @@ pub async fn save(
return Ok(ok_response(&context, result));
}
let command = RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
name: "page.body.save".into(),
command_id: format!("document_save_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
@@ -518,6 +537,73 @@ pub async fn save(
dry_run: false,
validate_only: false,
};
let mut result = execute_runtime_command_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
if let Value::Object(map) = &mut result {
map.insert("executedCommand".into(), json!("page.body.save"));
map.insert("canonicalCommand".into(), json!("page.body.save"));
map.insert("compatRoute".into(), json!("/api/documents/save"));
}
Ok(ok_response(&context, result))
}
pub async fn title(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentTitleRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let title = body.title.trim();
if title.is_empty() {
return Err(
WebError::bad_request_code("title_required", "缺少有效页面标题")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let command = RuntimeCommandEnvelopeWire {
name: "page.head.updateTitle".into(),
command_id: format!("page_title_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: None,
}),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
"title": title,
}),
preflight_data: None,
reason: Some("mnote-web page title update".into()),
refs: vec![body
.command_name
.unwrap_or_else(|| "page.head.updateTitle".into())],
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
state.config(),
&context,
@@ -525,7 +611,94 @@ pub async fn save(
command,
)
.await?;
Ok(ok_response(&context, result))
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"owner": "mnote-web",
"meta": {
"commandName": "page.head.updateTitle",
"canonicalCommand": "page.head.updateTitle",
},
"result": result,
})),
))
}
pub async fn options(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentOptionsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let command = RuntimeCommandEnvelopeWire {
name: "page.layout.updateOptions".into(),
command_id: format!("page_options_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: None,
}),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
"options": body.options,
}),
preflight_data: None,
reason: Some("mnote-web page layout update".into()),
refs: vec![body
.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into())],
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"owner": "mnote-web",
"meta": {
"commandName": "page.layout.updateOptions",
"canonicalCommand": "page.layout.updateOptions",
},
"result": result,
})),
))
}
#[cfg(test)]
@@ -597,6 +770,14 @@ mod tests {
),
mutation_fixtures_json: Some(
r#"{
"documents:updateTitle": {
"ok": true,
"title": "服务端页面(改名)"
},
"documents:updateOptions": {
"ok": true,
"show_toc": false
},
"documents:updateContent": {
"ok": true,
"updated_at": "2026-04-18T09:45:00Z",
@@ -691,7 +872,7 @@ mod tests {
}
#[tokio::test]
async fn documents_save_route_executes_documents_save_command() {
async fn documents_save_route_executes_page_body_save_command() {
let response = app()
.oneshot(
Request::builder()
@@ -728,5 +909,71 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["revision"], 8);
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
assert_eq!(payload["result"]["executedCommand"], "page.body.save");
assert_eq!(payload["result"]["canonicalCommand"], "page.body.save");
}
#[tokio::test]
async fn documents_title_route_executes_page_head_update_title() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/title")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": "doc_1",
"workspaceId": "ws_demo",
"title": "服务端页面(改名)",
"commandName": "page.head.updateTitle"
})
.to_string(),
))
.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["meta"]["commandName"], "page.head.updateTitle");
assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
}
#[tokio::test]
async fn documents_options_route_executes_page_layout_update_options() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/options")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": "doc_1",
"workspaceId": "ws_demo",
"options": {
"showToc": false
},
"commandName": "page.layout.updateOptions"
})
.to_string(),
))
.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["meta"]["commandName"], "page.layout.updateOptions");
assert_eq!(payload["meta"]["canonicalCommand"], "page.layout.updateOptions");
}
}