fix: preserve review comments across unrelated edits

This commit is contained in:
Valdemar Lorenzen
2026-10-20 14:58:56 +02:00
parent 2727425518
commit 31203889bb
10 changed files with 422 additions and 147 deletions
+34 -27
View File
@@ -2160,38 +2160,45 @@ impl DiffState {
lines
}
pub fn review_anchor_current(&self, anchor: &crate::review::Anchor) -> bool {
pub fn review_anchor_range(
&self,
anchor: &crate::review::Anchor,
) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
use crate::review::Side;
let Some(comparison) = &self.comparison else {
return false;
return None;
};
if self.local_review.document.root != comparison.root {
return false;
return None;
}
comparison.files.iter().enumerate().any(|(index, file)| {
if !file.loaded || file.notice.is_some() {
return false;
}
let (path, lines, eof) = match anchor.side {
Side::Before => (
file.old_path.as_deref().unwrap_or(&file.path),
&file.before,
file.before_eof,
),
Side::After => (
file.path.as_path(),
&file.after,
file.after_eof && !self.live_files.contains(&index),
),
};
anchor.matches_lines(
&comparison.revisions[anchor.side.index()],
path,
anchor.side,
lines,
eof,
)
})
comparison
.files
.iter()
.enumerate()
.find_map(|(index, file)| {
if !file.loaded || file.notice.is_some() {
return None;
}
let (path, lines, eof) = match anchor.side {
Side::Before => (
file.old_path.as_deref().unwrap_or(&file.path),
&file.before,
file.before_eof,
),
Side::After => (
file.path.as_path(),
&file.after,
file.after_eof && !self.live_files.contains(&index),
),
};
anchor.mapped_range(
&comparison.revisions[anchor.side.index()],
path,
anchor.side,
lines,
eof,
)
})
}
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
+42 -35
View File
@@ -744,29 +744,35 @@ impl Editor {
} else {
"unresolved"
};
let mapped = diff.review_anchor_range(anchor);
let current = mapped.is_some();
let location = if current {
"current"
} else {
"original snapshot"
};
let range = mapped.unwrap_or(anchor.start..anchor.end);
let title = format!(
"Comment #{id} · {status} · {}:{}:{}–{}:{} · {:?} · :w saves review",
"Comment #{id} · {status} · {location} · {}:{}:{}–{}:{} · {:?} · :w saves review",
anchor.path.display(),
anchor.start.row + 1,
anchor.start.column + 1,
anchor.end.row + 1,
anchor.end.column + 1,
range.start.row + 1,
range.start.column + 1,
range.end.row + 1,
range.end.column + 1,
anchor.side
);
let target = diff.comparison.as_ref().and_then(|comparison| {
(comparison.revisions[anchor.side.index()] == anchor.revision)
.then(|| {
comparison.files.iter().position(|file| {
let path = if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
};
path == &anchor.path
})
})
.flatten()
comparison.files.iter().position(|file| {
let path = if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
};
path == &anchor.path
})
});
let stale = !current
&& target.is_none_or(|index| diff.comparison.as_ref().unwrap().files[index].loaded);
let start = anchor.start;
let owner = self.diff_owner.unwrap();
self.select_pane(owner);
@@ -781,7 +787,7 @@ impl Editor {
self.place_navigation(start);
}
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
if target.is_none() {
if stale {
self.set_message("Comment is stale; original snapshot anchor preserved");
}
Ok(())
@@ -821,11 +827,11 @@ impl Editor {
if selected_path != Some(&comment.anchor.path) {
return;
}
if !diff.review_anchor_current(&comment.anchor) {
let Some(range) = diff.review_anchor_range(&comment.anchor) else {
self.set_message("Comment is stale; original snapshot anchor preserved");
return;
}
let position = comment.anchor.start;
};
let position = range.start;
let focus = if comment.anchor.side == Side::Before {
Focus::Before
} else {
@@ -867,18 +873,19 @@ impl Editor {
);
for comment in &state.document.comments {
let anchor = &comment.anchor;
let status = if diff.review_anchor_current(anchor) {
let mapped = diff.review_anchor_range(anchor);
let range = mapped.clone().unwrap_or(anchor.start..anchor.end);
let status = if mapped.is_some() {
"current"
} else if diff.comparison.as_ref().is_some_and(|comparison| {
comparison.revisions[anchor.side.index()] == anchor.revision
&& comparison.files.iter().any(|file| {
!file.loaded
&& if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
} else {
file.path == anchor.path
}
})
comparison.files.iter().any(|file| {
!file.loaded
&& if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
} else {
file.path == anchor.path
}
})
}) {
"not checked (file not loaded)"
} else {
@@ -895,10 +902,10 @@ impl Editor {
"\n## Comment #{} · {resolution} · {status}\n{}:{}:{}–{}:{} · {:?}\nRevision: {}\n{}\n",
comment.id,
anchor.path.display(),
anchor.start.row + 1,
anchor.start.column + 1,
anchor.end.row + 1,
anchor.end.column + 1,
range.start.row + 1,
range.start.column + 1,
range.end.row + 1,
range.end.column + 1,
anchor.side,
anchor.revision,
comment.body
+87 -9
View File
@@ -76,21 +76,99 @@ impl Anchor {
&self.content[offset(self.start)..offset(self.end)]
}
pub fn matches_lines(
/// Map an unchanged selection through edits without changing its saved snapshot.
pub fn mapped_range(
&self,
revision: &str,
path: &Path,
side: Side,
lines: &[String],
eof: bool,
) -> bool {
self.revision == revision
&& self.path == path
&& self.side == side
&& self.content.split('\n').eq(lines
.iter()
.map(String::as_str)
.chain((eof || lines.is_empty()).then_some("")))
) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
use crate::plugin::TextPosition;
if self.path != path
|| self.side != side
|| (self.revision != revision
&& !(self.revision.starts_with("working:") && revision.starts_with("working:")))
{
return None;
}
if self.content.split('\n').eq(lines
.iter()
.map(String::as_str)
.chain((eof || lines.is_empty()).then_some("")))
{
return Some(self.start..self.end);
}
let mut content = lines.join("\n");
if eof && !lines.is_empty() {
content.push('\n');
}
// Split LF explicitly: editor columns count a retained CR separately.
fn tokens(text: &str) -> Vec<&str> {
text.split_inclusive('\n')
.flat_map(|line| {
line.strip_suffix('\n')
.unwrap_or(line)
.graphemes(true)
.chain(line.ends_with('\n').then_some("\n"))
})
.collect()
}
let before = tokens(&self.content);
let after = tokens(&content);
let index = |position: TextPosition| {
self.content
.split('\n')
.take(position.row)
.map(|line| line.graphemes(true).count() + 1)
.sum::<usize>()
+ position.column
};
let start = index(self.start);
let end = index(self.end);
if start == end {
return None;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(10);
let (mut old, mut new) = (0, 0);
for op in similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&before,
&after,
Some(deadline),
) {
let (tag, old_range, new_range) = op.as_tag_tuple();
let old_end = old + old_range.len();
if tag == similar::DiffTag::Equal && start >= old && end <= old_end {
// Use the unchanged context, not just potentially repeated selected text.
let byte_start: usize = before[..old].iter().map(|text| text.len()).sum();
let byte_len: usize = before[old..old_end].iter().map(|text| text.len()).sum();
let context = &self.content[byte_start..byte_start + byte_len];
if self.content.match_indices(context).take(2).count() != 1
|| content.match_indices(context).take(2).count() != 1
{
return None;
}
let mapped = new + start - old;
let position = |offset: usize| {
let mut position = TextPosition { row: 0, column: 0 };
for grapheme in &after[..offset] {
if *grapheme == "\n" {
position.row += 1;
position.column = 0;
} else {
position.column += 1;
}
}
position
};
return Some(position(mapped)..position(mapped + end - start));
}
old = old_end;
new += new_range.len();
}
None
}
}
+34 -19
View File
@@ -4366,16 +4366,18 @@ fn draw_diff_pane(
.document
.comments
.iter()
.filter(|comment| {
.filter_map(|comment| {
let before = comment.anchor.side == crate::review::Side::Before;
let path = if before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
};
(unified || before == (focus == Focus::Before))
&& comment.anchor.path == *path
&& diff.review_anchor_current(&comment.anchor)
if !(unified || before == (focus == Focus::Before)) || comment.anchor.path != *path {
return None;
}
diff.review_anchor_range(&comment.anchor)
.map(|range| (comment, range))
});
paint_diff_content(
frame,
@@ -4407,7 +4409,12 @@ fn paint_diff_content<'a>(
highlight_trailing_whitespace: bool,
whitespace_characters: Option<&BTreeMap<char, char>>,
whitespace_scope: WhitespaceScope,
review_comments: impl Iterator<Item = &'a crate::review::Comment> + Clone,
review_comments: impl Iterator<
Item = (
&'a crate::review::Comment,
std::ops::Range<crate::plugin::TextPosition>,
),
> + Clone,
selected_review: Option<u64>,
) -> Rect {
if view.info.display_len == 0 {
@@ -4430,8 +4437,7 @@ fn paint_diff_content<'a>(
let width = usize::from(content.width).saturating_sub(gutter);
let selected_anchor = review_comments
.clone()
.find(|comment| selected_review == Some(comment.id))
.map(|comment| &comment.anchor);
.find(|(comment, _)| selected_review == Some(comment.id));
for (screen, display_row, segments) in
diff_screen_rows(view.info.display_len, top, content.height, wrapped)
{
@@ -4584,22 +4590,24 @@ fn paint_diff_content<'a>(
};
paint_trailing_whitespace(frame, &text, text_area, left);
}
if let Some(anchor) = selected_anchor {
let side = match anchor.side {
if let Some((comment, range)) = &selected_anchor {
let side = match comment.anchor.side {
crate::review::Side::Before => Focus::Before,
crate::review::Side::After => Focus::After,
};
if let Some(source_row) = view
.review_row(side, display_row)
.filter(|source_row| anchor.rows().contains(source_row))
{
let start = if source_row == anchor.start.row {
anchor.start.column
if let Some(source_row) = view.review_row(side, display_row).filter(|source_row| {
(range.start.row
..=range.end.row
- usize::from(range.end.column == 0 && range.end.row > range.start.row))
.contains(source_row)
}) {
let start = if source_row == range.start.row {
range.start.column
} else {
0
};
let end = if source_row == anchor.end.row {
anchor.end.column
let end = if source_row == range.end.row {
range.end.column
} else {
usize::MAX
};
@@ -4615,7 +4623,7 @@ fn paint_diff_content<'a>(
}
}
if gutter > 0 {
for comment in review_comments {
for (comment, range) in review_comments {
let side = match comment.anchor.side {
crate::review::Side::Before => Focus::Before,
crate::review::Side::After => Focus::After,
@@ -4626,7 +4634,14 @@ fn paint_diff_content<'a>(
{
let source = view.review_row(side, display);
if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0)
|| !source.is_some_and(|row| comment.anchor.rows().contains(&row))
|| !source.is_some_and(|row| {
(range.start.row
..=range.end.row
- usize::from(
range.end.column == 0 && range.end.row > range.start.row,
))
.contains(&row)
})
{
continue;
}