feat: follow forward working-checkout changes in diffs

This commit is contained in:
Valdemar Lorenzen
2026-10-13 13:38:46 +02:00
parent b16232aa1d
commit bfdb7c544f
12 changed files with 1118 additions and 62 deletions
+209 -3
View File
@@ -122,6 +122,10 @@ pub enum Operation {
mode: ComparisonMode,
buffers: Box<[(PathBuf, String)]>,
},
RefreshWorking {
comparison_id: u64,
buffers: Box<[(PathBuf, String)]>,
},
LoadFile {
comparison_id: u64,
file_index: usize,
@@ -131,6 +135,7 @@ pub enum Operation {
pub enum Outcome {
Catalog(Catalog),
Comparison(Comparison),
WorkingRefresh(Option<Comparison>),
File {
comparison_id: u64,
file_index: usize,
@@ -190,6 +195,9 @@ pub enum DisplayRow {
enum RequestKind {
Catalog,
Comparison,
WorkingRefresh {
comparison_id: u64,
},
File {
comparison_id: u64,
file_index: usize,
@@ -297,6 +305,9 @@ impl DiffState {
}
pub(crate) fn take_request(&mut self) -> Option<Operation> {
if !self.request_ready() {
return None;
}
self.pending.take()
}
@@ -304,7 +315,49 @@ impl DiffState {
self.pending.is_some()
}
pub(crate) fn request_ready(&self) -> bool {
self.pending.as_ref().is_some_and(|operation| {
!self.refresh_pending() || matches!(operation, Operation::RefreshWorking { .. })
})
}
pub(crate) fn refresh_pending(&self) -> bool {
matches!(self.request_kind, Some(RequestKind::WorkingRefresh { .. }))
}
pub(crate) fn queue_working_refresh(&mut self) {
if self.phase != Phase::View
|| self.loading
|| self.request_kind.is_some()
|| self.pending.is_some()
|| self.comparison.is_none()
|| !self
.after
.as_ref()
.is_some_and(|after| after.target == RevisionTarget::Working)
{
return;
}
if let Some(comparison_id) = self.comparison_id {
self.request_kind = Some(RequestKind::WorkingRefresh { comparison_id });
self.pending = Some(Operation::RefreshWorking {
comparison_id,
buffers: Box::new([]),
});
}
}
fn queue(&mut self, operation: Operation) {
if self.refresh_pending()
&& !matches!(self.pending, Some(Operation::RefreshWorking { .. }))
&& matches!(operation, Operation::LoadFile { .. })
{
// Finish the refresh before issuing a load against its frozen comparison ID.
self.pending = Some(operation);
self.loading = true;
self.error = None;
return;
}
self.request_kind = Some(match operation {
Operation::Compare { .. } => RequestKind::Comparison,
Operation::LoadFile {
@@ -321,9 +374,66 @@ impl DiffState {
self.error = None;
}
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
if self.request_kind.is_none() {
return;
return false;
}
if let Some(RequestKind::WorkingRefresh { comparison_id }) = self.request_kind {
if self.comparison_id != Some(comparison_id) {
return false;
}
self.request_kind = None;
let deferred = self.pending.take();
match result {
Ok(Outcome::WorkingRefresh(None)) => {
if let Some(operation) = deferred {
self.queue(operation);
}
return false;
}
Ok(Outcome::WorkingRefresh(Some(comparison))) => {
let selected = self
.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(self.file))
.map(|file| &file.path);
self.file = selected
.and_then(|path| {
comparison.files.iter().position(|file| &file.path == path)
})
.unwrap_or(0);
if let Some(files) = &mut self.files {
files.replace_paths(
comparison
.files
.iter()
.enumerate()
.map(|(index, file)| (index, file.path.clone())),
);
}
self.comparison = Some(comparison);
self.comparison_id = Some(id);
self.snapshot_roots = [None, None];
self.reviewed_files.clear();
self.live_files.clear();
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
self.prepare_selected_file();
self.normalize_focus();
}
Err(error) => {
if let Some(operation) = deferred {
self.queue(operation);
}
self.error = Some(error);
}
Ok(_) => self.error = Some("Unexpected repository response".into()),
}
return true;
}
if matches!(result, Ok(Outcome::WorkingRefresh(_))) {
return false;
}
if let Ok(Outcome::File {
comparison_id,
@@ -337,7 +447,7 @@ impl DiffState {
file_index: *file_index,
}))
{
return;
return false;
}
let expected = self.request_kind.take();
self.loading = false;
@@ -436,6 +546,7 @@ impl DiffState {
Ok(_) => self.error = Some("Unexpected repository response".into()),
Err(error) => self.error = Some(error),
}
true
}
fn refilter(&mut self) {
@@ -817,6 +928,15 @@ impl DiffState {
self.focus = match (self.focus, self.pane_layout()) {
(Focus::Files, _) if self.files_visible => Focus::Files,
(Focus::Before | Focus::After, PaneLayout::Split) => self.focus,
(Focus::Before | Focus::After, PaneLayout::Empty)
if self
.comparison
.as_ref()
.is_some_and(|comparison| !comparison.files.is_empty())
&& !self.selected_file_loaded() =>
{
self.focus
}
_ => self.first_text_focus(),
};
if let Some(files) = &mut self.files {
@@ -956,6 +1076,16 @@ impl DiffState {
}
fn prepare_selected_file(&mut self) {
if self.refresh_pending() {
self.align_file();
if !self.selected_file_loaded() {
self.load_file(self.file);
} else if matches!(self.pending, Some(Operation::LoadFile { .. })) {
self.pending = None;
self.loading = false;
}
return;
}
self.pending = None;
self.request_kind = None;
self.loading = false;
@@ -3148,6 +3278,82 @@ mod tests {
state
}
#[test]
fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() {
let mut state = live_state(vec!["old".into()], vec!["new".into()], false);
state.loading = false;
state.pending = None;
state.request_kind = None;
state.comparison_id = Some(7);
state.word_wrap = true;
state.view_mode = ViewMode::Unified;
state.comparison_mode = ComparisonMode::Snapshots;
state.split_axis = crate::panes::SplitAxis::Horizontal;
state.files_visible = false;
state.reviewed_files.insert(0);
state.error = Some("previous notice".into());
let files = state.files.as_mut().unwrap();
files.set_view(FileView::Tree);
files.start_search();
files.query = "file".into();
files.refilter();
let original = format!("{state:?}");
state.queue_working_refresh();
assert!(matches!(
state.take_request(),
Some(Operation::RefreshWorking {
comparison_id: 7,
..
})
));
assert!(!state.apply_result(8, Ok(Outcome::WorkingRefresh(None))));
assert_eq!(format!("{state:?}"), original);
state.queue_working_refresh();
state.take_request();
assert!(state.apply_result(9, Err("refresh failed".into())));
assert_eq!(state.comparison_id, Some(7));
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
assert_eq!(state.error.as_deref(), Some("refresh failed"));
state.queue_working_refresh();
state.take_request();
let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false)
.comparison
.take()
.unwrap();
replacement.files.insert(0, comparison().files.remove(0));
assert!(state.apply_result(10, Ok(Outcome::WorkingRefresh(Some(replacement)))));
assert_eq!(state.comparison_id, Some(10));
assert_eq!(state.file, 1);
assert_eq!(state.focus, Focus::After);
assert!(state.word_wrap && !state.files_visible);
assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal);
assert_eq!(state.view_mode, ViewMode::Unified);
assert_eq!(state.comparison_mode, ComparisonMode::Snapshots);
assert!(state.reviewed_files.is_empty());
let files = state.files.as_ref().unwrap();
assert_eq!(files.query, "file");
assert_eq!(files.view, FileView::Tree);
assert!(files.searching);
assert_eq!(
files.entries().nth(files.selected).unwrap().path,
Path::new("file")
);
let refreshed = format!("{state:?}");
assert!(!state.apply_result(7, Ok(Outcome::WorkingRefresh(None))));
assert_eq!(format!("{state:?}"), refreshed);
state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into());
state.queue_working_refresh();
assert!(!state.request_queued());
state.after.as_mut().unwrap().target = RevisionTarget::Working;
state.load_file(0);
let user_request = format!("{state:?}");
state.queue_working_refresh();
assert_eq!(format!("{state:?}"), user_request);
}
#[test]
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
let mut state = DiffState::new();