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