790 lines
27 KiB
Rust
790 lines
27 KiB
Rust
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 active_row_id: Option<String>,
|
|
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,
|
|
},
|
|
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<String>,
|
|
target_row_id: Option<String>,
|
|
},
|
|
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, 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::<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) {
|
|
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<String> {
|
|
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::<Vec<_>>()
|
|
}
|
|
|
|
fn keyboard_command_transition(
|
|
state: &FileTreeRuntimeState,
|
|
command: FileTreeKeyboardCommand,
|
|
row_ids: Vec<String>,
|
|
target_row_id: Option<String>,
|
|
) -> 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<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, 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<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(),
|
|
},
|
|
},
|
|
)));
|
|
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);
|
|
}
|
|
}
|