per file history

This commit is contained in:
vcoppe committed 2026-10-16 08:42:45 +02:00
1 parent 1ba5436af3
commit 55a8b020e0
6 files changed
+676 -175

No files matched your search

+11 -11
View File
@@ -16,8 +16,8 @@ use crate::{
}; };
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Engine { pub struct Engine<H: History = GlobalHistory> {
history: History, history: H,
selection: Selection, selection: Selection,
order: FileOrder, order: FileOrder,
statistics_cache: StatisticsCache, statistics_cache: StatisticsCache,
@@ -29,7 +29,7 @@ pub struct Engine {
clipboard: Option<Clipboard>, clipboard: Option<Clipboard>,
} }
impl Engine { impl<H: History> Engine<H> {
pub fn statistics(&self) -> &StatisticsBuffer { pub fn statistics(&self) -> &StatisticsBuffer {
&self.statistics_buffer &self.statistics_buffer
} }
@@ -143,13 +143,13 @@ impl Engine {
.into_iter() .into_iter()
.map(|file| (file.id, file)) .map(|file| (file.id, file))
.collect(); .collect();
let diff = History::diff_between(before.as_ref(), Some(&files)); let diff = diff_between(before.as_ref(), Some(&files));
let mut diff = let mut diff =
(!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty())) (!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty()))
.then_some(diff); .then_some(diff);
self.categories = restored.categories; self.categories = restored.categories;
self.order = FileOrder(restored.order); self.order = FileOrder(restored.order);
self.history = History::restored(files); self.history = H::restored(files);
self.refresh(diff.as_ref()); self.refresh(diff.as_ref());
sort_diff(&mut diff, &self.order.0); sort_diff(&mut diff, &self.order.0);
Outcome { 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 { 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 { pub fn can_redo(&self) -> bool {
self.history.can_redo() self.history.can_redo(&self.selection.file_ids())
} }
/// Currently selected elements. /// Currently selected elements.
@@ -216,8 +216,8 @@ impl Engine {
// the files change iff there is a diff, but selection and order actions have none // the files change iff there is a diff, but selection and order actions have none
let mut diff = match action { let mut diff = match action {
Action::Edit(command) => self.edit(command)?, Action::Edit(command) => self.edit(command)?,
Action::Undo => self.history.undo(), Action::Undo => self.history.undo(&self.selection.file_ids()),
Action::Redo => self.history.redo(), Action::Redo => self.history.redo(&self.selection.file_ids()),
Action::Select { selection, mode } => { Action::Select { selection, mode } => {
self.selection self.selection
.select(self.history.current(), selection, mode); .select(self.history.current(), selection, mode);
@@ -1,12 +1,11 @@
use std::{ use std::rc::Rc;
collections::{HashMap, HashSet},
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)] #[derive(Debug, Default)]
pub struct History { pub struct GlobalHistory {
entries: Vec<Files>, entries: Vec<Files>,
index: Option<usize>, index: Option<usize>,
/// Whether the first entry is the state the history starts from, which is not undone: files /// 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, floor: bool,
} }
impl History { impl GlobalHistory {
/// 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)))
}
/// Whether `entry` holds exactly the files of the current entry. /// Whether `entry` holds exactly the files of the current entry.
fn is_current(&self, entry: &Files) -> bool { fn is_current(&self, entry: &Files) -> bool {
let current = self.current(); 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> { fn get(&self, index: usize) -> Option<&Files> {
self.entries.get(index) self.entries.get(index)
} }
@@ -120,47 +49,86 @@ impl History {
if prev == cur { if prev == cur {
return None; return None;
} }
Some(Self::diff_between( Some(diff_between(
prev.map_or_default(|i| self.get(i)), prev.map_or_default(|i| self.get(i)),
cur.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. fn current(&self) -> Option<&Files> {
#[derive(Debug, Default, Clone, PartialEq, Eq)] self.index.map_or_default(|i| self.get(i))
pub struct Diff { }
pub added: Vec<FileId>,
pub removed: Vec<FileId>, /// Files are told apart by their identity, not their content.
pub modified: Vec<FileId>, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::File;
impl History { impl GlobalHistory {
/// A step that is expected not to fail. /// A step that is expected not to fail.
fn push_ok( fn push_ok(
&mut self, &mut self,
@@ -172,10 +140,10 @@ mod tests {
#[test] #[test]
fn test_new_file() { fn test_new_file() {
let mut history = History::default(); let mut history = GlobalHistory::default();
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert!(history.current().is_none()); assert!(history.current().is_none());
let diff = history.push_ok(|e| { let diff = history.push_ok(|e| {
@@ -184,8 +152,8 @@ mod tests {
Ok(()) Ok(())
}); });
assert!(history.can_undo()); assert!(history.can_undo(&[]));
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert!(history.current().is_some()); assert!(history.current().is_some());
assert!(diff.is_some()); assert!(diff.is_some());
@@ -196,7 +164,7 @@ mod tests {
assert!(diff.modified.is_empty()); 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 file = Rc::new(File::default());
let id = file.id; let id = file.id;
history history
@@ -210,43 +178,43 @@ mod tests {
#[test] #[test]
fn test_failed_command_pushes_nothing() { fn test_failed_command_pushes_nothing() {
let mut history = History::default(); let mut history = GlobalHistory::default();
let diff = history.create_and_push_next(|e| { let diff = history.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default())); e.insert(FileId::default(), Rc::new(File::default()));
Err(CommandError::InvalidData("nope".to_string())) Err(CommandError::InvalidData("nope".to_string()))
}); });
assert_eq!(diff, 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()); assert!(history.current().is_none());
} }
#[test] #[test]
fn test_undo_redo() { fn test_undo_redo() {
let mut history = History::default(); let mut history = GlobalHistory::default();
assert!(history.undo().is_none()); assert!(history.undo(&[]).is_none());
assert!(history.redo().is_none()); assert!(history.redo(&[]).is_none());
let id = add_file(&mut history); let id = add_file(&mut history);
let diff = history.undo().unwrap(); let diff = history.undo(&[]).unwrap();
assert_eq!(diff.removed, vec![id]); assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty()); assert!(diff.added.is_empty());
assert!(history.current().is_none()); assert!(history.current().is_none());
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
assert!(history.can_redo()); assert!(history.can_redo(&[]));
assert!(history.undo().is_none()); assert!(history.undo(&[]).is_none());
let diff = history.redo().unwrap(); let diff = history.redo(&[]).unwrap();
assert_eq!(diff.added, vec![id]); assert_eq!(diff.added, vec![id]);
assert!(history.current().unwrap().contains_key(&id)); assert!(history.current().unwrap().contains_key(&id));
assert!(history.can_undo()); assert!(history.can_undo(&[]));
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert!(history.redo().is_none()); assert!(history.redo(&[]).is_none());
} }
#[test] #[test]
fn test_modified_and_removed() { fn test_modified_and_removed() {
let mut history = History::default(); let mut history = GlobalHistory::default();
let id = add_file(&mut history); let id = add_file(&mut history);
let diff = history let diff = history
@@ -282,13 +250,13 @@ mod tests {
assert!(history.current().unwrap().is_empty()); assert!(history.current().unwrap().is_empty());
// going back restores the renamed file // going back restores the renamed file
history.undo(); history.undo(&[]);
assert_eq!(history.current().unwrap()[&id].info.name, "renamed"); assert_eq!(history.current().unwrap()[&id].info.name, "renamed");
} }
#[test] #[test]
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() { 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); let id = add_file(&mut history);
// a copy, even if it has the same content, is another file for the history: files are // a copy, even if it has the same content, is another file for the history: files are
@@ -316,17 +284,17 @@ mod tests {
#[test] #[test]
fn test_push_after_undo_drops_redo_branch() { 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 first = add_file(&mut history);
let second = add_file(&mut history); let second = add_file(&mut history);
history.undo(); history.undo(&[]);
assert!(history.can_redo()); assert!(history.can_redo(&[]));
assert!(!history.current().unwrap().contains_key(&second)); assert!(!history.current().unwrap().contains_key(&second));
let third = add_file(&mut history); let third = add_file(&mut history);
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert!(history.redo().is_none()); assert!(history.redo(&[]).is_none());
let current = history.current().unwrap(); let current = history.current().unwrap();
assert!(current.contains_key(&first)); assert!(current.contains_key(&first));
assert!(current.contains_key(&third)); assert!(current.contains_key(&third));
@@ -342,24 +310,24 @@ mod tests {
#[test] #[test]
fn test_restored_history_starts_from_its_entry() { fn test_restored_history_starts_from_its_entry() {
let (entry, id) = restored_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)); assert!(history.current().unwrap().contains_key(&id));
// there is nothing to go back to, nor forward to // there is nothing to go back to, nor forward to
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert!(history.undo().is_none()); assert!(history.undo(&[]).is_none());
assert!(history.redo().is_none()); assert!(history.redo(&[]).is_none());
assert!(history.current().unwrap().contains_key(&id)); assert!(history.current().unwrap().contains_key(&id));
} }
#[test] #[test]
fn test_restored_history_cannot_undo_an_empty_entry_either() { 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.current().is_some_and(|files| files.is_empty()));
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
assert!(history.undo().is_none()); assert!(history.undo(&[]).is_none());
// the history is not the one of an engine that has done nothing // the history is not the one of an engine that has done nothing
assert!(history.current().is_some()); assert!(history.current().is_some());
} }
@@ -367,39 +335,39 @@ mod tests {
#[test] #[test]
fn test_restored_history_undoes_what_comes_after_it_and_no_further() { fn test_restored_history_undoes_what_comes_after_it_and_no_further() {
let (entry, restored) = restored_entry(); let (entry, restored) = restored_entry();
let mut history = History::restored(entry); let mut history = GlobalHistory::restored(entry);
let added = add_file(&mut history); let added = add_file(&mut history);
assert!(history.can_undo()); assert!(history.can_undo(&[]));
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert_eq!(history.current().unwrap().len(), 2); assert_eq!(history.current().unwrap().len(), 2);
// back to the restored files // back to the restored files
let diff = history.undo().unwrap(); let diff = history.undo(&[]).unwrap();
assert_eq!(diff.removed, vec![added]); assert_eq!(diff.removed, vec![added]);
assert!(diff.added.is_empty() && diff.modified.is_empty()); assert!(diff.added.is_empty() && diff.modified.is_empty());
assert_eq!(history.current().unwrap().len(), 1); assert_eq!(history.current().unwrap().len(), 1);
assert!(history.current().unwrap().contains_key(&restored)); assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
assert!(history.can_redo()); assert!(history.can_redo(&[]));
assert!(history.undo().is_none()); assert!(history.undo(&[]).is_none());
assert!(history.current().unwrap().contains_key(&restored)); assert!(history.current().unwrap().contains_key(&restored));
let diff = history.redo().unwrap(); let diff = history.redo(&[]).unwrap();
assert_eq!(diff.added, vec![added]); assert_eq!(diff.added, vec![added]);
assert!(history.can_undo()); assert!(history.can_undo(&[]));
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
// and again // and again
assert!(history.undo().is_some()); assert!(history.undo(&[]).is_some());
assert!(history.undo().is_none()); assert!(history.undo(&[]).is_none());
assert!(history.current().unwrap().contains_key(&restored)); assert!(history.current().unwrap().contains_key(&restored));
} }
#[test] #[test]
fn test_restored_history_removing_its_files_can_be_undone() { fn test_restored_history_removing_its_files_can_be_undone() {
let (entry, restored) = restored_entry(); let (entry, restored) = restored_entry();
let mut history = History::restored(entry); let mut history = GlobalHistory::restored(entry);
let diff = history let diff = history
.push_ok(|e| { .push_ok(|e| {
@@ -410,26 +378,26 @@ mod tests {
assert_eq!(diff.removed, vec![restored]); assert_eq!(diff.removed, vec![restored]);
assert!(history.current().unwrap().is_empty()); assert!(history.current().unwrap().is_empty());
let diff = history.undo().unwrap(); let diff = history.undo(&[]).unwrap();
assert_eq!(diff.added, vec![restored]); assert_eq!(diff.added, vec![restored]);
assert!(history.current().unwrap().contains_key(&restored)); assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
} }
#[test] #[test]
fn test_restored_history_push_after_undo_drops_redo_branch() { fn test_restored_history_push_after_undo_drops_redo_branch() {
let (entry, restored) = restored_entry(); let (entry, restored) = restored_entry();
let mut history = History::restored(entry); let mut history = GlobalHistory::restored(entry);
add_file(&mut history); add_file(&mut history);
history.undo().unwrap(); history.undo(&[]).unwrap();
assert!(history.can_redo()); assert!(history.can_redo(&[]));
let other = add_file(&mut history); let other = add_file(&mut history);
assert!(!history.can_redo()); assert!(!history.can_redo(&[]));
assert!(history.can_undo()); assert!(history.can_undo(&[]));
let diff = history.undo().unwrap(); let diff = history.undo(&[]).unwrap();
assert_eq!(diff.removed, vec![other]); assert_eq!(diff.removed, vec![other]);
assert!(history.current().unwrap().contains_key(&restored)); assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo()); assert!(!history.can_undo(&[]));
} }
} }
+88
View File
@@ -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,
}
}
+334
View File
@@ -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]));
}
}
+8
View File
@@ -312,6 +312,14 @@ fn neighbour<T: Copy + PartialEq + std::hash::Hash + Eq>(
} }
impl Selection { 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. /// The files holding the selected elements, as a selection of files.
pub fn to_files(&self) -> Selection { pub fn to_files(&self) -> Selection {
match self { match self {
+105 -2
View File
@@ -153,7 +153,7 @@ fn test_invalid_load_is_rejected() {
}) })
)); ));
assert!(engine.history.current().is_none()); assert!(engine.history.current().is_none());
assert!(!engine.history.can_undo()); assert!(!engine.history.can_undo(&[]));
assert!(engine.order.0.is_empty()); assert!(engine.order.0.is_empty());
} }
@@ -162,7 +162,7 @@ fn test_command_with_nothing_to_do_is_rejected() {
let mut engine = TestEngine::default(); let mut engine = TestEngine::default();
new(&mut engine, "file"); new(&mut engine, "file");
assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); 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); 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![])); engine.restore(restored_from(&engine, &ids, vec![]));
assert!(!engine.can_undo()); 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());
}
}