feat: track checkout identity for pinned diff editability

This commit is contained in:
2026-10-13 17:04:15 +02:00
parent e3e087f16f
commit 99fa5a2aff
14 changed files with 705 additions and 170 deletions
+157 -44
View File
@@ -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);
+156 -30
View File
@@ -2588,7 +2588,7 @@ impl Editor {
self.diff_display_options = (view_mode, split_axis);
}
pub(crate) fn queue_working_refreshes(&mut self) {
pub(crate) fn queue_checkout_refreshes(&mut self, follow_working: bool) {
for index in 0..self.tab_order.len() {
let tab = self.tab_order[index];
let owner = if tab == self.tab_id {
@@ -2599,7 +2599,7 @@ impl Editor {
if let Some(owner) = owner {
self.with_pane(owner, |editor| {
if let Some(diff) = &mut editor.diff {
diff.queue_working_refresh();
diff.queue_checkout_refresh(follow_working);
}
});
}
@@ -2638,7 +2638,19 @@ impl Editor {
{
Some(buffers)
}
Operation::RefreshWorking { buffers, .. } => Some(buffers),
Operation::RefreshCheckout {
follow_working: true,
buffers,
..
} if self
.diff
.as_ref()
.or(self.parked_diff.as_ref())
.and_then(|diff| diff.after.as_ref())
.is_some_and(|after| after.target == RevisionTarget::Working) =>
{
Some(buffers)
}
_ => None,
};
if let Some(buffers) = buffers {
@@ -2694,6 +2706,25 @@ impl Editor {
return self.with_pane(owner, |editor| editor.apply_diff_result(id, result));
}
self.outstanding_diff_request = None;
if self.diff.as_ref().is_some_and(DiffState::refresh_pending)
&& !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))
{
self.finish_completion();
self.snippet = None;
self.clear_hover();
if self.active.view.mode == Mode::Insert {
self.finish_insert();
self.leave_insert();
}
}
let preserved_view = (self.diff.as_ref().is_some_and(|diff| {
diff.refresh_pending()
&& diff
.after
.as_ref()
.is_some_and(|after| after.target != RevisionTarget::Working)
}) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))))
.then(|| self.active.view.inherit());
let Some(diff) = &mut self.diff else {
return false;
};
@@ -2701,15 +2732,10 @@ impl Editor {
return false;
}
let refresh = diff.refresh_pending();
let previous = diff.comparison_id;
if !diff.apply_result(id, result) {
return false;
}
let replaced = previous != diff.comparison_id;
if refresh {
if !replaced {
return true;
}
self.pending_lsp_navigation = None;
self.diff_bound = None;
} else if let Some(files) = &mut diff.files {
@@ -2719,7 +2745,34 @@ impl Editor {
self.diff.as_ref().and_then(|diff| diff.comparison_id) != Some(role.0)
});
self.bind_diff_buffer();
let pinned_refresh = preserved_view.is_some();
if self.active.diff_snapshot.is_some()
&& let Some(mut view) = preserved_view
{
view.clamp(&self.active.lines);
self.active.view = view;
}
let reviewed = self.diff.as_mut().and_then(|diff| {
(refresh
&& diff
.after
.as_ref()
.is_some_and(|after| after.target != RevisionTarget::Working))
.then(|| std::mem::take(&mut diff.reviewed_files))
});
self.refresh_live_diff();
if let Some(reviewed) = reviewed {
self.diff
.as_mut()
.expect("comparison remains open")
.reviewed_files = reviewed;
}
if pinned_refresh {
self.diff
.as_mut()
.expect("comparison remains open")
.follow_caret = false;
}
if refresh || obsolete_snapshot {
self.prune_diff_snapshots();
}
@@ -2976,8 +3029,15 @@ impl Editor {
diff.focus
};
let key = (comparison_id, diff.file, focus);
let writable = focus == Focus::After && diff.after_editable();
if self.diff_bound.is_some_and(|(id, file, side, buffer)| {
(id, file, side) == key && buffer == self.active.id
(id, file, side) == key
&& buffer == self.active.id
&& if writable {
self.active.diff_snapshot.is_none() && diff.live_buffer == Some(self.active.id)
} else {
self.active.diff_snapshot == Some(key)
}
}) {
return;
}
@@ -2996,7 +3056,6 @@ impl Editor {
diff.corresponding_row(from, focus, target.active.row)
.map(|row| (row, column))
});
let writable = focus == Focus::After && diff.after_editable();
// Binding a comparison side is not a user departure from its palette.
let palette = self.palette.take();
if writable {
@@ -3139,7 +3198,7 @@ impl Editor {
}
});
if let Some(buffer) = buffer
&& diff.working_after_available()
&& diff.live_after_available()
&& diff.live_revision != Some(buffer.text_revision)
{
diff.refresh_after(&buffer.lines, buffer.text_revision);
@@ -3833,10 +3892,6 @@ impl Editor {
return Err("Definition target is binary or non-UTF-8".into());
}
let writable = focus == Focus::After
&& diff
.after
.as_ref()
.is_some_and(|revision| revision.target == RevisionTarget::Working)
&& comparison.editable_paths.contains(&file.path)
&& file.status != crate::diff::FileStatus::Removed
&& file
@@ -3872,13 +3927,7 @@ impl Editor {
let file = &comparison.files[file_index];
let live = if writable {
Some(target)
} else if focus == Focus::Before
&& diff
.after
.as_ref()
.is_some_and(|revision| revision.target == RevisionTarget::Working)
&& comparison.editable_paths.contains(&file.path)
{
} else if focus == Focus::Before && comparison.editable_paths.contains(&file.path) {
let identity = path_identity(&comparison.root.join(&file.path)).ok();
std::iter::once(&self.active)
.chain(self.parked.values())
@@ -12780,14 +12829,14 @@ mod tests {
let visible_tab = editor.tab_id();
let visible_pane = editor.pane_id();
let cursor = editor.cursor();
editor.queue_working_refreshes();
editor.queue_checkout_refreshes(true);
let (tab, request, operation) = editor.take_next_diff_request().unwrap();
assert_eq!(tab, hidden_tab);
assert!(
matches!(operation, Operation::RefreshWorking { comparison_id, .. }
matches!(operation, Operation::RefreshCheckout { comparison_id, .. }
if comparison_id == old_comparison)
);
assert!(!editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(None))));
assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None))));
assert_eq!(
editor.tab_diff(hidden_tab).unwrap().comparison_id,
Some(old_comparison)
@@ -12797,12 +12846,12 @@ mod tests {
assert_eq!(editor.buffer_id(), old_snapshot);
assert_eq!(editor.cursor(), cursor);
editor.queue_working_refreshes();
editor.queue_checkout_refreshes(true);
let (_, request, _) = editor.take_next_diff_request().unwrap();
let replacement = editor.with_pane(owner, |editor| {
editor.diff.as_mut().unwrap().comparison.take().unwrap()
});
assert!(editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(Some(replacement)))));
assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
assert_eq!(
editor.tab_diff(hidden_tab).unwrap().comparison_id,
Some(request)
@@ -12816,7 +12865,7 @@ mod tests {
.apply(vec![Effect::Insert("rejected".into())])
.is_err()
);
assert!(!editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(None))));
assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None))));
editor.with_pane(owner, |editor| {
assert_ne!(editor.buffer_id(), old_snapshot);
@@ -12846,7 +12895,7 @@ mod tests {
let mut target = lazy_lsp_target(&mut editor);
let old_comparison = editor.diff().unwrap().comparison_id.unwrap();
let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap();
editor.queue_working_refreshes();
editor.queue_checkout_refreshes(true);
let (_, refresh, _) = editor.take_next_diff_request().unwrap();
let loaded_before = target.before.clone();
let loaded_after = target.after.clone();
@@ -12878,7 +12927,7 @@ mod tests {
let changed = request_kind != "noop_navigation";
let outcome = if changed { Some(replacement) } else { None };
assert_eq!(
editor.apply_diff_result(refresh, Ok(Outcome::WorkingRefresh(outcome))),
editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(outcome))),
changed
);
if changed {
@@ -13832,6 +13881,83 @@ mod tests {
assert!(editor.dirty());
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), ["beta"]);
for refresh_error in [false, true] {
let path = directory.path("file.txt");
fs::write(&path, "original").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
historical_diff(
&mut editor,
&directory.0,
vec!["before".into()],
vec!["original".into()],
);
let endpoints = (
editor.diff().unwrap().before.clone(),
editor.diff().unwrap().after.clone(),
);
editor.queue_checkout_refreshes(false);
let (_, request, _) = editor.take_next_diff_request().unwrap();
let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap();
promoted.editable_paths.insert("file.txt".into());
editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted))));
editor.diff.as_mut().unwrap().focus = Focus::After;
editor.bind_diff_buffer();
assert!(editor.diff_editing());
let live = editor.buffer_id();
type_text(&mut editor, "ix");
assert_eq!(editor.mode(), Mode::Insert);
let comparison_id = editor.diff().unwrap().comparison_id;
editor.queue_checkout_refreshes(false);
let (_, request, _) = editor.take_next_diff_request().unwrap();
if refresh_error {
assert!(editor.apply_diff_result(request, Err("checkout unavailable".into())));
assert_eq!(editor.diff().unwrap().comparison_id, comparison_id);
} else {
let mut demoted = editor.diff.as_mut().unwrap().comparison.take().unwrap();
demoted.editable_paths.clear();
demoted.files[0].after = vec!["original".into()];
demoted.files[0].after_eof = false;
demoted.files[0].hunks = crate::diff::line_hunks(
&demoted.files[0].before,
false,
&demoted.files[0].after,
false,
);
assert!(
editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(demoted))),)
);
}
assert!(!editor.diff_editable());
assert_eq!(editor.lines(), ["original"]);
assert_eq!(editor.buffer(live).unwrap().lines, ["xoriginal"]);
assert!(editor.buffer(live).unwrap().dirty());
assert_eq!(editor.buffer(live).unwrap().view.mode, Mode::Normal);
assert!(
editor
.apply(vec![Effect::Insert("blocked".into())])
.is_err()
);
editor.execute("w");
assert_eq!(fs::read_to_string(&path).unwrap(), "original");
editor.queue_checkout_refreshes(false);
let (_, request, _) = editor.take_next_diff_request().unwrap();
let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap();
promoted.editable_paths.insert("file.txt".into());
editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted))));
assert!(editor.diff_editing());
assert!(editor.diff().unwrap().error.is_none());
assert_eq!(editor.buffer_id(), live);
assert_eq!(editor.lines(), ["xoriginal"]);
assert_eq!(
(
editor.diff().unwrap().before.clone(),
editor.diff().unwrap().after.clone(),
),
endpoints,
);
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), ["original"]);
}
}
#[test]
+131 -25
View File
@@ -311,6 +311,9 @@ struct PreparedComparison {
after: String,
working_after: bool,
working_anchor: Option<String>,
pinned_commit: Option<String>,
pinned_checkout: Option<String>,
editable_after: bool,
}
pub struct Repository {
@@ -933,7 +936,7 @@ impl Repository {
"log",
"--no-graph",
"-r",
&format!("commit_id({})", serde_json::to_string(id).unwrap()),
&format!("present(commit_id({}))", serde_json::to_string(id).unwrap()),
"-T",
"conflict",
],
@@ -1335,6 +1338,19 @@ impl Repository {
}
Ok(base.into())
}
fn pinned_checkout(&self, id: &str) -> Result<Option<String>, String> {
if self.kind == RepoKind::Git {
return Ok(Some(id.into()));
}
// Private remote history need not exist in the active jj operation.
let revset = format!("present(commit_id({}))", serde_json::to_string(id).unwrap());
let identity = text(self.jj(
&self.root,
&["log", "--no-graph", "-r", &revset, "-T", "change_id"],
)?)?;
let identity = identity.trim();
Ok((!identity.is_empty()).then(|| identity.into()))
}
fn working_anchor(&self) -> Result<Option<String>, String> {
if self.kind == RepoKind::Jujutsu {
return Ok(Some(
@@ -1417,11 +1433,20 @@ impl Repository {
|(before, after, label)| (before, after, label.as_str()),
);
let working_after = *after_target == RevisionTarget::Working;
let working_anchor = if working_after {
self.working_anchor()?
} else {
let checkout = self.working_anchor()?;
let pinned = if working_after {
None
} else {
let (store, id) = self.resolve(after_target)?;
Some((store, id))
};
let pinned_checkout = pinned
.as_ref()
.map(|(_, id)| self.pinned_checkout(id))
.transpose()?
.flatten();
let editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == checkout);
let left = self.snapshot(before_target)?;
let same = before_target == after_target && (!working_after || buffers.is_empty());
let right = if same {
@@ -1429,7 +1454,8 @@ impl Repository {
} else if working_after {
self.working_snapshot(buffers)?
} else {
self.snapshot(after_target)?
let (store, id) = pinned.as_ref().unwrap();
self.commit_snapshot(store, id)?
};
let mut prepared = PreparedComparison {
id: comparison_id,
@@ -1439,31 +1465,91 @@ impl Repository {
before: before_label.into(),
after: after.label.clone(),
working_after,
working_anchor,
working_anchor: working_after.then(|| checkout.clone()).flatten(),
pinned_commit: pinned.map(|(_, id)| id),
pinned_checkout,
editable_after,
};
let comparison = self.build_comparison(&mut prepared, same)?;
if working_after && self.working_anchor()? != prepared.working_anchor {
return Err("Working checkout moved during comparison".into());
if self.working_anchor()? != checkout {
return Err("Checkout moved during comparison".into());
}
self.comparison = Some(prepared);
Ok(comparison)
}
/// Advance only a selected Working After, keeping its original Before frozen.
pub fn refresh_working_after(
/// Follow Working forward when enabled, or update a pinned After's eligibility.
/// Pinned sources and path catalogs never change when checkout eligibility does.
pub fn refresh_checkout(
&mut self,
comparison_id: u64,
new_id: u64,
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Option<Comparison>, String> {
let result = self.refresh_checkout_inner(comparison_id, new_id, follow_working, buffers);
if result.is_err()
&& let Some(prepared) = self.comparison.as_mut()
&& prepared.id == comparison_id
&& !prepared.working_after
{
// The frontend revokes on error; a recovered checkout must emit a
// fresh promotion even if its identity did not change meanwhile.
prepared.editable_after = false;
}
result
}
fn refresh_checkout_inner(
&mut self,
comparison_id: u64,
new_id: u64,
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Option<Comparison>, String> {
let Some(prepared) = self
.comparison
.as_ref()
.filter(|prepared| prepared.id == comparison_id && prepared.working_after)
.filter(|prepared| prepared.id == comparison_id)
else {
return Ok(None);
};
self.check_cancelled()?;
if prepared.working_after && !follow_working {
return Ok(None);
}
if prepared.pinned_checkout.is_none()
&& let Some(id) = prepared.pinned_commit.as_deref()
{
// A privately loaded endpoint may later be imported into active jj.
// Retry only its frozen commit ID, never the mutable remote alias.
let identity = self.pinned_checkout(id)?;
self.comparison.as_mut().unwrap().pinned_checkout = identity;
}
let prepared = self.comparison.as_ref().unwrap();
let anchor = self.working_anchor()?;
if !prepared.working_after {
let editable = prepared.pinned_checkout.is_some() && prepared.pinned_checkout == anchor;
if editable == prepared.editable_after {
return Ok(None);
}
// No live contents enter the frozen comparison, including dirty
// frontend buffer overlays.
let mut prepared = self.comparison.take().unwrap();
prepared.editable_after = editable;
let comparison = self.comparison_catalog(&prepared);
let result = self.check_cancelled().and_then(|()| {
if self.working_anchor()? != anchor {
return Err("Checkout moved during comparison".into());
}
Ok(comparison)
});
if result.is_ok() {
prepared.id = new_id;
}
self.comparison = Some(prepared);
return result.map(Some);
}
if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? {
return Ok(None);
}
@@ -1560,35 +1646,55 @@ impl Repository {
removed.insert(j);
}
}
let files: Vec<_> = files
prepared.files = files
.into_iter()
.enumerate()
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
.filter_map(|(i, file)| {
(!removed.contains(&i)).then_some((file.path, file.old_path, file.status))
})
.collect();
let editable_paths = if prepared.working_after {
files
Ok(self.comparison_catalog(prepared))
}
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison {
let files = prepared
.files
.iter()
.map(|(path, old_path, status)| ChangedFile {
path: path.clone(),
old_path: old_path.clone(),
status: *status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
})
.collect();
let editable_paths = if prepared.editable_after {
prepared
.files
.iter()
.filter(|file| {
right_view
.get(&file.path)
.filter(|(path, _, _)| {
prepared
.right
.get(path)
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
})
.map(|file| file.path.clone())
.map(|(path, _, _)| path.clone())
.collect()
} else {
BTreeSet::new()
};
prepared.files = files
.iter()
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
.collect();
Ok(Comparison {
Comparison {
before: prepared.before.clone(),
after: prepared.after.clone(),
files,
root: self.root.clone(),
editable_paths,
})
}
}
/// Prepare one catalog entry using its original comparison's frozen sources.
+27 -30
View File
@@ -84,12 +84,13 @@ impl DiffWorker {
} => repository
.compare(id, &before, &after, mode, &buffers)
.map(DiffOutcome::Comparison),
DiffOperation::RefreshWorking {
DiffOperation::RefreshCheckout {
comparison_id,
follow_working,
buffers,
} => repository
.refresh_working_after(comparison_id, id, &buffers)
.map(DiffOutcome::WorkingRefresh),
.refresh_checkout(comparison_id, id, follow_working, &buffers)
.map(DiffOutcome::CheckoutRefresh),
DiffOperation::LoadFile {
comparison_id,
file_index,
@@ -1151,14 +1152,13 @@ impl EditorSession {
self.editor.refresh_files()
}
fn refresh_working_diffs_at(&mut self, now: Instant) {
if !self.diff_follow_working_copy
|| now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1)
{
fn refresh_checkout_diffs_at(&mut self, now: Instant) {
if now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1) {
return;
}
self.last_diff_refresh = now;
self.editor.queue_working_refreshes();
self.editor
.queue_checkout_refreshes(self.diff_follow_working_copy);
}
/// Drains ready plugin work without waiting for command or server responses.
@@ -1170,7 +1170,7 @@ impl EditorSession {
let mut changed = self.refresh_files_at(now);
changed |= self.synchronize_documents();
changed |= self.poll_battery();
self.refresh_working_diffs_at(now);
self.refresh_checkout_diffs_at(now);
changed |= self.poll_diff();
for update in self.lsp.poll() {
match self.apply_update("core", update) {
@@ -1234,7 +1234,7 @@ mod tests {
};
#[test]
fn working_diff_polling_uses_injected_interval_and_never_queues_historical_or_disabled_views() {
fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() {
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
@@ -1279,27 +1279,24 @@ mod tests {
})),
);
let now = session.last_diff_refresh;
session.diff_follow_working_copy = false;
session.refresh_working_diffs_at(now + Duration::from_secs(1));
assert!(session.editor.take_next_diff_request().is_none());
session.diff_follow_working_copy = true;
session.refresh_working_diffs_at(now + Duration::from_millis(999));
assert!(session.editor.take_next_diff_request().is_none());
session.refresh_working_diffs_at(now + Duration::from_secs(1));
let request = session.editor.take_next_diff_request();
assert_eq!(request.is_some(), working);
if let Some((_, refresh, operation)) = request {
assert!(
matches!(operation, DiffOperation::RefreshWorking { comparison_id, .. }
if comparison_id == id)
);
session.refresh_working_diffs_at(now + Duration::from_secs(2));
for (tick, follow) in [(1, false), (2, true)] {
session.diff_follow_working_copy = follow;
session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1));
assert!(session.editor.take_next_diff_request().is_none());
assert!(
!session
.editor
.apply_diff_result(refresh, Ok(DiffOutcome::WorkingRefresh(None)))
);
session.refresh_checkout_diffs_at(now + Duration::from_secs(tick));
let request = session.editor.take_next_diff_request();
assert_eq!(request.is_some(), !working || follow);
if let Some((_, refresh, operation)) = request {
assert!(
matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. }
if comparison_id == id && follow_working == follow)
);
assert!(
!session
.editor
.apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None)))
);
}
}
}
}
+6 -4
View File
@@ -4864,7 +4864,7 @@ mod tests {
fn diff_borders_keep_full_revisions_and_each_panes_own_editability() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut panels = WheelPanels::default();
for case in ["working", "unlisted", "historical", "binary"] {
for case in ["working", "checked_out", "unlisted", "historical", "binary"] {
let mut file = syntax_diff_file("new.rs", "fn old() {}", "fn new() {}");
file.status = FileStatus::Renamed;
file.old_path = Some("old.rs".into());
@@ -4876,14 +4876,14 @@ mod tests {
let comparison = diff.comparison.as_mut().unwrap();
comparison.before = "abcdef0123456789 (baseline)".into();
comparison.after = "fedcba9876543210 (destination)".into();
if case != "unlisted" {
if matches!(case, "working" | "checked_out" | "binary") {
comparison.editable_paths.insert("new.rs".into());
}
if case == "historical" {
if matches!(case, "historical" | "checked_out") {
diff.after.as_mut().unwrap().target =
crate::diff::RevisionTarget::Commit("fedcba9876543210".into());
}
let writable = case == "working";
let writable = matches!(case, "working" | "checked_out");
assert_eq!(diff.after_editable(), writable, "{case}");
for focus in [Focus::Before, Focus::After] {
diff.focus = focus;
@@ -9737,6 +9737,7 @@ end
}
git(&["add", "."]);
git(&["commit", "-qm", "base"]);
git(&["commit", "--allow-empty", "-qm", "unmatched checkout"]);
for index in 0..8 {
directory.file(
&format!("file-{index}.txt"),
@@ -9763,6 +9764,7 @@ end
ready(&mut session, Phase::Before);
session.handle(Input::Enter);
session.handle(Input::Down);
session.handle(Input::Down);
session.handle(Input::Enter);
ready(&mut session, Phase::View);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();