mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
per file history
This commit is contained in:
1 parent
1ba5436af3
commit
55a8b020e0
6 files changed
+676
-175
No files matched your search
+11
-11
@@ -16,8 +16,8 @@ use crate::{
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Engine {
|
||||
history: History,
|
||||
pub struct Engine<H: History = GlobalHistory> {
|
||||
history: H,
|
||||
selection: Selection,
|
||||
order: FileOrder,
|
||||
statistics_cache: StatisticsCache,
|
||||
@@ -29,7 +29,7 @@ pub struct Engine {
|
||||
clipboard: Option<Clipboard>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
impl<H: History> Engine<H> {
|
||||
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);
|
||||
|
||||
@@ -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<Files>,
|
||||
index: Option<usize>,
|
||||
/// 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<F>(&mut self, f: F) -> Result<Option<Diff>, 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<Diff> {
|
||||
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<Diff> {
|
||||
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<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
|
||||
let cur_ids: HashSet<FileId> = 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<FileId, Rc<File>>;
|
||||
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<FileId>,
|
||||
pub removed: Vec<FileId>,
|
||||
pub modified: Vec<FileId>,
|
||||
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<F>(&mut self, f: F) -> Result<Option<Diff>, 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<Diff> {
|
||||
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<Diff> {
|
||||
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(&[]));
|
||||
}
|
||||
}
|
||||
@@ -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<FileId, Rc<File>>;
|
||||
|
||||
/// 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<F>(&mut self, f: F) -> Result<Option<Diff>, 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<Diff>;
|
||||
|
||||
/// Goes forward one step, for the same files as [`History::undo`].
|
||||
fn redo(&mut self, files: &[FileId]) -> Option<Diff>;
|
||||
|
||||
/// 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<FileId>,
|
||||
pub removed: Vec<FileId>,
|
||||
pub modified: Vec<FileId>,
|
||||
}
|
||||
|
||||
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<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
|
||||
let cur_ids: HashSet<FileId> = 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,
|
||||
}
|
||||
}
|
||||
@@ -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<FileId, FileHistory>,
|
||||
}
|
||||
|
||||
/// The successive states of a file, the first one being where it starts from.
|
||||
#[derive(Debug)]
|
||||
struct FileHistory {
|
||||
entries: Vec<Rc<File>>,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl FileHistory {
|
||||
fn new(file: Rc<File>) -> Self {
|
||||
Self {
|
||||
entries: vec![file],
|
||||
index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn current(&self) -> &Rc<File> {
|
||||
&self.entries[self.index]
|
||||
}
|
||||
|
||||
fn push(&mut self, file: Rc<File>) {
|
||||
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<Diff> {
|
||||
let mut modified: Vec<FileId> = 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<F>(&mut self, f: F) -> Result<Option<Diff>, 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<Diff> {
|
||||
self.step(files, false)
|
||||
}
|
||||
|
||||
fn redo(&mut self, files: &[FileId]) -> Option<Diff> {
|
||||
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<Diff> {
|
||||
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<Diff> {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,14 @@ fn neighbour<T: Copy + PartialEq + std::hash::Hash + Eq>(
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// The ids of the files holding the selected elements.
|
||||
pub fn file_ids(&self) -> Vec<FileId> {
|
||||
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 {
|
||||
|
||||
+105
-2
@@ -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<PerFileHistory>, name: &str) -> FileId {
|
||||
engine
|
||||
.execute(Action::Edit(Command::New(New {
|
||||
name,
|
||||
trackpoint: None,
|
||||
})))
|
||||
.unwrap();
|
||||
*engine.order().last().unwrap()
|
||||
}
|
||||
|
||||
fn select(engine: &mut Engine<PerFileHistory>, ids: &[FileId]) {
|
||||
engine
|
||||
.execute(Action::Select {
|
||||
selection: Selection::File {
|
||||
file_ids: ids.iter().copied().collect(),
|
||||
},
|
||||
mode: SelectMode::Replace,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn rename(engine: &mut Engine<PerFileHistory>, name: &str) {
|
||||
engine
|
||||
.execute(Action::Edit(Command::Metadata(Metadata { name, desc: "" })))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn name_of(engine: &Engine<PerFileHistory>, id: FileId) -> String {
|
||||
engine.file_structure(&id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creating_a_file_is_not_undoable() {
|
||||
let mut engine = Engine::<PerFileHistory>::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::<PerFileHistory>::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::<PerFileHistory>::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::<PerFileHistory>::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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user