feat: mark diff files as reviewed

This commit is contained in:
Valdemar Lorenzen
2026-10-13 13:07:02 +02:00
parent c912abaf17
commit 38fcf86195
6 changed files with 522 additions and 38 deletions
+190
View File
@@ -213,6 +213,7 @@ pub struct DiffState {
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub comparison_id: Option<u64>,
pub reviewed_files: BTreeSet<usize>,
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
pub files: Option<FilePicker>,
pub files_visible: bool,
@@ -259,6 +260,7 @@ impl DiffState {
error: None,
comparison: None,
comparison_id: None,
reviewed_files: BTreeSet::new(),
files: None,
snapshot_roots: [None, None],
files_visible: true,
@@ -371,6 +373,7 @@ impl DiffState {
}
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
self.live_files.clear();
self.reviewed_files.clear();
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
@@ -649,6 +652,7 @@ impl DiffState {
self.phase = Phase::After;
self.comparison = None;
self.comparison_id = None;
self.reviewed_files.clear();
self.queue(operation);
}
}
@@ -872,6 +876,45 @@ impl DiffState {
})
}
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
let comparison = self
.comparison
.as_ref()
.filter(|_| self.phase == Phase::View)
.ok_or("No comparison is open")?;
let index = if self.focus == Focus::Files {
let entry = self
.files
.as_ref()
.filter(|_| self.files_visible)
.and_then(|files| files.entries().nth(files.selected))
.ok_or("No changed file is selected")?;
if entry.is_dir {
return Err("Select a changed file, not a directory");
}
entry
.catalog_index
.filter(|&index| {
comparison
.files
.get(index)
.is_some_and(|file| file.path == entry.path)
})
.ok_or("No changed file is selected")?
} else {
self.file
};
if comparison.files.get(index).is_none() {
return Err("No changed file is selected");
}
if self.reviewed_files.remove(&index) {
Ok(false)
} else {
self.reviewed_files.insert(index);
Ok(true)
}
}
pub(crate) fn select_file(&mut self, path: &Path) -> bool {
let Some(index) = self.comparison.as_ref().and_then(|comparison| {
let relative = path.strip_prefix(&comparison.root).unwrap_or(path);
@@ -1138,6 +1181,17 @@ impl DiffState {
let file = &self.comparison.as_ref().unwrap().files[self.file];
let was_live = self.live_files.contains(&self.file);
let changed_lines = changed_line_range(&file.after, lines);
if self.reviewed_files.contains(&self.file) {
let unchanged = if was_live {
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
} else {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
file.after_eof == eof && file.after == live_content(lines, eof)
};
if !unchanged {
self.reviewed_files.remove(&self.file);
}
}
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
self.live_revision = Some(revision);
return;
@@ -3094,6 +3148,142 @@ mod tests {
state
}
#[test]
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
let mut state = DiffState::new();
let mut changes = comparison();
changes.files[0].path = "nested/z.txt".into();
changes.files[1].path = "nested/a.txt".into();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
for view in [FileView::List, FileView::Tree] {
let files = state.files.as_mut().unwrap();
files.set_view(view);
files.clear_filter();
files.start_search();
for ch in "nested/a".chars() {
files.handle_filter_input(&Input::Char(ch));
}
files.reveal_path(Path::new("nested/a.txt"));
assert_eq!(state.file, 0);
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
assert_eq!(state.toggle_reviewed(), Ok(false));
assert!(state.reviewed_files.is_empty());
}
let files = state.files.as_mut().unwrap();
files.clear_filter();
files.finish_search();
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
files.selected = selected;
assert_eq!(
state.toggle_reviewed(),
Err("Select a changed file, not a directory")
);
assert!(state.reviewed_files.is_empty());
let files = state.files.as_mut().unwrap();
files.start_search();
for ch in "missing".chars() {
files.handle_filter_input(&Input::Char(ch));
}
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[(0, PathBuf::from("ordinary.txt"))],
));
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
state.focus = Focus::Before;
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
assert!(state.select_file_index(2));
state.focus = Focus::Before;
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
}
#[test]
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
let mut state = DiffState::new();
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
let mut changes = comparison();
changes.files[0].loaded = false;
changes.files[0].before.clear();
changes.files[0].after.clear();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
let file = comparison().files.remove(0);
state.apply_result(
2,
Ok(Outcome::File {
comparison_id: 1,
file_index: 0,
file,
}),
);
assert!(state.selected_file_loaded());
state.set_view_mode(ViewMode::Unified);
state.set_view_mode(ViewMode::Split);
state.split_axis = crate::panes::SplitAxis::Horizontal;
assert!(state.select_file_index(1));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.before = Some(revision(
RevisionTarget::Commit("base".into()),
"base",
"",
Category::Commits,
));
state.after = Some(revision(
RevisionTarget::Commit("head".into()),
"head",
"",
Category::Commits,
));
state.toggle_comparison_mode().unwrap();
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
assert!(state.reviewed_files.is_empty());
}
#[test]
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
for (after, eof, lines) in [
(vec!["changed".into()], false, vec!["changed".into()]),
(
vec!["changed".into()],
true,
vec!["changed".into(), String::new()],
),
(Vec::new(), false, vec![String::new()]),
] {
let mut state = live_state(vec!["base".into()], after, eof);
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&lines, 1);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.refresh_after(&lines, 2);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.refresh_after(&["edited".into()], 3);
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into(), String::new()], 4);
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into()], 5);
assert!(state.reviewed_files.is_empty());
}
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
state.comparison.as_mut().unwrap().files[0].loaded = false;
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into()], 1);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
}
#[test]
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
let mut state = live_state(