feat(tree): close rust family shell cutover
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user