feat: track checkout identity for pinned diff editability
This commit is contained in:
+157
-44
@@ -1,7 +1,7 @@
|
||||
use crate::editor::{BackspaceKind, Input};
|
||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
@@ -108,7 +108,7 @@ pub struct Comparison {
|
||||
pub after: String,
|
||||
pub files: Vec<ChangedFile>,
|
||||
pub root: PathBuf,
|
||||
/// Regular working-After candidates; loaded binary details are excluded.
|
||||
/// Eligible regular-file After paths; loaded binary details are excluded.
|
||||
pub editable_paths: BTreeSet<PathBuf>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
@@ -122,8 +122,9 @@ pub enum Operation {
|
||||
mode: ComparisonMode,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
RefreshWorking {
|
||||
RefreshCheckout {
|
||||
comparison_id: u64,
|
||||
follow_working: bool,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
LoadFile {
|
||||
@@ -135,7 +136,7 @@ pub enum Operation {
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
Comparison(Comparison),
|
||||
WorkingRefresh(Option<Comparison>),
|
||||
CheckoutRefresh(Option<Comparison>),
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
@@ -195,7 +196,7 @@ pub enum DisplayRow {
|
||||
enum RequestKind {
|
||||
Catalog,
|
||||
Comparison,
|
||||
WorkingRefresh {
|
||||
CheckoutRefresh {
|
||||
comparison_id: u64,
|
||||
},
|
||||
File {
|
||||
@@ -238,6 +239,7 @@ pub struct DiffState {
|
||||
pub live_revision: Option<u64>,
|
||||
pub follow_caret: bool,
|
||||
live_files: BTreeSet<usize>,
|
||||
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
|
||||
context_lines: usize,
|
||||
expand_lines: usize,
|
||||
pending: Option<Operation>,
|
||||
@@ -285,6 +287,7 @@ impl DiffState {
|
||||
live_revision: None,
|
||||
follow_caret: false,
|
||||
live_files: BTreeSet::new(),
|
||||
frozen_after: BTreeMap::new(),
|
||||
context_lines: 10,
|
||||
expand_lines: 15,
|
||||
pending: Some(Operation::Discover),
|
||||
@@ -317,31 +320,32 @@ impl DiffState {
|
||||
|
||||
pub(crate) fn request_ready(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|operation| {
|
||||
!self.refresh_pending() || matches!(operation, Operation::RefreshWorking { .. })
|
||||
!self.refresh_pending() || matches!(operation, Operation::RefreshCheckout { .. })
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_pending(&self) -> bool {
|
||||
matches!(self.request_kind, Some(RequestKind::WorkingRefresh { .. }))
|
||||
matches!(self.request_kind, Some(RequestKind::CheckoutRefresh { .. }))
|
||||
}
|
||||
|
||||
pub(crate) fn queue_working_refresh(&mut self) {
|
||||
pub(crate) fn queue_checkout_refresh(&mut self, follow_working: bool) {
|
||||
if self.phase != Phase::View
|
||||
|| self.loading
|
||||
|| self.request_kind.is_some()
|
||||
|| self.pending.is_some()
|
||||
|| self.comparison.is_none()
|
||||
|| !self
|
||||
|| self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target == RevisionTarget::Working)
|
||||
.is_none_or(|after| after.target == RevisionTarget::Working && !follow_working)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(comparison_id) = self.comparison_id {
|
||||
self.request_kind = Some(RequestKind::WorkingRefresh { comparison_id });
|
||||
self.pending = Some(Operation::RefreshWorking {
|
||||
self.request_kind = Some(RequestKind::CheckoutRefresh { comparison_id });
|
||||
self.pending = Some(Operation::RefreshCheckout {
|
||||
comparison_id,
|
||||
follow_working,
|
||||
buffers: Box::new([]),
|
||||
});
|
||||
}
|
||||
@@ -349,7 +353,7 @@ impl DiffState {
|
||||
|
||||
fn queue(&mut self, operation: Operation) {
|
||||
if self.refresh_pending()
|
||||
&& !matches!(self.pending, Some(Operation::RefreshWorking { .. }))
|
||||
&& !matches!(self.pending, Some(Operation::RefreshCheckout { .. }))
|
||||
&& matches!(operation, Operation::LoadFile { .. })
|
||||
{
|
||||
// Finish the refresh before issuing a load against its frozen comparison ID.
|
||||
@@ -378,20 +382,28 @@ impl DiffState {
|
||||
if self.request_kind.is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(RequestKind::WorkingRefresh { comparison_id }) = self.request_kind {
|
||||
if let Some(RequestKind::CheckoutRefresh { 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)) => {
|
||||
Ok(Outcome::CheckoutRefresh(None)) => {
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Ok(Outcome::WorkingRefresh(Some(comparison))) => {
|
||||
Ok(Outcome::CheckoutRefresh(Some(mut comparison))) => {
|
||||
let pinned = self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working);
|
||||
let view = (self.top, self.left_before, self.left_after);
|
||||
if !pinned {
|
||||
self.reviewed_files.clear();
|
||||
}
|
||||
let selected = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
@@ -402,6 +414,19 @@ impl DiffState {
|
||||
comparison.files.iter().position(|file| &file.path == path)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if pinned {
|
||||
self.restore_frozen_after();
|
||||
if let Some(old) = self.comparison.take() {
|
||||
// Checkout-only refreshes retain the frozen catalog order.
|
||||
for (file, replacement) in
|
||||
old.files.into_iter().zip(&mut comparison.files)
|
||||
{
|
||||
if file.loaded {
|
||||
*replacement = file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(files) = &mut self.files {
|
||||
files.replace_paths(
|
||||
comparison
|
||||
@@ -414,25 +439,34 @@ impl DiffState {
|
||||
self.comparison = Some(comparison);
|
||||
self.comparison_id = Some(id);
|
||||
self.snapshot_roots = [None, None];
|
||||
self.reviewed_files.clear();
|
||||
self.frozen_after.clear();
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.prepare_selected_file();
|
||||
if pinned {
|
||||
self.top = view.0.min(self.display_rows.len().saturating_sub(1));
|
||||
self.left_before = view.1.min(self.max_before);
|
||||
self.left_after = view.2.min(self.max_after);
|
||||
}
|
||||
self.normalize_focus();
|
||||
}
|
||||
Err(error) => {
|
||||
self.revoke_pinned_after();
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
self.error = Some(error);
|
||||
}
|
||||
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
||||
Ok(_) => {
|
||||
self.revoke_pinned_after();
|
||||
self.error = Some("Unexpected repository response".into());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if matches!(result, Ok(Outcome::WorkingRefresh(_))) {
|
||||
if matches!(result, Ok(Outcome::CheckoutRefresh(_))) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(Outcome::File {
|
||||
@@ -483,6 +517,7 @@ impl DiffState {
|
||||
}
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
self.live_files.clear();
|
||||
self.frozen_after.clear();
|
||||
self.reviewed_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
@@ -972,18 +1007,48 @@ impl DiffState {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& self.working_after_available()
|
||||
fn revoke_pinned_after(&mut self) {
|
||||
if self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_none_or(|after| after.target == RevisionTarget::Working)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(comparison) = &mut self.comparison {
|
||||
comparison.editable_paths.clear();
|
||||
}
|
||||
self.restore_frozen_after();
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.rebuild_file(false);
|
||||
self.refresh_files();
|
||||
}
|
||||
|
||||
pub(crate) fn working_after_available(&self) -> bool {
|
||||
fn restore_frozen_after(&mut self) {
|
||||
if let Some(comparison) = &mut self.comparison {
|
||||
for (index, (after, eof)) in std::mem::take(&mut self.frozen_after) {
|
||||
if let Some(file) = comparison.files.get_mut(index) {
|
||||
file.after = after;
|
||||
file.after_eof = eof;
|
||||
file.hunks =
|
||||
line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
|
||||
update_eof_notice(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& self.live_after_available()
|
||||
}
|
||||
|
||||
pub(crate) fn live_after_available(&self) -> bool {
|
||||
self.phase == Phase::View
|
||||
&& self.selected_file_loaded()
|
||||
&& self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison.files.get(self.file).is_some_and(|file| {
|
||||
comparison.editable_paths.contains(&file.path)
|
||||
@@ -1302,13 +1367,22 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
||||
if !self.working_after_available() {
|
||||
if !self.live_after_available() {
|
||||
return;
|
||||
}
|
||||
if self.live_revision == Some(revision) {
|
||||
return;
|
||||
}
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
if self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working)
|
||||
{
|
||||
self.frozen_after
|
||||
.entry(self.file)
|
||||
.or_insert_with(|| (file.after.clone(), file.after_eof));
|
||||
}
|
||||
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) {
|
||||
@@ -3297,19 +3371,19 @@ mod tests {
|
||||
files.start_search();
|
||||
files.query = "file".into();
|
||||
files.refilter();
|
||||
let original = format!("{state:?}");
|
||||
state.queue_working_refresh();
|
||||
state.queue_checkout_refresh(true);
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::RefreshWorking {
|
||||
Some(Operation::RefreshCheckout {
|
||||
comparison_id: 7,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!state.apply_result(8, Ok(Outcome::WorkingRefresh(None))));
|
||||
assert_eq!(format!("{state:?}"), original);
|
||||
assert!(!state.apply_result(8, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert_eq!(state.comparison_id, Some(7));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
|
||||
state.queue_working_refresh();
|
||||
state.queue_checkout_refresh(true);
|
||||
state.take_request();
|
||||
assert!(state.apply_result(9, Err("refresh failed".into())));
|
||||
assert_eq!(state.comparison_id, Some(7));
|
||||
@@ -3317,14 +3391,14 @@ mod tests {
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
assert_eq!(state.error.as_deref(), Some("refresh failed"));
|
||||
|
||||
state.queue_working_refresh();
|
||||
state.queue_checkout_refresh(true);
|
||||
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!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
|
||||
assert_eq!(state.comparison_id, Some(10));
|
||||
assert_eq!(state.file, 1);
|
||||
assert_eq!(state.focus, Focus::After);
|
||||
@@ -3341,17 +3415,55 @@ mod tests {
|
||||
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);
|
||||
assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert_eq!(state.comparison_id, Some(10));
|
||||
assert_eq!(state.file, 1);
|
||||
state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into());
|
||||
state.queue_working_refresh();
|
||||
assert!(!state.request_queued());
|
||||
state.queue_checkout_refresh(false);
|
||||
assert!(state.request_queued());
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::RefreshCheckout {
|
||||
follow_working: false,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None))));
|
||||
state.reviewed_files.insert(1);
|
||||
state.queue_checkout_refresh(false);
|
||||
state.take_request();
|
||||
let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false)
|
||||
.comparison
|
||||
.take()
|
||||
.unwrap();
|
||||
demoted.files.insert(0, comparison().files.remove(0));
|
||||
demoted.editable_paths.clear();
|
||||
assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted)))));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
assert!(!state.live_after_available());
|
||||
state
|
||||
.comparison
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.editable_paths
|
||||
.insert("file".into());
|
||||
state.refresh_after(&["dirty".into()], 1);
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]);
|
||||
state.queue_checkout_refresh(false);
|
||||
state.take_request();
|
||||
assert!(state.apply_result(13, Err("checkout unavailable".into())));
|
||||
assert!(!state.live_after_available());
|
||||
assert_eq!(state.comparison_id, Some(12));
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]);
|
||||
state.after.as_mut().unwrap().target = RevisionTarget::Working;
|
||||
state.queue_checkout_refresh(false);
|
||||
assert!(!state.request_queued());
|
||||
state.load_file(0);
|
||||
let user_request = format!("{state:?}");
|
||||
state.queue_working_refresh();
|
||||
assert_eq!(format!("{state:?}"), user_request);
|
||||
state.queue_checkout_refresh(true);
|
||||
assert!(matches!(
|
||||
state.pending,
|
||||
Some(Operation::LoadFile { file_index: 0, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3807,6 +3919,7 @@ mod tests {
|
||||
assert_eq!(empty.rows[0].kind, RowKind::Equal);
|
||||
assert_eq!(empty.after_display_row(0), Some(0));
|
||||
empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into());
|
||||
empty.comparison.as_mut().unwrap().editable_paths.clear();
|
||||
empty.refresh_after(&["must not apply".into()], 2);
|
||||
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
empty.handle(Input::Escape);
|
||||
|
||||
Reference in New Issue
Block a user