2026-04-28 16:30:51 +08:00
|
|
|
use super::drag_drop_state::TreeShellDragEffect;
|
|
|
|
|
use super::filetree_runtime::{
|
2026-05-08 00:41:03 +08:00
|
|
|
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeRuntimeAction,
|
|
|
|
|
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeState,
|
2026-04-28 16:30:51 +08:00
|
|
|
};
|
|
|
|
|
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 {
|
2026-05-08 00:41:03 +08:00
|
|
|
active_row_id: Option<String>,
|
2026-04-28 16:30:51 +08:00
|
|
|
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,
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
FileTreeKeyboardCommand {
|
|
|
|
|
command: FileTreeKeyboardCommand,
|
|
|
|
|
row_ids: Vec<String>,
|
|
|
|
|
target_row_id: Option<String>,
|
|
|
|
|
},
|
2026-04-28 16:30:51 +08:00
|
|
|
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,
|
|
|
|
|
},
|
2026-05-16 07:38:45 +08:00
|
|
|
ResourceLifecycle {
|
|
|
|
|
command_name: String,
|
|
|
|
|
resource_kind: String,
|
|
|
|
|
asset_id: String,
|
|
|
|
|
},
|
2026-04-28 16:30:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-08 00:41:03 +08:00
|
|
|
active_row_id: transition.state.active_row_id.clone(),
|
2026-04-28 16:30:51 +08:00
|
|
|
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,
|
|
|
|
|
}),
|
2026-05-08 00:41:03 +08:00
|
|
|
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::KeyboardCommand {
|
|
|
|
|
command,
|
|
|
|
|
row_ids,
|
|
|
|
|
target_row_id,
|
|
|
|
|
}) => Some(TreeShellHostEvent::FileTreeKeyboardCommand {
|
|
|
|
|
command: *command,
|
|
|
|
|
row_ids: row_ids.clone(),
|
|
|
|
|
target_row_id: target_row_id.clone(),
|
|
|
|
|
}),
|
2026-04-28 16:30:51 +08:00
|
|
|
_ => 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()),
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-05-20 20:48:18 +08:00
|
|
|
root_uri: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
},
|
|
|
|
|
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 {
|
2026-05-08 00:41:03 +08:00
|
|
|
active_row_id: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
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()),
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-05-20 20:48:18 +08:00
|
|
|
root_uri: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
},
|
|
|
|
|
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 {
|
2026-05-08 00:41:03 +08:00
|
|
|
active_row_id: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
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()),
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-05-20 20:48:18 +08:00
|
|
|
root_uri: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
},
|
|
|
|
|
state: FileTreeRuntimeState::default(),
|
|
|
|
|
action: FileTreeRuntimeAction::UpdateDropTarget {
|
|
|
|
|
row_id: Some("asset:image".into()),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
drop_target_result.dom_patches,
|
|
|
|
|
vec![TreeShellDomPatch::FileTreeState {
|
2026-05-08 00:41:03 +08:00
|
|
|
active_row_id: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
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()),
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-05-20 20:48:18 +08:00
|
|
|
root_uri: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
},
|
|
|
|
|
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 {
|
2026-05-08 00:41:03 +08:00
|
|
|
active_row_id: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
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,
|
|
|
|
|
}],
|
2026-05-20 20:48:18 +08:00
|
|
|
root_uri: None,
|
2026-04-28 16:30:51 +08:00
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-05-16 07:38:45 +08:00
|
|
|
TreeShellCommandEvent::ResourceLifecycle {
|
|
|
|
|
command_name: "tree.resource.archive".into(),
|
|
|
|
|
resource_kind: "file".into(),
|
|
|
|
|
asset_id: "asset:a".into(),
|
|
|
|
|
},
|
|
|
|
|
TreeShellCommandEvent::ResourceLifecycle {
|
|
|
|
|
command_name: "tree.resource.restore".into(),
|
|
|
|
|
resource_kind: "file".into(),
|
|
|
|
|
asset_id: "asset:a".into(),
|
|
|
|
|
},
|
|
|
|
|
TreeShellCommandEvent::ResourceLifecycle {
|
|
|
|
|
command_name: "tree.resource.purge".into(),
|
|
|
|
|
resource_kind: "file".into(),
|
|
|
|
|
asset_id: "asset:a".into(),
|
|
|
|
|
},
|
|
|
|
|
TreeShellCommandEvent::ResourceLifecycle {
|
|
|
|
|
command_name: "tree.resource.rename".into(),
|
|
|
|
|
resource_kind: "file".into(),
|
|
|
|
|
asset_id: "asset:a".into(),
|
|
|
|
|
},
|
2026-04-28 16:30:51 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-16 07:38:45 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"kind": "resourceLifecycle",
|
|
|
|
|
"commandName": "tree.resource.archive",
|
|
|
|
|
"resourceKind": "file",
|
|
|
|
|
"assetId": "asset:a"
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"kind": "resourceLifecycle",
|
|
|
|
|
"commandName": "tree.resource.restore",
|
|
|
|
|
"resourceKind": "file",
|
|
|
|
|
"assetId": "asset:a"
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"kind": "resourceLifecycle",
|
|
|
|
|
"commandName": "tree.resource.purge",
|
|
|
|
|
"resourceKind": "file",
|
|
|
|
|
"assetId": "asset:a"
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"kind": "resourceLifecycle",
|
|
|
|
|
"commandName": "tree.resource.rename",
|
|
|
|
|
"resourceKind": "file",
|
|
|
|
|
"assetId": "asset:a"
|
2026-04-28 16:30:51 +08:00
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|