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
+63
View File
@@ -1047,5 +1047,68 @@
"task-026"
],
"note": "树域 Rust family 默认执行面已完成;主 Web 执行面仍需通过 task-019 至 task-026 迁到 mnote-web / Rust family。"
},
"rust_web_3_4_undo": {
"source_doc": "design/03-rust-web/process/3-4-undo.md",
"status": "in_progress",
"dependency_chain": "A -> B/C -> D/E/F -> G -> H",
"tasks": [
{
"id": "task-3-4-A",
"title": "Page Aggregate 升级为 core-protocol projection contract",
"status": "in_progress",
"depends_on": [],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol page_aggregate && cargo test -p bridge-runtime page_aggregate && cargo test -p mnote-web page_aggregate"
},
{
"id": "task-3-4-B",
"title": "page.* 成为页面默认写命令族",
"status": "in_progress",
"depends_on": ["task-3-4-A"],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime page_body_save && cargo test -p bridge-runtime page_head_update_title && cargo test -p bridge-runtime page_layout_update_options && cargo test -p mnote-web documents_save"
},
{
"id": "task-3-4-C",
"title": "3000 Rust shell 消费 tree realtime event stream",
"status": "in_progress",
"depends_on": ["task-3-4-A"],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_events && cargo test -p mnote-web tree_command && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js"
},
{
"id": "task-3-4-D",
"title": "Search 收口为 server-first / kernel-aware 主链",
"status": "in_progress",
"depends_on": ["task-3-4-B", "task-3-4-C"],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol search && cargo test -p bridge-runtime search_documents_query && cargo test -p mnote-web search && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task125-rust-web-search-server-first-smoke.js"
},
{
"id": "task-3-4-E",
"title": "AI bridge 与结构化写链收口",
"status": "in_progress",
"depends_on": ["task-3-4-B", "task-3-4-C"],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol ai && cargo test -p mnote-web hermes && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task126-rust-web-ai-bridge-structured-write-smoke.js"
},
{
"id": "task-3-4-F",
"title": "Mindmap 成为 kernel projection / command truth",
"status": "in_progress",
"depends_on": ["task-3-4-B", "task-3-4-C"],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap && cargo test -p bridge-runtime mindmap && cargo test -p mnote-web mindmap && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task127-rust-web-mindmap-kernel-projection-smoke.js"
},
{
"id": "task-3-4-G",
"title": "Legacy Next / React 兼容层退役 gate",
"status": "in_progress",
"depends_on": ["task-3-4-D", "task-3-4-E", "task-3-4-F"],
"validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web gateway && cargo test -p mnote-web legacy_next && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task117-next-retirement-guard.js"
},
{
"id": "task-3-4-H",
"title": "设计文档状态治理与执行节奏",
"status": "in_progress",
"depends_on": ["task-3-4-G"],
"validation": "cd /mnt/Data1T/mnote && rg -n \"\\\\[ \\\\]\" design/03-rust-web/process/3-4-undo.md && rg -n \"\\\\[done\\\\]|\\\\[recycle\\\\]\" design/03-rust-web design/old && git diff --check && git status --short --untracked-files=all"
}
]
}
}
+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 {
+68
View File
@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiSession {
pub session_id: String,
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
pub owner: AiRuntimeOwner,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AiRuntimeOwner {
RustWebHermes,
CompatReactIsland,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AiToolCall {
pub call_id: String,
pub tool_name: String,
pub args: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AiEvent {
pub event_id: String,
pub session_id: String,
pub kind: AiEventKind,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AiEventKind {
SessionCreated,
ToolCallPlanned,
ToolCallApplied,
StructuredWriteCompleted,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiStructuredWriteResult {
pub ok: bool,
pub write_kind: AiStructuredWriteKind,
pub command_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AiStructuredWriteKind {
SummaryNode,
AiNoteNode,
ReferenceEdge,
PageBody,
}
+2 -1
View File
@@ -20,7 +20,8 @@ pub use model::{
};
pub use tiptap::{
EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType,
TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs,
TiptapBlockquoteAttrs, TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapListAttrs,
TiptapListItemAttrs, TiptapMark, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs,
};
#[cfg(test)]
+131 -1
View File
@@ -1,12 +1,19 @@
pub mod command;
pub mod common;
pub mod ai;
pub mod editor;
pub mod governance;
pub mod kernel;
pub mod mindmap;
pub mod page_aggregate;
pub mod query;
pub mod search;
pub mod tool;
pub use ai::{
AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind,
AiStructuredWriteResult, AiToolCall,
};
pub use command::{
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
@@ -42,12 +49,23 @@ pub use kernel::{
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
};
pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
pub use mindmap::{
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
MindmapTreeNode,
};
pub use page_aggregate::{
PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout,
PageOptions, PagePermissions, PageStats, PageTree,
};
pub use query::{
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
};
pub use search::{
SearchHighlight, SearchProjectionOwner, SearchQuery, SearchResultProjection, SearchScope,
};
pub use tool::{
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
@@ -232,4 +250,116 @@ mod tests {
assert!(recovery.write_toolset);
assert_eq!(recovery.tool_names, &["event_replay", "index_rebuild"]);
}
#[test]
fn page_aggregate_projection_contract_covers_kernel_owned_fields() {
let projection = PageAggregateProjection {
schema: "mnote.page_aggregate.v1".into(),
projection_version: 1,
source: PageAggregateSource::KernelProjection,
page_id: "page_1".into(),
parent_id: Some("root".into()),
title: "Rust 页面".into(),
path: vec!["root".into(), "page_1".into()],
sidebar_tree_membership: vec!["workspace-sidebar".into()],
body_ref: Some("page_1:body".into()),
layout_options: serde_json::json!({"wideLayout": true}),
updated_at: Some("2026-04-29T00:00:00Z".into()),
identity: page_aggregate::PageIdentity {
document_id: "page_1".into(),
workspace_id: "ws_demo".into(),
},
head: page_aggregate::PageHead {
title: "Rust 页面".into(),
updated_at: serde_json::json!("2026-04-29T00:00:00Z"),
permissions: page_aggregate::PagePermissions {
read_only: false,
disable_download: false,
disable_copy: false,
},
},
layout: page_aggregate::PageLayout {
page_options: page_aggregate::PageOptions::default(),
},
body: page_aggregate::PageBody {
content: serde_json::json!([]),
revision: serde_json::json!(7),
conflict_detection_key: serde_json::json!("page_1:7"),
},
tree: page_aggregate::PageTree {
page_subtree: serde_json::json!({"rootNodeId": "page_1"}),
},
stats: page_aggregate::PageStats::default(),
};
assert_eq!(projection.source, PageAggregateSource::KernelProjection);
assert_eq!(projection.projection_version, 1);
assert_eq!(projection.page_id, "page_1");
assert_eq!(projection.identity.document_id, "page_1");
}
#[test]
fn search_projection_contract_marks_owner_and_scope() {
let result = SearchResultProjection {
id: "page_1".into(),
title: "Rust 搜索".into(),
snippet: "Rust Web".into(),
updated_at: Some("2026-04-29T00:00:00Z".into()),
created_at: None,
match_field: "title".into(),
has_ocr: false,
public_path: "/documents/page_1".into(),
node_id: Some("page_1".into()),
subtree_root_id: Some("page_1".into()),
evidence: vec![],
score: 3.0,
projection_owner: SearchProjectionOwner::RustKernel,
};
let query = SearchQuery {
query: "Rust".into(),
workspace_id: "ws_demo".into(),
scope: SearchScope::Workspace,
page_id: None,
limit: 20,
};
assert_eq!(query.scope, SearchScope::Workspace);
assert_eq!(result.projection_owner, SearchProjectionOwner::RustKernel);
}
#[test]
fn ai_protocol_contract_exposes_structured_write_result() {
let result = AiStructuredWriteResult {
ok: true,
write_kind: AiStructuredWriteKind::SummaryNode,
command_name: "tree.node.create".into(),
target_id: Some("summary_1".into()),
revision: Some(2),
};
assert_eq!(result.write_kind, AiStructuredWriteKind::SummaryNode);
assert_eq!(result.command_name, "tree.node.create");
}
#[test]
fn mindmap_projection_and_command_contract_cover_kernel_truth() {
let projection = MindmapProjection {
schema: "mnote.mindmap_projection.v1".into(),
map_id: "mind_1".into(),
root_node: "root".into(),
nodes: vec![],
edges: vec![],
layout_hints: serde_json::json!({"layout": "right"}),
revision: 1,
owner: MindmapProjectionOwner::RustKernel,
};
let command = MindmapCommand::RenameNode {
map_id: "mind_1".into(),
node_id: "root".into(),
title: "新标题".into(),
};
assert_eq!(projection.owner, MindmapProjectionOwner::RustKernel);
assert!(matches!(command, MindmapCommand::RenameNode { .. }));
}
}
+90
View File
@@ -2,6 +2,96 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapProjection {
pub schema: String,
pub map_id: String,
pub root_node: String,
#[serde(default)]
pub nodes: Vec<MindmapProjectionNode>,
#[serde(default)]
pub edges: Vec<MindmapProjectionEdge>,
#[serde(default)]
pub layout_hints: Value,
pub revision: u64,
pub owner: MindmapProjectionOwner,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapProjectionNode {
pub node_id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapProjectionEdge {
pub edge_id: String,
pub from_node_id: String,
pub to_node_id: String,
pub edge_kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MindmapProjectionOwner {
RustKernel,
CompatBlob,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MindmapCommand {
CreateNode {
#[serde(rename = "mapId", alias = "map_id")]
map_id: String,
#[serde(rename = "parentId", alias = "parent_id")]
parent_id: String,
node: MindmapNodeInput,
},
RenameNode {
#[serde(rename = "mapId", alias = "map_id")]
map_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
title: String,
},
MoveNode {
#[serde(rename = "mapId", alias = "map_id")]
map_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
#[serde(rename = "newParentId", alias = "new_parent_id")]
new_parent_id: String,
#[serde(rename = "sortOrder", alias = "sort_order")]
sort_order: Option<i64>,
},
DeleteNode {
#[serde(rename = "mapId", alias = "map_id")]
map_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
},
SetLayout {
#[serde(rename = "mapId", alias = "map_id")]
map_id: String,
#[serde(rename = "layoutHints", alias = "layout_hints")]
layout_hints: Value,
},
AttachPageRef {
#[serde(rename = "mapId", alias = "map_id")]
map_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
#[serde(rename = "pageId", alias = "page_id")]
page_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapNodeRef {
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub enum PageAggregateSource {
KernelProjection,
CompatMetaContentJoin,
Fixture,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageAggregateProjection {
pub schema: String,
pub projection_version: u32,
pub source: PageAggregateSource,
pub page_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub title: String,
#[serde(default)]
pub path: Vec<String>,
#[serde(default)]
pub sidebar_tree_membership: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_ref: Option<String>,
pub layout_options: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
pub identity: PageIdentity,
pub head: PageHead,
pub layout: PageLayout,
pub body: PageBody,
pub tree: PageTree,
pub stats: PageStats,
}
impl PageAggregateProjection {
pub const SCHEMA: &'static str = "mnote.page_aggregate.v1";
pub const VERSION: u32 = 1;
pub fn source_label(&self) -> &'static str {
match self.source {
PageAggregateSource::KernelProjection => "rust-kernel",
PageAggregateSource::CompatMetaContentJoin => "compat-join",
PageAggregateSource::Fixture => "fixture",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PageIdentity {
pub document_id: String,
pub workspace_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageHead {
pub title: String,
pub updated_at: Value,
pub permissions: PagePermissions,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PagePermissions {
pub read_only: bool,
pub disable_download: bool,
pub disable_copy: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageLayout {
pub page_options: PageOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageOptions {
pub wide_layout: bool,
pub small_text: bool,
pub layout_density: String,
pub show_heading_numbers: bool,
pub show_toc: bool,
pub show_structure: bool,
pub protect_editing: bool,
pub show_word_count: bool,
pub collapse_backlinks: bool,
pub page_font: String,
pub hide_child_pages: bool,
pub show_block_ref_count: bool,
pub embed_default_block_id: Value,
}
impl Default for PageOptions {
fn default() -> Self {
Self {
wide_layout: false,
small_text: false,
layout_density: "normal".into(),
show_heading_numbers: true,
show_toc: false,
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: false,
page_font: "default".into(),
hide_child_pages: false,
show_block_ref_count: false,
embed_default_block_id: Value::Null,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageBody {
pub content: Value,
pub revision: Value,
pub conflict_detection_key: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageTree {
pub page_subtree: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct PageStats {
pub word_count: u64,
pub character_count: u64,
pub block_count: u64,
pub todo_total: u64,
pub todo_done: u64,
}
+63
View File
@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SearchQuery {
pub query: String,
pub workspace_id: String,
pub scope: SearchScope,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_id: Option<String>,
pub limit: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SearchScope {
Workspace,
CurrentPage,
Pinned,
Recent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SearchResultProjection {
pub id: String,
pub title: String,
pub snippet: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
pub match_field: String,
pub has_ocr: bool,
pub public_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subtree_root_id: Option<String>,
#[serde(default)]
pub evidence: Vec<SearchHighlight>,
pub score: f64,
pub projection_owner: SearchProjectionOwner,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SearchHighlight {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
pub snippet: String,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub meta: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum SearchProjectionOwner {
RustKernel,
CompatIndex,
}
+1 -1
View File
@@ -42,7 +42,7 @@ impl AppConfig {
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()),
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", true),
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false),
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
+7 -132
View File
@@ -1,136 +1,11 @@
//! 类型安全的 Page Aggregate 结构体,替代 serde_json::Value 的临时实现
//! Page Aggregate HTTP/SSR adapter
//!
//! 参考契约文档: design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md
//! 核心契约定义在 `core-protocol`mnote-web 仅保留 builder 便于
//! 迁移期把 meta/content join 适配为同一个 projection。
pub mod builder;
use serde::Serialize;
use serde_json::Value;
/// Page Aggregate 根结构
///
/// 对应 JSON 契约:
/// ```json
/// {
/// "schema": "mnote.page_aggregate.v1",
/// "identity": { ... },
/// "head": { ... },
/// "layout": { ... },
/// "body": { ... },
/// "tree": { ... },
/// "stats": { ... }
/// }
/// ```
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageAggregate {
pub schema: String,
pub identity: PageIdentity,
pub head: PageHead,
pub layout: PageLayout,
pub body: PageBody,
pub tree: PageTree,
pub stats: PageStats,
}
impl PageAggregate {
/// 创建一个新的 builder 用于构建 PageAggregate。
pub fn builder() -> builder::PageAggregateBuilder {
builder::PageAggregateBuilder::new()
}
/// 序列化为 `serde_json::Value`,输出与当前 `build_page_aggregate_snapshot` 完全一致的 JSON。
///
/// 使用 `#[serde(rename_all = "camelCase")]` 确保字段名与前端契约一致。
pub fn to_json_value(&self) -> Value {
serde_json::to_value(self).expect("PageAggregate 序列化不应失败")
}
/// 获取页面标题(`head.title`)的便捷方法。
pub fn head_title(&self) -> &str {
&self.head.title
}
}
/// 页面身份标识。
///
/// 标识这份页面聚合属于哪一个 page aggregate。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageIdentity {
pub document_id: String,
pub workspace_id: String,
}
/// 页面头部正式真相。
///
/// 承载页面头部的 title、updatedAt 及权限信息。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageHead {
pub title: String,
pub updated_at: Value,
pub permissions: PagePermissions,
}
/// 页面权限。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PagePermissions {
pub read_only: bool,
pub disable_download: bool,
pub disable_copy: bool,
}
/// 页面布局。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageLayout {
pub page_options: PageOptions,
}
/// 页面选项 / 编辑器运行时设置。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageOptions {
pub wide_layout: bool,
pub small_text: bool,
pub layout_density: String,
pub show_heading_numbers: bool,
pub show_toc: bool,
pub show_structure: bool,
pub protect_editing: bool,
pub show_word_count: bool,
pub collapse_backlinks: bool,
pub page_font: String,
pub hide_child_pages: bool,
pub show_block_ref_count: bool,
pub embed_default_block_id: Value,
}
/// 页面正文内容与保存元数据。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageBody {
pub content: Value,
pub revision: Value,
pub conflict_detection_key: Value,
}
/// 当前页面对应的子树投影。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTree {
pub page_subtree: Value,
}
/// 页面统计附属投影。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageStats {
pub word_count: u64,
pub character_count: u64,
pub block_count: u64,
pub todo_total: u64,
pub todo_done: u64,
}
pub use core_protocol::{
PageAggregateProjection as PageAggregate, PageAggregateSource, PageBody, PageHead,
PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree,
};
@@ -10,10 +10,10 @@
//! ```
use crate::page_aggregate::{
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions,
PageStats, PageTree,
PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
PagePermissions, PageStats, PageTree,
};
use serde_json::Value;
use serde_json::{to_value, Value};
/// PageAggregate 构建器。
///
@@ -23,6 +23,11 @@ use serde_json::Value;
pub struct PageAggregateBuilder {
document_id: String,
workspace_id: String,
source: PageAggregateSource,
parent_id: Option<String>,
path: Vec<String>,
sidebar_tree_membership: Vec<String>,
body_ref: Option<String>,
title: String,
updated_at: Value,
read_only: bool,
@@ -58,6 +63,11 @@ impl PageAggregateBuilder {
Self {
document_id: String::new(),
workspace_id: "default".to_string(),
source: PageAggregateSource::CompatMetaContentJoin,
parent_id: None,
path: Vec::new(),
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
body_ref: None,
title: "无标题".to_string(),
updated_at: Value::Null,
read_only: false,
@@ -100,6 +110,31 @@ impl PageAggregateBuilder {
self
}
pub fn source(mut self, value: PageAggregateSource) -> Self {
self.source = value;
self
}
pub fn parent_id(mut self, value: Option<String>) -> Self {
self.parent_id = value;
self
}
pub fn path(mut self, value: Vec<String>) -> Self {
self.path = value;
self
}
pub fn sidebar_tree_membership(mut self, value: Vec<String>) -> Self {
self.sidebar_tree_membership = value;
self
}
pub fn body_ref(mut self, value: Option<String>) -> Self {
self.body_ref = value;
self
}
// ── Head ──
pub fn title(mut self, value: impl Into<String>) -> Self {
@@ -249,8 +284,40 @@ impl PageAggregateBuilder {
/// 消费 builder 并产出 `PageAggregate`。
pub fn build(self) -> PageAggregate {
let page_options = PageOptions {
wide_layout: self.wide_layout,
small_text: self.small_text,
layout_density: self.layout_density,
show_heading_numbers: self.show_heading_numbers,
show_toc: self.show_toc,
show_structure: self.show_structure,
protect_editing: self.protect_editing,
show_word_count: self.show_word_count,
collapse_backlinks: self.collapse_backlinks,
page_font: self.page_font,
hide_child_pages: self.hide_child_pages,
show_block_ref_count: self.show_block_ref_count,
embed_default_block_id: self.embed_default_block_id,
};
let layout_options = to_value(&page_options).expect("PageOptions 序列化不应失败");
let updated_at_text = self.updated_at.as_str().map(ToOwned::to_owned);
let page_id = self.document_id.clone();
PageAggregate {
schema: "mnote.page_aggregate.v1".to_string(),
projection_version: PageAggregate::VERSION,
source: self.source,
page_id,
parent_id: self.parent_id,
title: self.title.clone(),
path: if self.path.is_empty() {
vec![self.document_id.clone()]
} else {
self.path
},
sidebar_tree_membership: self.sidebar_tree_membership,
body_ref: self.body_ref,
layout_options,
updated_at: updated_at_text,
identity: PageIdentity {
document_id: self.document_id,
workspace_id: self.workspace_id,
@@ -265,21 +332,7 @@ impl PageAggregateBuilder {
},
},
layout: PageLayout {
page_options: PageOptions {
wide_layout: self.wide_layout,
small_text: self.small_text,
layout_density: self.layout_density,
show_heading_numbers: self.show_heading_numbers,
show_toc: self.show_toc,
show_structure: self.show_structure,
protect_editing: self.protect_editing,
show_word_count: self.show_word_count,
collapse_backlinks: self.collapse_backlinks,
page_font: self.page_font,
hide_child_pages: self.hide_child_pages,
show_block_ref_count: self.show_block_ref_count,
embed_default_block_id: self.embed_default_block_id,
},
page_options,
},
body: PageBody {
content: self.content,
+2 -2
View File
@@ -42,8 +42,8 @@ pub async fn next_ai_agent_run(
trace_id: context.trace.trace_id,
target: format!("{}/bridge", state.config().hermes_base_path),
notes: vec![
"当前保留 Next route 兼容边界,后续用于把 /api/ai-agent/run 收口到 Rust Web 层",
"此占位实现不复制业务裁决,只声明桥接目标与迁移方向",
"/api/ai-agent/run 是 legacy compat endpointcanonical route 是 /api/hermes/bridge",
"结构化写入必须通过 Hermes/Rust bridge 再落到 page/tree/edge command",
],
})
}
+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");
}
}
+78 -5
View File
@@ -39,16 +39,48 @@ pub async fn health(
pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>,
Json(runtime_input): Json<RuntimeInput>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let session_id = format!("hermes_{}", context.trace.request_id);
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
Ok(runtime_input) => runtime_input,
Err(_) => {
return Ok((
StatusCode::OK,
stamp_ai_bridge_headers(),
Json(json!({
"ok": true,
"bridge": "hermes_session",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"structuredWrite": {
"owner": "rust-web-hermes",
"allowedCommands": [
"page.body.save",
"tree.node.create",
"kernel.edge.attach"
]
},
"compatPayload": payload,
})),
));
}
};
let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) {
Ok(result) => json!({
"ok": true,
"bridge": "hermes_runtime_result",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(),
"contract": ai_bridge_contract(&session_id),
"result": result,
}),
Err(error) => {
@@ -67,9 +99,12 @@ pub async fn bridge_runtime(
json!({
"ok": success.ok,
"bridge": "hermes_runtime_plan",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(),
"contract": ai_bridge_contract(&session_id),
"plan": success.plan,
})
}
@@ -87,14 +122,18 @@ pub async fn bridge_runtime(
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
}
fn ai_bridge_contract() -> Value {
fn ai_bridge_contract(session_id: &str) -> Value {
json!({
"schema": "mnote.ai_bridge.v1",
"owner": "mnote-web",
"bridge": "hermes",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
"canonicalRoute": "/api/hermes/bridge",
"sessionOwner": "rust-web-hermes",
"toolEventOwner": "rust-web-hermes",
"clientActionOwner": "rust-web-hermes"
"clientActionOwner": "rust-web-hermes",
"structuredWriteOwner": "rust-web-hermes"
})
}
@@ -212,5 +251,39 @@ mod tests {
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert!(payload["eventStreamEndpoint"].as_str().unwrap_or_default().contains("/api/hermes/events/"));
}
#[tokio::test]
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"stream": true,
"scope": "document",
"messages": [{"role": "user", "content": "生成摘要"}],
"context": {"documentId": "doc_1"}
})
.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["bridge"], "hermes_session");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert_eq!(payload["contract"]["structuredWriteOwner"], "rust-web-hermes");
}
}
@@ -21,12 +21,15 @@ pub async fn mindmap_object_shell(
"mindmapId": mindmap_id,
"projection": {
"schema": "mnote.mindmap_projection.v1",
"source": "rust-web-object-shell"
"source": "rust-kernel",
"owner": "rust-kernel",
"queryName": "mindmap.projection.get"
},
"island": {
"kind": "react_mindmap_runtime",
"mountId": "mnote-mindmap-island",
"legacyCompat": "next-app-router"
"runtimeRole": "renderer_adapter",
"commandName": "mindmap.command.apply"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id
@@ -145,6 +148,8 @@ mod tests {
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("mnote.mindmap_shell.v1"));
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
assert!(html.contains("rust-web-object-shell"));
assert!(html.contains("mindmap.projection.get"));
assert!(html.contains("mindmap.command.apply"));
assert!(!html.contains("next-app-router"));
}
}
+2
View File
@@ -59,6 +59,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/session/refresh", post(session::refresh_session))
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/title", post(documents::title))
.route("/api/documents/options", post(documents::options))
.route("/api/documents/save", post(documents::save))
.route(
"/api/documents/runtime/transform",
+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");
}
}
+32
View File
@@ -153,8 +153,19 @@ struct StreamPollState {
}
fn stream_event(event_name: &str, payload: &Value) -> Event {
let event_id = payload
.get("revision")
.and_then(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.or_else(|| value.as_u64().map(|number| number.to_string()))
})
.or_else(|| payload.get("cursor").and_then(Value::as_str).map(ToOwned::to_owned))
.unwrap_or_else(|| "0".into());
Event::default()
.event(event_name)
.id(event_id)
.json_data(payload)
.expect("SSE 事件必须可序列化")
}
@@ -245,4 +256,25 @@ mod tests {
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
assert!(text.contains("\"kind\":\"snapshot\""));
}
#[tokio::test]
async fn tree_events_route_includes_event_id_and_revision() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0")
.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 text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("id: "));
assert!(text.contains("\"revision\""));
}
}
@@ -477,6 +477,7 @@ pub fn build_stream_delta_payload(
let scope = resolve_stream_scope(query);
json!({
"kind": "delta",
"revision": cursor.clone().unwrap_or_else(|| "0".into()),
"stream": scope.as_str(),
"projection": scope.projection(),
"requestId": context.trace.request_id,
@@ -570,6 +571,7 @@ pub async fn load_stream_snapshot(
Ok(json!({
"kind": "snapshot",
"revision": cursor.clone().unwrap_or_else(|| "0".into()),
"scope": scope.as_str(),
"stream": scope.as_str(),
"projection": scope.projection(),
+166 -140
View File
@@ -6,6 +6,7 @@ use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
@@ -24,9 +25,10 @@ use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::json;
use std::path::{Component, Path as FsPath, PathBuf};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -52,7 +54,7 @@ pub async fn document_page_shell(
query.workspace_id.as_deref(),
)
.await?;
let title = aggregate.head_title();
let title = aggregate.head.title.as_str();
let workspace_id = aggregate.identity.workspace_id.clone();
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let mut workspace_projection = load_workspace_shell_projection(
@@ -86,6 +88,7 @@ pub async fn document_page_shell(
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
@@ -105,6 +108,7 @@ pub async fn document_page_shell(
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
{}
{}
</body>
</html>"#,
escape_html(title),
@@ -113,6 +117,7 @@ pub async fn document_page_shell(
body_content,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
render_document_title_controller_script(),
render_editor_island_adapter_script(),
);
let mut response = Html(html).into_response();
@@ -128,6 +133,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
"workspaceId": aggregate.identity.workspace_id,
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
"saveEndpoint": "/api/documents/save",
"titleEndpoint": "/api/documents/title",
"editorHostKind": "leptos_tiptap_island",
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
"requestId": context.trace.request_id,
@@ -136,6 +142,123 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
.unwrap_or_else(|_| "{}".to_string())
}
fn render_document_title_controller_script() -> &'static str {
r#"<script>
(() => {
const CONTRACT = 'mnote.document_title_controller.v1';
const input = document.querySelector('[data-page-title-input="true"]');
if (!(input instanceof HTMLTextAreaElement)) return;
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
let lastSavedTitle = input.value.trim() || '无标题';
let saving = false;
const cssEscape = (value) => {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
};
const autosize = () => {
input.style.height = 'auto';
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
};
const setStatus = (status, message) => {
input.setAttribute('data-title-save-status', status);
const shell = input.closest('.document-shell');
if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
if (message) input.setAttribute('data-title-save-error', message);
else input.removeAttribute('data-title-save-error');
};
const setText = (selector, title) => {
document.querySelectorAll(selector).forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
};
const updateVisibleTitle = (title) => {
document.title = title;
setText('[data-page-title-current]', title);
const current = document.querySelector('.wolai-breadcrumb-current');
if (current instanceof HTMLElement) {
let titleNode = current.querySelector('[data-page-title-current]');
if (!(titleNode instanceof HTMLElement)) {
titleNode = document.createElement('span');
titleNode.setAttribute('data-page-title-current', 'true');
current.appendChild(titleNode);
}
titleNode.textContent = title;
}
if (!documentId) return;
const escapedId = cssEscape(documentId);
setText(`[data-node-id="${escapedId}"] .tree-link-title`, title);
setText(`[data-document-id="${escapedId}"] .tree-link-title`, title);
setText(`[data-doc-id="${escapedId}"] .tree-link-title`, title);
setText(`[data-node-id="${escapedId}"] .wolai-row-title`, title);
setText(`a[href="/documents/${escapedId}"] .wolai-row-title`, title);
setText(`a[href^="/documents/${escapedId}?"] .wolai-row-title`, title);
};
const saveTitle = async () => {
const title = input.value.trim() || '无标题';
autosize();
if (!documentId || saving || title === lastSavedTitle) {
updateVisibleTitle(title);
setStatus('saved');
return;
}
saving = true;
setStatus('saving');
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId,
workspaceId: workspaceId || null,
title,
commandName: 'page.head.updateTitle',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
}
lastSavedTitle = title;
updateVisibleTitle(title);
setStatus('saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: { documentId, workspaceId: workspaceId || null, title, payload },
}));
} catch (error) {
setStatus('error', error instanceof Error ? error.message : String(error));
} finally {
saving = false;
}
};
input.addEventListener('input', () => {
autosize();
setStatus(input.value.trim() === lastSavedTitle ? 'saved' : 'dirty');
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
input.blur();
}
});
input.addEventListener('blur', () => { void saveTitle(); });
autosize();
updateVisibleTitle(lastSavedTitle);
setStatus('saved');
})();
</script>"#
}
fn render_editor_island_adapter_script() -> &'static str {
r#"<script type="module">
(() => {
@@ -441,6 +564,7 @@ pub async fn page_aggregate(
query.workspace_id.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
let mut response = (
StatusCode::OK,
Json(json!({
@@ -454,6 +578,11 @@ pub async fn page_aggregate(
)
.into_response();
stamp_shell_headers(response.headers_mut(), "page-aggregate");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
Ok(response)
}
@@ -482,145 +611,24 @@ async fn build_page_aggregate_snapshot(
)
.await?;
let conflict_detection_key = content
.get("conflictDetectionKey")
.or_else(|| content.get("conflict_detection_key"))
.cloned()
.unwrap_or(Value::Null);
let page_subtree = content
.get("pageSubtree")
.or_else(|| content.get("page_subtree"))
.cloned()
.unwrap_or(Value::Null);
let todo_total = meta
.get("todo_total")
.or_else(|| meta.get("todo_total_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let todo_done = meta
.get("todo_done")
.or_else(|| meta.get("todo_done_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let projection = execute_runtime_query_against_data(
context,
workspace_id,
RuntimeQueryEnvelopeWire {
name: "page.aggregate.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
},
json!({
"meta": meta,
"content": content,
}),
)?;
Ok(PageAggregate::builder()
// identity
.document_id(
meta.get("id")
.and_then(Value::as_str)
.unwrap_or(document_id),
)
.workspace_id(
meta.get("workspace_id")
.and_then(Value::as_str)
.unwrap_or("default"),
)
// head
.title(
meta.get("title")
.and_then(Value::as_str)
.unwrap_or("无标题"),
)
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
.read_only(
meta.get("can_edit")
.and_then(Value::as_bool)
.map(|can_edit| !can_edit)
.unwrap_or(false),
)
.disable_download(
meta.get("disable_download")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.disable_copy(
meta.get("disable_copy")
.and_then(Value::as_bool)
.unwrap_or(false),
)
// layout
.wide_layout(
meta.get("wide_layout")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.small_text(
meta.get("use_small_text")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_heading_numbers(
meta.get("show_heading_numbers")
.and_then(Value::as_bool)
.unwrap_or(true),
)
.show_toc(
meta.get("show_toc")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_structure(
meta.get("show_structure")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.protect_editing(
meta.get("protect_editing")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_word_count(
meta.get("show_word_count")
.and_then(Value::as_bool)
.unwrap_or(true),
)
.collapse_backlinks(
meta.get("collapse_backlinks")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.page_font(
meta.get("page_font")
.and_then(Value::as_str)
.unwrap_or("default"),
)
.layout_density(
meta.get("layout_density")
.and_then(Value::as_str)
.unwrap_or("normal"),
)
.hide_child_pages(
meta.get("hide_child_pages")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.show_block_ref_count(
meta.get("show_block_ref_count")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.embed_default_block_id(
meta.get("embed_default_block_id")
.cloned()
.unwrap_or(Value::Null),
)
// body
.content(content.get("content").cloned().unwrap_or(Value::Null))
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
.conflict_detection_key(conflict_detection_key)
// tree
.page_subtree(page_subtree)
// stats
.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(todo_total)
.todo_done(todo_done)
.build())
serde_json::from_value::<PageAggregate>(projection)
.map_err(|error| WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}")))
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
@@ -929,6 +937,15 @@ mod tests {
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
assert!(html.contains("aria-label=\"页面标题\""));
assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1"));
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(!html.contains("mnote-web-document-shell"));
}
@@ -945,12 +962,21 @@ mod tests {
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-kernel")
);
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["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "KernelProjection");
assert_eq!(payload["result"]["projectionVersion"], 1);
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
}
@@ -19,6 +19,8 @@ pub fn DocumentPage(
title: String,
/// 文档 id
document_id: String,
/// 工作区 id
workspace_id: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
@@ -44,10 +46,27 @@ pub fn DocumentPage(
.unwrap_or_else(|| "个人空间".to_string());
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()} data-workspace-id={workspace_id.clone()}>
<header class="document-shell-header">
<div class="document-page-icon" aria-hidden="true">""</div>
<h1>{title}</h1>
<div class="document-page-icon" aria-hidden="true">
<svg class="mnote-symbol mnote-symbol--document" data-icon="home" viewBox="0 0 24 24" focusable="false">
<path d="M4 10.5 12 3l8 7.5"></path>
<path d="M6.5 9.5V21h11V9.5"></path>
<path d="M9.5 21v-6h5v6"></path>
</svg>
</div>
<h1 class="document-title-heading">
<textarea
id="mnote-page-title-input"
class="document-title-input"
aria-label="页面标题"
data-page-title-input="true"
data-document-id={document_id.clone()}
data-workspace-id={workspace_id.clone()}
data-title-endpoint="/api/documents/title"
rows="1"
>{title.clone()}</textarea>
</h1>
<div class="document-shell-meta" aria-label="页面元信息">
<span><span aria-hidden="true">""</span>{workspace_label}</span>
<span><span aria-hidden="true">""</span>"已同步"</span>
+653 -35
View File
@@ -4,20 +4,63 @@ use leptos::prelude::*;
const SIDEBAR_TREE_JS: &str = r##"
(function(){
if (window.__mnoteSidebarTreeRuntimeStarted) return;
window.__mnoteSidebarTreeRuntimeStarted = true;
var PAGE_DRAG_MIME = 'application/x-mnote-page-tree-node';
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
var mnoteNavigationInFlight = '';
var draggingPageNodeId = '';
var activePageDropRow = null;
var draggingFileTreeRowIds = [];
var activeFileTreeDropRow = null;
var projectionRefreshTimer = 0;
function closestAction(target, selector) {
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
}
function currentDocumentId() {
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function resolveWorkspaceId(trigger) {
var direct = (trigger.getAttribute('data-workspace-id') || '').trim();
if (direct) return direct;
var root = trigger.closest('[data-workspace-id]');
return root ? (root.getAttribute('data-workspace-id') || '').trim() : '';
if (root) {
var value = (root.getAttribute('data-workspace-id') || '').trim();
if (value) return value;
}
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
}
function setCommandPending(trigger, pending) {
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
if ('disabled' in trigger) {
if (pending) trigger.setAttribute('disabled', 'disabled');
else trigger.removeAttribute('disabled');
}
}
async function dispatchTreeCommand(trigger, body) {
trigger.setAttribute('data-pending', 'true');
trigger.setAttribute('disabled', 'disabled');
setCommandPending(trigger, true);
try {
var response = await fetch('/api/tree/commands', {
method: 'POST',
@@ -28,15 +71,30 @@ const SIDEBAR_TREE_JS: &str = r##"
if (!response.ok || !payload || !payload.result) {
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
}
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: body, result: payload.result } }));
return payload.result;
} catch (error) {
trigger.removeAttribute('disabled');
trigger.setAttribute('data-pending', 'false');
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
setCommandPending(trigger, false);
if (trigger instanceof HTMLElement) {
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
}
throw error;
}
}
function navigateToDocument(nodeId, workspaceId) {
if (!nodeId) return;
var url = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
if (mnoteNavigationInFlight === url) return;
mnoteNavigationInFlight = url;
document.documentElement.setAttribute('data-mnote-navigation-pending', 'true');
document.documentElement.setAttribute('data-mnote-navigation-target', nodeId);
if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') {
window.__mnoteTreeLiveEventSource.close();
}
window.location.assign(url);
}
async function createPage(trigger, parentId) {
var workspaceId = resolveWorkspaceId(trigger);
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
@@ -48,7 +106,7 @@ const SIDEBAR_TREE_JS: &str = r##"
title: '新页面'
});
var nextWorkspaceId = result.workspaceId || workspaceId;
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
navigateToDocument(result.documentId, nextWorkspaceId);
}
function switchSidebarTreeTab(trigger) {
@@ -69,6 +127,192 @@ const SIDEBAR_TREE_JS: &str = r##"
}
}
function updateTitleEverywhere(documentId, title) {
if (!documentId) return;
var selectors = [
'[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title',
'[data-document-id="' + cssEscape(documentId) + '"] .tree-link-title',
'[data-doc-id="' + cssEscape(documentId) + '"] .tree-link-title',
'[data-node-id="' + cssEscape(documentId) + '"] .wolai-row-title'
];
selectors.forEach(function(selector) {
document.querySelectorAll(selector).forEach(function(node) {
if (node instanceof HTMLElement) node.textContent = title;
});
});
}
function readProjection(value) {
if (!value || typeof value !== 'object') return null;
if (value.result && typeof value.result === 'object') return value.result;
if (value.snapshot && value.snapshot.tree) return value.snapshot.tree;
if (value.data && value.data.tree) return value.data.tree;
if (value.tree && typeof value.tree === 'object') return value.tree;
return value;
}
function projectionItems(projection) {
var resolved = readProjection(projection);
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
}
function nodeIdOf(item) {
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
}
function rowIdOf(item) {
return String(item && (item.rowId || item.nodeId || item.id) || '').trim();
}
function parentIdOf(item) {
return String(item && (item.parentNodeId || item.parentId || '') || '').trim();
}
function titleOf(item) {
return String(item && item.title || '无标题').trim() || '无标题';
}
function groupRowsByParent(rows) {
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
var grouped = new Map();
rows.forEach(function(item) {
var parentId = parentIdOf(item);
if (!ids.has(parentId)) parentId = '';
if (!grouped.has(parentId)) grouped.set(parentId, []);
grouped.get(parentId).push(item);
});
return grouped;
}
function renderPageRows(parentId, grouped, activeId) {
return (grouped.get(parentId) || []).map(function(item) {
var nodeId = nodeIdOf(item);
var title = titleOf(item);
var depth = Number(item.depth || 0);
var parent = parentIdOf(item);
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false;
var toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
: '<span class="tree-spacer" aria-hidden="true"></span>';
var childHtml = expandable && expanded
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId) + '</ul>'
: '';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '" data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="' + escapeHtml(nodeId) + '" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
}).join('');
}
function renderPageProjection(projection) {
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return false;
var rows = projectionItems(projection).filter(function(item) {
return String(item.rowKind || 'document') === 'document';
});
var activeId = currentDocumentId();
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
return true;
}
function fileDocumentId(item) {
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
if (meta.documentId) return String(meta.documentId).trim();
if (item && item.documentId) return String(item.documentId).trim();
if (item && item.rowKind === 'document') return nodeIdOf(item);
if (item && item.rowKind === 'index') return nodeIdOf(item).replace(/^index:/, '');
return '';
}
function fileAssetId(item) {
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
if (meta.assetId) return String(meta.assetId).trim();
if (item && item.assetId) return String(item.assetId).trim();
if (item && item.rowKind === 'asset') return nodeIdOf(item).replace(/^asset:/, '');
return '';
}
function iconKindOf(item) {
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
}
function renderFileRows(parentId, grouped, activeId) {
return (grouped.get(parentId) || []).map(function(item) {
var nodeId = nodeIdOf(item);
var rowId = rowIdOf(item);
var rowKind = String(item.rowKind || 'document');
var title = titleOf(item);
var depth = Number(item.depth || 0);
var parent = parentIdOf(item);
var documentId = fileDocumentId(item);
var assetId = fileAssetId(item);
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false;
var selected = rowId === 'doc:' + activeId || rowId === 'index:' + activeId || documentId === activeId;
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
var toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="' + escapeHtml(rowId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
: '<span class="tree-spacer" aria-hidden="true"></span>';
var createAction = rowKind === 'document'
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
: '';
var childHtml = expandable && expanded
? '<ul class="tree-children">' + renderFileRows(nodeId, grouped, activeId) + '</ul>'
: '';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
}).join('');
}
function renderFileProjection(projection) {
var tree = document.getElementById('sidebar-file-tree-root');
if (!tree) return false;
var rows = projectionItems(projection);
var activeId = currentDocumentId();
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>') + '</ul>';
return true;
}
async function fetchProjection(path, workspaceId) {
var url = new URL(path, window.location.origin);
url.searchParams.set('workspaceId', workspaceId || 'default');
url.searchParams.set('depth', '99');
var response = await fetch(url.toString(), { cache: 'no-store' });
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || !payload.result) throw new Error('tree_projection_failed_' + response.status);
return payload.result;
}
function scheduleProjectionRefresh(workspaceId) {
if (projectionRefreshTimer) window.clearTimeout(projectionRefreshTimer);
projectionRefreshTimer = window.setTimeout(function() {
projectionRefreshTimer = 0;
var resolvedWorkspaceId = workspaceId || resolveWorkspaceId(document.body);
Promise.all([
fetchProjection('/api/tree/projections/sidebar', resolvedWorkspaceId).then(renderPageProjection),
fetchProjection('/api/tree/projections/file', resolvedWorkspaceId).then(renderFileProjection)
]).then(function() {
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'true');
}).catch(function(error) {
document.documentElement.setAttribute('data-mnote-tree-live-apply-error', error instanceof Error ? error.message : String(error));
});
}, 180);
}
function toggleChildren(row, button) {
var li = row && row.parentElement;
var children = li ? li.querySelector(':scope > .tree-children') : null;
if (!children) return;
children.classList.toggle('tree-children--collapsed');
var collapsed = children.classList.contains('tree-children--collapsed');
row.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (button) button.textContent = collapsed ? '▸' : '▾';
}
function dispatchSidebarEvent(name, detail) {
document.documentElement.setAttribute('data-mnote-last-tree-action', name);
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
}
document.addEventListener('click', function(e) {
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
if (tabTrigger) {
@@ -84,6 +328,43 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var fileTree = document.getElementById('sidebar-file-tree-root');
if (fileTree && fileTree.contains(e.target)) {
var fileBtn = closestAction(e.target, '[data-rust-action]');
var fileRow = closestAction(e.target, '.tree-row[data-shell-mode="filetree"]');
if (!fileRow) return;
var fileAction = fileBtn ? fileBtn.getAttribute('data-rust-action') : 'open';
var rowId = fileRow.getAttribute('data-row-id') || '';
var rowKind = fileRow.getAttribute('data-row-kind') || '';
var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || '';
var assetId = fileRow.getAttribute('data-asset-id') || '';
if (fileAction === 'toggle') {
e.preventDefault();
toggleChildren(fileRow, fileBtn);
return;
}
if (fileAction === 'create') {
e.preventDefault();
void createPage(fileBtn || fileRow, documentId || fileRow.getAttribute('data-node-id'));
return;
}
if (fileAction === 'menu') {
e.preventDefault();
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
return;
}
e.preventDefault();
fileTree.querySelectorAll('.tree-row[data-selected="true"]').forEach(function(row) {
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'false');
});
fileRow.setAttribute('data-selected', 'true');
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
if ((rowKind === 'document' || rowKind === 'index') && documentId) {
navigateToDocument(documentId, resolveWorkspaceId(fileRow));
}
return;
}
var tree = document.getElementById('sidebar-tree-root');
if (!tree || !tree.contains(e.target)) return;
var btn = closestAction(e.target, '[data-rust-action]');
@@ -94,18 +375,11 @@ const SIDEBAR_TREE_JS: &str = r##"
if (action === 'toggle') {
var row = btn.closest('.tree-row');
if (!row) return;
var li = row.parentElement;
var children = li.querySelector(':scope > .tree-children');
if (children) {
children.classList.toggle('tree-children--collapsed');
var isCollapsed = children.classList.contains('tree-children--collapsed');
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
btn.textContent = isCollapsed ? '▸' : '▾';
}
toggleChildren(row, btn);
e.preventDefault();
} else if (action === 'open') {
var workspaceId = resolveWorkspaceId(btn);
window.location.href = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
navigateToDocument(nodeId, workspaceId);
e.preventDefault();
} else if (action === 'create') {
e.preventDefault();
@@ -119,23 +393,323 @@ const SIDEBAR_TREE_JS: &str = r##"
workspaceId: resolveWorkspaceId(btn),
documentId: nodeId,
title: title.trim()
}).then(function(){ window.location.reload(); });
}).then(function(){
updateTitleEverywhere(nodeId, title.trim());
scheduleProjectionRefresh(resolveWorkspaceId(btn));
});
}
} else if (action === 'menu') {
e.preventDefault();
dispatchSidebarEvent('tree.page.context-menu', { documentId: nodeId, target: { documentId: nodeId } });
}
});
function readPageDragNodeId(event) {
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
return (fromTransfer || draggingPageNodeId || '').trim();
}
function clearPageDropFeedback() {
if (activePageDropRow instanceof HTMLElement) {
activePageDropRow.setAttribute('data-drop-feedback', 'false');
}
activePageDropRow = null;
}
function canDropPage(sourceNodeId, targetRow) {
if (!sourceNodeId || !(targetRow instanceof HTMLElement)) return false;
var targetNodeId = targetRow.getAttribute('data-node-id') || '';
if (!targetNodeId || targetNodeId === sourceNodeId) return false;
var sourceNode = document.querySelector('#sidebar-tree-root .tree-node[data-node-id="' + cssEscape(sourceNodeId) + '"]');
return !(sourceNode instanceof HTMLElement && sourceNode.contains(targetRow));
}
function pageDropPosition(event, row) {
var rect = row.getBoundingClientRect();
var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
if (ratio < 0.25) return 'before';
if (ratio > 0.75) return 'after';
return 'inside';
}
function resolvePageMoveTarget(targetRow, position) {
var targetNodeId = targetRow.getAttribute('data-node-id') || '';
var parentId = targetRow.getAttribute('data-parent-id') || null;
if (position === 'inside') {
var children = targetRow.parentElement ? targetRow.parentElement.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="page"]') : [];
return { parentId: targetNodeId, sortOrder: children.length };
}
var siblings = Array.from(document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]')).filter(function(row) {
return (row.getAttribute('data-parent-id') || '') === (parentId || '');
});
var index = siblings.indexOf(targetRow);
return { parentId: parentId, sortOrder: Math.max(0, index + (position === 'after' ? 1 : 0)) };
}
document.addEventListener('dragstart', function(event) {
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
if (pageRow) {
draggingPageNodeId = pageRow.getAttribute('data-node-id') || '';
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(PAGE_DRAG_MIME, draggingPageNodeId);
event.dataTransfer.setData('text/plain', draggingPageNodeId);
}
return;
}
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"][draggable="true"]');
if (fileRow) {
var rowId = fileRow.getAttribute('data-row-id') || '';
draggingFileTreeRowIds = rowId ? [rowId] : [];
if (event.dataTransfer) {
var payload = JSON.stringify({ type: 'mnote-file-tree-dnd', version: 1, rowIds: draggingFileTreeRowIds });
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
event.dataTransfer.setData('application/x-mnote-file-tree', payload);
event.dataTransfer.setData('text/plain', payload);
}
}
});
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return;
var currentPath = window.location.pathname;
var match = currentPath.match(/^\/documents\/([^\/]+)/);
if (match) {
var activeId = match[1];
var links = tree.querySelectorAll('[data-rust-action="open"]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-node-id') === activeId) {
links[i].closest('.tree-row').setAttribute('data-active', 'true');
}
document.addEventListener('dragover', function(event) {
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"]');
var sourceNodeId = readPageDragNodeId(event);
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
event.preventDefault();
clearPageDropFeedback();
pageRow.setAttribute('data-drop-feedback', 'true');
pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow));
activePageDropRow = pageRow;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
return;
}
var fileTree = document.getElementById('sidebar-file-tree-root');
if (fileTree && fileTree.contains(event.target)) {
var hasFiles = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], 'Files') >= 0;
var hasInternal = event.dataTransfer && Array.prototype.indexOf.call(event.dataTransfer.types || [], FILETREE_DRAG_MIME) >= 0;
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return;
event.preventDefault();
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles || event.altKey ? 'copy' : 'move';
}
});
document.addEventListener('drop', function(event) {
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"]');
var sourceNodeId = readPageDragNodeId(event);
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
event.preventDefault();
var position = pageDropPosition(event, pageRow);
var target = resolvePageMoveTarget(pageRow, position);
clearPageDropFeedback();
draggingPageNodeId = '';
void dispatchTreeCommand(pageRow, {
action: 'move',
workspaceId: resolveWorkspaceId(pageRow),
documentId: sourceNodeId,
parentId: target.parentId,
sortOrder: target.sortOrder
}).then(function(){ scheduleProjectionRefresh(resolveWorkspaceId(pageRow)); });
return;
}
var fileTree = document.getElementById('sidebar-file-tree-root');
if (fileTree && fileTree.contains(event.target)) {
var targetRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : [];
var raw = event.dataTransfer ? event.dataTransfer.getData(FILETREE_DRAG_MIME) || event.dataTransfer.getData('application/x-mnote-file-tree') || '' : '';
var rowIds = draggingFileTreeRowIds.slice();
if (raw) {
try {
var parsed = JSON.parse(raw);
if (Array.isArray(parsed.rowIds)) rowIds = parsed.rowIds;
} catch (_) {}
}
if (!files.length && !rowIds.length) return;
event.preventDefault();
var detail = {
workspaceId: resolveWorkspaceId(targetRow || fileTree),
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
documentId: targetRow ? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id') : null,
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null
};
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
activeFileTreeDropRow = null;
if (files.length) {
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
} else {
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true }));
}
draggingFileTreeRowIds = [];
}
});
document.addEventListener('dragend', function() {
draggingPageNodeId = '';
draggingFileTreeRowIds = [];
clearPageDropFeedback();
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
activeFileTreeDropRow = null;
});
window.addEventListener('tree:title-updated', function(event) {
var detail = event.detail || {};
updateTitleEverywhere(detail.documentId, detail.title);
scheduleProjectionRefresh(detail.workspaceId);
});
window.addEventListener('tree:snapshot', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
if (renderPageProjection(payload)) {
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot');
}
scheduleProjectionRefresh(payload && payload.workspaceId);
});
window.addEventListener('tree:delta', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
var data = payload && (payload.data || payload.delta || payload);
var documents = data && (data.upsertDocuments || data.upsert_documents);
if (Array.isArray(documents)) {
documents.forEach(function(doc) {
updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题');
});
}
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
scheduleProjectionRefresh(payload && payload.workspaceId);
});
window.addEventListener('tree:resync', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
scheduleProjectionRefresh(payload && payload.workspaceId);
});
var tree = document.getElementById('sidebar-tree-root');
var activeId = currentDocumentId();
if (tree && activeId) {
var activeRow = tree.querySelector('.tree-row[data-node-id="' + cssEscape(activeId) + '"]');
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'true');
}
})();
"##;
const TREE_LIVE_CONTROLLER_JS: &str = r##"
(function(){
if (window.__mnoteTreeLiveControllerStarted) return;
window.__mnoteTreeLiveControllerStarted = true;
function readBootstrap() {
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
var fallback = {
schema: 'mnote.tree_live_bootstrap.v1',
transport: 'convex-command-log-sse',
endpoint: '/api/tree/events',
resyncEndpoint: '/api/tree/projections/sidebar',
rootIds: [],
initialRevision: null
};
if (!script || !script.textContent) return fallback;
try {
return Object.assign(fallback, JSON.parse(script.textContent));
} catch (_) {
return fallback;
}
}
function resolveWorkspaceId() {
var withWorkspace = document.querySelector('[data-workspace-id]');
if (withWorkspace) {
var value = (withWorkspace.getAttribute('data-workspace-id') || '').trim();
if (value) return value;
}
var params = new URLSearchParams(window.location.search);
return (params.get('workspaceId') || 'default').trim() || 'default';
}
function dispatchTreeEvent(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
}
function applyStatus(status) {
document.documentElement.setAttribute('data-mnote-tree-live-status', status);
}
function applyTransport(transport) {
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'convex-command-log-sse');
}
function closeActiveSource() {
var source = window.__mnoteTreeLiveEventSource;
if (source && typeof source.close === 'function') {
source.close();
window.__mnoteTreeLiveEventSource = null;
applyStatus('closed');
}
}
function start() {
if (!('EventSource' in window)) {
applyStatus('unsupported');
return;
}
var bootstrap = readBootstrap();
applyTransport(bootstrap.transport || 'convex-command-log-sse');
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
var failures = 0;
var source = new EventSource(url.toString());
window.__mnoteTreeLiveEventSource = source;
applyStatus('connecting');
source.addEventListener('open', function(){
failures = 0;
applyStatus('connected');
});
source.addEventListener('snapshot', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('delta', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('resync', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.onerror = function(){
failures += 1;
applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting');
if (failures >= 3) {
dispatchTreeEvent('tree:resync-requested', { endpoint: bootstrap.resyncEndpoint, workspaceId: workspaceId });
}
};
}
window.__mnoteTreeLiveClose = closeActiveSource;
window.addEventListener('pagehide', closeActiveSource, { once: true });
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
})();
"##;
@@ -198,6 +772,17 @@ pub fn PageLayout(
None,
)
});
let tree_live_bootstrap = serde_json::json!({
"schema": "mnote.tree_live_bootstrap.v1",
"transport": "convex-command-log-sse",
"workspaceId": null,
"rootIds": [],
"initialRevision": null,
"endpoint": "/api/tree/events",
"resyncEndpoint": "/api/tree/projections/sidebar",
"views": ["page-tree", "file-tree"]
})
.to_string();
view! {
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
@@ -208,15 +793,17 @@ pub fn PageLayout(
<span class="wolai-sidebar-chevron" aria-hidden="true">""</span>
</div>
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
<a href="/search" class:active={current_nav == "search"} title="搜索"><span class="nav-icon">""</span></a>
<a href="/graph" title="关系图"><span class="nav-icon">""</span></a>
<a href="/actions" title="快捷动作"><span class="nav-icon">""</span></a>
<a href="/help" title="帮助"><span class="nav-icon">"?"</span></a>
<a href="/files" title="文件"><span class="nav-icon">""</span></a>
<a href="/more" title="更多"><span class="nav-icon">""</span></a>
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索"><svg class="mnote-symbol nav-icon" data-icon="search" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="5.5"></circle><path d="m15 15 5 5"></path></svg></a>
<a href="/graph" title="关系图" aria-label="关系图"><svg class="mnote-symbol nav-icon" data-icon="account_tree" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h5v5H6z"></path><path d="M15 3h5v5h-5z"></path><path d="M15 16h5v5h-5z"></path><path d="M11 8.5h2.5a4 4 0 0 0 4-4"></path><path d="M11 8.5h2.5a4 4 0 0 1 4 4V16"></path></svg></a>
<a href="/actions" title="快捷动作" aria-label="快捷动作"><svg class="mnote-symbol nav-icon" data-icon="bolt" viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 14h6l-1 8 9-13h-6z"></path></svg></a>
<a href="/help" title="帮助" aria-label="帮助"><svg class="mnote-symbol nav-icon" data-icon="help" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M9.8 9a2.4 2.4 0 0 1 4.5 1.2c0 1.7-2.3 2-2.3 3.8"></path><path d="M12 17.5h.01"></path></svg></a>
<a href="/files" title="文件" aria-label="文件"><svg class="mnote-symbol nav-icon" data-icon="inventory_2" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v4H4z"></path><path d="M6 10v9h12v-9"></path><path d="M9 14h6"></path></svg></a>
<a href="/more" title="更多" aria-label="更多"><svg class="mnote-symbol nav-icon" data-icon="more_horiz" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h.01"></path><path d="M12 12h.01"></path><path d="M19 12h.01"></path></svg></a>
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
<script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script>
</aside>
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
@@ -225,7 +812,7 @@ pub fn PageLayout(
<nav class="wolai-breadcrumb" aria-label="页面路径">
<span class="wolai-breadcrumb-root">"The Digital Atelier"</span>
<span class="wolai-breadcrumb-separator" aria-hidden="true">"/"</span>
<span class="wolai-breadcrumb-current"><span class="wolai-home-icon" aria-hidden="true">""</span>{topbar_title}</span>
<span class="wolai-breadcrumb-current"><svg class="mnote-symbol wolai-home-icon" data-icon="home" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5 12 3l8 7.5"></path><path d="M6.5 9.5V21h11V9.5"></path><path d="M9.5 21v-6h5v6"></path></svg><span data-page-title-current="true">{topbar_title}</span></span>
</nav>
</div>
<div class="wolai-topbar-actions" aria-label="页面操作">
@@ -248,3 +835,34 @@ pub fn PageLayout(
</div>
}
}
#[cfg(test)]
mod tests {
use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS};
#[test]
fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() {
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending"));
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
assert!(SIDEBAR_TREE_JS.contains("dragstart"));
assert!(SIDEBAR_TREE_JS.contains("drop"));
assert!(SIDEBAR_TREE_JS.contains("data-drop-feedback"));
assert!(SIDEBAR_TREE_JS.contains("action: 'move'"));
assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
}
#[test]
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
}
}
+10 -5
View File
@@ -9,7 +9,7 @@ use leptos::prelude::*;
/// MNOTE 搜索页面
///
/// 渲染搜索页面的 SSR 壳结构:
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
/// - `<main id="mnote-search-shell" data-island-host="search_interaction_island">`
/// - 搜索标题区域
/// - 搜索岛占位区
#[component]
@@ -26,10 +26,14 @@ pub fn SearchPage(
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
/// 服务端首批搜索结果 HTML(可选)
#[prop(optional)]
initial_results_html: Option<String>,
) -> impl IntoView {
let initial_results_html = initial_results_html.unwrap_or_default();
view! {
<PageLayout current_nav="search" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main id="mnote-search-shell" data-island-host="react_search_palette">
<main id="mnote-search-shell" data-island-host="search_interaction_island">
<header class="search-header">
<h1>{"搜索"}</h1>
<p class="search-meta">
@@ -43,7 +47,8 @@ pub fn SearchPage(
}}
</p>
</header>
<section id="mnote-search-island" data-react-island="search_palette"></section>
<section id="mnote-search-results" data-search-results-owner="rust-kernel" inner_html={initial_results_html}></section>
<section id="mnote-search-island" data-react-island="search_interaction"></section>
</main>
</PageLayout>
}
@@ -60,9 +65,9 @@ mod tests {
<SearchPage workspace_id="ws_demo" search_query="Rust" />
});
assert!(html.contains("mnote-search-shell"));
assert!(html.contains("data-island-host=\"react_search_palette\""));
assert!(html.contains("data-island-host=\"search_interaction_island\""));
assert!(html.contains("mnote-search-island"));
assert!(html.contains("data-react-island=\"search_palette\""));
assert!(html.contains("mnote-search-results"));
assert!(html.contains("搜索"));
}
+95 -2
View File
@@ -69,6 +69,30 @@ a:hover {
color: var(--wolai-accent-hover);
}
.mnote-symbol {
width: 18px;
height: 18px;
display: inline-flex;
flex: 0 0 auto;
color: currentColor;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
vertical-align: -0.18em;
}
.mnote-symbol[data-icon="home"] {
stroke-width: 2;
}
.mnote-symbol--document {
width: 56px;
height: 56px;
stroke-width: 1.35;
}
/* ===== 滚动条(悬停时显示) ===== */
::-webkit-scrollbar {
width: 8px;
@@ -1138,6 +1162,13 @@ body {
color: var(--atelier-text);
}
.sidebar-tree .tree-row[data-drop-feedback="true"],
.sidebar-tree .tree-row[data-drop-target="true"],
.sidebar-tree .tree-root[data-drop-target="true"] {
background: rgba(0, 110, 40, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
}
.sidebar-tree .tree-toggle,
.sidebar-tree .tree-spacer {
width: 16px;
@@ -1371,11 +1402,11 @@ body {
display: flex;
align-items: center;
color: #111111;
font-size: 72px;
line-height: 1;
margin-bottom: 12px;
}
.document-title-heading,
.document-shell-header h1 {
padding: 0;
margin: 0 0 14px;
@@ -1387,6 +1418,32 @@ body {
text-align: left;
}
.document-title-input {
width: 100%;
min-height: 52px;
display: block;
resize: none;
overflow: hidden;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
letter-spacing: 0;
text-align: inherit;
}
.document-title-input::placeholder {
color: #B8B5AF;
}
.document-title-input[data-title-save-status="error"] {
text-decoration: underline;
text-decoration-color: #D6545D;
text-underline-offset: 6px;
}
.document-shell-meta {
display: flex;
align-items: center;
@@ -1439,6 +1496,24 @@ body {
padding: 0 !important;
}
#mnote-leptos-tiptap-island-editor-root .block-handle-shell {
left: -52px !important;
z-index: 5;
opacity: 0.2;
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
}
#mnote-leptos-tiptap-island-editor-root .block-handle-shell:hover,
#mnote-leptos-tiptap-island-editor-root .block-handle-shell[data-dragging="true"] {
opacity: 0.6;
}
#mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-insert,
#mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-trigger {
pointer-events: auto;
}
#mnote-leptos-tiptap-island-editor-root .ProseMirror p {
margin: 0 0 24px;
}
@@ -1530,12 +1605,20 @@ body {
.document-page-icon {
height: 60px;
font-size: 54px;
}
.document-page-icon .mnote-symbol--document {
width: 44px;
height: 44px;
}
.document-shell-header h1 {
font-size: 32px;
}
#mnote-leptos-tiptap-island-editor-root .block-handle-shell {
left: -36px !important;
}
}
@media (max-width: 480px) {
@@ -1613,6 +1696,16 @@ mod tests {
assert!(MNOTE_CSS.contains(".ProseMirror"));
}
#[test]
fn mnote_css_keeps_tiptap_handles_in_margin_and_title_editable() {
assert!(MNOTE_CSS.contains(".document-title-input"));
assert!(MNOTE_CSS.contains(".block-handle-shell"));
assert!(MNOTE_CSS.contains("left: -52px !important"));
assert!(MNOTE_CSS.contains("pointer-events: none"));
assert!(MNOTE_CSS.contains(".mnote-symbol"));
assert!(MNOTE_CSS.contains("data-icon=\"home\""));
}
#[test]
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
@@ -57,18 +57,46 @@ fn render_filetree_row(
row: &FileTreeRenderRow,
children_by_parent: &BTreeMap<Option<String>, Vec<FileTreeRenderRow>>,
) {
let toggle_html = if row.expandable {
format!(
r#"<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="{row_id}" aria-label="{label} {title}">{marker}</button>"#,
row_id = escape_html(&row.row_id),
label = if row.expanded { "折叠" } else { "展开" },
title = escape_html(&row.title),
marker = if row.expanded { "" } else { "" },
)
} else {
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
};
let parent_attr = row
.parent_node_id
.as_deref()
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let create_action_html = if row.row_kind == "document" {
format!(
r#"<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="{row_id}" data-node-id="{node_id}" aria-label="新建子页面">+</button>"#,
row_id = escape_html(&row.row_id),
node_id = escape_html(&row.node_id),
)
} else {
String::new()
};
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}" data-document-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false"><span class="tree-spacer" aria-hidden="true"></span><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}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><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-asset-id="{asset_id}" 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-asset-id="{asset_id}"><span class="tree-link-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" },
test_id = row_test_id(&row.row_kind),
row_id = escape_html(&row.row_id),
row_kind = escape_html(&row.row_kind),
parent_attr = parent_attr,
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
selected = row.selected,
toggle_html = toggle_html,
icon_kind = escape_html(&row.icon_kind),
create_action_html = create_action_html,
title = escape_html(&row.title),
));
if row.expandable && row.expanded {
@@ -165,6 +193,7 @@ mod tests {
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
assert!(html.contains("data-testid=\"filetree-doc-row\""));
assert!(html.contains("data-testid=\"filetree-index-row\""));
assert!(html.contains("data-doc-id=\"page_root\""));
assert!(html.contains("tree-children"));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-selected=\"true\""));
+52 -7
View File
@@ -102,13 +102,13 @@ pub fn build_workspace_shell_projection(
id: "trash".into(),
label: "垃圾箱".into(),
href: "/trash".into(),
icon: "".into(),
icon: "delete".into(),
},
WorkspaceShellEntry {
id: "templates".into(),
label: "模板中心".into(),
href: "/templates".into(),
icon: "".into(),
icon: "inventory_2".into(),
},
],
}
@@ -321,6 +321,10 @@ 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("class=\"mnote-symbol"));
assert!(html.contains("data-icon=\"home\""));
assert!(html.contains("data-icon=\"star\""));
assert!(html.contains("data-icon=\"delete\""));
}
#[test]
@@ -422,9 +426,9 @@ pub fn render_workspace_shell_sidebar_html(
.iter()
.map(|entry| {
format!(
r#"<a href="{}" class="wolai-footer-entry"><span>{}</span>{}</a>"#,
r#"<a href="{}" class="wolai-footer-entry">{}{}</a>"#,
escape_html(&entry.href),
escape_html(&entry.icon),
render_symbol(&entry.icon, "wolai-footer-icon"),
escape_html(&entry.label),
)
})
@@ -432,7 +436,9 @@ pub fn render_workspace_shell_sidebar_html(
.join("");
format!(
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<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="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel"><span class="wolai-folder-icon">▱</span>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">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
r#"<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="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" 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">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" 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),
)
}
@@ -455,16 +461,55 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
""
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-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>"#,
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,
escape_html(item.icon.as_deref().unwrap_or("")),
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
escape_html(&item.title),
)
}
fn item_icon_name(icon: Option<&str>) -> &'static str {
match icon.unwrap_or_default().trim() {
"star" | "" => "star",
"folder" | "folder_open" | "" => "folder_open",
"delete" | "trash" | "" => "delete",
"inventory_2" | "template" | "" => "inventory_2",
"home" | "page" | "" | "" | "" => "home",
_ => "home",
}
}
fn render_symbol(icon: &str, class_name: &str) -> String {
let icon = item_icon_name(Some(icon));
let body = match icon {
"delete" => {
r#"<path d="M5 6h14"></path><path d="M9 6V4h6v2"></path><path d="M8 6l1 14h6l1-14"></path><path d="M10.5 10v6"></path><path d="M13.5 10v6"></path>"#
}
"folder_open" => {
r#"<path d="M3.5 6.5h6l2 2h9"></path><path d="M4 8.5v9.5h14.5l2-7.5H7l-3 7.5"></path>"#
}
"inventory_2" => {
r#"<path d="M4 6h16v4H4z"></path><path d="M6 10v9h12v-9"></path><path d="M9 14h6"></path>"#
}
"star" => {
r#"<path d="m12 3 2.5 5.2 5.7.8-4.1 4 1 5.7-5.1-2.7-5.1 2.7 1-5.7-4.1-4 5.7-.8z"></path>"#
}
_ => {
r#"<path d="M4 10.5 12 3l8 7.5"></path><path d="M6.5 9.5V21h11V9.5"></path><path d="M9.5 21v-6h5v6"></path>"#
}
};
format!(
r#"<svg class="mnote-symbol {}" data-icon="{}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">{}</svg>"#,
escape_html(class_name),
escape_html(icon),
body
)
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
@@ -68,6 +68,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
"documents.options.update" => "documents:updateOptions",
"page.layout.updateOptions" => "documents:updateOptions",
"mindmaps.put" => "mindmaps:put",
"mindmap.command.apply" => "mindmaps:applyCommand",
"mindmaps.delete" => "mindmaps:softDelete",
"mindmaps.restore" => "mindmaps:restore",
"mindmaps.purge" => "mindmaps:purge",
@@ -97,10 +98,13 @@ pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
"bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
"documents.meta.get" => "documents:getMeta",
"documents.content.get" => "documents:getContent",
"page.aggregate.get" => "documents:getPageAggregate",
"mindmaps.get" => "mindmaps:get",
"mindmap.projection.get" => "mindmaps:getProjection",
"blocks.get" => "blocks:getById",
"sidebar.dataset.list" => "sidebar:datasetList",
"search.documents" => "search:documents",
"search.documents.query" => "search:documents",
"search.recent" => "search:recent",
"get_page" => "pages:get",
"list_page_blocks" => "blocks:list_by_page",
+2 -2
View File
@@ -71,8 +71,8 @@ async function checkSearchShell() {
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Search shell 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-web-shell") === "search", "Search shell 缺少 search shell header");
assert(text.includes("mnote.search_shell.v1"), "Search shell 缺少 contract schema");
assert(text.includes("react_search_palette"), "Search shell 缺少 search palette island");
return { owner: "mnote-web", shell: "search", island: "react_search_palette" };
assert(text.includes("search_interaction_island"), "Search shell 缺少 search interaction island");
return { owner: "mnote-web", shell: "search", island: "search_interaction_island" };
}
async function checkAiBridge() {
+14
View File
@@ -96,6 +96,11 @@ async function readText(baseUrl, path, init) {
const text = await response.text();
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 缺少 mnote-web owner`);
assert.equal(
response.headers.get("x-mnote-legacy-upstream"),
null,
`${path} 默认主路径不应出现 Next fallback header`,
);
return { response, text };
}
@@ -116,6 +121,10 @@ async function validateSkipNextRuntimePlan() {
}
async function validateCoreShellsWithoutNext(baseUrl) {
const root = await readText(baseUrl, "/?workspaceId=ws_demo");
assert.match(root.text, /data-mnote-shell="workspace"/);
assert.doesNotMatch(root.text, /next-app-router/i);
const auth = await readText(baseUrl, "/auth");
assert.match(auth.text, /data-mnote-shell="auth"/);
assert.doesNotMatch(auth.text, /next-app-router/i);
@@ -134,6 +143,11 @@ async function validateCoreShellsWithoutNext(baseUrl) {
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust");
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
assert.match(search.text, /mnote\.search_shell\.v1/);
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1");
assert.equal(mindmap.response.headers.get("x-mnote-web-shell"), "mindmap");
assert.match(mindmap.text, /mnote\.mindmap_shell\.v1/);
assert.doesNotMatch(mindmap.text, /next-app-router/i);
}
async function validateDocs() {
@@ -0,0 +1,37 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
async function fetchText(path) {
const response = await fetch(`${BASE_URL}${path}`);
const text = await response.text();
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
return { response, text };
}
async function main() {
const page = await fetchText("/documents/doc_1?workspaceId=ws_demo");
assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/);
assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/);
assert.match(page.text, /\/api\/tree\/events/);
assert.match(page.text, /tree:snapshot/);
assert.match(page.text, /tree:delta/);
assert.match(page.text, /tree:resync/);
const events = await fetchText("/api/tree/events?workspaceId=ws_demo&maxPolls=0");
assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/);
assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web");
assert.match(events.text, /event:\s*snapshot/);
assert.match(events.text, /id:\s*/);
assert.match(events.text, /"revision"/);
console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events" }, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,46 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
async function main() {
const response = await fetch(`${BASE_URL}/search?workspaceId=ws_demo&q=Rust`);
const html = await response.text();
assert.equal(response.status, 200, `/search 请求失败: ${response.status} ${html.slice(0, 200)}`);
assert.equal(response.headers.get("x-mnote-web-shell"), "search");
assert.match(html, /mnote\.search_shell\.v1/);
assert.match(html, /search\.documents\.query/);
assert.match(html, /data-search-results-owner="rust-kernel"/);
assert.match(html, /search-result/);
assert.doesNotMatch(html, /react_search_palette/);
const api = await fetch(`${BASE_URL}/api/search/documents`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_demo",
query: "Rust",
filters: {
titleOnly: false,
exact: false,
includeOcr: false,
onlyCurrentPage: false,
timeRange: "any",
timeField: "updated",
},
}),
});
const payload = await api.json();
assert.equal(api.status, 200, `/api/search/documents 请求失败: ${api.status}`);
assert.equal(payload.projectionOwner, "rust-kernel");
assert.equal(payload.meta.queryName, "search.documents.query");
console.log(JSON.stringify({ ok: true, projectionOwner: payload.projectionOwner }, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,37 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
async function main() {
const response = await fetch(`${BASE_URL}/api/hermes/bridge`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
stream: true,
scope: "document",
messages: [{ role: "user", content: "生成摘要" }],
context: { documentId: "doc_1", workspaceId: "ws_demo" },
}),
});
const payload = await response.json();
assert.equal(response.status, 200, `/api/hermes/bridge 请求失败: ${response.status}`);
assert.equal(response.headers.get("x-mnote-ai-bridge-owner"), "rust-web-hermes");
assert.equal(payload.canonicalRoute, "/api/hermes/bridge");
assert.match(payload.eventStreamEndpoint || "", /\/api\/hermes\/events\//);
assert.equal(payload.contract.structuredWriteOwner, "rust-web-hermes");
assert.deepEqual(payload.structuredWrite.allowedCommands, [
"page.body.save",
"tree.node.create",
"kernel.edge.attach",
]);
console.log(JSON.stringify({ ok: true, bridgeOwner: "rust-web-hermes" }, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,25 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
async function main() {
const response = await fetch(`${BASE_URL}/mindmap/doc_1/mind_1`);
const html = await response.text();
assert.equal(response.status, 200, `/mindmap 请求失败: ${response.status} ${html.slice(0, 200)}`);
assert.equal(response.headers.get("x-mnote-web-shell"), "mindmap");
assert.match(html, /mnote\.mindmap_shell\.v1/);
assert.match(html, /mindmap\.projection\.get/);
assert.match(html, /mindmap\.command\.apply/);
assert.match(html, /rust-kernel/);
assert.doesNotMatch(html, /next-app-router/);
console.log(JSON.stringify({ ok: true, projectionOwner: "rust-kernel" }, null, 2));
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -692,7 +692,7 @@ export function DocumentAiAgentPanelRuntime({
}
try {
const res = await fetch("/api/ai-agent/run", {
const res = await fetch(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.runEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
+2
View File
@@ -36,6 +36,7 @@ export interface DocumentSearchResult {
score: number;
nodeId?: string | null;
subtreeRootId?: string | null;
projectionOwner?: "rust-kernel" | "compat-index";
evidence?: Array<{
kind: string;
nodeId?: string | null;
@@ -44,6 +45,7 @@ export interface DocumentSearchResult {
}
export interface DocumentSearchResponse {
projectionOwner?: "rust-kernel" | "compat-index";
results: DocumentSearchResult[];
recent: DocumentSearchResult[];
}