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
+655 -10
View File
@@ -21,8 +21,11 @@ use core_protocol::{
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview,
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock,
PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef,
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, PageBody, PageHead,
PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, PatchBlock,
PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef,
ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats,
};
use event_log::DomainEventRecord;
@@ -412,6 +415,13 @@ struct DocumentContentQueryPayload {
workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PageAggregateQueryPayload {
document_id: String,
workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MindmapGetQueryPayload {
@@ -686,6 +696,19 @@ struct MindmapPutCommandPayload {
create_only: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MindmapCommandApplyPayload {
document_id: String,
mindmap_id: String,
#[serde(default)]
workspace_id: Option<String>,
#[serde(default)]
commands: Vec<MindmapCommand>,
#[serde(default)]
projection_revision: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MindmapDeleteCommandPayload {
@@ -2927,10 +2950,39 @@ fn execute_query(
}),
}))
}
"mindmaps.get" => {
let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?;
"page.aggregate.get" => {
let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "mindmaps.get".into(),
name: "page.aggregate.get".into(),
payload: core_protocol::GetPageContent {
page_id: payload.document_id.clone(),
workspace_id: payload.workspace_id.clone(),
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"documentId": payload.document_id,
"workspaceId": payload.workspace_id,
}),
}))
}
"mindmaps.get" | "mindmap.projection.get" => {
let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?;
let query_name = if query_wire.name == "mindmap.projection.get" {
"mindmap.projection.get"
} else {
"mindmaps.get"
};
let query = QueryEnvelope {
name: query_name.into(),
payload: GetMindmap {
document_id: payload.document_id.clone(),
mindmap_id: payload.mindmap_id.clone(),
@@ -2997,15 +3049,20 @@ fn execute_query(
}),
}))
}
"search.documents" => {
"search.documents" | "search.documents.query" => {
let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?;
let query_name = if query_wire.name == "search.documents.query" {
"search.documents.query"
} else {
"search.documents"
};
let time_range = payload.time_range.clone().unwrap_or_else(|| "any".into());
let time_field = payload
.time_field
.clone()
.unwrap_or_else(|| "updated".into());
let query = QueryEnvelope {
name: "search.documents".into(),
name: query_name.into(),
payload: SearchDocuments {
query: payload.query.clone(),
workspace_id: payload.workspace_id.clone(),
@@ -5104,6 +5161,157 @@ fn build_document_content_result(
})
}
fn build_page_aggregate_projection_result(
data: &Value,
document_id: &str,
workspace_id: Option<&str>,
source: PageAggregateSource,
) -> Result<PageAggregateProjection, BridgeError> {
if data.is_null() {
return Err(BridgeError::not_found("page.aggregate.get 未返回页面聚合数据"));
}
let meta = data.get("meta").unwrap_or(data);
let content_result = data
.get("content")
.filter(|value| {
value.get("content").is_some()
|| value.get("revision").is_some()
|| value.get("pageSubtree").is_some()
|| value.get("page_subtree").is_some()
})
.unwrap_or(data);
let resolved_document_id = read_trimmed_string_field(meta, &["id", "documentId"])
.unwrap_or_else(|| document_id.to_string());
let resolved_workspace_id = read_trimmed_string_field(meta, &["workspace_id", "workspaceId"])
.or_else(|| workspace_id.map(ToOwned::to_owned))
.unwrap_or_else(|| "default".into());
let title = read_trimmed_string_field(meta, &["title"]).unwrap_or_else(|| "无标题".into());
let updated_at = read_trimmed_string_field(meta, &["updated_at", "updatedAt"]);
let parent_id = read_trimmed_string_field(meta, &["parent_id", "parentId"]);
let content = content_result
.get("content")
.cloned()
.unwrap_or_else(|| Value::Array(vec![]));
let revision = content_result
.get("revision")
.cloned()
.unwrap_or(Value::Null);
let conflict_detection_key = content_result
.get("conflictDetectionKey")
.or_else(|| content_result.get("conflict_detection_key"))
.cloned()
.unwrap_or(Value::Null);
let page_subtree = content_result
.get("pageSubtree")
.or_else(|| content_result.get("page_subtree"))
.cloned()
.unwrap_or_else(|| json!({ "rootNodeId": resolved_document_id }));
let page_options = PageOptions {
wide_layout: bool_field(meta, "wide_layout")
.or_else(|| bool_field(meta, "wideLayout"))
.unwrap_or(false),
small_text: bool_field(meta, "use_small_text")
.or_else(|| bool_field(meta, "smallText"))
.unwrap_or(false),
layout_density: read_trimmed_string_field(meta, &["layout_density", "layoutDensity"])
.unwrap_or_else(|| "normal".into()),
show_heading_numbers: bool_field(meta, "show_heading_numbers")
.or_else(|| bool_field(meta, "showHeadingNumbers"))
.unwrap_or(true),
show_toc: bool_field(meta, "show_toc")
.or_else(|| bool_field(meta, "showToc"))
.unwrap_or(false),
show_structure: bool_field(meta, "show_structure")
.or_else(|| bool_field(meta, "showStructure"))
.unwrap_or(false),
protect_editing: bool_field(meta, "protect_editing")
.or_else(|| bool_field(meta, "protectEditing"))
.unwrap_or(false),
show_word_count: bool_field(meta, "show_word_count")
.or_else(|| bool_field(meta, "showWordCount"))
.unwrap_or(true),
collapse_backlinks: bool_field(meta, "collapse_backlinks")
.or_else(|| bool_field(meta, "collapseBacklinks"))
.unwrap_or(false),
page_font: read_trimmed_string_field(meta, &["page_font", "pageFont"])
.unwrap_or_else(|| "default".into()),
hide_child_pages: bool_field(meta, "hide_child_pages")
.or_else(|| bool_field(meta, "hideChildPages"))
.unwrap_or(false),
show_block_ref_count: bool_field(meta, "show_block_ref_count")
.or_else(|| bool_field(meta, "showBlockRefCount"))
.unwrap_or(false),
embed_default_block_id: meta
.get("embed_default_block_id")
.or_else(|| meta.get("embedDefaultBlockId"))
.cloned()
.unwrap_or(Value::Null),
};
let layout_options = serde_json::to_value(&page_options).map_err(|error| {
BridgeError::transport(format!("PageOptions 序列化失败: {error}"))
})?;
let revision_ref = revision
.as_u64()
.map(|value| format!("{resolved_document_id}:{value}"));
Ok(PageAggregateProjection {
schema: PageAggregateProjection::SCHEMA.into(),
projection_version: PageAggregateProjection::VERSION,
source,
page_id: resolved_document_id.clone(),
parent_id,
title: title.clone(),
path: vec![resolved_document_id.clone()],
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
body_ref: revision_ref,
layout_options,
updated_at: updated_at.clone(),
identity: PageIdentity {
document_id: resolved_document_id,
workspace_id: resolved_workspace_id,
},
head: PageHead {
title,
updated_at: updated_at.map(Value::String).unwrap_or(Value::Null),
permissions: PagePermissions {
read_only: bool_field(meta, "can_edit").map(|can_edit| !can_edit).unwrap_or(false),
disable_download: bool_field(meta, "disable_download")
.or_else(|| bool_field(meta, "disableDownload"))
.unwrap_or(false),
disable_copy: bool_field(meta, "disable_copy")
.or_else(|| bool_field(meta, "disableCopy"))
.unwrap_or(false),
},
},
layout: PageLayout { page_options },
body: PageBody {
content,
revision,
conflict_detection_key,
},
tree: PageTree { page_subtree },
stats: PageStats {
word_count: meta.get("word_count").and_then(Value::as_u64).unwrap_or(0),
character_count: meta
.get("character_count")
.and_then(Value::as_u64)
.unwrap_or(0),
block_count: meta.get("block_count").and_then(Value::as_u64).unwrap_or(0),
todo_total: meta
.get("todo_total")
.or_else(|| meta.get("todo_total_count"))
.and_then(Value::as_u64)
.unwrap_or(0),
todo_done: meta
.get("todo_done")
.or_else(|| meta.get("todo_done_count"))
.and_then(Value::as_u64)
.unwrap_or(0),
},
})
}
fn normalize_mindmap_from_value(data: &Value) -> Result<MindmapTreeNode, BridgeError> {
if data.is_null() {
return Ok(default_mindmap_tree());
@@ -5131,6 +5339,98 @@ fn normalize_mindmap_from_value(data: &Value) -> Result<MindmapTreeNode, BridgeE
Ok(tree)
}
fn build_mindmap_projection_result(
data: &Value,
mindmap_id: &str,
) -> Result<MindmapProjection, BridgeError> {
let revision = data
.get("revision")
.or_else(|| data.get("meta").and_then(|meta| meta.get("revision")))
.and_then(Value::as_u64)
.unwrap_or(1);
let tree_input = data
.get("data")
.filter(|value| value.get("data").is_some() || value.get("children").is_some())
.unwrap_or(data);
let tree = normalize_mindmap_from_value(tree_input)?;
let root_node = tree
.data
.uid
.clone()
.unwrap_or_else(|| "root".into());
let mut nodes = Vec::new();
let mut edges = Vec::new();
collect_mindmap_projection_rows(&tree, None, &mut nodes, &mut edges);
Ok(MindmapProjection {
schema: "mnote.mindmap_projection.v1".into(),
map_id: mindmap_id.into(),
root_node,
nodes,
edges,
layout_hints: data
.get("layoutHints")
.or_else(|| data.get("layout_hints"))
.cloned()
.unwrap_or_else(|| json!({ "layout": "right" })),
revision,
owner: MindmapProjectionOwner::RustKernel,
})
}
fn collect_mindmap_projection_rows(
node: &MindmapTreeNode,
parent_id: Option<&str>,
nodes: &mut Vec<MindmapProjectionNode>,
edges: &mut Vec<MindmapProjectionEdge>,
) {
let node_id = node
.data
.uid
.clone()
.unwrap_or_else(|| format!("node_{}", nodes.len() + 1));
let title = node
.data
.text
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "未命名节点".into());
if let Some(parent_id) = parent_id {
edges.push(MindmapProjectionEdge {
edge_id: format!("{parent_id}->{node_id}"),
from_node_id: parent_id.into(),
to_node_id: node_id.clone(),
edge_kind: "mindmap_child".into(),
});
}
nodes.push(MindmapProjectionNode {
node_id: node_id.clone(),
title,
parent_id: parent_id.map(ToOwned::to_owned),
});
for child in &node.children {
collect_mindmap_projection_rows(child, Some(&node_id), nodes, edges);
}
}
fn search_documents_canonical_projection(evaluation: SearchDocumentsEvaluation) -> Value {
let mut payload = serde_json::to_value(evaluation).unwrap_or_else(|_| json!({
"enqueueAssetIds": [],
"results": []
}));
if let Some(map) = payload.as_object_mut() {
map.insert("projectionOwner".into(), json!("rust-kernel"));
if let Some(results) = map.get_mut("results").and_then(Value::as_array_mut) {
for result in results {
if let Some(result_map) = result.as_object_mut() {
result_map.insert("projectionOwner".into(), json!("rust-kernel"));
}
}
}
}
payload
}
fn walk_block_summaries(root_blocks: &[Value], max_nodes: usize) -> Vec<RuntimeBlockSummary> {
let mut queue = VecDeque::new();
for block in root_blocks {
@@ -7082,6 +7382,18 @@ fn execute_query_result(
BridgeError::transport(format!("documents.content.get result 序列化失败: {error}"))
})
}
"page.aggregate.get" => {
let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?;
let result = build_page_aggregate_projection_result(
&data,
&payload.document_id,
payload.workspace_id.as_deref(),
PageAggregateSource::KernelProjection,
)?;
serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}"))
})
}
"kernel.node.get" => {
let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?;
let node = normalize_kernel_node_from_record(&data, payload.workspace_id.as_deref())?;
@@ -7167,7 +7479,16 @@ fn execute_query_result(
BridgeError::transport(format!("mindmaps.get result 序列化失败: {error}"))
})
}
"search.documents" => {
"mindmap.projection.get" => {
let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?;
let result = build_mindmap_projection_result(&data, &payload.mindmap_id)?;
serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!(
"mindmap.projection.get result 序列化失败: {error}"
))
})
}
"search.documents" | "search.documents.query" => {
let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?;
let dataset: SearchDocumentsDataset =
serde_json::from_value(data).map_err(|error| {
@@ -7191,10 +7512,15 @@ fn execute_query_result(
},
&dataset,
);
serde_json::to_value(SearchDocumentsEvaluation {
let legacy_result = SearchDocumentsEvaluation {
enqueue_asset_ids: result.enqueue_asset_ids,
results: result.results,
})
};
if query_wire.name == "search.documents.query" {
serde_json::to_value(search_documents_canonical_projection(legacy_result))
} else {
serde_json::to_value(legacy_result)
}
.map_err(|error| {
BridgeError::transport(format!("search.documents result 序列化失败: {error}"))
})
@@ -7985,6 +8311,45 @@ fn execute_command(
}),
}))
}
"mindmap.command.apply" => {
let payload: MindmapCommandApplyPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "mindmap.command.apply".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()).or(Some(TargetRef {
workspace_id: payload.workspace_id.clone(),
page_id: Some(payload.document_id.clone()),
block_id: None,
})),
payload: payload.commands.clone(),
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: json!({
"documentId": payload.document_id,
"mindmapId": payload.mindmap_id,
"commands": payload.commands,
"projectionRevision": payload.projection_revision,
"canonicalCommand": "mindmap.command.apply",
}),
}))
}
"mindmaps.delete" => {
let payload: MindmapDeleteCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
@@ -9333,6 +9698,164 @@ mod tests {
}
}
#[test]
fn page_aggregate_get_query_executes_into_core_projection() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "page.aggregate.get".into(),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
}),
},
data: Some(json!({
"meta": {
"id": "doc_1",
"workspace_id": "ws_1",
"title": "聚合页面",
"parent_id": "root",
"updated_at": "2026-04-29T00:00:00Z"
},
"content": {
"content": [],
"revision": 7,
"conflict_detection_key": "doc_1:7",
"pageSubtree": {"rootNodeId": "doc_1"}
}
})),
})
.expect("page aggregate query should build");
assert_eq!(result["schema"], json!("mnote.page_aggregate.v1"));
assert_eq!(result["projectionVersion"], json!(1));
assert_eq!(result["source"], json!("KernelProjection"));
assert_eq!(result["pageId"], json!("doc_1"));
assert_eq!(result["identity"]["documentId"], json!("doc_1"));
assert_eq!(result["body"]["revision"], json!(7));
}
#[test]
fn search_documents_query_canonical_facade_keeps_projection_owner() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "search.documents.query".into(),
payload: json!({
"query": "rust",
"workspaceId": "ws_1",
"pageId": null,
"limit": 10,
"titleOnly": false,
"exact": false,
"includeOcr": true,
"timeRange": "any",
"timeField": "updated",
"customRangeFrom": null,
"customRangeTo": null,
}),
},
data: Some(json!({
"documents": [
{
"id": "page_1",
"workspaceId": "ws_1",
"title": "Rust Notes",
"rawText": "这里有 rust 搜索内容",
"createdAt": "2026-04-15T00:00:00Z",
"updatedAt": "2026-04-15T01:00:00Z"
}
],
"mindmaps": [],
"tables": [],
"tableRows": [],
"assets": []
})),
})
.expect("canonical search query result should build");
assert_eq!(result["projectionOwner"], json!("rust-kernel"));
assert_eq!(result["results"][0]["projectionOwner"], json!("rust-kernel"));
assert_eq!(result["results"][0]["id"], json!("page_1"));
}
#[test]
fn mindmap_projection_get_query_returns_kernel_projection() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "mindmap.projection.get".into(),
payload: json!({
"documentId": "doc_1",
"mindmapId": "mind_1",
"workspaceId": "ws_1",
}),
},
data: Some(json!({
"data": {
"data": {"uid": "root", "text": "中心主题"},
"children": [
{"data": {"uid": "child_1", "text": "子节点"}, "children": []}
]
},
"revision": 3
})),
})
.expect("mindmap projection should build");
assert_eq!(result["schema"], json!("mnote.mindmap_projection.v1"));
assert_eq!(result["owner"], json!("rust-kernel"));
assert_eq!(result["mapId"], json!("mind_1"));
assert_eq!(result["revision"], json!(3));
}
#[test]
fn mindmap_command_apply_plan_uses_kernel_command_facade() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "mindmap.command.apply".into(),
command_id: "cmd_mindmap_apply".into(),
idempotency_key: None,
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: None,
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: None,
payload: json!({
"documentId": "doc_1",
"mindmapId": "mind_1",
"commands": [
{"type": "renameNode", "mapId": "mind_1", "nodeId": "root", "title": "新标题"}
],
"projectionRevision": 3
}),
preflight_data: None,
reason: Some("mindmap command facade".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("mindmap command facade should plan");
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "mindmap.command.apply");
assert_eq!(plan.function_name, "mindmaps:applyCommand");
assert_eq!(plan.args_json["canonicalCommand"], json!("mindmap.command.apply"));
}
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => {
panic!("expected command plan")
}
}
}
#[test]
fn search_documents_query_executes_in_rust_runtime() {
let result = execute_runtime_query(RuntimeInput::Query {
@@ -10064,6 +10587,128 @@ mod tests {
);
}
#[test]
fn page_body_save_command_plan_uses_canonical_page_command() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "page.body.save".into(),
command_id: "cmd_page_body_save".into(),
idempotency_key: None,
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: None,
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 1,
"content": [],
"conflictDetectionKey": "doc_1:1"
}),
preflight_data: None,
reason: Some("canonical body save".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("page.body.save plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.body.save");
assert_eq!(plan.function_name, "documents:updateContent");
}
#[test]
fn page_head_update_title_command_plan_uses_canonical_page_command() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "page.head.updateTitle".into(),
command_id: "cmd_page_head_title".into(),
idempotency_key: None,
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: None,
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: None,
payload: json!({
"documentId": "doc_1",
"title": "新标题"
}),
preflight_data: None,
reason: Some("canonical title update".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("page.head.updateTitle plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.head.updateTitle");
assert_eq!(plan.function_name, "documents:updateTitle");
}
#[test]
fn page_layout_update_options_command_plan_uses_canonical_page_command() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "page.layout.updateOptions".into(),
command_id: "cmd_page_layout_options".into(),
idempotency_key: None,
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: None,
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: None,
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"options": {"showToc": true}
}),
preflight_data: None,
reason: Some("canonical layout update".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("page.layout.updateOptions plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.layout.updateOptions");
assert_eq!(plan.function_name, "documents:updateOptions");
}
#[test]
fn documents_save_command_plan_falls_back_from_invalid_editor_document_to_tiptap() {
let plan = execute_runtime_input(RuntimeInput::Command {