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
+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,
}