feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
@@ -1,4 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellDragEffect {
Copy,
Move,
@@ -0,0 +1,556 @@
use super::drag_drop_state::{resolve_drag_effect, TreeShellDragEffect};
use super::filetree_selection::{FileTreeSelectionModifiers, FileTreeSelectionState};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeEnvironment {
pub visible_row_ids: Vec<String>,
pub rows: Vec<FileTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeRow {
pub row_id: String,
pub row_kind: String,
pub document_id: Option<String>,
pub asset_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
pub selection: FileTreeSelectionState,
pub drag_row_ids: Vec<String>,
pub drag_effect: Option<TreeShellDragEffect>,
pub drop_target_row_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRuntimeTransition {
pub state: FileTreeRuntimeState,
pub outputs: BTreeSet<FileTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeRuntimeAction {
SelectRow {
row_id: String,
modifiers: FileTreeSelectionModifiers,
},
SelectContextRow {
row_id: String,
},
NormalizeVisibleRows,
ClearSelection,
ResolveDragRows {
row_id: String,
has_external_files: bool,
alt_key: bool,
},
UpdateDropTarget {
row_id: Option<String>,
},
DispatchInternalDrop {
target_row_id: Option<String>,
row_ids: Vec<String>,
copy: bool,
},
DispatchExternalDrop {
target_row_id: Option<String>,
file_count: u32,
},
OpenRow {
row_id: String,
},
ContextMenuRow {
row_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeRuntimeOutput {
DomPatch,
Intent(FileTreeIntentEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeIntentEvent {
Open {
target: FileTreeOpenTarget,
},
ContextMenu {
row_id: String,
target: FileTreeOpenTarget,
},
InternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
ExternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
file_count: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeOpenTarget {
Document {
document_id: String,
},
Index {
document_id: String,
},
AssetFolder {
document_id: String,
asset_id: String,
},
Asset {
document_id: String,
asset_id: String,
},
}
impl FileTreeRuntimeState {
pub fn reduce(
&self,
env: &FileTreeRuntimeEnvironment,
action: FileTreeRuntimeAction,
) -> FileTreeRuntimeTransition {
match action {
FileTreeRuntimeAction::SelectRow { row_id, modifiers } => {
let mut state = self.clone();
state.selection =
state
.selection
.select_row(&row_id, &env.visible_row_ids, modifiers);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::SelectContextRow { row_id } => {
let mut state = self.clone();
state.selection = state.selection.select_context_row(&row_id);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::NormalizeVisibleRows => {
let mut state = self.clone();
state.selection = state
.selection
.normalize_for_visible_rows(&env.visible_row_ids);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ClearSelection => {
let mut state = self.clone();
state.selection = state.selection.clear();
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ResolveDragRows {
row_id,
has_external_files,
alt_key,
} => {
let mut state = self.clone();
state.drag_row_ids = state
.selection
.resolve_drag_row_ids_for_visible_rows(&row_id, &env.visible_row_ids);
state.drag_effect = Some(resolve_drag_effect(has_external_files, alt_key));
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::UpdateDropTarget { row_id } => {
let mut state = self.clone();
state.drop_target_row_id = row_id;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id,
row_ids,
copy,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
let row_ids = row_ids
.into_iter()
.filter(|row_id| !row_id.is_empty())
.collect::<Vec<_>>();
if row_ids.is_empty() {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target: Some(target),
row_ids,
copy,
}),
],
)
}
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id,
file_count,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
if file_count == 0 {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target: Some(target),
file_count,
}),
],
)
}
FileTreeRuntimeAction::OpenRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open {
target,
})],
)
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu { row_id, target },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
}
fn resolve_open_target(
env: &FileTreeRuntimeEnvironment,
row_id: &str,
) -> Option<FileTreeOpenTarget> {
let row_by_id = env
.rows
.iter()
.map(|row| (row.row_id.as_str(), row))
.collect::<BTreeMap<_, _>>();
let row = row_by_id.get(row_id)?;
match row.row_kind.as_str() {
"doc" | "document" => Some(FileTreeOpenTarget::Document {
document_id: row.document_id.clone()?,
}),
"index" => Some(FileTreeOpenTarget::Index {
document_id: row.document_id.clone()?,
}),
"asset-folder" | "asset_folder" => Some(FileTreeOpenTarget::AssetFolder {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
"asset" => Some(FileTreeOpenTarget::Asset {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
_ => None,
}
}
fn transition<const N: usize>(
state: FileTreeRuntimeState,
outputs: [FileTreeRuntimeOutput; N],
) -> FileTreeRuntimeTransition {
FileTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
FileTreeIntentEvent, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment,
FileTreeRuntimeOutput, FileTreeRuntimeRow, FileTreeRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn env() -> FileTreeRuntimeEnvironment {
FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "index:root", "asset-folder:mind", "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: "index:root".into(),
row_kind: "index".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset-folder:mind".into(),
row_kind: "asset-folder".into(),
document_id: Some("root".into()),
asset_id: Some("mind".into()),
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
}
}
#[test]
fn filetree_runtime_reducer_covers_selection_drag_drop_and_open_menu_intents() {
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "doc:root".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("doc:root"));
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers {
shift_key: true,
..FileTreeSelectionModifiers::default()
},
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("index:root"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset-folder:mind"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset:image"));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: true,
},
);
assert_eq!(
transition.state.drag_row_ids,
ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"])
);
assert_eq!(
transition.state.drag_effect,
Some(TreeShellDragEffect::Copy)
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset-folder:mind".into()),
},
);
assert_eq!(
transition.state.drop_target_row_id.as_deref(),
Some("asset-folder:mind")
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "doc:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Document {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "index:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "asset-folder:mind".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ContextMenuRow {
row_id: "asset:image".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu {
row_id: "asset:image".into(),
target: FileTreeOpenTarget::Asset {
document_id: "root".into(),
asset_id: "image".into(),
},
},
)));
}
#[test]
fn filetree_runtime_dispatches_drop_intents_and_clears_drag_state() {
let state = FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("asset-folder:mind".into()),
..FileTreeRuntimeState::default()
};
let transition = state.reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("asset-folder:mind".into()),
row_ids: ids(&["asset:image"]),
copy: true,
},
);
assert!(transition.state.drag_row_ids.is_empty());
assert_eq!(transition.state.drag_effect, None);
assert_eq!(transition.state.drop_target_row_id, None);
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::InternalDrop {
target_row_id: Some("asset-folder:mind".into()),
target: Some(FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
}),
row_ids: ids(&["asset:image"]),
copy: true,
},
)));
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
},
)));
let rejected = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("missing:target".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
);
assert!(rejected
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(!rejected.outputs.iter().any(|output| matches!(
output,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
)));
}
}
@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1";
@@ -25,7 +25,7 @@ impl Default for FileTreeSelectionReducerContract {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionModifiers {
pub shift_key: bool,
@@ -33,7 +33,7 @@ pub struct FileTreeSelectionModifiers {
pub meta_key: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionState {
pub selected_row_ids: BTreeSet<String>,
@@ -148,6 +148,26 @@ impl FileTreeSelectionState {
}
vec![row_id.to_string()]
}
pub fn resolve_drag_row_ids_for_visible_rows(
&self,
row_id: &str,
visible_row_ids: &[String],
) -> Vec<String> {
if !self.selected_row_ids.contains(row_id) {
return vec![row_id.to_string()];
}
let ordered = visible_row_ids
.iter()
.filter(|visible_row_id| self.selected_row_ids.contains(*visible_row_id))
.cloned()
.collect::<Vec<_>>();
if ordered.is_empty() {
self.selected_row_ids.iter().cloned().collect()
} else {
ordered
}
}
}
fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> {
@@ -177,12 +197,11 @@ mod tests {
let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]);
let mut state = FileTreeSelectionState::default();
state = state.select_row(
"doc:b",
&visible,
FileTreeSelectionModifiers::default(),
state = state.select_row("doc:b", &visible, FileTreeSelectionModifiers::default());
assert_eq!(
state.selected_row_ids,
ids(&["doc:b"]).into_iter().collect()
);
assert_eq!(state.selected_row_ids, ids(&["doc:b"]).into_iter().collect());
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:b"));
@@ -211,7 +230,9 @@ mod tests {
);
assert_eq!(
state.selected_row_ids,
ids(&["doc:a", "doc:b", "asset:c", "asset:d"]).into_iter().collect()
ids(&["doc:a", "doc:b", "asset:c", "asset:d"])
.into_iter()
.collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:a"));
@@ -224,12 +245,18 @@ mod tests {
state.focused_row_id = Some("asset:missing".into());
state = state.normalize_for_visible_rows(&visible);
assert_eq!(state.selected_row_ids, ids(&["doc:a"]).into_iter().collect());
assert_eq!(
state.selected_row_ids,
ids(&["doc:a"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id, None);
state = state.select_context_row("asset:c");
assert_eq!(state.selected_row_ids, ids(&["asset:c"]).into_iter().collect());
assert_eq!(
state.selected_row_ids,
ids(&["asset:c"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c"));
assert_eq!(state.focused_row_id.as_deref(), Some("asset:c"));
@@ -1,8 +1,7 @@
use serde::Serialize;
use std::collections::BTreeSet;
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str =
"rust_page_focus_keyboard_reducer_v1";
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = "rust_page_focus_keyboard_reducer_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -24,6 +23,7 @@ impl Default for PageFocusKeyboardReducerContract {
"move_end",
"expand",
"collapse",
"toggle",
"open",
"context_menu",
]),
@@ -77,8 +77,8 @@ impl TreeShellFocusState {
.as_deref()
.and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id))
.unwrap_or(0);
let next_index = (current_index as isize + offset)
.clamp(0, (visible_ids.len() - 1) as isize) as usize;
let next_index =
(current_index as isize + offset).clamp(0, (visible_ids.len() - 1) as isize) as usize;
Self {
focused_id: Some(visible_ids[next_index].clone()),
}
@@ -105,14 +105,23 @@ mod tests {
.normalize(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:a"));
let state = state.move_next(&visible_ids).move_next(&visible_ids).move_next(&visible_ids);
let state = state
.move_next(&visible_ids)
.move_next(&visible_ids)
.move_next(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:c"));
assert_eq!(
state.move_previous(&visible_ids).focused_id.as_deref(),
Some("doc:b")
);
assert_eq!(state.move_home(&visible_ids).focused_id.as_deref(), Some("doc:a"));
assert_eq!(state.move_end(&visible_ids).focused_id.as_deref(), Some("doc:c"));
assert_eq!(
state.move_home(&visible_ids).focused_id.as_deref(),
Some("doc:a")
);
assert_eq!(
state.move_end(&visible_ids).focused_id.as_deref(),
Some("doc:c")
);
}
#[test]
@@ -139,6 +148,7 @@ mod tests {
assert!(contract.actions.contains("move_end"));
assert!(contract.actions.contains("expand"));
assert!(contract.actions.contains("collapse"));
assert!(contract.actions.contains("toggle"));
assert!(contract.actions.contains("open"));
assert!(contract.actions.contains("context_menu"));
}
+2 -1
View File
@@ -1,6 +1,6 @@
pub mod action_registry;
pub mod drag_drop_state;
pub mod dispatcher;
pub mod drag_drop_state;
pub mod expansion_state;
pub mod filetree_renderer;
pub mod filetree_runtime;
@@ -15,6 +15,7 @@ pub mod picker_runtime;
pub mod picker_state;
pub mod protocol;
pub mod renderer_input;
pub mod runtime_api;
pub mod state;
use leptos::prelude::*;
@@ -115,9 +115,8 @@ fn render_page_row(
}
pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#,
);
let mut html =
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
@@ -0,0 +1,589 @@
use super::focus_state::TreeShellFocusState;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeEnvironment {
pub visible_node_ids: Vec<String>,
pub expandable_node_ids: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rows: Vec<PageTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub position: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeState {
pub focused_id: Option<String>,
pub expanded_ids: BTreeSet<String>,
pub drop_feedback: Option<PageTreeDropFeedback>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRuntimeTransition {
pub state: PageTreeRuntimeState,
pub outputs: BTreeSet<PageTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PageTreeRuntimeAction {
Normalize,
Focus {
node_id: String,
},
MoveNext,
MovePrevious,
MoveHome,
MoveEnd,
Expand {
node_id: String,
},
Collapse {
node_id: String,
},
Toggle {
node_id: String,
},
OpenFocused,
ContextMenuFocused,
DispatchCreate {
parent_node_id: Option<String>,
},
DispatchRename {
node_id: String,
title: String,
},
UpdateDropFeedback {
feedback: Option<PageTreeDropFeedback>,
},
UpdateDropFeedbackForTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
DispatchMove {
source_node_id: String,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
},
DispatchMoveToTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeRuntimeOutput {
DomPatch,
Intent(PageTreeIntentEvent),
CommandDispatch(PageTreeCommandDispatchEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeIntentEvent {
Open { node_id: String },
ContextMenu { node_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeCommandDispatchEvent {
CreateNode {
command_name: &'static str,
parent_node_id: Option<String>,
},
RenameNode {
command_name: &'static str,
node_id: String,
title: String,
},
MoveSubtree {
command_name: &'static str,
source_node_id: String,
target_node_id: Option<String>,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
sort_order: Option<i64>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeDropFeedback {
pub source_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_node_id: Option<String>,
pub target_parent_id: Option<String>,
pub position: PageTreeDropPosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PageTreeDropPosition {
Before,
Inside,
After,
}
impl PageTreeRuntimeState {
pub fn reduce(
&self,
env: &PageTreeRuntimeEnvironment,
action: PageTreeRuntimeAction,
) -> PageTreeRuntimeTransition {
match action {
PageTreeRuntimeAction::Normalize => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.normalize(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::Focus { node_id } => {
let focused_id = env
.visible_node_ids
.iter()
.any(|visible_id| visible_id == &node_id)
.then_some(node_id);
self.with_focus(focused_id)
}
PageTreeRuntimeAction::MoveNext => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_next(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MovePrevious => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_previous(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MoveHome => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_home(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MoveEnd => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_end(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::Expand { node_id } => self.with_expansion(env, node_id, true),
PageTreeRuntimeAction::Collapse { node_id } => self.with_expansion(env, node_id, false),
PageTreeRuntimeAction::Toggle { node_id } => {
let expanded = !self.expanded_ids.contains(&node_id);
self.with_expansion(env, node_id, expanded)
}
PageTreeRuntimeAction::OpenFocused => {
self.with_focused_intent(PageTreeIntentEvent::Open {
node_id: self.focused_id.clone().unwrap_or_default(),
})
}
PageTreeRuntimeAction::ContextMenuFocused => {
self.with_focused_intent(PageTreeIntentEvent::ContextMenu {
node_id: self.focused_id.clone().unwrap_or_default(),
})
}
PageTreeRuntimeAction::DispatchCreate { parent_node_id } => transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name: "tree.node.create",
parent_node_id,
},
)],
),
PageTreeRuntimeAction::DispatchRename { node_id, title } => {
let title = title.trim().to_string();
if node_id.trim().is_empty() || title.is_empty() {
return transition(self.clone(), []);
}
transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name: "tree.node.rename",
node_id,
title,
},
)],
)
}
PageTreeRuntimeAction::UpdateDropFeedback { feedback } => {
let mut state = self.clone();
state.drop_feedback = feedback;
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
source_node_id,
target_node_id,
position,
} => {
let mut state = self.clone();
state.drop_feedback =
resolve_drop_feedback(env, &source_node_id, &target_node_id, position);
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
PageTreeRuntimeAction::DispatchMove {
source_node_id,
target_parent_id,
position,
} => transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id,
target_node_id: None,
target_parent_id,
position,
sort_order: None,
},
)],
),
PageTreeRuntimeAction::DispatchMoveToTarget {
source_node_id,
target_node_id,
position,
} => {
let Some(resolved_drop) =
resolve_drop_target(env, &source_node_id, &target_node_id, position)
else {
return transition(self.clone(), []);
};
transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id,
target_node_id: Some(target_node_id),
target_parent_id: resolved_drop.target_parent_id,
position,
sort_order: Some(resolved_drop.sort_order),
},
)],
)
}
}
}
fn with_focus(&self, focused_id: Option<String>) -> PageTreeRuntimeTransition {
let mut state = self.clone();
state.focused_id = focused_id;
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
fn with_expansion(
&self,
env: &PageTreeRuntimeEnvironment,
node_id: String,
expanded: bool,
) -> PageTreeRuntimeTransition {
let mut state = self.clone();
if env.expandable_node_ids.contains(&node_id) {
if expanded {
state.expanded_ids.insert(node_id);
} else {
state.expanded_ids.remove(&node_id);
}
}
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
fn with_focused_intent(&self, intent: PageTreeIntentEvent) -> PageTreeRuntimeTransition {
let has_focus = !match &intent {
PageTreeIntentEvent::Open { node_id } => node_id,
PageTreeIntentEvent::ContextMenu { node_id } => node_id,
}
.is_empty();
if has_focus {
transition(self.clone(), [PageTreeRuntimeOutput::Intent(intent)])
} else {
transition(self.clone(), [])
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedPageDropTarget {
target_parent_id: Option<String>,
sort_order: i64,
}
fn resolve_drop_feedback(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<PageTreeDropFeedback> {
let resolved = resolve_drop_target(env, source_node_id, target_node_id, position)?;
Some(PageTreeDropFeedback {
source_node_id: source_node_id.to_string(),
target_node_id: Some(target_node_id.to_string()),
target_parent_id: resolved.target_parent_id,
position,
})
}
fn resolve_drop_target(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<ResolvedPageDropTarget> {
let source_node_id = source_node_id.trim();
let target_node_id = target_node_id.trim();
if source_node_id.is_empty() || target_node_id.is_empty() || source_node_id == target_node_id {
return None;
}
let source_row = env.rows.iter().find(|row| row.node_id == source_node_id)?;
let target_row = env.rows.iter().find(|row| row.node_id == target_node_id)?;
if source_row.parent_node_id != target_row.parent_node_id {
return None;
}
let mut siblings = env
.rows
.iter()
.filter(|row| row.parent_node_id == target_row.parent_node_id)
.collect::<Vec<_>>();
siblings.sort_by(|left, right| {
left.position
.cmp(&right.position)
.then_with(|| left.node_id.cmp(&right.node_id))
});
let target_index = siblings
.iter()
.position(|row| row.node_id == target_row.node_id)? as i64;
let sort_order = match position {
PageTreeDropPosition::Before | PageTreeDropPosition::Inside => target_index,
PageTreeDropPosition::After => target_index + 1,
};
Some(ResolvedPageDropTarget {
target_parent_id: target_row.parent_node_id.clone(),
sort_order,
})
}
fn transition<const N: usize>(
state: PageTreeRuntimeState,
outputs: [PageTreeRuntimeOutput; N],
) -> PageTreeRuntimeTransition {
PageTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn page_tree_runtime_reducer_normalizes_focus_expansion_intents_and_drag_move() {
let env = PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:root", "doc:child", "doc:sibling"]),
expandable_node_ids: ids(&["doc:root"]).into_iter().collect(),
rows: vec![
PageTreeRuntimeRow {
node_id: "doc:root".into(),
parent_node_id: None,
position: 0,
},
PageTreeRuntimeRow {
node_id: "doc:child".into(),
parent_node_id: Some("doc:root".into()),
position: 0,
},
PageTreeRuntimeRow {
node_id: "doc:sibling".into(),
parent_node_id: Some("doc:root".into()),
position: 1,
},
],
};
let state = PageTreeRuntimeState {
focused_id: Some("doc:missing".into()),
..PageTreeRuntimeState::default()
};
let transition = state.reduce(&env, PageTreeRuntimeAction::Normalize);
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:root"));
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::DomPatch));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::MoveNext);
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:child"));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::Expand {
node_id: "doc:root".into(),
},
);
assert!(transition.state.expanded_ids.contains("doc:root"));
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::DomPatch));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::OpenFocused);
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
PageTreeIntentEvent::Open {
node_id: "doc:child".into(),
},
)));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::ContextMenuFocused);
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
PageTreeIntentEvent::ContextMenu {
node_id: "doc:child".into(),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::UpdateDropFeedback {
feedback: Some(PageTreeDropFeedback {
source_node_id: "doc:child".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::Inside,
}),
},
);
assert_eq!(
transition
.state
.drop_feedback
.as_ref()
.map(|feedback| feedback.position),
Some(PageTreeDropPosition::Inside)
);
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
source_node_id: "doc:child".into(),
target_node_id: "doc:sibling".into(),
position: PageTreeDropPosition::Before,
},
);
assert_eq!(
transition
.state
.drop_feedback
.as_ref()
.and_then(|feedback| feedback.target_node_id.as_deref()),
Some("doc:sibling")
);
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchMove {
source_node_id: "doc:child".into(),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::After,
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id: "doc:child".into(),
target_node_id: None,
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::After,
sort_order: None,
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchMoveToTarget {
source_node_id: "doc:child".into(),
target_node_id: "doc:sibling".into(),
position: PageTreeDropPosition::Before,
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id: "doc:child".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::Before,
sort_order: Some(1),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchCreate {
parent_node_id: Some("doc:root".into()),
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name: "tree.node.create",
parent_node_id: Some("doc:root".into()),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchRename {
node_id: "doc:child".into(),
title: "新标题".into(),
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name: "tree.node.rename",
node_id: "doc:child".into(),
title: "新标题".into(),
},
)));
}
}
@@ -174,23 +174,26 @@ mod tests {
let html = render_initial_picker_html(&PickerInitialRenderInput {
allow_root_pick: true,
root_active: false,
rows: vec![PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: false,
expanded: false,
active: true,
}, PickerRenderRow {
node_id: "page_other".into(),
parent_node_id: None,
title: "其他页面".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
}],
rows: vec![
PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: false,
expanded: false,
active: true,
},
PickerRenderRow {
node_id: "page_other".into(),
parent_node_id: None,
title: "其他页面".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
},
],
});
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
@@ -0,0 +1,277 @@
use super::picker_state::{PickerItem, PickerState};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeEnvironment {
pub items: Vec<PickerRuntimeItem>,
pub excluded_ids: BTreeSet<String>,
pub allow_root_pick: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeItem {
pub item_key: String,
pub document_id: Option<String>,
pub pickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeState {
pub active_item_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRuntimeTransition {
pub state: PickerRuntimeState,
pub outputs: BTreeSet<PickerRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PickerRuntimeAction {
Normalize,
Focus { item_key: String, focus_dom: bool },
Next,
Previous,
Home,
End,
Pick,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PickerRuntimeOutput {
DomPatch { focus_dom: bool },
Pick(PickerRuntimePickTarget),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PickerRuntimePickTarget {
Root,
Document { document_id: String },
}
impl PickerRuntimeState {
pub fn reduce(
&self,
env: &PickerRuntimeEnvironment,
action: PickerRuntimeAction,
) -> PickerRuntimeTransition {
match action {
PickerRuntimeAction::Normalize => self.with_picker_state(
env,
to_picker_state(self).normalize(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Focus {
item_key,
focus_dom,
} => {
if is_pickable_item_key(env, &item_key) {
let mut state = self.clone();
state.active_item_key = Some(item_key);
transition(state, [PickerRuntimeOutput::DomPatch { focus_dom }])
} else {
transition(self.clone(), [])
}
}
PickerRuntimeAction::Next => self.with_picker_state(
env,
to_picker_state(self).move_next(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Previous => self.with_picker_state(
env,
to_picker_state(self).move_previous(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Home => self.with_picker_state(
env,
to_picker_state(self).move_home(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::End => self.with_picker_state(
env,
to_picker_state(self).move_end(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Pick => {
if self.active_item_key.as_deref() == Some("__root__") && env.allow_root_pick {
return transition(
self.clone(),
[PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root)],
);
}
let items = picker_items(env);
if let Some(document_id) = to_picker_state(self).pick(&items, &env.excluded_ids) {
transition(
self.clone(),
[PickerRuntimeOutput::Pick(
PickerRuntimePickTarget::Document { document_id },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
fn with_picker_state(
&self,
_env: &PickerRuntimeEnvironment,
picker_state: PickerState,
focus_dom: bool,
) -> PickerRuntimeTransition {
transition(
PickerRuntimeState {
active_item_key: picker_state.active_item_key,
},
[PickerRuntimeOutput::DomPatch { focus_dom }],
)
}
}
fn picker_items(env: &PickerRuntimeEnvironment) -> Vec<PickerItem> {
env.items
.iter()
.filter(|item| item.item_key != "__root__" || env.allow_root_pick)
.map(|item| PickerItem {
item_key: item.item_key.clone(),
document_id: item.document_id.clone(),
pickable: item.pickable,
})
.collect()
}
fn to_picker_state(state: &PickerRuntimeState) -> PickerState {
PickerState {
active_item_key: state.active_item_key.clone(),
}
}
fn is_pickable_item_key(env: &PickerRuntimeEnvironment, item_key: &str) -> bool {
picker_items(env).iter().any(|item| {
item.pickable
&& item.item_key == item_key
&& item
.document_id
.as_ref()
.is_none_or(|document_id| !env.excluded_ids.contains(document_id))
})
}
fn transition<const N: usize>(
state: PickerRuntimeState,
outputs: [PickerRuntimeOutput; N],
) -> PickerRuntimeTransition {
PickerRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeOutput,
PickerRuntimePickTarget, PickerRuntimeState,
};
use std::collections::BTreeSet;
fn excluded(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn item(item_key: &str, document_id: Option<&str>, pickable: bool) -> PickerRuntimeItem {
PickerRuntimeItem {
item_key: item_key.into(),
document_id: document_id.map(ToOwned::to_owned),
pickable,
}
}
fn env() -> PickerRuntimeEnvironment {
PickerRuntimeEnvironment {
items: vec![
item("__root__", None, true),
item("doc:a", Some("doc:a"), true),
item("doc:hidden", Some("doc:hidden"), true),
item("doc:c", Some("doc:c"), true),
],
excluded_ids: excluded(&["doc:hidden"]),
allow_root_pick: true,
}
}
#[test]
fn picker_runtime_reducer_covers_navigation_pick_and_search_focus_boundary() {
let state = PickerRuntimeState {
active_item_key: Some("doc:hidden".into()),
};
let transition = state.reduce(&env(), PickerRuntimeAction::Normalize);
assert_eq!(
transition.state.active_item_key.as_deref(),
Some("__root__")
);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: false }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Next);
assert_eq!(transition.state.active_item_key.as_deref(), Some("doc:a"));
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: false }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::End);
assert_eq!(transition.state.active_item_key.as_deref(), Some("doc:c"));
let transition = transition.state.reduce(
&env(),
PickerRuntimeAction::Focus {
item_key: "__root__".into(),
focus_dom: true,
},
);
assert_eq!(
transition.state.active_item_key.as_deref(),
Some("__root__")
);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: true }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Pick);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root,)));
let transition = transition.state.reduce(
&env(),
PickerRuntimeAction::Focus {
item_key: "doc:c".into(),
focus_dom: true,
},
);
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Pick);
assert!(transition.outputs.contains(&PickerRuntimeOutput::Pick(
PickerRuntimePickTarget::Document {
document_id: "doc:c".into(),
},
)));
}
}
@@ -67,7 +67,9 @@ impl PickerState {
pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
Self {
active_item_key: pickable_items(items, excluded_ids).last().map(|item| item.item_key.clone()),
active_item_key: pickable_items(items, excluded_ids)
.last()
.map(|item| item.item_key.clone()),
}
}
@@ -78,7 +80,12 @@ impl PickerState {
.and_then(|item| item.document_id.clone())
}
fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>, offset: isize) -> Self {
fn move_by(
&self,
items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
offset: isize,
) -> Self {
let pickable = pickable_items(items, excluded_ids).collect::<Vec<_>>();
if pickable.is_empty() {
return Self::default();
@@ -88,8 +95,8 @@ impl PickerState {
.as_deref()
.and_then(|active| pickable.iter().position(|item| item.item_key == active))
.unwrap_or(0);
let next_index = (current_index as isize + offset)
.clamp(0, (pickable.len() - 1) as isize) as usize;
let next_index =
(current_index as isize + offset).clamp(0, (pickable.len() - 1) as isize) as usize;
Self {
active_item_key: Some(pickable[next_index].item_key.clone()),
}
@@ -111,8 +118,13 @@ fn pickable_items<'a>(
.filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids))
}
fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone())
fn first_pickable_item_key(
items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
) -> Option<String> {
pickable_items(items, excluded_ids)
.next()
.map(|item| item.item_key.clone())
}
fn is_pickable_item_key(
@@ -158,7 +170,9 @@ mod tests {
.normalize(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("root"));
let state = state.move_next(&items, &excluded_ids).move_next(&items, &excluded_ids);
let state = state
.move_next(&items, &excluded_ids)
.move_next(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c"));
@@ -177,7 +191,10 @@ mod tests {
let state = PickerState::default().move_end(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(
state.move_home(&items, &excluded_ids).active_item_key.as_deref(),
state
.move_home(&items, &excluded_ids)
.active_item_key
.as_deref(),
Some("doc:a")
);
@@ -40,15 +40,40 @@ pub struct TreeShellCommandDispatcher {
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeArtifactBoundary {
pub contract_name: &'static str,
pub family: &'static str,
pub version: u8,
pub execution_strategy: &'static str,
pub browser_bridge: &'static str,
pub wasm_module_url: Option<&'static str>,
pub js_glue_url: Option<&'static str>,
pub input_fields: BTreeSet<&'static str>,
pub output_channels: BTreeSet<&'static str>,
pub event_kinds: BTreeSet<&'static str>,
pub runtime_api: TreeShellRuntimeApiBoundary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeApiBoundary {
pub request_contract: &'static str,
pub result_contract: &'static str,
pub reduce_endpoint: &'static str,
pub state_snapshots: BTreeSet<&'static str>,
pub dom_patch_kinds: BTreeSet<&'static str>,
pub host_event_kinds: BTreeSet<&'static str>,
pub command_event_kinds: BTreeSet<&'static str>,
}
impl Default for TreeShellRuntimeArtifactBoundary {
fn default() -> Self {
Self {
contract_name: "rust_tree_shell_runtime_artifact_v1",
family: "rust_family",
version: 1,
execution_strategy: "browser_bridge",
browser_bridge: "iframe_srcdoc",
wasm_module_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"),
js_glue_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js"),
input_fields: BTreeSet::from([
"rendererInput",
"projectionItems",
@@ -67,6 +92,31 @@ impl Default for TreeShellRuntimeArtifactBoundary {
"dragDrop",
"pick",
]),
runtime_api: TreeShellRuntimeApiBoundary {
request_contract: "TreeShellRuntimeRequest",
result_contract: "TreeShellRuntimeResult",
reduce_endpoint: "/api/tree/runtime/reduce",
state_snapshots: BTreeSet::from(["page", "fileTree", "picker"]),
dom_patch_kinds: BTreeSet::from(["pageState", "fileTreeState", "pickerState"]),
host_event_kinds: BTreeSet::from([
"pageOpen",
"pageContextMenu",
"fileTreeOpen",
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"pickerPickRoot",
"pickerPickDocument",
]),
command_event_kinds: BTreeSet::from([
"createNode",
"renameNode",
"moveSubtree",
"copyResource",
"moveResource",
"uploadResource",
]),
},
}
}
}
@@ -197,10 +247,12 @@ mod tests {
});
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!(filetree
.filetree_selection
.selected_row_ids
.contains("asset:a"));
assert!(
filetree
.filetree_selection
.selected_row_ids
.contains("asset:a")
);
assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!(
filetree
@@ -245,6 +297,18 @@ mod tests {
artifact.contract_name,
"rust_tree_shell_runtime_artifact_v1"
);
assert_eq!(artifact.family, "rust_family");
assert_eq!(artifact.version, 1);
assert_eq!(artifact.execution_strategy, "browser_bridge");
assert_eq!(artifact.browser_bridge, "iframe_srcdoc");
assert_eq!(
artifact.wasm_module_url,
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm")
);
assert_eq!(
artifact.js_glue_url,
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js")
);
assert!(artifact.input_fields.contains("rendererInput"));
assert!(artifact.input_fields.contains("projectionItems"));
assert!(artifact.input_fields.contains("expandedIds"));
@@ -254,6 +318,90 @@ mod tests {
assert!(artifact.output_channels.contains("domPatch"));
assert!(artifact.output_channels.contains("intentEvent"));
assert!(artifact.output_channels.contains("commandDispatchEvent"));
assert_eq!(
artifact.runtime_api.request_contract,
"TreeShellRuntimeRequest"
);
assert_eq!(
artifact.runtime_api.result_contract,
"TreeShellRuntimeResult"
);
assert_eq!(
artifact.runtime_api.reduce_endpoint,
"/api/tree/runtime/reduce"
);
assert!(artifact.runtime_api.state_snapshots.contains("page"));
assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
assert!(artifact.runtime_api.state_snapshots.contains("picker"));
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
assert!(
artifact
.runtime_api
.dom_patch_kinds
.contains("fileTreeState")
);
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeOpen")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeInternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeExternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("pickerPickDocument")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("createNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("renameNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveSubtree")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("copyResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("uploadResource")
);
assert!(artifact.event_kinds.contains("focus"));
assert!(artifact.event_kinds.contains("keyboard"));
assert!(artifact.event_kinds.contains("expandCollapse"));
@@ -0,0 +1,880 @@
use super::drag_drop_state::TreeShellDragEffect;
use super::filetree_runtime::{
FileTreeIntentEvent, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeOutput,
FileTreeRuntimeState,
};
use super::page_runtime::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
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,
};
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 crate::tree_shell::drag_drop_state::TreeShellDragEffect;
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
}
])
);
}
}