4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
+731 -14
View File
@@ -34,7 +34,7 @@ use index_fts::{
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, VecDeque};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::env;
use std::sync::atomic::{AtomicU64, Ordering};
use storage_convex_bridge::{
@@ -122,6 +122,8 @@ pub struct RuntimeCommandEnvelopeWire {
pub source: RuntimeSourceWire,
pub target: Option<RuntimeTargetWire>,
pub payload: Value,
#[serde(default)]
pub preflight_data: Option<Value>,
pub reason: Option<String>,
pub refs: Vec<String>,
pub dry_run: bool,
@@ -606,6 +608,190 @@ struct DocumentMoveCommandPayload {
sort_order: i64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMovePreflightDocument {
id: String,
workspace_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct DocumentMoveSnapshotDocument {
id: String,
#[serde(default, alias = "workspace_id")]
workspace_id: Option<String>,
#[serde(default, alias = "parent_id")]
parent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMovePreflightPayload {
source_document: DocumentMovePreflightDocument,
target_parent_document: Option<DocumentMovePreflightDocument>,
#[serde(default)]
target_ancestor_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMoveSnapshotSidebarPayload {
#[serde(default)]
documents: Vec<DocumentMoveSnapshotDocument>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMoveSnapshotPayload {
#[serde(default)]
documents: Vec<DocumentMoveSnapshotDocument>,
#[serde(default, alias = "sidebarSnapshot")]
sidebar_snapshot: Option<DocumentMoveSnapshotSidebarPayload>,
}
fn derive_document_move_preflight_from_snapshot(
payload: &DocumentMoveCommandPayload,
snapshot: &DocumentMoveSnapshotPayload,
) -> Result<DocumentMovePreflightPayload, BridgeError> {
let documents = if !snapshot.documents.is_empty() {
snapshot.documents.clone()
} else if let Some(sidebar_snapshot) = snapshot.sidebar_snapshot.as_ref() {
sidebar_snapshot.documents.clone()
} else {
return Err(BridgeError::validation(
"move preflightData 缺少 documents 快照",
));
};
let document_by_id: HashMap<String, DocumentMoveSnapshotDocument> = documents
.into_iter()
.map(|document| (document.id.clone(), document))
.collect();
let source_document =
document_by_id
.get(payload.document_id.as_str())
.ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?;
let target_parent_document = payload
.parent_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|parent_id| {
document_by_id
.get(parent_id)
.cloned()
.ok_or_else(|| BridgeError::validation("目标父页面不存在或无权限"))
})
.transpose()?;
let mut target_ancestor_ids = Vec::new();
let mut cursor = target_parent_document
.as_ref()
.and_then(|document| document.parent_id.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let mut depth = 0;
while let Some(parent_id) = cursor {
if depth >= 256 {
break;
}
target_ancestor_ids.push(parent_id.clone());
cursor = document_by_id
.get(parent_id.as_str())
.and_then(|document| document.parent_id.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
depth += 1;
}
Ok(DocumentMovePreflightPayload {
source_document: DocumentMovePreflightDocument {
id: source_document.id.clone(),
workspace_id: source_document.workspace_id.clone(),
},
target_parent_document: target_parent_document.map(|document| DocumentMovePreflightDocument {
id: document.id,
workspace_id: document.workspace_id,
}),
target_ancestor_ids,
})
}
fn resolve_document_move_preflight(
payload: &DocumentMoveCommandPayload,
preflight_data: Option<&Value>,
) -> Result<Option<DocumentMovePreflightPayload>, BridgeError> {
let Some(raw_preflight) = preflight_data else {
return Ok(None);
};
if let Ok(preflight) =
serde_json::from_value::<DocumentMovePreflightPayload>(raw_preflight.clone())
{
return Ok(Some(preflight));
}
let snapshot = serde_json::from_value::<DocumentMoveSnapshotPayload>(raw_preflight.clone())
.map_err(|error| BridgeError::validation(format!("move preflightData 非法: {error}")))?;
derive_document_move_preflight_from_snapshot(payload, &snapshot).map(Some)
}
fn validate_document_move_legality(
payload: &DocumentMoveCommandPayload,
preflight_data: Option<&Value>,
) -> Result<(), BridgeError> {
let Some(parent_id) = payload.parent_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
if parent_id == payload.document_id {
return Err(BridgeError::validation("不能把页面移动到自身下面"));
}
let Some(preflight) = resolve_document_move_preflight(payload, preflight_data)? else {
return Ok(());
};
if preflight.source_document.id.trim() == parent_id {
return Err(BridgeError::validation("不能把页面移动到自身下面"));
}
if preflight
.target_ancestor_ids
.iter()
.any(|ancestor_id| ancestor_id.trim() == payload.document_id)
{
return Err(BridgeError::validation("不能把页面移动到自己的后代下面"));
}
if let Some(target_parent_document) = preflight.target_parent_document.as_ref() {
let source_workspace_id = preflight
.source_document
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let target_workspace_id = target_parent_document
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if source_workspace_id.is_some()
&& target_workspace_id.is_some()
&& source_workspace_id != target_workspace_id
{
return Err(BridgeError::validation("暂不支持跨工作空间移动页面"));
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentDeleteCommandPayload {
@@ -5557,10 +5743,15 @@ fn execute_command(
}),
}))
}
"documents.embed" => {
"documents.embed" | "tree.node.embed" => {
let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.embed" {
"tree.node.embed"
} else {
"documents.embed"
};
let command = CommandEnvelope {
name: "documents.embed".into(),
name: command_name.into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
@@ -5571,9 +5762,7 @@ fn execute_command(
workspace_id: payload.workspace_id.clone(),
revision: payload.revision,
content_json: serde_json::to_string(&payload.content).map_err(|error| {
BridgeError::validation(format!(
"documents.embed content 序列化失败: {error}"
))
BridgeError::validation(format!("{command_name} content 序列化失败: {error}"))
})?,
conflict_detection_key: payload.conflict_detection_key.clone(),
},
@@ -5818,6 +6007,7 @@ fn execute_command(
}
"documents.move" | "tree.subtree.move" => {
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?;
let command_name = if command_wire.name == "tree.subtree.move" {
"tree.subtree.move"
} else {
@@ -5854,11 +6044,16 @@ fn execute_command(
}),
}))
}
"documents.delete" => {
"documents.delete" | "tree.node.archive" => {
let payload: DocumentDeleteCommandPayload =
parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.archive" {
"tree.node.archive"
} else {
"documents.delete"
};
let command = CommandEnvelope {
name: "documents.delete".into(),
name: command_name.into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
@@ -5886,11 +6081,16 @@ fn execute_command(
}),
}))
}
"documents.restore" => {
"documents.restore" | "tree.node.restore" => {
let payload: DocumentRestoreCommandPayload =
parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.restore" {
"tree.node.restore"
} else {
"documents.restore"
};
let command = CommandEnvelope {
name: "documents.restore".into(),
name: command_name.into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
@@ -6017,10 +6217,15 @@ fn execute_command(
}),
}))
}
"documents.purge" => {
"documents.purge" | "tree.node.purge" => {
let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.purge" {
"tree.node.purge"
} else {
"documents.purge"
};
let command = CommandEnvelope {
name: "documents.purge".into(),
name: command_name.into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
@@ -6048,11 +6253,16 @@ fn execute_command(
}),
}))
}
"documents.copy_tree" => {
"documents.copy_tree" | "tree.subtree.copy" => {
let payload: DocumentCopyTreeCommandPayload =
parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.subtree.copy" {
"tree.subtree.copy"
} else {
"documents.copy_tree"
};
let command = CommandEnvelope {
name: "documents.copy_tree".into(),
name: command_name.into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
@@ -6426,6 +6636,7 @@ mod tests {
"type": "paragraph",
},
}),
preflight_data: None,
reason: Some("替换块快照".into()),
refs: vec![],
dry_run: false,
@@ -6732,6 +6943,7 @@ mod tests {
},
"createOnly": true,
}),
preflight_data: None,
reason: Some("保存导图".into()),
refs: vec!["task-032".into()],
dry_run: false,
@@ -7229,6 +7441,7 @@ mod tests {
},
"conflictDetectionKey": "doc_1:4"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-055".into()],
dry_run: false,
@@ -7326,6 +7539,7 @@ mod tests {
],
"conflictDetectionKey": "doc_1:6"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-save-fallback".into()],
dry_run: false,
@@ -7427,6 +7641,7 @@ mod tests {
],
"conflictDetectionKey": "doc_1:7"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-save-prefer-editor".into()],
dry_run: false,
@@ -7489,6 +7704,7 @@ mod tests {
],
"conflictDetectionKey": "doc_1:8"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-save-content-only".into()],
dry_run: false,
@@ -7540,6 +7756,7 @@ mod tests {
"blockId": "block_1",
"targetDocumentId": "doc_2",
}),
preflight_data: None,
reason: Some("移动块".into()),
refs: vec![],
dry_run: false,
@@ -7594,6 +7811,7 @@ mod tests {
"targetDocumentId": "doc_2",
"targetBlockId": "anchor_1",
}),
preflight_data: None,
reason: Some("嵌入块".into()),
refs: vec![],
dry_run: false,
@@ -7653,6 +7871,7 @@ mod tests {
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
}),
preflight_data: None,
reason: Some("嵌入页面".into()),
refs: vec![],
dry_run: false,
@@ -7683,6 +7902,504 @@ mod tests {
}
}
#[test]
fn tree_lifecycle_command_aliases_keep_tree_command_names() {
let cases = [
(
"tree.node.archive",
"documents:softDelete",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
}),
json!({
"id": "doc_1",
}),
),
(
"tree.node.restore",
"documents:restore",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
}),
json!({
"id": "doc_1",
}),
),
(
"tree.node.purge",
"documents:purge",
json!({
"documentId": "doc_1",
}),
json!({
"id": "doc_1",
}),
),
];
for (command_name, function_name, payload, args_json) in cases {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: command_name.into(),
command_id: format!("cmd_{command_name}"),
idempotency_key: Some(format!("idem_{command_name}")),
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,
preflight_data: None,
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("command plan should build");
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, function_name);
assert_eq!(plan.command_name, command_name);
assert_eq!(plan.args_json, args_json);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
}
}
#[test]
fn tree_subtree_move_command_rejects_self_target_via_preflight() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_self".into(),
idempotency_key: Some("idem_tree_move_self".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",
"parentId": "doc_1",
"sortOrder": 0,
"movePreflight": {
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetAncestorIds": []
}
}),
preflight_data: Some(json!({
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetAncestorIds": []
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("self move should be rejected");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "不能把页面移动到自身下面");
}
#[test]
fn tree_subtree_move_command_rejects_descendant_target_via_preflight() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_descendant".into(),
idempotency_key: Some("idem_tree_move_descendant".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",
"parentId": "child_1",
"sortOrder": 0,
"movePreflight": {
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "child_1",
"workspaceId": "ws_1",
"parentId": "doc_1"
},
"targetAncestorIds": ["doc_1"]
}
}),
preflight_data: Some(json!({
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "child_1",
"workspaceId": "ws_1",
"parentId": "doc_1"
},
"targetAncestorIds": ["doc_1"]
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("descendant move should be rejected");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
}
#[test]
fn tree_subtree_move_command_rejects_cross_workspace_target_via_preflight() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_cross_workspace".into(),
idempotency_key: Some("idem_tree_move_cross_workspace".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",
"parentId": "parent_remote",
"sortOrder": 0,
"movePreflight": {
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "parent_remote",
"workspaceId": "ws_2",
"parentId": null
},
"targetAncestorIds": []
}
}),
preflight_data: Some(json!({
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "parent_remote",
"workspaceId": "ws_2",
"parentId": null
},
"targetAncestorIds": []
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("cross-workspace move should be rejected");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "暂不支持跨工作空间移动页面");
}
#[test]
fn tree_subtree_move_command_rejects_descendant_target_via_snapshot_documents() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_desc_snapshot".into(),
idempotency_key: Some("idem_tree_move_desc_snapshot".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",
"parentId": "child_1",
"sortOrder": 0
}),
preflight_data: Some(json!({
"documents": [
{
"id": "doc_1",
"workspace_id": "ws_1",
"parent_id": null
},
{
"id": "child_1",
"workspace_id": "ws_1",
"parent_id": "doc_1"
}
]
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("descendant move should be rejected from snapshot documents");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
}
#[test]
fn tree_subtree_move_command_rejects_missing_target_parent_via_snapshot_documents() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_missing_parent_snapshot".into(),
idempotency_key: Some("idem_tree_move_missing_parent_snapshot".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",
"parentId": "missing_parent",
"sortOrder": 0
}),
preflight_data: Some(json!({
"documents": [
{
"id": "doc_1",
"workspace_id": "ws_1",
"parent_id": null
}
]
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("missing parent should be rejected from snapshot documents");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "目标父页面不存在或无权限");
}
#[test]
fn tree_embed_and_copy_aliases_keep_tree_command_names() {
let embed_plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.node.embed".into(),
command_id: "cmd_tree_embed_1".into(),
idempotency_key: Some("idem_tree_embed".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_2".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_2",
"workspaceId": "ws_1",
"revision": 5,
"content": [{ "id": "block_1", "type": "pageReference" }],
"conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
}),
preflight_data: None,
reason: Some("树命令嵌入页面".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("embed plan should build");
match embed_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.command_name, "tree.node.embed");
assert_eq!(
plan.args_json,
json!({
"id": "doc_2",
"content": [{ "id": "block_1", "type": "pageReference" }],
"expectedRevision": 5,
"conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
})
);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
let copy_plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.copy".into(),
command_id: "cmd_tree_copy_1".into(),
idempotency_key: Some("idem_tree_copy".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("parent_1".into()),
block_id: None,
}),
payload: json!({
"items": [
{
"documentId": "doc_1",
"recursive": true,
}
],
"targetParentId": "parent_1",
}),
preflight_data: None,
reason: Some("树命令复制子树".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("copy plan should build");
match copy_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:copyTree");
assert_eq!(plan.command_name, "tree.subtree.copy");
assert_eq!(
plan.args_json,
json!({
"items": [
{
"documentId": "doc_1",
"recursive": true,
}
],
"targetParentId": "parent_1",
})
);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
}
#[test]
fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
let plan = execute_runtime_input(RuntimeInput::Tool {
@@ -482,6 +482,7 @@ pub async fn save(
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
}),
preflight_data: None,
reason: Some("mnote-web human editor save".into()),
refs: vec!["mnote-web-editor-runtime".into()],
dry_run: false,
+114 -9
View File
@@ -1,34 +1,138 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
use crate::routes::stream_support::{
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
StreamSnapshotQuery,
};
use axum::extract::{Extension, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream;
use serde_json::Value;
use std::convert::Infallible;
use std::time::Duration;
use tokio::time::sleep;
pub async fn events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<StreamSnapshotQuery>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
let payload = load_stream_snapshot(state.config(), &context, &query).await?;
let event = snapshot_event(&payload);
let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?;
let initial_cursor = read_stream_cursor_from_payload(&initial_payload);
let max_polls = query.max_polls;
let poll_ms = query.poll_ms.unwrap_or(2_000).max(250);
let state_for_stream = state.clone();
let context_for_stream = context.clone();
let query_for_stream = query.clone();
let stream = stream::unfold(
Some(StreamPollState {
app_state: state_for_stream,
context: context_for_stream,
query: query_for_stream,
current_cursor: initial_cursor,
polls: 0,
initial_payload,
initial_emitted: false,
}),
move |state| async move {
let mut state = state?;
Ok(Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
if !state.initial_emitted {
state.initial_emitted = true;
return Some((
Ok(stream_event("snapshot", &state.initial_payload)),
Some(state),
));
}
loop {
if let Some(max_polls) = max_polls {
if state.polls >= max_polls {
return None;
}
}
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
let Ok((workspace_id, overview)) =
load_stream_overview(state.app_state.config(), &state.context, &state.query)
.await
else {
return None;
};
let Some(change) =
resolve_stream_change(&overview, state.current_cursor.as_deref())
else {
continue;
};
state.current_cursor = change.cursor.clone();
match change.kind {
StreamChangeKind::Delta => {
let payload = build_stream_delta_payload(
&state.context,
&state.query,
&workspace_id,
&overview,
change.cursor,
change.delta.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
);
return Some((Ok(stream_event("delta", &payload)), Some(state)));
}
StreamChangeKind::Resync => {
let mut next_query = state.query.clone();
next_query.cursor = change.cursor;
let Ok(snapshot_payload) = load_stream_snapshot(
state.app_state.config(),
&state.context,
&next_query,
)
.await
else {
return None;
};
state.query = next_query;
state.current_cursor =
read_stream_cursor_from_payload(&snapshot_payload);
return Some((
Ok(stream_event(
"resync",
&with_stream_kind(&snapshot_payload, "resync"),
)),
Some(state),
));
}
}
}
},
);
Ok(Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
))
}
fn snapshot_event(payload: &Value) -> Event {
#[derive(Clone)]
struct StreamPollState {
app_state: AppState,
context: RequestContext,
query: StreamSnapshotQuery,
current_cursor: Option<String>,
polls: u32,
initial_payload: Value,
initial_emitted: bool,
}
fn stream_event(event_name: &str, payload: &Value) -> Event {
Event::default()
.event("snapshot")
.event(event_name)
.json_data(payload)
.expect("SSE snapshot 事件必须可序列化")
.expect("SSE 事件必须可序列化")
}
#[cfg(test)]
@@ -62,7 +166,7 @@ mod tests {
let response = app()
.oneshot(
Request::builder()
.uri("/api/stream/events?workspaceId=ws_demo")
.uri("/api/stream/events?workspaceId=ws_demo&maxPolls=0")
.body(Body::empty())
.expect("request"),
)
@@ -75,7 +179,8 @@ mod tests {
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
assert!(text.contains("\"scope\":\"workspace\""));
assert!(text.contains("\"stream\":\"workspace\""));
assert!(text.contains("\"projection\":\"sidebar_tree\""));
assert!(text.contains("\"workspaceId\":\"ws_demo\""));
}
}
@@ -13,6 +13,15 @@ use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save",
"page.layout.updateOptions",
"documents.stats.update",
"blocks.patch",
"blocks.move",
"blocks.embed",
];
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct StreamSnapshotQuery {
@@ -21,6 +30,8 @@ pub struct StreamSnapshotQuery {
pub depth: Option<u32>,
pub cursor: Option<String>,
pub limit: Option<u32>,
pub poll_ms: Option<u64>,
pub max_polls: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -29,6 +40,19 @@ pub enum StreamSnapshotScope {
Subtree,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamChangeKind {
Delta,
Resync,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StreamChange {
pub kind: StreamChangeKind,
pub cursor: Option<String>,
pub delta: Option<Value>,
}
impl StreamSnapshotScope {
pub fn as_str(self) -> &'static str {
match self {
@@ -36,6 +60,19 @@ impl StreamSnapshotScope {
Self::Subtree => "subtree",
}
}
pub fn projection(self) -> &'static str {
match self {
Self::Workspace => "sidebar_tree",
Self::Subtree => "page_tree",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DecodedStreamCursor {
created_at: String,
id: String,
}
pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope {
@@ -52,6 +89,15 @@ pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope
}
}
fn normalize_root_node_id(query: &StreamSnapshotQuery) -> Option<String> {
query
.root_node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn workspace_overview_query(
workspace_id: &str,
query: &StreamSnapshotQuery,
@@ -72,6 +118,246 @@ fn workspace_overview_query(
}
}
fn is_record(value: &Value) -> bool {
value.is_object()
}
fn read_string_field(value: &Value, keys: &[&str]) -> Option<String> {
let map = value.as_object()?;
for key in keys {
let candidate = map.get(*key).and_then(Value::as_str).map(str::trim).unwrap_or("");
if !candidate.is_empty() {
return Some(candidate.to_string());
}
}
None
}
fn read_array_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec<Value>> {
let map = value.as_object()?;
for key in keys {
if let Some(items) = map.get(*key).and_then(Value::as_array) {
return Some(items);
}
}
None
}
fn encode_stream_cursor(id: &str, created_at: &str) -> Option<String> {
let id = id.trim();
let created_at = created_at.trim();
if id.is_empty() || created_at.is_empty() {
return None;
}
Some(json!({
"createdAt": created_at,
"id": id,
})
.to_string())
}
fn encode_command_cursor(row: &Value) -> Option<String> {
let id = read_string_field(row, &["id", "command_id", "commandId"])?;
let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?;
encode_stream_cursor(&id, &created_at)
}
fn encode_domain_event_cursor(row: &Value) -> Option<String> {
let id = read_string_field(row, &["event_id", "eventId", "id"])?;
let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?;
encode_stream_cursor(&format!("domain_event:{id}"), &created_at)
}
fn decode_stream_cursor(raw: &str) -> Option<DecodedStreamCursor> {
let parsed = serde_json::from_str::<Value>(raw).ok()?;
Some(DecodedStreamCursor {
created_at: read_string_field(&parsed, &["createdAt"])?,
id: read_string_field(&parsed, &["id"])?,
})
}
pub fn resolve_stream_cursor(
overview: Option<&Value>,
fallback: Option<&str>,
) -> Option<String> {
let fallback = fallback
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let Some(overview) = overview else {
return fallback;
};
let command_cursor = read_array_field(overview, &["command_logs", "commandLogs"])
.and_then(|rows| rows.first())
.and_then(encode_command_cursor);
let domain_event_cursor = read_array_field(overview, &["domain_events", "domainEvents"])
.and_then(|rows| rows.first())
.and_then(encode_domain_event_cursor);
match (command_cursor, domain_event_cursor) {
(None, None) => fallback,
(Some(cursor), None) => Some(cursor),
(None, Some(cursor)) => Some(cursor),
(Some(command_cursor), Some(domain_event_cursor)) => {
let decoded_command = decode_stream_cursor(&command_cursor);
let decoded_domain_event = decode_stream_cursor(&domain_event_cursor);
match (decoded_command, decoded_domain_event) {
(Some(command), Some(event)) => {
if event.created_at > command.created_at {
Some(domain_event_cursor)
} else {
Some(command_cursor)
}
}
(Some(_), None) => Some(command_cursor),
(None, Some(_)) => Some(domain_event_cursor),
(None, None) => fallback,
}
}
}
}
fn collect_new_command_logs(
rows: &[Value],
previous_cursor: Option<&str>,
) -> (Vec<Value>, bool) {
let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else {
return (rows.to_vec(), false);
};
let previous_index = rows.iter().position(|row| {
let id = read_string_field(row, &["id", "command_id", "commandId"]).unwrap_or_default();
let created_at =
read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])
.unwrap_or_default();
id == previous_cursor.id && created_at == previous_cursor.created_at
});
if let Some(index) = previous_index {
(rows.iter().take(index).cloned().collect(), false)
} else {
(rows.to_vec(), !rows.is_empty())
}
}
fn read_command_payload_delta(row: &Value) -> Option<Value> {
let command_name = read_string_field(row, &["command_name", "commandName"]).unwrap_or_default();
if TREE_STREAM_NOOP_COMMANDS.contains(&command_name.as_str()) {
return Some(json!({ "op": "noop" }));
}
let payload = row.as_object()?.get("payload")?;
if !is_record(payload) {
return None;
}
let candidate = payload
.as_object()
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
if candidate
.as_object()
.and_then(|map| map.get("op"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return Some(candidate.clone());
}
None
}
pub fn resolve_stream_change(
overview: &Value,
previous_cursor: Option<&str>,
) -> Option<StreamChange> {
let next_cursor = resolve_stream_cursor(Some(overview), previous_cursor);
let previous_cursor = previous_cursor
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if next_cursor == previous_cursor {
return None;
}
let rows = read_array_field(overview, &["command_logs", "commandLogs"]).cloned().unwrap_or_default();
let (new_rows, drifted) = collect_new_command_logs(&rows, previous_cursor.as_deref());
if !drifted && new_rows.len() == 1 {
if let Some(delta) = read_command_payload_delta(&new_rows[0]) {
return Some(StreamChange {
kind: StreamChangeKind::Delta,
cursor: next_cursor,
delta: Some(delta),
});
}
}
Some(StreamChange {
kind: StreamChangeKind::Resync,
cursor: next_cursor,
delta: None,
})
}
pub fn read_stream_cursor_from_payload(payload: &Value) -> Option<String> {
read_string_field(payload, &["cursor"])
}
pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
if let Some(mut map) = payload.as_object().cloned() {
map.insert("kind".into(), Value::String(kind.into()));
return Value::Object(map);
}
json!({
"kind": kind,
"data": payload,
})
}
pub fn build_stream_delta_payload(
context: &RequestContext,
query: &StreamSnapshotQuery,
workspace_id: &str,
overview: &Value,
cursor: Option<String>,
delta: Value,
) -> Value {
let scope = resolve_stream_scope(query);
json!({
"kind": "delta",
"stream": scope.as_str(),
"projection": scope.projection(),
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"workspaceId": workspace_id,
"rootNodeId": normalize_root_node_id(query),
"depth": query.depth,
"cursor": cursor,
"data": delta,
"snapshot": Value::Null,
"overview": overview,
})
}
pub async fn load_stream_overview(
config: &AppConfig,
context: &RequestContext,
query: &StreamSnapshotQuery,
) -> Result<(String, Value), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let overview = execute_runtime_query_via_convex(
config,
context,
Some(&effective_workspace_id),
workspace_overview_query(&effective_workspace_id, query),
)
.await?;
Ok((effective_workspace_id, overview))
}
pub async fn load_stream_snapshot(
config: &AppConfig,
context: &RequestContext,
@@ -102,17 +388,13 @@ pub async fn load_stream_snapshot(
})
}
StreamSnapshotScope::Subtree => {
let root_node_id = query
.root_node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
let root_node_id = normalize_root_node_id(query)
.expect("subtree scope 已确保 rootNodeId 存在");
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
let tree = execute_kernel_query(
context,
&effective_workspace_id,
subtree_query(&effective_workspace_id, root_node_id, query.depth),
subtree_query(&effective_workspace_id, &root_node_id, query.depth),
dataset.clone(),
)?;
@@ -131,15 +413,20 @@ pub async fn load_stream_snapshot(
)
.await
.ok();
let cursor = resolve_stream_cursor(overview.as_ref(), query.cursor.as_deref());
Ok(json!({
"kind": "snapshot",
"scope": scope.as_str(),
"stream": scope.as_str(),
"projection": scope.projection(),
"cursor": cursor,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"workspaceId": effective_workspace_id,
"rootNodeId": query.root_node_id,
"rootNodeId": normalize_root_node_id(query),
"depth": query.depth,
"data": snapshot,
"snapshot": snapshot,
"overview": overview,
}))
@@ -147,7 +434,11 @@ pub async fn load_stream_snapshot(
#[cfg(test)]
mod tests {
use super::{resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope};
use super::{
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope,
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
};
use serde_json::json;
#[test]
fn stream_scope_defaults_to_workspace() {
@@ -167,4 +458,130 @@ mod tests {
StreamSnapshotScope::Subtree
);
}
#[test]
fn stream_cursor_prefers_newer_domain_event() {
let overview = json!({
"command_logs": [
{
"command_id": "cmd_1",
"created_at": "2026-04-25T10:00:00Z"
}
],
"domain_events": [
{
"event_id": "evt_2",
"created_at": "2026-04-25T10:00:01Z"
}
]
});
assert_eq!(
resolve_stream_cursor(Some(&overview), None),
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_2"}"#.into())
);
}
#[test]
fn stream_change_detects_delta_from_single_new_command() {
let overview = json!({
"command_logs": [
{
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.node.archive",
"payload": {
"streamDelta": {
"op": "remove_document",
"documentId": "page_2"
}
}
},
{
"command_id": "cmd_1",
"created_at": "2026-04-25T10:00:01Z"
}
],
"domain_events": []
});
let change = resolve_stream_change(
&overview,
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Delta);
assert_eq!(
change.cursor,
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into())
);
assert_eq!(
change.delta,
Some(json!({
"op": "remove_document",
"documentId": "page_2"
}))
);
}
#[test]
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
let overview = json!({
"command_logs": [
{
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "page.body.save",
"payload": {
"documentId": "page_1"
}
},
{
"command_id": "cmd_1",
"created_at": "2026-04-25T10:00:01Z"
}
],
"domain_events": []
});
let change = resolve_stream_change(
&overview,
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Delta);
assert_eq!(change.delta, Some(json!({ "op": "noop" })));
}
#[test]
fn stream_change_falls_back_to_resync_when_delta_is_unstable() {
let overview = json!({
"command_logs": [
{
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.subtree.move",
"payload": {
"documentId": "page_2"
}
},
{
"command_id": "cmd_1",
"created_at": "2026-04-25T10:00:01Z"
}
],
"domain_events": []
});
let change = resolve_stream_change(
&overview,
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Resync);
assert_eq!(change.delta, None);
}
}
+502 -17
View File
@@ -27,6 +27,8 @@ pub struct TreeShellQuery {
pub root_node_id: Option<String>,
pub depth: Option<u32>,
pub active_document_id: Option<String>,
pub focused_document_id: Option<String>,
pub active_picker_item_key: Option<String>,
pub actor_id: Option<String>,
pub channel: Option<String>,
pub host: Option<String>,
@@ -162,6 +164,8 @@ fn build_tree_shell_html(
workspace_id: &str,
root_node_id: Option<&str>,
active_document_id: Option<&str>,
focused_document_id: Option<&str>,
active_picker_item_key: Option<&str>,
channel: &str,
host: Option<&str>,
context: &RequestContext,
@@ -175,6 +179,8 @@ fn build_tree_shell_html(
"workspaceId": workspace_id,
"rootNodeId": root_node_id,
"activeDocumentId": active_document_id,
"focusedDocumentId": focused_document_id,
"activePickerItemKey": active_picker_item_key,
"actorId": context.auth.actor_id,
"channel": channel,
"host": host,
@@ -663,6 +669,21 @@ fn build_tree_shell_html(
.tree-kind-badge[data-kind="table"] {
color: #b45309;
}
.tree-kind-badge[data-kind="pdf"] {
color: #dc2626;
}
.tree-kind-badge[data-kind="book"] {
color: #0f766e;
}
.tree-kind-badge[data-kind="image"] {
color: #0891b2;
}
.tree-kind-badge[data-kind="video"] {
color: #ea580c;
}
.tree-kind-badge[data-kind="audio"] {
color: #16a34a;
}
.tree-kind-badge[data-kind="file"] {
color: #64748b;
}
@@ -776,6 +797,14 @@ fn build_tree_shell_html(
typeof state.activeDocumentId === "string" && state.activeDocumentId.trim()
? state.activeDocumentId.trim()
: "";
const focusedDocumentId =
typeof state.focusedDocumentId === "string" && state.focusedDocumentId.trim()
? state.focusedDocumentId.trim()
: "";
const activePickerItemKey =
typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim()
? state.activePickerItemKey.trim()
: "";
const mode = (() => {
const rawMode =
typeof state.mode === "string" ? state.mode.trim() : "";
@@ -938,19 +967,43 @@ fn build_tree_shell_html(
.filter((item) => item.childCount > 0 && item.expandedByDefault)
.map((item) => item.nodeId),
);
let focusedNodeId =
activeDocumentId && itemById.has(activeDocumentId)
? activeDocumentId
: roots[0]?.nodeId || "";
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
let fileTreeAnchorRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
let fileTreeFocusedRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
let currentActiveDocumentId = activeDocumentId;
let currentFocusedDocumentId = focusedDocumentId;
let currentActivePickerItemKey = activePickerItemKey;
const resolvePickerRootFocused = () =>
mode === "picker" && currentActivePickerItemKey === "__root__";
const resolveFocusedNodeIdFromHostState = () => {
const pickerRootFocused = resolvePickerRootFocused();
return mode === "picker"
? currentActivePickerItemKey &&
currentActivePickerItemKey !== "__root__" &&
itemById.has(currentActivePickerItemKey)
? currentActivePickerItemKey
: currentActiveDocumentId && itemById.has(currentActiveDocumentId)
? currentActiveDocumentId
: pickerRootFocused
? ""
: roots[0]?.nodeId || ""
: currentFocusedDocumentId && itemById.has(currentFocusedDocumentId)
? currentFocusedDocumentId
: currentActiveDocumentId && itemById.has(currentActiveDocumentId)
? currentActiveDocumentId
: roots[0]?.nodeId || "";
};
let focusedNodeId = resolveFocusedNodeIdFromHostState();
let selectedFileTreeRowIds = new Set(
currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : []
);
let fileTreeAnchorRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null;
let fileTreeFocusedRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null;
let visibleFileTreeRowIds = [];
let draggingPageNodeId = "";
let activePageDropNodeId = null;
let draggingFileTreeRowIds = [];
let activeFileTreeDropRowId = null;
let activeFileTreeRootDrop = false;
let activeCursor = itemById.get(activeDocumentId) || null;
let activeCursor = itemById.get(currentActiveDocumentId) || null;
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
expanded.add(activeCursor.parentNodeId);
activeCursor = itemById.get(activeCursor.parentNodeId) || null;
@@ -1400,6 +1453,36 @@ fn build_tree_shell_html(
<path d="M3.8 6.6h8.4M6.6 3.8v8.4M9.4 3.8v8.4" stroke="currentColor" stroke-width="1.1"/>
</svg>
`,
pdf: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M6 10.8V6.5h1.5a1.2 1.2 0 1 1 0 2.4H6m3.2-2.4v4.3m0 0c1.1 0 1.8-.8 1.8-2.1 0-1.3-.7-2.2-1.8-2.2m-1.7 4.3h1.7" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
book: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4.2 3.2h6.2a1.6 1.6 0 0 1 1.6 1.6v7.4H5.4a1.2 1.2 0 0 0-1.2 1.2V4.4a1.2 1.2 0 0 1 1.2-1.2Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M5.4 12.2V4.1M7 6h3.1M7 8.2h3.1" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
`,
image: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="3" y="3" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
<circle cx="6.2" cy="6.2" r="1.1" stroke="currentColor" stroke-width="1"/>
<path d="M4.5 11 7.1 8.6l1.8 1.7 1.7-1.5L12 11" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
video: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="3" y="3.4" width="7.8" height="9.2" rx="1.4" stroke="currentColor" stroke-width="1.2"/>
<path d="m9.8 7 2.8-1.7v5.4L9.8 9" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
`,
audio: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6.4 4.2v7.6a1.5 1.5 0 1 1-1-1.4V5.6l5.2-1.2v5.2a1.5 1.5 0 1 1-1-1.4V3.5L6.4 4.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
`,
file: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
@@ -1432,10 +1515,16 @@ fn build_tree_shell_html(
throw new Error(await readErrorMessage(response));
}
const data = await response.json().catch(() => null);
if (!data || data.ok !== true || !data.result) {
if (!data || typeof data !== "object") {
throw new Error("tree command 返回了无效响应");
}
return data.result;
if (data.ok === true && data.result) {
return data.result;
}
if (data.result && typeof data.result === "object") {
return data.result;
}
return data;
} finally {
setBusy(false);
}
@@ -1453,9 +1542,101 @@ fn build_tree_shell_html(
return (childrenByParentId.get(parentId) || []).slice();
};
const PAGE_DRAG_MIME = "application/x-mnote-page-tree-node";
const clearPageDropFeedback = () => {
if (!activePageDropNodeId) {
return;
}
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="page"][data-node-id="${activePageDropNodeId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropFeedback = "false";
}
activePageDropNodeId = null;
};
const setPageDropFeedback = (nodeId) => {
const nextNodeId = normalizeText(nodeId);
if (activePageDropNodeId && activePageDropNodeId !== nextNodeId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="page"][data-node-id="${activePageDropNodeId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropFeedback = "false";
}
}
if (!nextNodeId) {
activePageDropNodeId = null;
return;
}
const nextRow = appElement.querySelector(
`.tree-row[data-shell-mode="page"][data-node-id="${nextNodeId}"]`,
);
if (nextRow instanceof HTMLElement) {
nextRow.dataset.dropFeedback = "true";
}
activePageDropNodeId = nextNodeId;
};
const resolvePageDropTargetNodeId = (element) => {
const row = element instanceof Element
? element.closest('.tree-row[data-shell-mode="page"]')
: null;
if (!(row instanceof HTMLElement)) {
return "";
}
return normalizeText(row.dataset.nodeId);
};
const readPageDragNodeId = (event) => {
const raw =
event.dataTransfer?.getData(PAGE_DRAG_MIME) ||
event.dataTransfer?.getData("text/plain") ||
draggingPageNodeId ||
"";
return normalizeText(raw);
};
const canAcceptPageDrop = (sourceNodeId, targetNodeId) => {
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) {
return false;
}
const sourceItem = itemById.get(sourceNodeId);
const targetItem = itemById.get(targetNodeId);
if (!sourceItem || !targetItem) {
return false;
}
return sourceItem.parentNodeId === targetItem.parentNodeId;
};
const postPageExpandChange = (nodeId, nextExpanded) => {
if (mode !== "page" || !nodeId) return;
postToHost("tree.page.expand.changed", {
documentId: nodeId,
expanded: nextExpanded === true,
target: { documentId: nodeId },
payload: { documentId: nodeId, expanded: nextExpanded === true },
});
};
const postPageFocusChange = (nodeId) => {
if (mode !== "page" || !nodeId) return;
postToHost("tree.page.focus.changed", {
documentId: nodeId,
target: { documentId: nodeId },
payload: { documentId: nodeId },
});
};
const toggleExpand = (nodeId) => {
if (expanded.has(nodeId)) expanded.delete(nodeId);
else expanded.add(nodeId);
const nextExpanded = !expanded.has(nodeId);
if (nextExpanded) expanded.add(nodeId);
else expanded.delete(nodeId);
postPageExpandChange(nodeId, nextExpanded);
renderTree();
};
@@ -1473,13 +1654,150 @@ fn build_tree_shell_html(
return visible;
};
const getVisiblePickerEntries = () => {
if (mode !== "picker") {
return [];
}
const visible = [];
if (allowRootPick) {
visible.push({
pickerItemKey: "__root__",
item: null,
});
}
const walk = (entries) => {
entries.forEach((item) => {
visible.push({
pickerItemKey: item.nodeId,
item,
});
if (item.childCount > 0 && expanded.has(item.nodeId)) {
walk(getSiblings(item.nodeId));
}
});
};
walk(roots);
return visible;
};
const focusNode = (nodeId) => {
if (!nodeId || !itemById.has(nodeId)) return;
if (focusedNodeId === nodeId) {
focusRowElement(nodeId);
return;
}
focusedNodeId = nodeId;
postPageFocusChange(nodeId);
renderTree();
focusRowElement(nodeId);
};
const postPickerFocusChange = (pickerItemKey) => {
if (mode !== "picker") return;
const normalizedItemKey = normalizeText(pickerItemKey);
const documentId =
normalizedItemKey && normalizedItemKey !== "__root__"
? normalizedItemKey
: null;
postToHost("tree.picker.focus.changed", {
documentId,
itemKey: normalizedItemKey || null,
pickerItemKey: normalizedItemKey || null,
target: { documentId },
payload: {
documentId,
itemKey: normalizedItemKey || null,
},
});
};
const applyPickerFocusByItemKey = (pickerItemKey) => {
if (mode !== "picker") return;
const normalizedItemKey = normalizeText(pickerItemKey);
const nextPickerItemKey =
normalizedItemKey === "__root__"
? "__root__"
: itemById.has(normalizedItemKey)
? normalizedItemKey
: "";
const nextDocumentId =
nextPickerItemKey && nextPickerItemKey !== "__root__"
? nextPickerItemKey
: null;
currentActivePickerItemKey = nextPickerItemKey;
currentActiveDocumentId = nextDocumentId;
focusedNodeId = nextDocumentId || "";
renderTree();
if (nextDocumentId) {
focusRowElement(nextDocumentId);
}
postPickerFocusChange(nextPickerItemKey || null);
};
const handlePickerCommand = (command) => {
if (mode !== "picker") return;
const normalizedCommand = normalizeText(command);
const visible = getVisiblePickerEntries();
if (visible.length === 0) {
return;
}
const currentPickerItemKey =
currentActivePickerItemKey ||
(currentActiveDocumentId && itemById.has(currentActiveDocumentId)
? currentActiveDocumentId
: allowRootPick
? "__root__"
: visible[0]?.pickerItemKey || "");
const currentIndex = visible.findIndex(
(entry) => entry.pickerItemKey === currentPickerItemKey,
);
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
if (normalizedCommand === "pick") {
const target = visible[resolvedIndex];
if (!target) {
return;
}
if (target.pickerItemKey === "__root__") {
setLastAction("已选择根目录");
postToHost("tree.pick.root", {
documentId: null,
target: { documentId: null },
payload: { documentId: null },
});
return;
}
handleNavigate(target.pickerItemKey);
return;
}
let nextIndex = resolvedIndex;
if (normalizedCommand === "next") {
nextIndex = Math.min(visible.length - 1, resolvedIndex + 1);
} else if (normalizedCommand === "previous") {
nextIndex = Math.max(0, resolvedIndex - 1);
} else if (normalizedCommand === "home") {
nextIndex = 0;
} else if (normalizedCommand === "end") {
nextIndex = visible.length - 1;
} else {
return;
}
const target = visible[nextIndex];
if (!target) {
return;
}
applyPickerFocusByItemKey(target.pickerItemKey);
};
const openFileTreeContextMenu = ({
documentId,
assetId,
@@ -1575,6 +1893,7 @@ fn build_tree_shell_html(
event.preventDefault();
if (item.childCount > 0 && !expanded.has(item.nodeId)) {
expanded.add(item.nodeId);
postPageExpandChange(item.nodeId, true);
renderTree();
focusRowElement(item.nodeId);
return;
@@ -1589,6 +1908,7 @@ fn build_tree_shell_html(
event.preventDefault();
if (item.childCount > 0 && expanded.has(item.nodeId)) {
expanded.delete(item.nodeId);
postPageExpandChange(item.nodeId, false);
renderTree();
focusRowElement(item.nodeId);
return;
@@ -1747,6 +2067,41 @@ fn build_tree_shell_html(
}
};
const handlePageDropMove = async (sourceNodeId, targetNodeId) => {
const sourceItem = itemById.get(sourceNodeId);
const targetItem = itemById.get(targetNodeId);
if (!sourceItem || !targetItem) return;
const siblings = getSiblings(targetItem.parentNodeId);
const targetIndex = siblings.findIndex((entry) => entry.nodeId === targetNodeId);
if (targetIndex < 0) return;
try {
const result = await sendCommand({
action: "move",
workspaceId,
documentId: sourceNodeId,
parentId: targetItem.parentNodeId,
sortOrder: targetIndex,
});
const documentId =
typeof result.documentId === "string" && result.documentId.trim()
? result.documentId.trim()
: sourceNodeId;
setStatus("移动页面成功");
setLastAction(`页面已拖放到 ${targetItem.title}`);
postToHost("tree.subtree.moved", {
documentId,
target: { documentId },
payload: { documentId },
});
scheduleRefresh();
} catch (error) {
const message = error instanceof Error ? error.message : "拖拽移动失败";
setStatus(message, "error");
setLastAction("拖拽移动失败", "error");
window.alert(message);
}
};
const createKindBadge = (kind) => {
const badge = document.createElement("span");
badge.className = "tree-kind-badge";
@@ -1756,9 +2111,19 @@ fn build_tree_shell_html(
? ICONS.mindmap
: kind === "table"
? ICONS.table
: kind === "index"
? ICONS.index
: kind === "page"
: kind === "pdf"
? ICONS.pdf
: kind === "book"
? ICONS.book
: kind === "image"
? ICONS.image
: kind === "video"
? ICONS.video
: kind === "audio"
? ICONS.audio
: kind === "index"
? ICONS.index
: kind === "page"
? ICONS.page
: ICONS.file;
return badge;
@@ -1784,17 +2149,21 @@ fn build_tree_shell_html(
const hasChildren = item.childCount > 0;
const row = document.createElement("div");
row.className = "tree-row";
row.dataset.active = String(item.nodeId === activeDocumentId);
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
row.dataset.focused = String(item.nodeId === focusedNodeId);
row.dataset.nodeId = item.nodeId;
row.dataset.shellMode = mode;
row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId);
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
row.setAttribute("role", "treeitem");
row.setAttribute("aria-level", String(item.depth + 1));
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
row.draggable = mode === "page";
row.dataset.draggable = String(mode === "page");
row.addEventListener("focus", () => {
if (focusedNodeId !== item.nodeId) {
focusedNodeId = item.nodeId;
postPageFocusChange(item.nodeId);
renderTree();
}
});
@@ -1804,6 +2173,58 @@ fn build_tree_shell_html(
event.preventDefault();
openContextMenu(item.nodeId, event.clientX, event.clientY);
});
row.addEventListener("dragstart", (event) => {
if (mode !== "page") return;
draggingPageNodeId = item.nodeId;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId);
event.dataTransfer.setData("text/plain", item.nodeId);
}
setLastAction(`开始拖拽页面 ${item.title}`);
});
row.addEventListener("dragover", (event) => {
if (mode !== "page") return;
const sourceNodeId = readPageDragNodeId(event);
const targetNodeId = resolvePageDropTargetNodeId(event.target);
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
clearPageDropFeedback();
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
setPageDropFeedback(targetNodeId);
});
row.addEventListener("dragleave", (event) => {
if (mode !== "page") return;
const relatedTarget =
event.relatedTarget instanceof Node ? event.relatedTarget : null;
if (relatedTarget && row.contains(relatedTarget)) {
return;
}
if (activePageDropNodeId === item.nodeId) {
clearPageDropFeedback();
}
});
row.addEventListener("drop", (event) => {
if (mode !== "page") return;
const sourceNodeId = readPageDragNodeId(event);
const targetNodeId = resolvePageDropTargetNodeId(event.target);
clearPageDropFeedback();
draggingPageNodeId = "";
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
return;
}
event.preventDefault();
void handlePageDropMove(sourceNodeId, targetNodeId);
});
row.addEventListener("dragend", () => {
if (mode !== "page") return;
draggingPageNodeId = "";
clearPageDropFeedback();
});
if (hasChildren) {
const toggleButton = document.createElement("button");
@@ -2008,7 +2429,9 @@ fn build_tree_shell_html(
const row = document.createElement("div");
row.className = "tree-row";
row.style.marginLeft = `${item.depth * 22}px`;
row.dataset.active = String(item.rowKind === "document" && documentId === activeDocumentId);
row.dataset.active = String(
item.rowKind === "document" && documentId === currentActiveDocumentId
);
row.dataset.nodeId = item.nodeId;
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
@@ -2195,6 +2618,7 @@ fn build_tree_shell_html(
rootButton.type = "button";
rootButton.className = "tree-row";
rootButton.setAttribute("data-testid", "tree-picker-root");
rootButton.dataset.focused = String(resolvePickerRootFocused());
rootButton.addEventListener("click", () => {
setLastAction("已选择根目录");
postToHost("tree.pick.root", {
@@ -2244,6 +2668,52 @@ fn build_tree_shell_html(
}
};
window.addEventListener("message", (event) => {
const payload = event.data;
if (!payload || typeof payload !== "object") {
return;
}
if (normalizeText(payload.channel) !== channel) {
return;
}
const messageType = normalizeText(payload.type);
if (messageType === "tree.picker.command") {
handlePickerCommand(payload.command);
return;
}
if (messageType !== "tree.shell.state.patch") {
return;
}
let changed = false;
const nextActiveDocumentId = normalizeText(payload.activeDocumentId);
const nextFocusedDocumentId = normalizeText(payload.focusedDocumentId);
const nextActivePickerItemKey = normalizeText(payload.activePickerItemKey);
if (nextActiveDocumentId !== currentActiveDocumentId) {
currentActiveDocumentId = nextActiveDocumentId;
changed = true;
}
if (nextFocusedDocumentId !== currentFocusedDocumentId) {
currentFocusedDocumentId = nextFocusedDocumentId;
changed = true;
}
if (nextActivePickerItemKey !== currentActivePickerItemKey) {
currentActivePickerItemKey = nextActivePickerItemKey;
changed = true;
}
if (!changed) {
return;
}
focusedNodeId = resolveFocusedNodeIdFromHostState();
renderTree();
if (mode === "page" && focusedNodeId) {
focusRowElement(focusedNodeId);
}
});
createRootButton.addEventListener("click", () => {
if (mode === "picker") return;
void handleCreate(null);
@@ -2257,6 +2727,9 @@ fn build_tree_shell_html(
};
renderTree();
if (mode === "page" && focusedNodeId) {
postPageFocusChange(focusedNodeId);
}
if (mode === "filetree") {
emitFileTreeSelectionChange();
}
@@ -2328,6 +2801,8 @@ pub async fn tree_shell(
&effective_workspace_id,
query.root_node_id.as_deref(),
query.active_document_id.as_deref(),
query.focused_document_id.as_deref(),
query.active_picker_item_key.as_deref(),
&normalize_channel(query.channel),
query.host.as_deref(),
&effective_context,
@@ -2389,6 +2864,7 @@ fn create_command_wire(
"accessScope": access_scope,
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
}),
preflight_data: None,
reason: Some("tree-shell create".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
@@ -2423,6 +2899,7 @@ fn create_command_wire(
"documentId": document_id,
"title": title,
}),
preflight_data: None,
reason: Some("tree-shell rename".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
@@ -2461,6 +2938,7 @@ fn create_command_wire(
"parentId": parent_id,
"sortOrder": sort_order,
}),
preflight_data: None,
reason: Some("tree-shell move".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
@@ -2645,6 +3123,11 @@ mod tests {
assert!(html.contains("test-shell"));
assert!(html.contains("tree-action-menu"));
assert!(html.contains("tree.page.context-menu"));
assert!(html.contains("tree.page.expand.changed"));
assert!(html.contains("tree.page.focus.changed"));
assert!(html.contains("tree.shell.state.patch"));
assert!(html.contains("application/x-mnote-page-tree-node"));
assert!(html.contains("页面已拖放到"));
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
assert!(html.contains("setAttribute(\"aria-level\""));
}
@@ -2670,6 +3153,8 @@ mod tests {
assert!(html.contains("\"allowRootPick\":true"));
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
assert!(html.contains("tree.pick.root"));
assert!(html.contains("tree.picker.command"));
assert!(html.contains("tree.picker.focus.changed"));
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
}