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, pub rows: Vec, } #[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, pub asset_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileTreeRuntimeState { pub active_row_id: Option, pub selection: FileTreeSelectionState, pub drag_row_ids: Vec, pub drag_effect: Option, pub drop_target_row_id: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileTreeRuntimeTransition { pub state: FileTreeRuntimeState, pub outputs: BTreeSet, } #[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, }, DispatchInternalDrop { target_row_id: Option, row_ids: Vec, copy: bool, }, DispatchExternalDrop { target_row_id: Option, file_count: u32, }, OpenRow { row_id: String, }, FocusNext, FocusPrevious, FocusFirst, FocusLast, OpenFocused, ContextMenuFocused, BeginRenameFocused, DeleteSelection, CopySelection, CutSelection, PasteIntoFocused, Escape, 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, }, KeyboardCommand { command: FileTreeKeyboardCommand, row_ids: Vec, target_row_id: Option, }, InternalDrop { target_row_id: Option, target: Option, row_ids: Vec, copy: bool, }, ExternalDrop { target_row_id: Option, target: Option, file_count: u32, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum FileTreeKeyboardCommand { Rename, Delete, Copy, Cut, Paste, } #[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::>(); 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) { let mut state = self.clone(); state.active_row_id = Some(row_id); transition( state, [ FileTreeRuntimeOutput::DomPatch, FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }), ], ) } else { transition(self.clone(), []) } } FileTreeRuntimeAction::FocusNext => transition( focus_relative_row(self, env, 1), [FileTreeRuntimeOutput::DomPatch], ), FileTreeRuntimeAction::FocusPrevious => transition( focus_relative_row(self, env, -1), [FileTreeRuntimeOutput::DomPatch], ), FileTreeRuntimeAction::FocusFirst => transition( focus_absolute_row(self, env, 0), [FileTreeRuntimeOutput::DomPatch], ), FileTreeRuntimeAction::FocusLast => transition( focus_absolute_row(self, env, env.visible_row_ids.len().saturating_sub(1)), [FileTreeRuntimeOutput::DomPatch], ), FileTreeRuntimeAction::OpenFocused => { if let Some(row_id) = self.selection.focused_row_id.clone() { self.reduce(env, FileTreeRuntimeAction::OpenRow { row_id }) } else { transition(self.clone(), []) } } FileTreeRuntimeAction::ContextMenuFocused => { if let Some(row_id) = self.selection.focused_row_id.clone() { self.reduce(env, FileTreeRuntimeAction::ContextMenuRow { row_id }) } else { transition(self.clone(), []) } } FileTreeRuntimeAction::BeginRenameFocused => keyboard_command_transition( self, FileTreeKeyboardCommand::Rename, selected_or_focused_rows(self), self.selection.focused_row_id.clone(), ), FileTreeRuntimeAction::DeleteSelection => keyboard_command_transition( self, FileTreeKeyboardCommand::Delete, selected_or_focused_rows(self), self.selection.focused_row_id.clone(), ), FileTreeRuntimeAction::CopySelection => keyboard_command_transition( self, FileTreeKeyboardCommand::Copy, selected_or_focused_rows(self), self.selection.focused_row_id.clone(), ), FileTreeRuntimeAction::CutSelection => keyboard_command_transition( self, FileTreeKeyboardCommand::Cut, selected_or_focused_rows(self), self.selection.focused_row_id.clone(), ), FileTreeRuntimeAction::PasteIntoFocused => keyboard_command_transition( self, FileTreeKeyboardCommand::Paste, Vec::new(), self.selection.focused_row_id.clone(), ), FileTreeRuntimeAction::Escape => { let mut state = self.clone(); state.drag_row_ids = Vec::new(); state.drag_effect = None; state.drop_target_row_id = None; transition(state, [FileTreeRuntimeOutput::DomPatch]) } 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 focus_relative_row( state: &FileTreeRuntimeState, env: &FileTreeRuntimeEnvironment, offset: isize, ) -> FileTreeRuntimeState { if env.visible_row_ids.is_empty() { return state.clone(); } let current = state .selection .focused_row_id .as_ref() .and_then(|row_id| { env.visible_row_ids .iter() .position(|candidate| candidate == row_id) }) .unwrap_or(0); let next = if offset.is_negative() { current.saturating_sub(offset.unsigned_abs()) } else { (current + offset as usize).min(env.visible_row_ids.len() - 1) }; focus_absolute_row(state, env, next) } fn focus_absolute_row( state: &FileTreeRuntimeState, env: &FileTreeRuntimeEnvironment, index: usize, ) -> FileTreeRuntimeState { let mut next = state.clone(); next.selection.focused_row_id = env.visible_row_ids.get(index).cloned(); next } fn selected_or_focused_rows(state: &FileTreeRuntimeState) -> Vec { if !state.selection.selected_row_ids.is_empty() { return state.selection.selected_row_ids.iter().cloned().collect(); } state .selection .focused_row_id .iter() .cloned() .collect::>() } fn keyboard_command_transition( state: &FileTreeRuntimeState, command: FileTreeKeyboardCommand, row_ids: Vec, target_row_id: Option, ) -> FileTreeRuntimeTransition { if row_ids.is_empty() && !matches!(command, FileTreeKeyboardCommand::Paste) { return transition(state.clone(), []); } transition( state.clone(), [FileTreeRuntimeOutput::Intent( FileTreeIntentEvent::KeyboardCommand { command, row_ids, target_row_id, }, )], ) } fn resolve_open_target( env: &FileTreeRuntimeEnvironment, row_id: &str, ) -> Option { let row_by_id = env .rows .iter() .map(|row| (row.row_id.as_str(), row)) .collect::>(); 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( state: FileTreeRuntimeState, outputs: [FileTreeRuntimeOutput; N], ) -> FileTreeRuntimeTransition { FileTreeRuntimeTransition { state, outputs: outputs.into_iter().collect(), } } #[cfg(test)] mod tests { use super::{ FileTreeIntentEvent, FileTreeKeyboardCommand, 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 { 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(), }, }, ))); assert_eq!(transition.state.active_row_id.as_deref(), Some("doc:root")); 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 { .. }) ))); } #[test] fn filetree_runtime_keyboard_contract_separates_active_selection_and_focus() { let state = FileTreeRuntimeState { active_row_id: Some("doc:root".into()), selection: crate::tree_shell::filetree_selection::FileTreeSelectionState::from_selected( &["doc:root".to_string()], ), ..FileTreeRuntimeState::default() }; let focused = state.reduce(&env(), FileTreeRuntimeAction::FocusNext); assert_eq!(focused.state.active_row_id.as_deref(), Some("doc:root")); assert_eq!( focused.state.selection.focused_row_id.as_deref(), Some("index:root") ); assert!(focused .state .selection .selected_row_ids .contains("doc:root")); let opened = focused .state .reduce(&env(), FileTreeRuntimeAction::OpenFocused); assert_eq!(opened.state.active_row_id.as_deref(), Some("index:root")); assert!(opened.outputs.contains(&FileTreeRuntimeOutput::Intent( FileTreeIntentEvent::Open { target: FileTreeOpenTarget::Index { document_id: "root".into(), }, }, ))); let rename = focused .state .reduce(&env(), FileTreeRuntimeAction::BeginRenameFocused); assert!(rename.outputs.contains(&FileTreeRuntimeOutput::Intent( FileTreeIntentEvent::KeyboardCommand { command: FileTreeKeyboardCommand::Rename, row_ids: vec!["doc:root".into()], target_row_id: Some("index:root".into()), }, ))); let cut = focused .state .reduce(&env(), FileTreeRuntimeAction::CutSelection); assert!(cut.outputs.contains(&FileTreeRuntimeOutput::Intent( FileTreeIntentEvent::KeyboardCommand { command: FileTreeKeyboardCommand::Cut, row_ids: vec!["doc:root".into()], target_row_id: Some("index:root".into()), }, ))); let escaped = FileTreeRuntimeState { drag_row_ids: vec!["doc:root".into()], drag_effect: Some(TreeShellDragEffect::Move), drop_target_row_id: Some("index:root".into()), ..focused.state } .reduce(&env(), FileTreeRuntimeAction::Escape); assert!(escaped.state.drag_row_ids.is_empty()); assert_eq!(escaped.state.drag_effect, None); assert_eq!(escaped.state.drop_target_row_id, None); } }