feat(tree): complete rust family runtime checklist
- add tree shell runtime artifact contracts and page/filetree/picker runtime reducers - sink tree.subtree.move write operation through Rust and formalize command event plans - harden file tree search projection contract and route thin-proxy boundaries - record completed harness tasks and move design docs into process/done
This commit is contained in:
@@ -1,45 +1,44 @@
|
||||
use adapter_onlyoffice::{
|
||||
prepare_callback, prepare_forcesave, prepare_proxy_request, resolve_session, sign_config,
|
||||
OnlyOfficeCallbackPreparationInput, OnlyOfficeForcesavePreparationInput,
|
||||
OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput, prepare_callback,
|
||||
prepare_forcesave, prepare_proxy_request, resolve_session, sign_config,
|
||||
OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput,
|
||||
};
|
||||
use core_domain::Timestamp;
|
||||
use core_protocol::editor::{EditorBlockDocumentTiptapBridge, TiptapNode};
|
||||
use core_protocol::{
|
||||
ActorPayload, BlockProps, CommandEnvelope, ContentNode, ContentNodePayload,
|
||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||
DocumentReadStats, DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType,
|
||||
EditorCommand, EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock,
|
||||
GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind,
|
||||
KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge,
|
||||
KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree,
|
||||
KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren,
|
||||
KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType,
|
||||
KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter,
|
||||
KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
|
||||
KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult,
|
||||
KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult,
|
||||
KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, MindmapNodeData,
|
||||
MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, PatchBlock,
|
||||
PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef,
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, BlockProps,
|
||||
CommandEnvelope, ContentNode, ContentNodePayload, DocumentContentResult,
|
||||
DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta,
|
||||
DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats,
|
||||
DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand,
|
||||
EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand,
|
||||
GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge,
|
||||
KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
|
||||
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview,
|
||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock,
|
||||
PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef,
|
||||
ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats,
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label,
|
||||
};
|
||||
use event_log::DomainEventRecord;
|
||||
use index_fts::{
|
||||
IndexCursor, MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation,
|
||||
SearchDocumentsRequest, can_rebuild_from_events, evaluate_search_documents,
|
||||
rebuild_from_events,
|
||||
can_rebuild_from_events, evaluate_search_documents, rebuild_from_events, IndexCursor,
|
||||
MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation,
|
||||
SearchDocumentsRequest,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use storage_convex_bridge::{
|
||||
BridgeContext, BridgeError, BridgeErrorKind, build_query_request, build_write_request,
|
||||
build_query_request, build_write_request, BridgeContext, BridgeError, BridgeErrorKind,
|
||||
};
|
||||
|
||||
static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
@@ -1032,6 +1031,28 @@ fn resolve_document_move_order_plan(
|
||||
build_document_move_order_plan_from_snapshot(payload, &snapshot).map(Some)
|
||||
}
|
||||
|
||||
fn document_move_write_operation(
|
||||
workspace_id: Option<&str>,
|
||||
plan: Option<&DocumentMoveOrderPlan>,
|
||||
) -> Value {
|
||||
let Some(plan) = plan else {
|
||||
return Value::Null;
|
||||
};
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.write_operation",
|
||||
"schemaVersion": 1,
|
||||
"operation": "tree.subtree.move.write",
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": plan.document_id,
|
||||
"fromParentId": plan.from_parent_id,
|
||||
"toParentId": plan.to_parent_id,
|
||||
"requestedSortOrder": plan.requested_sort_order,
|
||||
"normalizedSortOrder": plan.normalized_sort_order,
|
||||
"patches": plan.patches,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_document_move_preflight(
|
||||
payload: &DocumentMoveCommandPayload,
|
||||
preflight_data: Option<&Value>,
|
||||
@@ -2048,6 +2069,32 @@ fn tree_domain_event_plan(event_type: &str, stream_delta_hint: Value) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn tree_domain_event_plan_with_payload(
|
||||
event_type: &str,
|
||||
payload: Value,
|
||||
stream_delta_hint: Value,
|
||||
) -> Value {
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": event_type,
|
||||
"payload": payload,
|
||||
"streamDeltaHint": stream_delta_hint,
|
||||
})
|
||||
}
|
||||
|
||||
fn tree_resync_required_hint(reason: &str, args: Value) -> Value {
|
||||
let mut hint_args = serde_json::Map::new();
|
||||
hint_args.insert("reason".into(), Value::String(reason.into()));
|
||||
if let Some(args) = args.as_object() {
|
||||
for (key, value) in args {
|
||||
hint_args.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
tree_stream_delta_hint("resync_required", Value::Object(hint_args))
|
||||
}
|
||||
|
||||
fn read_trimmed_str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
|
||||
value
|
||||
.get(field)
|
||||
@@ -2077,6 +2124,23 @@ fn materialize_tree_stream_delta_from_hint(hint: &Value, result: &Value) -> Opti
|
||||
|
||||
match kind {
|
||||
"noop" => Some(json!({ "op": "noop" })),
|
||||
"resync_required" => {
|
||||
let mut delta = serde_json::Map::new();
|
||||
delta.insert("op".into(), Value::String("resync_required".into()));
|
||||
if let Some(reason) = read_trimmed_str_field(args, "reason") {
|
||||
delta.insert("reason".into(), Value::String(reason.into()));
|
||||
}
|
||||
if let Some(page_id) = read_trimmed_str_field(args, "pageId") {
|
||||
delta.insert("pageId".into(), Value::String(page_id.into()));
|
||||
}
|
||||
if let Some(document_id) = read_trimmed_str_field(args, "documentId") {
|
||||
delta.insert("documentId".into(), Value::String(document_id.into()));
|
||||
}
|
||||
if let Some(block_id) = read_trimmed_str_field(args, "blockId") {
|
||||
delta.insert("blockId".into(), Value::String(block_id.into()));
|
||||
}
|
||||
Some(Value::Object(delta))
|
||||
}
|
||||
"remove_document" => {
|
||||
let document_id = read_trimmed_str_field(args, "documentId")?;
|
||||
Some(json!({
|
||||
@@ -2244,6 +2308,13 @@ fn tree_artifact_payload(
|
||||
map.insert("streamDelta".into(), stream_delta.clone());
|
||||
}
|
||||
}
|
||||
if let Some(formal_payload) = domain_event_plan.get("payload").and_then(Value::as_object) {
|
||||
if let Some(map) = payload.as_object_mut() {
|
||||
for (key, value) in formal_payload {
|
||||
map.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
payload
|
||||
}
|
||||
@@ -2439,9 +2510,9 @@ pub fn execute_runtime_query(input: RuntimeInput) -> Result<Value, BridgeError>
|
||||
tool,
|
||||
data,
|
||||
} => execute_tool_result(context, tool, data.unwrap_or(Value::Null)),
|
||||
RuntimeInput::Command { .. } | RuntimeInput::CommandArtifact { .. } => Err(BridgeError::validation(
|
||||
"execute_runtime_query 仅支持 query 输入",
|
||||
)),
|
||||
RuntimeInput::Command { .. } | RuntimeInput::CommandArtifact { .. } => Err(
|
||||
BridgeError::validation("execute_runtime_query 仅支持 query 输入"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2452,7 +2523,10 @@ pub fn build_success_response(plan: RuntimeExecutionPlan) -> RuntimeSuccess {
|
||||
pub fn build_artifact_success_response(
|
||||
artifacts: Option<RuntimeCommandArtifactPlan>,
|
||||
) -> RuntimeArtifactSuccess {
|
||||
RuntimeArtifactSuccess { ok: true, artifacts }
|
||||
RuntimeArtifactSuccess {
|
||||
ok: true,
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_runtime_command_artifact(
|
||||
@@ -2466,11 +2540,7 @@ pub fn execute_runtime_command_artifact(
|
||||
result,
|
||||
now,
|
||||
} => Ok(build_runtime_command_artifact_plan(
|
||||
&context,
|
||||
&command,
|
||||
&plan,
|
||||
&result,
|
||||
&now,
|
||||
&context, &command, &plan, &result, &now,
|
||||
)),
|
||||
_ => Err(BridgeError::validation(
|
||||
"execute_runtime_command_artifact 仅支持 command artifact 输入",
|
||||
@@ -6540,7 +6610,11 @@ fn build_file_tree_projection_result(
|
||||
|
||||
fn normalize_projection_query(raw: Option<&str>) -> Option<String> {
|
||||
let query = raw?.trim().to_lowercase();
|
||||
if query.is_empty() { None } else { Some(query) }
|
||||
if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(query)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_tree_item_matches_query(item: &KernelProjectionItem, query: &str) -> bool {
|
||||
@@ -7217,11 +7291,28 @@ fn execute_command(
|
||||
actor_id: request.actor_id,
|
||||
idempotency_key: request.idempotency_key,
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
args_json: {
|
||||
let stream_delta_hint = tree_resync_required_hint(
|
||||
"blocks.patch",
|
||||
json!({
|
||||
"documentId": payload.document_id,
|
||||
"blockId": payload.block_id,
|
||||
}),
|
||||
);
|
||||
let domain_event_payload = block_patch_domain_event_payload(&payload);
|
||||
json!({
|
||||
"id": payload.document_id,
|
||||
"blockId": payload.block_id,
|
||||
"nextBlock": payload.next_block,
|
||||
}),
|
||||
"streamDeltaHint": stream_delta_hint,
|
||||
"domainEventHint": tree_domain_event_hint("block.patched"),
|
||||
"domainEventPlan": tree_domain_event_plan_with_payload(
|
||||
"block.patched",
|
||||
domain_event_payload,
|
||||
stream_delta_hint,
|
||||
),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
"blocks.move" => {
|
||||
@@ -7245,6 +7336,14 @@ fn execute_command(
|
||||
validate_only: command_wire.validate_only,
|
||||
};
|
||||
let request = build_write_request(&context, &command)?;
|
||||
let stream_delta_hint = tree_resync_required_hint(
|
||||
"blocks.move",
|
||||
json!({
|
||||
"documentId": payload.target_document_id,
|
||||
"blockId": payload.block_id,
|
||||
}),
|
||||
);
|
||||
let domain_event_payload = block_move_domain_event_payload(&payload);
|
||||
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
|
||||
command_name: command.name,
|
||||
command_id: command.command_id,
|
||||
@@ -7259,6 +7358,13 @@ fn execute_command(
|
||||
"id": payload.block_id,
|
||||
"sourceDocumentId": payload.source_document_id,
|
||||
"targetDocumentId": payload.target_document_id,
|
||||
"streamDeltaHint": stream_delta_hint,
|
||||
"domainEventHint": tree_domain_event_hint("block.moved"),
|
||||
"domainEventPlan": tree_domain_event_plan_with_payload(
|
||||
"block.moved",
|
||||
domain_event_payload,
|
||||
stream_delta_hint,
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -7283,6 +7389,14 @@ fn execute_command(
|
||||
validate_only: command_wire.validate_only,
|
||||
};
|
||||
let request = build_write_request(&context, &command)?;
|
||||
let stream_delta_hint = tree_resync_required_hint(
|
||||
"blocks.embed",
|
||||
json!({
|
||||
"documentId": payload.target_document_id,
|
||||
"blockId": payload.block_id,
|
||||
}),
|
||||
);
|
||||
let domain_event_payload = block_embed_domain_event_payload(&payload);
|
||||
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
|
||||
command_name: command.name,
|
||||
command_id: command.command_id,
|
||||
@@ -7298,6 +7412,13 @@ fn execute_command(
|
||||
"blockId": payload.block_id,
|
||||
"targetDocumentId": payload.target_document_id,
|
||||
"targetBlockId": payload.target_block_id,
|
||||
"streamDeltaHint": stream_delta_hint,
|
||||
"domainEventHint": tree_domain_event_hint("block.embedded"),
|
||||
"domainEventPlan": tree_domain_event_plan_with_payload(
|
||||
"block.embedded",
|
||||
domain_event_payload,
|
||||
stream_delta_hint,
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -7426,8 +7547,7 @@ fn execute_command(
|
||||
}))
|
||||
}
|
||||
"documents.stats.update" => {
|
||||
let payload: DocumentStatsCommandPayload =
|
||||
parse_payload(command_wire.payload.clone())?;
|
||||
let payload: DocumentStatsCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let stats = payload.stats;
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.stats.update".into(),
|
||||
@@ -7549,14 +7669,35 @@ fn execute_command(
|
||||
actor_id: request.actor_id,
|
||||
idempotency_key: request.idempotency_key,
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
args_json: {
|
||||
let stream_delta_hint = tree_resync_required_hint(
|
||||
"page_body_saved",
|
||||
json!({
|
||||
"pageId": payload.document_id,
|
||||
"documentId": payload.document_id,
|
||||
}),
|
||||
);
|
||||
let domain_event_payload = document_save_domain_event_payload(
|
||||
&payload,
|
||||
&editor_document,
|
||||
&canonical_content,
|
||||
)?;
|
||||
json!({
|
||||
"id": payload.document_id,
|
||||
"content": canonical_content,
|
||||
"editorDocument": editor_document,
|
||||
"tiptapDocument": payload.tiptap_document,
|
||||
"expectedRevision": payload.revision,
|
||||
"conflictDetectionKey": payload.conflict_detection_key,
|
||||
}),
|
||||
"streamDeltaHint": stream_delta_hint,
|
||||
"domainEventHint": tree_domain_event_hint("page.body.saved"),
|
||||
"domainEventPlan": tree_domain_event_plan_with_payload(
|
||||
"page.body.saved",
|
||||
domain_event_payload,
|
||||
stream_delta_hint,
|
||||
),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
"documents.embed" | "tree.node.embed" => {
|
||||
@@ -7874,6 +8015,10 @@ fn execute_command(
|
||||
"parentId": payload.parent_id,
|
||||
"sortOrder": payload.sort_order,
|
||||
"normalizedMove": normalized_move,
|
||||
"treeWriteOperation": document_move_write_operation(
|
||||
context.workspace_id.as_deref(),
|
||||
normalized_move.as_ref(),
|
||||
),
|
||||
"streamDeltaHint": tree_stream_delta_hint("move_document", json!({
|
||||
"documentId": payload.document_id,
|
||||
"parentId": payload.parent_id,
|
||||
@@ -8625,6 +8770,93 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
|
||||
)
|
||||
}
|
||||
|
||||
fn stable_json_content_hash(value: &Value) -> Result<String, BridgeError> {
|
||||
let serialized = serde_json::to_string(value).map_err(|error| {
|
||||
BridgeError::validation(format!("复合命令 payload content hash 序列化失败: {error}"))
|
||||
})?;
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in serialized.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
Ok(format!("fnv1a64:{hash:016x}"))
|
||||
}
|
||||
|
||||
fn editor_document_block_ids(document: &EditorBlockDocument) -> Vec<String> {
|
||||
let mut ids = document.root_block_ids.clone();
|
||||
for block in &document.blocks {
|
||||
if !ids.iter().any(|id| id == &block.block_id) {
|
||||
ids.push(block.block_id.clone());
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn document_save_domain_event_payload(
|
||||
payload: &DocumentSaveCommandPayload,
|
||||
editor_document: &EditorBlockDocument,
|
||||
canonical_content: &Value,
|
||||
) -> Result<Value, BridgeError> {
|
||||
Ok(json!({
|
||||
"page": {
|
||||
"id": payload.document_id.clone(),
|
||||
"workspaceId": payload.workspace_id.clone(),
|
||||
},
|
||||
"snapshot": {
|
||||
"version": payload.revision,
|
||||
"contentHash": stable_json_content_hash(canonical_content)?,
|
||||
"updatedAt": Value::Null,
|
||||
},
|
||||
"blocks": {
|
||||
"ids": editor_document_block_ids(editor_document),
|
||||
"count": editor_document.blocks.len(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn block_patch_domain_event_payload(payload: &BlockPatchCommandPayload) -> Value {
|
||||
let next_type = read_trimmed_str_field(&payload.next_block, "type")
|
||||
.or_else(|| read_trimmed_str_field(&payload.next_block, "blockType"));
|
||||
json!({
|
||||
"document": {
|
||||
"id": payload.document_id.clone(),
|
||||
"workspaceId": payload.workspace_id.clone(),
|
||||
},
|
||||
"block": {
|
||||
"id": payload.block_id.clone(),
|
||||
},
|
||||
"patch": {
|
||||
"summary": "replace_block",
|
||||
"nextType": next_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn block_move_domain_event_payload(payload: &BlockMoveCommandPayload) -> Value {
|
||||
json!({
|
||||
"block": {
|
||||
"id": payload.block_id.clone(),
|
||||
},
|
||||
"move": {
|
||||
"sourceDocumentId": payload.source_document_id.clone(),
|
||||
"targetDocumentId": payload.target_document_id.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn block_embed_domain_event_payload(payload: &BlockEmbedCommandPayload) -> Value {
|
||||
json!({
|
||||
"block": {
|
||||
"id": payload.block_id.clone(),
|
||||
},
|
||||
"embed": {
|
||||
"sourceDocumentId": payload.source_document_id.clone(),
|
||||
"targetDocumentId": payload.target_document_id.clone(),
|
||||
"targetBlockId": payload.target_block_id.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn to_bridge_context(context: RuntimeBridgeContextWire) -> BridgeContext {
|
||||
BridgeContext {
|
||||
deployment_id: context.deployment_id,
|
||||
@@ -8791,6 +9023,26 @@ mod tests {
|
||||
"id": "block_1",
|
||||
"type": "paragraph",
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
},
|
||||
"domainEventHint": {
|
||||
"family": "tree",
|
||||
"eventType": "tree.block.patched"
|
||||
},
|
||||
"domainEventPlan": {
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "tree.block.patched",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -9866,6 +10118,108 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plans_include_formal_domain_event_contract() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: "cmd_save_contract_1".into(),
|
||||
idempotency_key: Some("idem_save_contract_1".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".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": 8,
|
||||
"content": [
|
||||
{
|
||||
"id": "legacy_content_1",
|
||||
"type": "paragraph",
|
||||
"content": "来自旧式 content-only 保存"
|
||||
}
|
||||
],
|
||||
"conflictDetectionKey": "doc_1:8"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存正文".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.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "page_body_saved",
|
||||
"pageId": "doc_1",
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"eventType": "page.body.saved"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "page.body.saved",
|
||||
"payload": {
|
||||
"page": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
},
|
||||
"snapshot": {
|
||||
"version": 8,
|
||||
"contentHash": "fnv1a64:d039f8f3496411e8",
|
||||
"updatedAt": null
|
||||
},
|
||||
"blocks": {
|
||||
"ids": ["legacy_content_1"],
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "page_body_saved",
|
||||
"pageId": "doc_1",
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_move_command_plan_maps_to_blocks_move() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
@@ -9912,6 +10266,43 @@ mod tests {
|
||||
"id": "block_1",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "blocks.move",
|
||||
"documentId": "doc_2",
|
||||
"blockId": "block_1"
|
||||
}
|
||||
},
|
||||
"domainEventHint": {
|
||||
"family": "tree",
|
||||
"eventType": "block.moved"
|
||||
},
|
||||
"domainEventPlan": {
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "block.moved",
|
||||
"payload": {
|
||||
"block": {
|
||||
"id": "block_1"
|
||||
},
|
||||
"move": {
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2"
|
||||
}
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "blocks.move",
|
||||
"documentId": "doc_2",
|
||||
"blockId": "block_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -9968,6 +10359,44 @@ mod tests {
|
||||
"blockId": "block_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"targetBlockId": "anchor_1",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "blocks.embed",
|
||||
"documentId": "doc_2",
|
||||
"blockId": "block_1"
|
||||
}
|
||||
},
|
||||
"domainEventHint": {
|
||||
"family": "tree",
|
||||
"eventType": "block.embedded"
|
||||
},
|
||||
"domainEventPlan": {
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "block.embedded",
|
||||
"payload": {
|
||||
"block": {
|
||||
"id": "block_1"
|
||||
},
|
||||
"embed": {
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"targetBlockId": "anchor_1"
|
||||
}
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "blocks.embed",
|
||||
"documentId": "doc_2",
|
||||
"blockId": "block_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -9976,6 +10405,159 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_commands_include_formal_domain_event_contract() {
|
||||
let cases = [
|
||||
(
|
||||
"blocks.patch",
|
||||
json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"blockId": "block_1",
|
||||
"nextBlock": {
|
||||
"id": "block_1",
|
||||
"type": "paragraph"
|
||||
}
|
||||
}),
|
||||
"block.patched",
|
||||
json!({
|
||||
"document": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
},
|
||||
"block": {
|
||||
"id": "block_1"
|
||||
},
|
||||
"patch": {
|
||||
"summary": "replace_block",
|
||||
"nextType": "paragraph"
|
||||
}
|
||||
}),
|
||||
),
|
||||
(
|
||||
"blocks.move",
|
||||
json!({
|
||||
"sourceDocumentId": "doc_1",
|
||||
"blockId": "block_1",
|
||||
"targetDocumentId": "doc_2"
|
||||
}),
|
||||
"block.moved",
|
||||
json!({
|
||||
"block": {
|
||||
"id": "block_1"
|
||||
},
|
||||
"move": {
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2"
|
||||
}
|
||||
}),
|
||||
),
|
||||
(
|
||||
"blocks.embed",
|
||||
json!({
|
||||
"sourceDocumentId": "doc_1",
|
||||
"blockId": "block_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"targetBlockId": "anchor_1"
|
||||
}),
|
||||
"block.embedded",
|
||||
json!({
|
||||
"block": {
|
||||
"id": "block_1"
|
||||
},
|
||||
"embed": {
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"targetBlockId": "anchor_1"
|
||||
}
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (command_name, payload, event_type, expected_payload) in cases {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: format!("cmd_{command_name}_contract"),
|
||||
idempotency_key: Some(format!("idem_{command_name}_contract")),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_2".into()),
|
||||
block_id: Some("block_1".into()),
|
||||
}),
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some("块命令 formal contract".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("block command plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": command_name,
|
||||
"documentId": if command_name == "blocks.patch" {
|
||||
"doc_1"
|
||||
} else {
|
||||
"doc_2"
|
||||
},
|
||||
"blockId": "block_1"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"eventType": event_type
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": event_type,
|
||||
"payload": expected_payload,
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": command_name,
|
||||
"documentId": if command_name == "blocks.patch" {
|
||||
"doc_1"
|
||||
} else {
|
||||
"doc_2"
|
||||
},
|
||||
"blockId": "block_1"
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_embed_command_plan_maps_to_documents_update_content() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
@@ -10695,6 +11277,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: "cmd_tree_move_write".into(),
|
||||
idempotency_key: Some("idem_tree_move_write".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_b".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_b",
|
||||
"parentId": "target",
|
||||
"sortOrder": 99
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"documents": [
|
||||
{ "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" },
|
||||
{ "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" },
|
||||
{ "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" },
|
||||
{ "id": "doc_c", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 0, "created_at": "2026-04-25T00:00:03Z" }
|
||||
]
|
||||
})),
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("command plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json["treeWriteOperation"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.write_operation",
|
||||
"schemaVersion": 1,
|
||||
"operation": "tree.subtree.move.write",
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_b",
|
||||
"fromParentId": "source",
|
||||
"toParentId": "target",
|
||||
"requestedSortOrder": 99,
|
||||
"normalizedSortOrder": 1,
|
||||
"patches": [
|
||||
{
|
||||
"documentId": "doc_b",
|
||||
"parentId": "target",
|
||||
"sortOrder": 1,
|
||||
"moved": true
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_command_artifact_plan_materializes_domain_event_payload_from_rust_plan() {
|
||||
let context = demo_context();
|
||||
@@ -12397,12 +13051,10 @@ mod tests {
|
||||
result["items"][0]["resourceMeta"]["resourceKind"],
|
||||
json!("document")
|
||||
);
|
||||
assert!(
|
||||
result["items"][0]["capabilities"]
|
||||
.as_array()
|
||||
.map(|caps| caps.contains(&json!("create-child")))
|
||||
.unwrap_or(false)
|
||||
);
|
||||
assert!(result["items"][0]["capabilities"]
|
||||
.as_array()
|
||||
.map(|caps| caps.contains(&json!("create-child")))
|
||||
.unwrap_or(false));
|
||||
assert_eq!(result["items"][1]["parentNodeId"], json!("page_root"));
|
||||
assert_eq!(result["items"][1]["position"], json!(1));
|
||||
}
|
||||
|
||||
@@ -2242,6 +2242,10 @@ fn build_tree_shell_html(
|
||||
if (nextExpanded) expanded.add(nodeId);
|
||||
else expanded.delete(nodeId);
|
||||
postPageExpandChange(nodeId, nextExpanded);
|
||||
if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) {
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
};
|
||||
|
||||
@@ -2410,6 +2414,11 @@ fn build_tree_shell_html(
|
||||
}
|
||||
focusedNodeId = nodeId;
|
||||
postPageFocusChange(nodeId);
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeActiveDom();
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
focusRowElement(nodeId);
|
||||
};
|
||||
@@ -2460,7 +2469,11 @@ fn build_tree_shell_html(
|
||||
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
|
||||
expanded.add(item.nodeId);
|
||||
postPageExpandChange(item.nodeId, true);
|
||||
renderTree();
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeExpansionDom(item.nodeId);
|
||||
} else {
|
||||
renderTree();
|
||||
}
|
||||
focusRowElement(item.nodeId);
|
||||
return;
|
||||
}
|
||||
@@ -2474,7 +2487,11 @@ fn build_tree_shell_html(
|
||||
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
|
||||
expanded.delete(item.nodeId);
|
||||
postPageExpandChange(item.nodeId, false);
|
||||
renderTree();
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeExpansionDom(item.nodeId);
|
||||
} else {
|
||||
renderTree();
|
||||
}
|
||||
focusRowElement(item.nodeId);
|
||||
return;
|
||||
}
|
||||
@@ -2524,12 +2541,13 @@ fn build_tree_shell_html(
|
||||
});
|
||||
};
|
||||
|
||||
const applyPickerFocusByItemKey = (pickerItemKey) => {
|
||||
const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => {
|
||||
if (mode !== "picker") return;
|
||||
const result = computePickerStateActionResult({
|
||||
kind: "focus",
|
||||
itemKey: pickerItemKey,
|
||||
});
|
||||
const shouldFocusDom = options.focusDom === true;
|
||||
const nextPickerItemKey = result.nextItemKey || "";
|
||||
const nextDocumentId =
|
||||
nextPickerItemKey && nextPickerItemKey !== "__root__"
|
||||
@@ -2541,11 +2559,11 @@ fn build_tree_shell_html(
|
||||
focusedNodeId = nextDocumentId || "";
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(nextPickerItemKey);
|
||||
if (shouldFocusDom) focusPickerRowElement(nextPickerItemKey);
|
||||
} else {
|
||||
renderTree();
|
||||
}
|
||||
if (nextDocumentId) {
|
||||
if (shouldFocusDom && nextDocumentId) {
|
||||
focusRowElement(nextDocumentId);
|
||||
}
|
||||
postPickerFocusChange(nextPickerItemKey || null);
|
||||
@@ -2567,6 +2585,27 @@ fn build_tree_shell_html(
|
||||
return result;
|
||||
};
|
||||
|
||||
const postPickerPickResultToHost = (result) => {
|
||||
if (mode !== "picker" || !result) return;
|
||||
if (result.pickedRoot) {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.pickedDocumentId) {
|
||||
postToHost("tree.pick", {
|
||||
documentId: result.pickedDocumentId,
|
||||
itemKey: result.nextItemKey || result.pickedDocumentId,
|
||||
target: { documentId: result.pickedDocumentId },
|
||||
payload: { documentId: result.pickedDocumentId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickerCommand = (command) => {
|
||||
if (mode !== "picker") return;
|
||||
|
||||
@@ -2576,19 +2615,7 @@ fn build_tree_shell_html(
|
||||
}
|
||||
|
||||
if (normalizedCommand === "pick") {
|
||||
const result = applyPickerStateAction({ kind: "pick" });
|
||||
if (result.pickedRoot) {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.pickedDocumentId) {
|
||||
handleNavigate(result.pickedDocumentId);
|
||||
}
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2947,7 +2974,10 @@ fn build_tree_shell_html(
|
||||
element.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const action = normalizeText(element.dataset.rustAction);
|
||||
if (action === "open") {
|
||||
if (action === "toggle") {
|
||||
event.preventDefault();
|
||||
toggleExpand(item.nodeId);
|
||||
} else if (action === "open") {
|
||||
handleNavigate(item.nodeId);
|
||||
} else if (action === "create") {
|
||||
void handleCreate(item.nodeId);
|
||||
@@ -2961,6 +2991,72 @@ fn build_tree_shell_html(
|
||||
});
|
||||
};
|
||||
|
||||
const patchPageTreeActiveDom = () => {
|
||||
if (mode !== "page") return;
|
||||
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
const nodeId = normalizeText(row.dataset.nodeId);
|
||||
const isFocused = nodeId === focusedNodeId;
|
||||
row.dataset.active = String(nodeId === currentActiveDocumentId);
|
||||
row.dataset.focused = String(isFocused);
|
||||
row.tabIndex = isFocused ? 0 : -1;
|
||||
row.dataset.dropFeedback = String(activePageDropNodeId === nodeId);
|
||||
});
|
||||
};
|
||||
|
||||
const patchPageTreeExpansionDom = (nodeId) => {
|
||||
if (mode !== "page") return false;
|
||||
const normalizedNodeId = normalizeText(nodeId);
|
||||
if (!normalizedNodeId) return false;
|
||||
const item = itemById.get(normalizedNodeId);
|
||||
if (!item) return false;
|
||||
const nodeElement = appElement.querySelector(
|
||||
`.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`,
|
||||
);
|
||||
if (!(nodeElement instanceof HTMLElement)) return false;
|
||||
const row = nodeElement.querySelector(
|
||||
`:scope > .tree-row[data-node-id="${CSS.escape(normalizedNodeId)}"]`,
|
||||
);
|
||||
const children = getSiblings(normalizedNodeId);
|
||||
const hasChildren = item.childCount > 0 && children.length > 0;
|
||||
const isExpanded = hasChildren && expanded.has(normalizedNodeId);
|
||||
if (row instanceof HTMLElement) {
|
||||
row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false");
|
||||
const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]');
|
||||
if (toggleButton instanceof HTMLButtonElement) {
|
||||
toggleButton.textContent = isExpanded ? "▾" : "▸";
|
||||
toggleButton.setAttribute(
|
||||
"aria-label",
|
||||
`${isExpanded ? "折叠" : "展开"} ${item.title}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!hasChildren) {
|
||||
patchPageTreeActiveDom();
|
||||
return true;
|
||||
}
|
||||
let childrenList = Array.from(nodeElement.children).find(
|
||||
(child) => child instanceof HTMLElement && child.classList.contains("tree-children"),
|
||||
);
|
||||
if (isExpanded) {
|
||||
if (!(childrenList instanceof HTMLElement)) {
|
||||
childrenList = document.createElement("ul");
|
||||
childrenList.className = "tree-children";
|
||||
children.forEach((child) => {
|
||||
childrenList.appendChild(renderNode(child));
|
||||
});
|
||||
nodeElement.appendChild(childrenList);
|
||||
}
|
||||
childrenList.hidden = false;
|
||||
childrenList.style.display = "";
|
||||
} else if (childrenList instanceof HTMLElement) {
|
||||
childrenList.hidden = true;
|
||||
childrenList.style.display = "none";
|
||||
}
|
||||
patchPageTreeActiveDom();
|
||||
return true;
|
||||
};
|
||||
|
||||
const hydrateInitialPageTree = () => {
|
||||
if (mode !== "page") return false;
|
||||
const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]');
|
||||
@@ -3210,12 +3306,8 @@ fn build_tree_shell_html(
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
row.dataset.focused = String(resolvePickerRootFocused());
|
||||
row.addEventListener("click", () => {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3228,7 +3320,8 @@ fn build_tree_shell_html(
|
||||
(!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId),
|
||||
);
|
||||
row.addEventListener("click", () => {
|
||||
handleNavigate(item.nodeId);
|
||||
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3447,7 +3540,14 @@ fn build_tree_shell_html(
|
||||
linkButton.className = "tree-link";
|
||||
linkButton.setAttribute("data-testid", "tree-node-open");
|
||||
linkButton.setAttribute("aria-label", `打开 ${item.title}`);
|
||||
linkButton.addEventListener("click", () => handleNavigate(item.nodeId));
|
||||
linkButton.addEventListener("click", () => {
|
||||
if (mode === "picker") {
|
||||
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
return;
|
||||
}
|
||||
handleNavigate(item.nodeId);
|
||||
});
|
||||
|
||||
const titleElement = document.createElement("span");
|
||||
titleElement.className = "tree-link-title";
|
||||
@@ -3812,13 +3912,10 @@ fn build_tree_shell_html(
|
||||
rootButton.className = "tree-row";
|
||||
rootButton.setAttribute("data-testid", "tree-picker-root");
|
||||
rootButton.dataset.focused = String(resolvePickerRootFocused());
|
||||
rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1;
|
||||
rootButton.addEventListener("click", () => {
|
||||
setLastAction("已选择根目录");
|
||||
postToHost("tree.pick.root", {
|
||||
documentId: null,
|
||||
target: { documentId: null },
|
||||
payload: { documentId: null },
|
||||
});
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
@@ -3903,7 +4000,6 @@ fn build_tree_shell_html(
|
||||
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
||||
if (mode === "picker" && usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(currentActivePickerItemKey);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
@@ -4345,8 +4441,12 @@ mod tests {
|
||||
assert!(html.contains("tree.shell.state.patch"));
|
||||
assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\""));
|
||||
assert!(html.contains("applyPageKeyboardAction"));
|
||||
assert!(html.contains("patchPageTreeActiveDom"));
|
||||
assert!(html.contains("patchPageTreeExpansionDom"));
|
||||
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"page\""));
|
||||
assert!(html.contains("data-rust-action=\"toggle\""));
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(html.contains("hydrateInitialPageTree"));
|
||||
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(html.contains("application/x-mnote-page-tree-node"));
|
||||
@@ -4382,8 +4482,14 @@ mod tests {
|
||||
assert!(html.contains("tree.picker.focus.changed"));
|
||||
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
||||
assert!(html.contains("applyPickerStateAction"));
|
||||
assert!(html.contains("postPickerPickResultToHost"));
|
||||
assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
|
||||
assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
|
||||
assert!(html.contains("const shouldFocusDom = options.focusDom === true"));
|
||||
assert!(html.contains("if (shouldFocusDom) focusPickerRowElement"));
|
||||
assert!(html.contains("patchPickerActiveDom"));
|
||||
assert!(html.contains("hydrateInitialPickerTree"));
|
||||
assert!(html.contains("tabindex=\""));
|
||||
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||
}
|
||||
@@ -4442,6 +4548,9 @@ mod tests {
|
||||
assert!(filetree_html.contains("\"filetreeSelection\""));
|
||||
assert!(filetree_html.contains("\"selectedRowIds\""));
|
||||
assert!(filetree_html.contains("\"commandDispatcher\""));
|
||||
assert!(filetree_html.contains("\"runtimeArtifact\""));
|
||||
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
|
||||
assert!(filetree_html.contains("\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"));
|
||||
|
||||
let picker_response = app()
|
||||
.oneshot(
|
||||
@@ -4460,6 +4569,8 @@ mod tests {
|
||||
assert!(picker_html.contains("\"mode\":\"picker\""));
|
||||
assert!(picker_html.contains("\"activePickerItem\":\"page_child\""));
|
||||
assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]"));
|
||||
assert!(picker_html.contains("\"runtimeArtifact\""));
|
||||
assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -3,12 +3,15 @@ pub mod drag_drop_state;
|
||||
pub mod dispatcher;
|
||||
pub mod expansion_state;
|
||||
pub mod filetree_renderer;
|
||||
pub mod filetree_runtime;
|
||||
pub mod filetree_selection;
|
||||
pub mod focus_state;
|
||||
pub mod keyboard_state;
|
||||
pub mod loader;
|
||||
pub mod page_renderer;
|
||||
pub mod page_runtime;
|
||||
pub mod picker_renderer;
|
||||
pub mod picker_runtime;
|
||||
pub mod picker_state;
|
||||
pub mod protocol;
|
||||
pub mod renderer_input;
|
||||
|
||||
@@ -78,8 +78,19 @@ fn render_page_row(
|
||||
.find(|source| source.node_id == row.node_id)
|
||||
.map(|source| source.expanded)
|
||||
.unwrap_or(false);
|
||||
let toggle_html = if row.expandable {
|
||||
format!(
|
||||
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}">{marker}</button>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
label = if expanded { "折叠" } else { "展开" },
|
||||
title = escape_html(&row.title),
|
||||
marker = if expanded { "▾" } else { "▸" },
|
||||
)
|
||||
} else {
|
||||
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
|
||||
};
|
||||
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="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}"><span class="tree-spacer" aria-hidden="true"></span><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="{node_id}"><span class="tree-link-title">{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="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_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="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<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="{node_id}"><span class="tree-link-title">{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="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
|
||||
@@ -87,6 +98,7 @@ fn render_page_row(
|
||||
active = active,
|
||||
focused = focused,
|
||||
tab_index = if focused { "0" } else { "-1" },
|
||||
toggle_html = toggle_html,
|
||||
title = escape_html(&row.title),
|
||||
));
|
||||
if row.expandable && expanded {
|
||||
@@ -206,6 +218,8 @@ mod tests {
|
||||
assert!(html.contains("data-rust-rendered-row=\"page\""));
|
||||
assert!(html.contains("data-shell-mode=\"page\""));
|
||||
assert!(html.contains("data-rust-action=\"open\""));
|
||||
assert!(html.contains("data-rust-action=\"toggle\""));
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(html.contains("data-rust-action=\"create\""));
|
||||
assert!(html.contains("draggable=\"true\""));
|
||||
assert!(html.contains("tree-children"));
|
||||
|
||||
@@ -52,11 +52,12 @@ fn render_picker_row(
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
|
||||
) {
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
active = row.active,
|
||||
tabindex = if row.active { "0" } else { "-1" },
|
||||
title = escape_html(&row.title),
|
||||
));
|
||||
if row.expandable && row.expanded {
|
||||
@@ -77,8 +78,9 @@ pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
|
||||
);
|
||||
if input.allow_root_pick {
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
||||
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
||||
focused = input.root_active,
|
||||
tabindex = if input.root_active { "0" } else { "-1" },
|
||||
));
|
||||
}
|
||||
|
||||
@@ -180,6 +182,14 @@ mod tests {
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
active: true,
|
||||
}, PickerRenderRow {
|
||||
node_id: "page_other".into(),
|
||||
parent_node_id: None,
|
||||
title: "其他页面".into(),
|
||||
depth: 0,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
active: false,
|
||||
}],
|
||||
});
|
||||
|
||||
@@ -190,5 +200,7 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"tree-picker-row\""));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-focused=\"true\""));
|
||||
assert!(html.contains("tabindex=\"0\""));
|
||||
assert!(html.contains("tabindex=\"-1\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct TreeShellRendererInput {
|
||||
pub excluded_picker_ids: BTreeSet<String>,
|
||||
pub picker_state_reducer: Option<PickerStateReducerContract>,
|
||||
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||
pub runtime_artifact: TreeShellRuntimeArtifactBoundary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -35,6 +36,41 @@ pub struct TreeShellCommandDispatcher {
|
||||
pub command_names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TreeShellRuntimeArtifactBoundary {
|
||||
pub contract_name: &'static str,
|
||||
pub input_fields: BTreeSet<&'static str>,
|
||||
pub output_channels: BTreeSet<&'static str>,
|
||||
pub event_kinds: BTreeSet<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for TreeShellRuntimeArtifactBoundary {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contract_name: "rust_tree_shell_runtime_artifact_v1",
|
||||
input_fields: BTreeSet::from([
|
||||
"rendererInput",
|
||||
"projectionItems",
|
||||
"expandedIds",
|
||||
"selectedRowIds",
|
||||
"activePickerItem",
|
||||
"focusedId",
|
||||
]),
|
||||
output_channels: BTreeSet::from(["domPatch", "intentEvent", "commandDispatchEvent"]),
|
||||
event_kinds: BTreeSet::from([
|
||||
"focus",
|
||||
"keyboard",
|
||||
"expandCollapse",
|
||||
"selection",
|
||||
"contextMenu",
|
||||
"dragDrop",
|
||||
"pick",
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeShellRendererInput {
|
||||
pub fn page(input: PageTreeRendererInput) -> Self {
|
||||
Self {
|
||||
@@ -49,6 +85,7 @@ impl TreeShellRendererInput {
|
||||
excluded_picker_ids: BTreeSet::new(),
|
||||
picker_state_reducer: None,
|
||||
command_dispatcher: input.command_dispatcher,
|
||||
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +102,7 @@ impl TreeShellRendererInput {
|
||||
excluded_picker_ids: BTreeSet::new(),
|
||||
picker_state_reducer: None,
|
||||
command_dispatcher: input.command_dispatcher,
|
||||
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +119,7 @@ impl TreeShellRendererInput {
|
||||
excluded_picker_ids: input.excluded_picker_ids,
|
||||
picker_state_reducer: Some(PickerStateReducerContract::default()),
|
||||
command_dispatcher: input.command_dispatcher,
|
||||
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,8 +152,8 @@ pub struct PickerRendererInput {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||
TreeShellRendererInput, TreeShellRendererMode,
|
||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput,
|
||||
TreeShellCommandDispatcher, TreeShellRendererInput, TreeShellRendererMode,
|
||||
};
|
||||
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -158,7 +197,10 @@ mod tests {
|
||||
});
|
||||
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
|
||||
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
|
||||
assert!(filetree.filetree_selection.selected_row_ids.contains("asset:a"));
|
||||
assert!(filetree
|
||||
.filetree_selection
|
||||
.selected_row_ids
|
||||
.contains("asset:a"));
|
||||
assert!(filetree.page_focus_keyboard_reducer.is_none());
|
||||
assert_eq!(
|
||||
filetree
|
||||
@@ -188,4 +230,36 @@ mod tests {
|
||||
Some("rust_picker_state_reducer_v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_renderer_input_exposes_runtime_artifact_boundary() {
|
||||
let page = TreeShellRendererInput::page(PageTreeRendererInput {
|
||||
projection_item_ids: vec!["doc:root".into()],
|
||||
expanded_ids: set(&["doc:root"]),
|
||||
focused_id: Some("doc:root".into()),
|
||||
command_dispatcher: dispatcher(),
|
||||
});
|
||||
|
||||
let artifact = page.runtime_artifact;
|
||||
assert_eq!(
|
||||
artifact.contract_name,
|
||||
"rust_tree_shell_runtime_artifact_v1"
|
||||
);
|
||||
assert!(artifact.input_fields.contains("rendererInput"));
|
||||
assert!(artifact.input_fields.contains("projectionItems"));
|
||||
assert!(artifact.input_fields.contains("expandedIds"));
|
||||
assert!(artifact.input_fields.contains("selectedRowIds"));
|
||||
assert!(artifact.input_fields.contains("activePickerItem"));
|
||||
assert!(artifact.input_fields.contains("focusedId"));
|
||||
assert!(artifact.output_channels.contains("domPatch"));
|
||||
assert!(artifact.output_channels.contains("intentEvent"));
|
||||
assert!(artifact.output_channels.contains("commandDispatchEvent"));
|
||||
assert!(artifact.event_kinds.contains("focus"));
|
||||
assert!(artifact.event_kinds.contains("keyboard"));
|
||||
assert!(artifact.event_kinds.contains("expandCollapse"));
|
||||
assert!(artifact.event_kinds.contains("selection"));
|
||||
assert!(artifact.event_kinds.contains("contextMenu"));
|
||||
assert!(artifact.event_kinds.contains("dragDrop"));
|
||||
assert!(artifact.event_kinds.contains("pick"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user