diff --git a/gpx-rs/src/engine/mod.rs b/gpx-rs/src/engine/mod.rs index 1fc6822f9..1c2f462ef 100644 --- a/gpx-rs/src/engine/mod.rs +++ b/gpx-rs/src/engine/mod.rs @@ -16,8 +16,8 @@ use crate::{ }; #[derive(Debug, Default)] -pub struct Engine { - history: History, +pub struct Engine { + history: H, selection: Selection, order: FileOrder, statistics_cache: StatisticsCache, @@ -29,7 +29,7 @@ pub struct Engine { clipboard: Option, } -impl Engine { +impl Engine { pub fn statistics(&self) -> &StatisticsBuffer { &self.statistics_buffer } @@ -143,13 +143,13 @@ impl Engine { .into_iter() .map(|file| (file.id, file)) .collect(); - let diff = History::diff_between(before.as_ref(), Some(&files)); + let diff = diff_between(before.as_ref(), Some(&files)); let mut diff = (!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty())) .then_some(diff); self.categories = restored.categories; self.order = FileOrder(restored.order); - self.history = History::restored(files); + self.history = H::restored(files); self.refresh(diff.as_ref()); sort_diff(&mut diff, &self.order.0); Outcome { @@ -181,14 +181,14 @@ impl Engine { }) } - /// Whether there is something to undo. + /// Whether there is something to undo (for the selected files, with a per-file history). pub fn can_undo(&self) -> bool { - self.history.can_undo() + self.history.can_undo(&self.selection.file_ids()) } - /// Whether there is something to redo. + /// Whether there is something to redo (for the selected files, with a per-file history). pub fn can_redo(&self) -> bool { - self.history.can_redo() + self.history.can_redo(&self.selection.file_ids()) } /// Currently selected elements. @@ -216,8 +216,8 @@ impl Engine { // the files change iff there is a diff, but selection and order actions have none let mut diff = match action { Action::Edit(command) => self.edit(command)?, - Action::Undo => self.history.undo(), - Action::Redo => self.history.redo(), + Action::Undo => self.history.undo(&self.selection.file_ids()), + Action::Redo => self.history.redo(&self.selection.file_ids()), Action::Select { selection, mode } => { self.selection .select(self.history.current(), selection, mode); diff --git a/gpx-rs/src/engine/state/history.rs b/gpx-rs/src/engine/state/history/global.rs similarity index 68% rename from gpx-rs/src/engine/state/history.rs rename to gpx-rs/src/engine/state/history/global.rs index d8d722983..c91cabfd5 100644 --- a/gpx-rs/src/engine/state/history.rs +++ b/gpx-rs/src/engine/state/history/global.rs @@ -1,12 +1,11 @@ -use std::{ - collections::{HashMap, HashSet}, - rc::Rc, -}; +use std::rc::Rc; -use crate::{CommandError, File, FileId}; +use super::{Diff, Files, History, diff_between}; +use crate::{CommandError, FileId}; +/// One history for the whole workspace: each entry is a state of all the files. #[derive(Debug, Default)] -pub struct History { +pub struct GlobalHistory { entries: Vec, index: Option, /// Whether the first entry is the state the history starts from, which is not undone: files @@ -14,37 +13,7 @@ pub struct History { floor: bool, } -impl History { - /// A history whose first entry is `entry`, from which nothing can be undone. - pub fn restored(entry: Files) -> Self { - Self { - entries: vec![entry], - index: Some(0), - floor: true, - } - } - - pub fn current(&self) -> Option<&Files> { - self.index.map_or_default(|i| self.get(i)) - } - - /// Applies `f` to a copy of the current entry and makes the copy the new current entry. - /// - /// Nothing is pushed, and `None` is returned, if `f` fails (the error is returned) or if it - /// leaves every file as it was: a step that undoes nothing is not worth a place in the - /// history. Files are told apart by their identity, not their content. - pub fn create_and_push_next(&mut self, f: F) -> Result, CommandError> - where - F: FnOnce(&mut Files) -> Result<(), CommandError>, - { - let mut next = self.current().map_or_default(|c| c.clone()); - f(&mut next)?; - if self.is_current(&next) { - return Ok(None); - } - Ok(self.record_diff(|history| history.push(next))) - } - +impl GlobalHistory { /// Whether `entry` holds exactly the files of the current entry. fn is_current(&self, entry: &Files) -> bool { let current = self.current(); @@ -57,46 +26,6 @@ impl History { }) } - pub fn undo(&mut self) -> Option { - self.record_diff(|history| { - if let Some(i) = history.index { - if i == 0 { - if !history.floor { - history.index = None; - } - } else { - history.index = Some(i - 1); - } - } - }) - } - - pub fn redo(&mut self) -> Option { - self.record_diff(|history| match history.index { - Some(i) => { - if i + 1 < history.entries.len() { - history.index = Some(i + 1); - } - } - None => { - if !history.entries.is_empty() { - history.index = Some(0); - } - } - }) - } - - pub fn can_undo(&self) -> bool { - self.index.is_some_and(|i| i > 0 || !self.floor) - } - - pub fn can_redo(&self) -> bool { - match self.index { - Some(i) => i + 1 < self.entries.len(), - None => !self.entries.is_empty(), - } - } - fn get(&self, index: usize) -> Option<&Files> { self.entries.get(index) } @@ -120,47 +49,86 @@ impl History { if prev == cur { return None; } - Some(Self::diff_between( + Some(diff_between( prev.map_or_default(|i| self.get(i)), cur.map_or_default(|i| self.get(i)), )) } - - /// Which files differ between two states. - pub fn diff_between(prev: Option<&Files>, cur: Option<&Files>) -> Diff { - let prev_ids: HashSet = prev.map_or_default(|e| e.keys().copied().collect()); - let cur_ids: HashSet = cur.map_or_default(|e| e.keys().copied().collect()); - let mut modified = vec![]; - for id in prev_ids.intersection(&cur_ids) { - let before = prev.map_or_default(|e| e.get(id)); - let after = cur.map_or_default(|e| e.get(id)); - if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) { - modified.push(*id); - } - } - Diff { - added: cur_ids.difference(&prev_ids).copied().collect(), - removed: prev_ids.difference(&cur_ids).copied().collect(), - modified, - } - } } -pub type Files = HashMap>; +impl History for GlobalHistory { + fn restored(entry: Files) -> Self { + Self { + entries: vec![entry], + index: Some(0), + floor: true, + } + } -/// Which files were added, removed or modified by a change of the current history entry. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct Diff { - pub added: Vec, - pub removed: Vec, - pub modified: Vec, + fn current(&self) -> Option<&Files> { + self.index.map_or_default(|i| self.get(i)) + } + + /// Files are told apart by their identity, not their content. + fn create_and_push_next(&mut self, f: F) -> Result, CommandError> + where + F: FnOnce(&mut Files) -> Result<(), CommandError>, + { + let mut next = self.current().map_or_default(|c| c.clone()); + f(&mut next)?; + if self.is_current(&next) { + return Ok(None); + } + Ok(self.record_diff(|history| history.push(next))) + } + + fn undo(&mut self, _files: &[FileId]) -> Option { + self.record_diff(|history| { + if let Some(i) = history.index { + if i == 0 { + if !history.floor { + history.index = None; + } + } else { + history.index = Some(i - 1); + } + } + }) + } + + fn redo(&mut self, _files: &[FileId]) -> Option { + self.record_diff(|history| match history.index { + Some(i) => { + if i + 1 < history.entries.len() { + history.index = Some(i + 1); + } + } + None => { + if !history.entries.is_empty() { + history.index = Some(0); + } + } + }) + } + + fn can_undo(&self, _files: &[FileId]) -> bool { + self.index.is_some_and(|i| i > 0 || !self.floor) + } + + fn can_redo(&self, _files: &[FileId]) -> bool { + match self.index { + Some(i) => i + 1 < self.entries.len(), + None => !self.entries.is_empty(), + } + } } #[cfg(test)] mod tests { use super::*; + use crate::File; - impl History { + impl GlobalHistory { /// A step that is expected not to fail. fn push_ok( &mut self, @@ -172,10 +140,10 @@ mod tests { #[test] fn test_new_file() { - let mut history = History::default(); + let mut history = GlobalHistory::default(); - assert!(!history.can_undo()); - assert!(!history.can_redo()); + assert!(!history.can_undo(&[])); + assert!(!history.can_redo(&[])); assert!(history.current().is_none()); let diff = history.push_ok(|e| { @@ -184,8 +152,8 @@ mod tests { Ok(()) }); - assert!(history.can_undo()); - assert!(!history.can_redo()); + assert!(history.can_undo(&[])); + assert!(!history.can_redo(&[])); assert!(history.current().is_some()); assert!(diff.is_some()); @@ -196,7 +164,7 @@ mod tests { assert!(diff.modified.is_empty()); } - fn add_file(history: &mut History) -> FileId { + fn add_file(history: &mut GlobalHistory) -> FileId { let file = Rc::new(File::default()); let id = file.id; history @@ -210,43 +178,43 @@ mod tests { #[test] fn test_failed_command_pushes_nothing() { - let mut history = History::default(); + let mut history = GlobalHistory::default(); let diff = history.create_and_push_next(|e| { e.insert(FileId::default(), Rc::new(File::default())); Err(CommandError::InvalidData("nope".to_string())) }); assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string()))); - assert!(!history.can_undo()); + assert!(!history.can_undo(&[])); assert!(history.current().is_none()); } #[test] fn test_undo_redo() { - let mut history = History::default(); - assert!(history.undo().is_none()); - assert!(history.redo().is_none()); + let mut history = GlobalHistory::default(); + assert!(history.undo(&[]).is_none()); + assert!(history.redo(&[]).is_none()); let id = add_file(&mut history); - let diff = history.undo().unwrap(); + let diff = history.undo(&[]).unwrap(); assert_eq!(diff.removed, vec![id]); assert!(diff.added.is_empty()); assert!(history.current().is_none()); - assert!(!history.can_undo()); - assert!(history.can_redo()); - assert!(history.undo().is_none()); + assert!(!history.can_undo(&[])); + assert!(history.can_redo(&[])); + assert!(history.undo(&[]).is_none()); - let diff = history.redo().unwrap(); + let diff = history.redo(&[]).unwrap(); assert_eq!(diff.added, vec![id]); assert!(history.current().unwrap().contains_key(&id)); - assert!(history.can_undo()); - assert!(!history.can_redo()); - assert!(history.redo().is_none()); + assert!(history.can_undo(&[])); + assert!(!history.can_redo(&[])); + assert!(history.redo(&[]).is_none()); } #[test] fn test_modified_and_removed() { - let mut history = History::default(); + let mut history = GlobalHistory::default(); let id = add_file(&mut history); let diff = history @@ -282,13 +250,13 @@ mod tests { assert!(history.current().unwrap().is_empty()); // going back restores the renamed file - history.undo(); + history.undo(&[]); assert_eq!(history.current().unwrap()[&id].info.name, "renamed"); } #[test] fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() { - let mut history = History::default(); + let mut history = GlobalHistory::default(); let id = add_file(&mut history); // a copy, even if it has the same content, is another file for the history: files are @@ -316,17 +284,17 @@ mod tests { #[test] fn test_push_after_undo_drops_redo_branch() { - let mut history = History::default(); + let mut history = GlobalHistory::default(); let first = add_file(&mut history); let second = add_file(&mut history); - history.undo(); - assert!(history.can_redo()); + history.undo(&[]); + assert!(history.can_redo(&[])); assert!(!history.current().unwrap().contains_key(&second)); let third = add_file(&mut history); - assert!(!history.can_redo()); - assert!(history.redo().is_none()); + assert!(!history.can_redo(&[])); + assert!(history.redo(&[]).is_none()); let current = history.current().unwrap(); assert!(current.contains_key(&first)); assert!(current.contains_key(&third)); @@ -342,24 +310,24 @@ mod tests { #[test] fn test_restored_history_starts_from_its_entry() { let (entry, id) = restored_entry(); - let mut history = History::restored(entry); + let mut history = GlobalHistory::restored(entry); assert!(history.current().unwrap().contains_key(&id)); // there is nothing to go back to, nor forward to - assert!(!history.can_undo()); - assert!(!history.can_redo()); - assert!(history.undo().is_none()); - assert!(history.redo().is_none()); + assert!(!history.can_undo(&[])); + assert!(!history.can_redo(&[])); + assert!(history.undo(&[]).is_none()); + assert!(history.redo(&[]).is_none()); assert!(history.current().unwrap().contains_key(&id)); } #[test] fn test_restored_history_cannot_undo_an_empty_entry_either() { - let mut history = History::restored(Files::new()); + let mut history = GlobalHistory::restored(Files::new()); assert!(history.current().is_some_and(|files| files.is_empty())); - assert!(!history.can_undo()); - assert!(history.undo().is_none()); + assert!(!history.can_undo(&[])); + assert!(history.undo(&[]).is_none()); // the history is not the one of an engine that has done nothing assert!(history.current().is_some()); } @@ -367,39 +335,39 @@ mod tests { #[test] fn test_restored_history_undoes_what_comes_after_it_and_no_further() { let (entry, restored) = restored_entry(); - let mut history = History::restored(entry); + let mut history = GlobalHistory::restored(entry); let added = add_file(&mut history); - assert!(history.can_undo()); - assert!(!history.can_redo()); + assert!(history.can_undo(&[])); + assert!(!history.can_redo(&[])); assert_eq!(history.current().unwrap().len(), 2); // back to the restored files - let diff = history.undo().unwrap(); + let diff = history.undo(&[]).unwrap(); assert_eq!(diff.removed, vec![added]); assert!(diff.added.is_empty() && diff.modified.is_empty()); assert_eq!(history.current().unwrap().len(), 1); assert!(history.current().unwrap().contains_key(&restored)); - assert!(!history.can_undo()); - assert!(history.can_redo()); - assert!(history.undo().is_none()); + assert!(!history.can_undo(&[])); + assert!(history.can_redo(&[])); + assert!(history.undo(&[]).is_none()); assert!(history.current().unwrap().contains_key(&restored)); - let diff = history.redo().unwrap(); + let diff = history.redo(&[]).unwrap(); assert_eq!(diff.added, vec![added]); - assert!(history.can_undo()); - assert!(!history.can_redo()); + assert!(history.can_undo(&[])); + assert!(!history.can_redo(&[])); // and again - assert!(history.undo().is_some()); - assert!(history.undo().is_none()); + assert!(history.undo(&[]).is_some()); + assert!(history.undo(&[]).is_none()); assert!(history.current().unwrap().contains_key(&restored)); } #[test] fn test_restored_history_removing_its_files_can_be_undone() { let (entry, restored) = restored_entry(); - let mut history = History::restored(entry); + let mut history = GlobalHistory::restored(entry); let diff = history .push_ok(|e| { @@ -410,26 +378,26 @@ mod tests { assert_eq!(diff.removed, vec![restored]); assert!(history.current().unwrap().is_empty()); - let diff = history.undo().unwrap(); + let diff = history.undo(&[]).unwrap(); assert_eq!(diff.added, vec![restored]); assert!(history.current().unwrap().contains_key(&restored)); - assert!(!history.can_undo()); + assert!(!history.can_undo(&[])); } #[test] fn test_restored_history_push_after_undo_drops_redo_branch() { let (entry, restored) = restored_entry(); - let mut history = History::restored(entry); + let mut history = GlobalHistory::restored(entry); add_file(&mut history); - history.undo().unwrap(); - assert!(history.can_redo()); + history.undo(&[]).unwrap(); + assert!(history.can_redo(&[])); let other = add_file(&mut history); - assert!(!history.can_redo()); - assert!(history.can_undo()); - let diff = history.undo().unwrap(); + assert!(!history.can_redo(&[])); + assert!(history.can_undo(&[])); + let diff = history.undo(&[]).unwrap(); assert_eq!(diff.removed, vec![other]); assert!(history.current().unwrap().contains_key(&restored)); - assert!(!history.can_undo()); + assert!(!history.can_undo(&[])); } } diff --git a/gpx-rs/src/engine/state/history/mod.rs b/gpx-rs/src/engine/state/history/mod.rs new file mode 100644 index 000000000..773c7290a --- /dev/null +++ b/gpx-rs/src/engine/state/history/mod.rs @@ -0,0 +1,88 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Debug, + rc::Rc, +}; + +use crate::{CommandError, File, FileId}; + +mod global; +mod per_file; + +pub use global::*; +pub use per_file::*; + +pub type Files = HashMap>; + +/// What the engine can undo and redo, and the files it holds as a result. +/// +/// There are two ways to keep it, which the engine can use interchangeably: +/// - [`GlobalHistory`] has one history for the whole workspace: every step is a state of all the +/// files, and undoing goes back to the previous one, whatever the files it concerns. Opening and +/// closing files are steps too. +/// - [`PerFileHistory`] has one history for each file, which starts when the file appears and ends +/// when it disappears. Undoing only concerns the files that are asked for, and files appearing +/// or disappearing are never undone. +/// +/// Files are told apart by their identity (`Rc::ptr_eq`), not their content. +pub trait History: Default + Debug { + /// A history that starts from `files`, which cannot be undone. + fn restored(files: Files) -> Self; + + /// The current state of the files, `None` if there is none yet. + fn current(&self) -> Option<&Files>; + + /// Applies `f` to a copy of the current files and makes the copy the new current state. + /// + /// Nothing is recorded, and `None` is returned, if `f` fails (the error is returned) or if it + /// leaves every file as it was: a step that undoes nothing is not worth a place in the history. + fn create_and_push_next(&mut self, f: F) -> Result, CommandError> + where + F: FnOnce(&mut Files) -> Result<(), CommandError>; + + /// Goes back one step. A per-file history does it for each of the given `files` (and ignores + /// the ones it does not know), a global one ignores them. + fn undo(&mut self, files: &[FileId]) -> Option; + + /// Goes forward one step, for the same files as [`History::undo`]. + fn redo(&mut self, files: &[FileId]) -> Option; + + /// Whether [`History::undo`] would do something for these `files`. + fn can_undo(&self, files: &[FileId]) -> bool; + + /// Whether [`History::redo`] would do something for these `files`. + fn can_redo(&self, files: &[FileId]) -> bool; +} + +/// Which files were added, removed or modified by a change of the current files. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Diff { + pub added: Vec, + pub removed: Vec, + pub modified: Vec, +} + +impl Diff { + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty() + } +} + +/// Which files differ between two states. +pub fn diff_between(prev: Option<&Files>, cur: Option<&Files>) -> Diff { + let prev_ids: HashSet = prev.map_or_default(|e| e.keys().copied().collect()); + let cur_ids: HashSet = cur.map_or_default(|e| e.keys().copied().collect()); + let mut modified = vec![]; + for id in prev_ids.intersection(&cur_ids) { + let before = prev.map_or_default(|e| e.get(id)); + let after = cur.map_or_default(|e| e.get(id)); + if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) { + modified.push(*id); + } + } + Diff { + added: cur_ids.difference(&prev_ids).copied().collect(), + removed: prev_ids.difference(&cur_ids).copied().collect(), + modified, + } +} diff --git a/gpx-rs/src/engine/state/history/per_file.rs b/gpx-rs/src/engine/state/history/per_file.rs new file mode 100644 index 000000000..467519402 --- /dev/null +++ b/gpx-rs/src/engine/state/history/per_file.rs @@ -0,0 +1,334 @@ +use std::rc::Rc; + +use super::{Diff, Files, History, diff_between}; +use crate::{CommandError, File, FileId}; + +use std::collections::HashMap; + +/// One history for each file, which begins with the state the file appears in (loaded, created, +/// produced by a command...) and ends when the file disappears: a file coming or going is never +/// undone, only what happens to it in between. +#[derive(Debug, Default)] +pub struct PerFileHistory { + current: Files, + stacks: HashMap, +} + +/// The successive states of a file, the first one being where it starts from. +#[derive(Debug)] +struct FileHistory { + entries: Vec>, + index: usize, +} + +impl FileHistory { + fn new(file: Rc) -> Self { + Self { + entries: vec![file], + index: 0, + } + } + + fn current(&self) -> &Rc { + &self.entries[self.index] + } + + fn push(&mut self, file: Rc) { + self.entries.truncate(self.index + 1); + self.entries.push(file); + self.index = self.entries.len() - 1; + } + + fn can_undo(&self) -> bool { + self.index > 0 + } + + fn can_redo(&self) -> bool { + self.index + 1 < self.entries.len() + } +} + +impl PerFileHistory { + /// Moves each of the files one step back (`forward` false) or forward, and says which ones + /// moved. + fn step(&mut self, files: &[FileId], forward: bool) -> Option { + let mut modified: Vec = vec![]; + for id in files { + let Some(history) = self.stacks.get_mut(id) else { + continue; + }; + if modified.contains(id) { + continue; + } + if forward && history.can_redo() { + history.index += 1; + } else if !forward && history.can_undo() { + history.index -= 1; + } else { + continue; + } + self.current.insert(*id, history.current().clone()); + modified.push(*id); + } + (!modified.is_empty()).then(|| Diff { + modified, + ..Default::default() + }) + } + + fn any(&self, files: &[FileId], f: impl Fn(&FileHistory) -> bool) -> bool { + files.iter().any(|id| self.stacks.get(id).is_some_and(&f)) + } +} + +impl History for PerFileHistory { + fn restored(files: Files) -> Self { + let stacks = files + .iter() + .map(|(id, file)| (*id, FileHistory::new(file.clone()))) + .collect(); + Self { + current: files, + stacks, + } + } + + fn current(&self) -> Option<&Files> { + Some(&self.current) + } + + fn create_and_push_next(&mut self, f: F) -> Result, CommandError> + where + F: FnOnce(&mut Files) -> Result<(), CommandError>, + { + let mut next = self.current.clone(); + f(&mut next)?; + let diff = diff_between(Some(&self.current), Some(&next)); + if diff.is_empty() { + return Ok(None); + } + for id in &diff.removed { + self.stacks.remove(id); + } + for id in &diff.added { + self.stacks.insert(*id, FileHistory::new(next[id].clone())); + } + for id in &diff.modified { + match self.stacks.get_mut(id) { + Some(history) => history.push(next[id].clone()), + None => { + self.stacks.insert(*id, FileHistory::new(next[id].clone())); + } + } + } + self.current = next; + Ok(Some(diff)) + } + + fn undo(&mut self, files: &[FileId]) -> Option { + self.step(files, false) + } + + fn redo(&mut self, files: &[FileId]) -> Option { + self.step(files, true) + } + + fn can_undo(&self, files: &[FileId]) -> bool { + self.any(files, FileHistory::can_undo) + } + + fn can_redo(&self, files: &[FileId]) -> bool { + self.any(files, FileHistory::can_redo) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push(history: &mut PerFileHistory, f: impl FnOnce(&mut Files)) -> Option { + history + .create_and_push_next(|files| { + f(files); + Ok(()) + }) + .unwrap() + } + + fn add_file(history: &mut PerFileHistory) -> FileId { + let file = Rc::new(File::default()); + let id = file.id; + push(history, |files| { + files.insert(id, file); + }) + .unwrap(); + id + } + + fn rename(history: &mut PerFileHistory, id: FileId, name: &str) -> Option { + push(history, |files| { + let mut file = (**files.get(&id).unwrap()).clone(); + file.info.name = name.to_string(); + files.insert(id, Rc::new(file)); + }) + } + + fn name(history: &PerFileHistory, id: FileId) -> String { + history.current().unwrap()[&id].info.name.clone() + } + + #[test] + fn test_a_new_file_cannot_be_undone() { + let mut history = PerFileHistory::default(); + assert!(history.current().unwrap().is_empty()); + let id = add_file(&mut history); + + assert!(!history.can_undo(&[id])); + assert!(!history.can_redo(&[id])); + assert!(history.undo(&[id]).is_none()); + assert!(history.current().unwrap().contains_key(&id)); + } + + #[test] + fn test_undo_redo_of_a_file() { + let mut history = PerFileHistory::default(); + let id = add_file(&mut history); + let before = name(&history, id); + let diff = rename(&mut history, id, "renamed").unwrap(); + assert_eq!(diff.modified, vec![id]); + assert!(history.can_undo(&[id])); + + let diff = history.undo(&[id]).unwrap(); + assert_eq!(diff.modified, vec![id]); + assert!(diff.added.is_empty() && diff.removed.is_empty()); + assert_eq!(name(&history, id), before); + assert!(!history.can_undo(&[id])); + assert!(history.can_redo(&[id])); + + history.redo(&[id]).unwrap(); + assert_eq!(name(&history, id), "renamed"); + assert!(history.redo(&[id]).is_none()); + } + + #[test] + fn test_only_the_given_files_are_undone() { + let mut history = PerFileHistory::default(); + let a = add_file(&mut history); + let b = add_file(&mut history); + rename(&mut history, a, "a"); + rename(&mut history, b, "b"); + + assert!(history.can_undo(&[a])); + assert!(!history.can_undo(&[])); + assert!(history.undo(&[]).is_none()); + + let diff = history.undo(&[a]).unwrap(); + assert_eq!(diff.modified, vec![a]); + assert_ne!(name(&history, a), "a"); + assert_eq!(name(&history, b), "b"); + // a has nothing more to undo, b still has + assert!(!history.can_undo(&[a])); + assert!(history.can_undo(&[a, b])); + assert!(history.undo(&[a]).is_none()); + } + + #[test] + fn test_several_files_are_undone_each_by_its_own_step() { + let mut history = PerFileHistory::default(); + let a = add_file(&mut history); + let b = add_file(&mut history); + rename(&mut history, a, "a1"); + rename(&mut history, a, "a2"); + rename(&mut history, b, "b1"); + + let diff = history.undo(&[a, b, a]).unwrap(); + assert_eq!(diff.modified, vec![a, b]); + // a is only moved once, even if it is asked for twice + assert_eq!(name(&history, a), "a1"); + assert_ne!(name(&history, b), "b1"); + } + + #[test] + fn test_a_step_after_an_undo_drops_the_redo_branch_of_that_file_only() { + let mut history = PerFileHistory::default(); + let a = add_file(&mut history); + let b = add_file(&mut history); + rename(&mut history, a, "a1"); + rename(&mut history, b, "b1"); + history.undo(&[a, b]).unwrap(); + + rename(&mut history, a, "a2"); + assert!(!history.can_redo(&[a])); + assert!(history.can_redo(&[b])); + } + + #[test] + fn test_a_step_that_changes_nothing_is_not_recorded() { + let mut history = PerFileHistory::default(); + let id = add_file(&mut history); + assert!(push(&mut history, |_| {}).is_none()); + assert!(!history.can_undo(&[id])); + } + + #[test] + fn test_failed_command_pushes_nothing() { + let mut history = PerFileHistory::default(); + let id = add_file(&mut history); + let result = history.create_and_push_next(|files| { + files.remove(&id); + Err(CommandError::InvalidData("nope".to_string())) + }); + assert_eq!(result, Err(CommandError::InvalidData("nope".to_string()))); + assert!(history.current().unwrap().contains_key(&id)); + } + + #[test] + fn test_removing_a_file_drops_its_history() { + let mut history = PerFileHistory::default(); + let id = add_file(&mut history); + rename(&mut history, id, "renamed"); + + let diff = push(&mut history, |files| { + files.remove(&id); + }) + .unwrap(); + assert_eq!(diff.removed, vec![id]); + assert!(history.current().unwrap().is_empty()); + assert!(!history.can_undo(&[id])); + assert!(history.undo(&[id]).is_none()); + assert!(history.current().unwrap().is_empty()); + } + + #[test] + fn test_a_file_that_comes_back_starts_over() { + let mut history = PerFileHistory::default(); + let file = Rc::new(File::default()); + let id = file.id; + push(&mut history, |files| { + files.insert(id, file.clone()); + }); + rename(&mut history, id, "renamed"); + push(&mut history, |files| { + files.remove(&id); + }); + push(&mut history, |files| { + files.insert(id, file.clone()); + }); + assert!(!history.can_undo(&[id])); + } + + #[test] + fn test_restored_files_cannot_be_undone() { + let file = Rc::new(File::default()); + let id = file.id; + let mut history = PerFileHistory::restored(Files::from([(id, file)])); + assert!(history.current().unwrap().contains_key(&id)); + assert!(!history.can_undo(&[id])); + assert!(history.undo(&[id]).is_none()); + + rename(&mut history, id, "renamed"); + assert!(history.can_undo(&[id])); + history.undo(&[id]).unwrap(); + assert!(!history.can_undo(&[id])); + } +} diff --git a/gpx-rs/src/engine/state/selection.rs b/gpx-rs/src/engine/state/selection.rs index bbd7cc108..8d24a3c7d 100644 --- a/gpx-rs/src/engine/state/selection.rs +++ b/gpx-rs/src/engine/state/selection.rs @@ -312,6 +312,14 @@ fn neighbour( } impl Selection { + /// The ids of the files holding the selected elements. + pub fn file_ids(&self) -> Vec { + match self.to_files() { + Selection::File { file_ids } => file_ids.into_iter().collect(), + _ => vec![], + } + } + /// The files holding the selected elements, as a selection of files. pub fn to_files(&self) -> Selection { match self { diff --git a/gpx-rs/src/engine/tests.rs b/gpx-rs/src/engine/tests.rs index 5f622cef9..dc3015851 100644 --- a/gpx-rs/src/engine/tests.rs +++ b/gpx-rs/src/engine/tests.rs @@ -153,7 +153,7 @@ fn test_invalid_load_is_rejected() { }) )); assert!(engine.history.current().is_none()); - assert!(!engine.history.can_undo()); + assert!(!engine.history.can_undo(&[])); assert!(engine.order.0.is_empty()); } @@ -162,7 +162,7 @@ fn test_command_with_nothing_to_do_is_rejected() { let mut engine = TestEngine::default(); new(&mut engine, "file"); assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); - assert!(!engine.history.can_redo()); + assert!(!engine.history.can_redo(&[])); assert_eq!(engine.history.current().unwrap().len(), 1); } @@ -1434,3 +1434,106 @@ fn test_overwriting_forgets_the_history() { engine.restore(restored_from(&engine, &ids, vec![])); assert!(!engine.can_undo()); } + +mod per_file_history { + use super::*; + + fn new_file(engine: &mut Engine, name: &str) -> FileId { + engine + .execute(Action::Edit(Command::New(New { + name, + trackpoint: None, + }))) + .unwrap(); + *engine.order().last().unwrap() + } + + fn select(engine: &mut Engine, ids: &[FileId]) { + engine + .execute(Action::Select { + selection: Selection::File { + file_ids: ids.iter().copied().collect(), + }, + mode: SelectMode::Replace, + }) + .unwrap(); + } + + fn rename(engine: &mut Engine, name: &str) { + engine + .execute(Action::Edit(Command::Metadata(Metadata { name, desc: "" }))) + .unwrap(); + } + + fn name_of(engine: &Engine, id: FileId) -> String { + engine.file_structure(&id).unwrap().name.clone() + } + + #[test] + fn test_creating_a_file_is_not_undoable() { + let mut engine = Engine::::default(); + let id = new_file(&mut engine, "file"); + assert!(!engine.can_undo()); + assert!(!engine.execute(Action::Undo).unwrap().changed); + assert_eq!(engine.order(), [id]); + } + + #[test] + fn test_undo_follows_the_selected_files() { + let mut engine = Engine::::default(); + let a = new_file(&mut engine, "a"); + let b = new_file(&mut engine, "b"); + + select(&mut engine, &[a]); + rename(&mut engine, "a2"); + select(&mut engine, &[b]); + rename(&mut engine, "b2"); + + // b is selected: only its change is undone + assert!(engine.can_undo()); + let outcome = engine.execute(Action::Undo).unwrap(); + assert_eq!(outcome.diff.unwrap().modified, vec![b]); + assert_eq!(name_of(&engine, b), "b"); + assert_eq!(name_of(&engine, a), "a2"); + assert!(!engine.can_undo()); + assert!(engine.can_redo()); + + select(&mut engine, &[a]); + assert!(engine.can_undo()); + assert!(!engine.can_redo()); + engine.execute(Action::Undo).unwrap(); + assert_eq!(name_of(&engine, a), "a"); + + select(&mut engine, &[b]); + engine.execute(Action::Redo).unwrap(); + assert_eq!(name_of(&engine, b), "b2"); + } + + #[test] + fn test_undo_without_selection_does_nothing() { + let mut engine = Engine::::default(); + new_file(&mut engine, "a"); + rename(&mut engine, "a2"); + engine + .execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }) + .unwrap(); + assert!(!engine.can_undo()); + assert!(!engine.execute(Action::Undo).unwrap().changed); + } + + #[test] + fn test_deleting_a_file_drops_its_history() { + let mut engine = Engine::::default(); + let id = new_file(&mut engine, "a"); + rename(&mut engine, "a2"); + engine + .execute(Action::Edit(Command::Delete(Delete { whole_files: false }))) + .unwrap(); + assert!(engine.order().is_empty()); + assert!(!engine.execute(Action::Undo).unwrap().changed); + assert!(engine.file_structure(&id).is_none()); + } +}