fix: preserve review comments across unrelated edits
This commit is contained in:
+34
-27
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user