Files
mnote/rust/crates/mnote-web/src/tree_shell/runtime_api.rs
T

880 lines
32 KiB
Rust
Raw Normal View History

2026-04-28 16:30:51 +08:00
use super::drag_drop_state::TreeShellDragEffect;
use super::filetree_runtime::{
FileTreeIntentEvent, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeOutput,
FileTreeRuntimeState,
};
use super::page_runtime::{
2026-04-29 12:24:44 +08:00
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent,
PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeOutput, PageTreeRuntimeState,
2026-04-28 16:30:51 +08:00
};
use super::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget,
PickerRuntimeState,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellRuntimeMode {
Page,
FileTree,
Picker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "mode",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellRuntimeRequest {
Page {
request_id: String,
environment: PageTreeRuntimeEnvironment,
state: PageTreeRuntimeState,
action: PageTreeRuntimeAction,
},
FileTree {
request_id: String,
environment: FileTreeRuntimeEnvironment,
state: FileTreeRuntimeState,
action: FileTreeRuntimeAction,
},
Picker {
request_id: String,
environment: PickerRuntimeEnvironment,
state: PickerRuntimeState,
action: PickerRuntimeAction,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeResult {
pub request_id: String,
pub mode: TreeShellRuntimeMode,
pub state: TreeShellRuntimeStateSnapshot,
pub dom_patches: Vec<TreeShellDomPatch>,
pub host_events: Vec<TreeShellHostEvent>,
pub command_events: Vec<TreeShellCommandEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", content = "state", rename_all = "camelCase")]
pub enum TreeShellRuntimeStateSnapshot {
Page(PageTreeRuntimeState),
FileTree(FileTreeRuntimeState),
Picker(PickerRuntimeState),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellDomPatch {
PageState {
focused_id: Option<String>,
expanded_ids: BTreeSet<String>,
drop_feedback: Option<PageTreeDropFeedback>,
},
FileTreeState {
selected_row_ids: BTreeSet<String>,
anchor_row_id: Option<String>,
focused_row_id: Option<String>,
drag_row_ids: Vec<String>,
drag_effect: Option<TreeShellDragEffect>,
drop_target_row_id: Option<String>,
},
PickerState {
active_item_key: Option<String>,
focus_dom: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellHostEvent {
PageOpen {
node_id: String,
},
PageContextMenu {
node_id: String,
},
FileTreeOpen {
target: super::filetree_runtime::FileTreeOpenTarget,
},
FileTreeContextMenu {
row_id: String,
target: super::filetree_runtime::FileTreeOpenTarget,
},
FileTreeInternalDrop {
target_row_id: Option<String>,
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
FileTreeExternalDrop {
target_row_id: Option<String>,
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
file_count: u32,
},
PickerPickRoot,
PickerPickDocument {
document_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellCommandEvent {
CreateNode {
command_name: String,
parent_node_id: Option<String>,
},
RenameNode {
command_name: String,
node_id: String,
title: String,
},
MoveSubtree {
command_name: String,
source_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_parent_id: Option<String>,
position: PageTreeDropPosition,
#[serde(default, skip_serializing_if = "Option::is_none")]
sort_order: Option<i64>,
},
CopyResource {
command_name: String,
source_asset_ids: Vec<String>,
target_document_id: String,
},
MoveResource {
command_name: String,
source_asset_ids: Vec<String>,
target_document_id: String,
},
UploadResource {
command_name: String,
target_document_id: String,
file_count: u32,
},
}
pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellRuntimeResult {
match request {
TreeShellRuntimeRequest::Page {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::PageState {
focused_id: transition.state.focused_id.clone(),
expanded_ids: transition.state.expanded_ids.clone(),
drop_feedback: transition.state.drop_feedback.clone(),
}),
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::Intent(PageTreeIntentEvent::Open { node_id }) => {
Some(TreeShellHostEvent::PageOpen {
node_id: node_id.clone(),
})
}
PageTreeRuntimeOutput::Intent(PageTreeIntentEvent::ContextMenu { node_id }) => {
Some(TreeShellHostEvent::PageContextMenu {
node_id: node_id.clone(),
})
}
_ => None,
})
.collect();
let command_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name,
parent_node_id,
},
) => Some(TreeShellCommandEvent::CreateNode {
command_name: command_name.to_string(),
parent_node_id: parent_node_id.clone(),
}),
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name,
node_id,
title,
},
) => Some(TreeShellCommandEvent::RenameNode {
command_name: command_name.to_string(),
node_id: node_id.clone(),
title: title.clone(),
}),
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name,
source_node_id,
target_node_id,
target_parent_id,
position,
sort_order,
},
) => Some(TreeShellCommandEvent::MoveSubtree {
command_name: command_name.to_string(),
source_node_id: source_node_id.clone(),
target_node_id: target_node_id.clone(),
target_parent_id: target_parent_id.clone(),
position: *position,
sort_order: *sort_order,
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::Page,
state: TreeShellRuntimeStateSnapshot::Page(transition.state),
dom_patches,
host_events,
command_events,
}
}
TreeShellRuntimeRequest::FileTree {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::FileTreeState {
selected_row_ids: transition.state.selection.selected_row_ids.clone(),
anchor_row_id: transition.state.selection.anchor_row_id.clone(),
focused_row_id: transition.state.selection.focused_row_id.clone(),
drag_row_ids: transition.state.drag_row_ids.clone(),
drag_effect: transition.state.drag_effect,
drop_target_row_id: transition.state.drop_target_row_id.clone(),
}),
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }) => {
Some(TreeShellHostEvent::FileTreeOpen {
target: target.clone(),
})
}
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ContextMenu {
row_id,
target,
}) => Some(TreeShellHostEvent::FileTreeContextMenu {
row_id: row_id.clone(),
target: target.clone(),
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target,
row_ids,
copy,
}) => Some(TreeShellHostEvent::FileTreeInternalDrop {
target_row_id: target_row_id.clone(),
target: target.clone(),
row_ids: row_ids.clone(),
copy: *copy,
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target,
file_count,
}) => Some(TreeShellHostEvent::FileTreeExternalDrop {
target_row_id: target_row_id.clone(),
target: target.clone(),
file_count: *file_count,
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::FileTree,
state: TreeShellRuntimeStateSnapshot::FileTree(transition.state),
dom_patches,
host_events,
command_events: Vec::new(),
}
}
TreeShellRuntimeRequest::Picker {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
PickerRuntimeOutput::DomPatch { focus_dom } => {
Some(TreeShellDomPatch::PickerState {
active_item_key: transition.state.active_item_key.clone(),
focus_dom: *focus_dom,
})
}
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root) => {
Some(TreeShellHostEvent::PickerPickRoot)
}
PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Document {
document_id,
}) => Some(TreeShellHostEvent::PickerPickDocument {
document_id: document_id.clone(),
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::Picker,
state: TreeShellRuntimeStateSnapshot::Picker(transition.state),
dom_patches,
host_events,
command_events: Vec::new(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent,
TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot,
};
2026-04-29 12:24:44 +08:00
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
2026-04-28 16:30:51 +08:00
use crate::tree_shell::filetree_runtime::{
FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow,
FileTreeRuntimeState,
};
use crate::tree_shell::filetree_selection::{
FileTreeSelectionModifiers, FileTreeSelectionState,
};
use crate::tree_shell::page_runtime::{
PageTreeDropPosition, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeState,
};
use crate::tree_shell::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState,
};
use serde_json::json;
use std::collections::BTreeSet;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn set(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn runtime_request_result_are_wire_safe_and_camel_case() {
let request = TreeShellRuntimeRequest::Page {
request_id: "req-page".into(),
environment: PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:a", "doc:b"]),
expandable_node_ids: set(&["doc:a"]),
rows: Vec::new(),
},
state: PageTreeRuntimeState {
focused_id: Some("doc:a".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
},
action: PageTreeRuntimeAction::MoveNext,
};
let encoded = serde_json::to_value(&request).expect("runtime request should serialize");
assert_eq!(
encoded,
json!({
"mode": "page",
"requestId": "req-page",
"environment": {
"visibleNodeIds": ["doc:a", "doc:b"],
"expandableNodeIds": ["doc:a"]
},
"state": {
"focusedId": "doc:a",
"expandedIds": [],
"dropFeedback": null
},
"action": {
"kind": "moveNext"
}
})
);
let decoded: TreeShellRuntimeRequest =
serde_json::from_value(encoded).expect("runtime request should deserialize");
let result = reduce_tree_shell_runtime(decoded);
assert_eq!(result.request_id, "req-page");
assert_eq!(result.mode, TreeShellRuntimeMode::Page);
assert_eq!(
result.state,
TreeShellRuntimeStateSnapshot::Page(PageTreeRuntimeState {
focused_id: Some("doc:b".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
})
);
assert_eq!(
result.dom_patches,
vec![TreeShellDomPatch::PageState {
focused_id: Some("doc:b".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
}]
);
assert!(result.host_events.is_empty());
assert!(result.command_events.is_empty());
}
#[test]
fn runtime_api_maps_page_intents_and_commands_to_structured_events() {
let environment = PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:a", "doc:b"]),
expandable_node_ids: set(&["doc:a"]),
rows: Vec::new(),
};
let state = PageTreeRuntimeState {
focused_id: Some("doc:b".into()),
..PageTreeRuntimeState::default()
};
let open = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-open".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::OpenFocused,
});
assert_eq!(
open.host_events,
vec![TreeShellHostEvent::PageOpen {
node_id: "doc:b".into(),
}]
);
let create_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-create".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::DispatchCreate {
parent_node_id: Some("doc:a".into()),
},
});
assert_eq!(
create_result.command_events,
vec![TreeShellCommandEvent::CreateNode {
command_name: "tree.node.create".into(),
parent_node_id: Some("doc:a".into()),
}]
);
let rename_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-rename".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::DispatchRename {
node_id: "doc:b".into(),
title: "新标题".into(),
},
});
assert_eq!(
rename_result.command_events,
vec![TreeShellCommandEvent::RenameNode {
command_name: "tree.node.rename".into(),
node_id: "doc:b".into(),
title: "新标题".into(),
}]
);
let move_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-move".into(),
environment,
state,
action: PageTreeRuntimeAction::DispatchMove {
source_node_id: "doc:b".into(),
target_parent_id: Some("doc:a".into()),
position: PageTreeDropPosition::Inside,
},
});
assert_eq!(
move_result.command_events,
vec![TreeShellCommandEvent::MoveSubtree {
command_name: "tree.subtree.move".into(),
source_node_id: "doc:b".into(),
target_node_id: None,
target_parent_id: Some("doc:a".into()),
position: PageTreeDropPosition::Inside,
sort_order: None,
}]
);
}
#[test]
fn runtime_api_maps_filetree_and_picker_results_to_common_output_channels() {
let filetree = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
});
assert_eq!(filetree.mode, TreeShellRuntimeMode::FileTree);
assert_eq!(
filetree.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: None,
}]
);
let drag_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-drag".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState {
selection: FileTreeSelectionState::from_selected(&["asset:image".to_string()]),
..FileTreeRuntimeState::default()
},
action: FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: false,
},
});
assert_eq!(
drag_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: None,
}]
);
let drop_target_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-drop-target".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset:image".into()),
},
});
assert_eq!(
drop_target_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: Some("asset:image".into()),
}]
);
let internal_drop = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-internal-drop".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("doc:root".into()),
..FileTreeRuntimeState::default()
},
action: FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("doc:root".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
});
assert_eq!(
internal_drop.host_events,
vec![TreeShellHostEvent::FileTreeInternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
row_ids: ids(&["asset:image"]),
copy: false,
}]
);
assert_eq!(
internal_drop.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: None,
}]
);
let external_drop = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-external-drop".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root"]),
rows: vec![FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
}],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
});
assert_eq!(
external_drop.host_events,
vec![TreeShellHostEvent::FileTreeExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
}]
);
let picker = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Picker {
request_id: "req-picker".into(),
environment: PickerRuntimeEnvironment {
items: vec![PickerRuntimeItem {
item_key: "doc:target".into(),
document_id: Some("target".into()),
pickable: true,
}],
excluded_ids: BTreeSet::new(),
allow_root_pick: false,
},
state: PickerRuntimeState {
active_item_key: Some("doc:target".into()),
},
action: PickerRuntimeAction::Pick,
});
assert_eq!(picker.mode, TreeShellRuntimeMode::Picker);
assert_eq!(
picker.host_events,
vec![TreeShellHostEvent::PickerPickDocument {
document_id: "target".into(),
}]
);
}
#[test]
fn runtime_command_event_schema_covers_tree_and_resource_command_channels() {
let events = vec![
TreeShellCommandEvent::CreateNode {
command_name: "tree.node.create".into(),
parent_node_id: Some("doc:parent".into()),
},
TreeShellCommandEvent::RenameNode {
command_name: "tree.node.rename".into(),
node_id: "doc:target".into(),
title: "新标题".into(),
},
TreeShellCommandEvent::MoveSubtree {
command_name: "tree.subtree.move".into(),
source_node_id: "doc:target".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:parent".into()),
position: PageTreeDropPosition::After,
sort_order: Some(3),
},
TreeShellCommandEvent::CopyResource {
command_name: "tree.resource.copy".into(),
source_asset_ids: vec!["asset:a".into()],
target_document_id: "doc:target".into(),
},
TreeShellCommandEvent::MoveResource {
command_name: "tree.resource.move".into(),
source_asset_ids: vec!["asset:a".into()],
target_document_id: "doc:target".into(),
},
TreeShellCommandEvent::UploadResource {
command_name: "tree.resource.upload".into(),
target_document_id: "doc:target".into(),
file_count: 2,
},
];
let encoded = serde_json::to_value(&events).expect("command events should serialize");
assert_eq!(
encoded,
json!([
{
"kind": "createNode",
"commandName": "tree.node.create",
"parentNodeId": "doc:parent"
},
{
"kind": "renameNode",
"commandName": "tree.node.rename",
"nodeId": "doc:target",
"title": "新标题"
},
{
"kind": "moveSubtree",
"commandName": "tree.subtree.move",
"sourceNodeId": "doc:target",
"targetNodeId": "doc:sibling",
"targetParentId": "doc:parent",
"position": "after",
"sortOrder": 3
},
{
"kind": "copyResource",
"commandName": "tree.resource.copy",
"sourceAssetIds": ["asset:a"],
"targetDocumentId": "doc:target"
},
{
"kind": "moveResource",
"commandName": "tree.resource.move",
"sourceAssetIds": ["asset:a"],
"targetDocumentId": "doc:target"
},
{
"kind": "uploadResource",
"commandName": "tree.resource.upload",
"targetDocumentId": "doc:target",
"fileCount": 2
}
])
);
}
}