fix: preserve review comments across unrelated edits
This commit is contained in:
@@ -1,10 +1,3 @@
|
||||
- [x] improve: checkmark persistance should be preserved in a diff where a certain file hasn't changed.
|
||||
- this is to avoid that changing a single file will cause all reviewed files to drop the reviewed state, making it harder to spot what actually requires a re-review and what can be ignored.
|
||||
- [x] bug: diff view - git blame doesn't show in diff buffers for some reason.
|
||||
- [x] feature: diff view - command to copy all review comments in the diff to the configured clipboard.
|
||||
- format should be the same as when printed via the cli
|
||||
- [x] improve: diff view - instead of a tab name following the format `Diff · +<N> -<M> · <filename>`, let's make it `Diff · <filename> +<N> -<M>`.
|
||||
- let's also insert another element in the top-right status bar that displays the total changes across the diff, so we don't have only per-file changes for the currently selected file
|
||||
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||
|
||||
+1
-1
@@ -502,7 +502,7 @@ The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewe
|
||||
|
||||
Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
|
||||
|
||||
Reopening requires the same repository root. Comments whose revision, path, or exact file text no longer matches are **stale**: their original anchors remain unchanged and their gutter markers disappear. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no automatic reanchoring, server, authentication, or review-publishing operation.
|
||||
Reopening requires the same repository root. Unchanged comment ranges follow edits elsewhere in the file, including inserted/deleted lines above and column shifts before a partial-line selection. Working-snapshot refreshes can retain these attachments; unrelated immutable revisions and paths do not match. Mapping requires an unchanged, unambiguous text region containing the selection. Modified/deleted selections or ambiguous repeated regions are **stale**: comments remain available through the overview and next/previous navigation, with original snapshot coordinates and no current-source marker or jump. Saved anchors and exported excerpts remain unchanged. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no server, authentication, or review-publishing operation.
|
||||
|
||||
Resolution is a manual human decision, independent of anchor freshness: a comment can be both resolved and stale. Overview entries and comment titles label resolved/unresolved status. Resolved comments remain in the overview and next/previous navigation, and can still be edited or reopened. Resolving never changes an anchor or deletes feedback.
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+7
-7
@@ -2895,15 +2895,15 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||
end: crate::plugin::TextPosition { row: 1, column: 0 },
|
||||
content: "new\n".into(),
|
||||
};
|
||||
assert!(state.review_anchor_current(&anchor));
|
||||
assert!(state.review_anchor_range(&anchor).is_some());
|
||||
state.live_files.insert(0);
|
||||
assert!(!state.review_anchor_current(&anchor));
|
||||
assert!(state.review_anchor_range(&anchor).is_none());
|
||||
state.comparison.as_mut().unwrap().files[0]
|
||||
.after
|
||||
.push(String::new());
|
||||
assert!(state.review_anchor_current(&anchor));
|
||||
assert!(state.review_anchor_range(&anchor).is_some());
|
||||
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
|
||||
assert!(!state.review_anchor_current(&anchor));
|
||||
assert!(state.review_anchor_range(&anchor).is_none());
|
||||
state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into());
|
||||
let before = Anchor {
|
||||
revision: "base".into(),
|
||||
@@ -2912,12 +2912,12 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||
content: "old\n".into(),
|
||||
..anchor
|
||||
};
|
||||
assert!(state.review_anchor_current(&before));
|
||||
assert!(state.review_anchor_range(&before).is_some());
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||
assert!(!state.review_anchor_current(&before));
|
||||
assert!(state.review_anchor_range(&before).is_none());
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = true;
|
||||
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
|
||||
assert!(!state.review_anchor_current(&before));
|
||||
assert!(state.review_anchor_range(&before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+85
-13
@@ -3282,6 +3282,85 @@ fn editing_a_reviewed_working_file_persists_its_invalidation() {
|
||||
assert!(store.load(&root, id).unwrap().reviewed_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
|
||||
use crate::review::{Anchor, Comment, Side};
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
fs::write(root.join("file.txt"), "header\nunique target\ntail\n").unwrap();
|
||||
let mut editor = editor();
|
||||
working_diff(&mut editor, &root, &["file.txt"]);
|
||||
editor.handle(Input::Enter);
|
||||
editor
|
||||
.diff
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.revisions[1] = "working:old".into();
|
||||
editor.diff.as_mut().unwrap().local_review.document = crate::review::Review::new(root);
|
||||
let anchor = Anchor {
|
||||
revision: "working:old".into(),
|
||||
path: "file.txt".into(),
|
||||
side: Side::After,
|
||||
start: TextPosition { row: 1, column: 7 },
|
||||
end: TextPosition { row: 1, column: 13 },
|
||||
content: editor.lines().join("\n"),
|
||||
};
|
||||
editor
|
||||
.diff
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.push(Comment {
|
||||
id: 1,
|
||||
anchor: anchor.clone(),
|
||||
body: "Feedback".into(),
|
||||
resolved: false,
|
||||
});
|
||||
editor
|
||||
.apply(vec![Effect::Replace(
|
||||
"inserted\nheader\nprefix unique target\ntail\n".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.refresh_live_diff();
|
||||
editor
|
||||
.diff
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.revisions[1] = "working:new".into();
|
||||
editor.execute("diff.review.edit 1");
|
||||
let owner = editor.diff_owner.unwrap();
|
||||
editor.select_pane(owner);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 14 });
|
||||
assert_eq!(
|
||||
editor.diff.as_ref().unwrap().local_review.document.comments[0].anchor,
|
||||
anchor
|
||||
);
|
||||
|
||||
editor
|
||||
.apply(vec![Effect::Replace(
|
||||
"inserted\nheader\nprefix unique changed\ntail\n".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.refresh_live_diff();
|
||||
set_carets(&mut editor, &[(0, 0)], 0);
|
||||
editor.execute("diff.review.edit 1");
|
||||
assert!(editor.message().contains("stale"));
|
||||
editor.select_pane(owner);
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
assert_eq!(
|
||||
editor.diff.as_ref().unwrap().local_review.document.comments[0].anchor,
|
||||
anchor
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
use crate::review::{Review, Store, Verdict};
|
||||
@@ -3362,10 +3441,11 @@ fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
assert_eq!(restored.storage_id, Some(id));
|
||||
assert_eq!(restored.comments[0].body, "First feedback revised");
|
||||
assert!(
|
||||
!reopened
|
||||
reopened
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.review_anchor_current(&restored.comments[0].anchor)
|
||||
.review_anchor_range(&restored.comments[0].anchor)
|
||||
.is_none()
|
||||
);
|
||||
reopened.execute("diff.review.new");
|
||||
let second_id = reopened
|
||||
@@ -3777,7 +3857,7 @@ fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
|
||||
};
|
||||
assert_eq!(anchor.start, start);
|
||||
assert_eq!(anchor.end, end);
|
||||
assert!(diff.review_anchor_current(anchor));
|
||||
assert!(diff.review_anchor_range(anchor).is_some());
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.pane_id(), owner);
|
||||
assert_eq!(
|
||||
@@ -3906,7 +3986,7 @@ fn local_review_visual_commands_and_palette_preserve_both_side_ranges() {
|
||||
Side::After
|
||||
}
|
||||
);
|
||||
assert!(diff.review_anchor_current(&anchor));
|
||||
assert!(diff.review_anchor_range(&anchor).is_some());
|
||||
let source_pane = editor.diff_owner.unwrap();
|
||||
editor.select_pane(source_pane);
|
||||
editor
|
||||
@@ -7511,15 +7591,7 @@ mod system_clipboard {
|
||||
let mut store = Store::open(&storage).unwrap();
|
||||
store.save(&mut review).unwrap();
|
||||
let id = review.storage_id.unwrap();
|
||||
let expected = format!(
|
||||
"Repository: {}\nReview {id} (revision 1): Review summary\n \
|
||||
Verdict: changes_requested; 1 unresolved, 1 resolved (2 total)\n\
|
||||
\nComment 1 [unresolved] file.txt (before) 0:1–1:1 @ base\n\
|
||||
e\u{301}👩💻\n界\nFirst\nfeedback\n\
|
||||
\nComment 2 [resolved] other.rs (after) 0:0–0:3 @ stale\n\
|
||||
old\nResolved feedback\n",
|
||||
root.display(),
|
||||
);
|
||||
let expected = review.format_text(true);
|
||||
let mut editor = fixture.editor();
|
||||
editor.set_clipboard_mode(mode).unwrap();
|
||||
editor.clipboard = vec!["internal".into()];
|
||||
|
||||
+131
-28
@@ -67,13 +67,18 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.reviewed_files, review.reviewed_files);
|
||||
assert_eq!(loaded.verdict, review.verdict);
|
||||
assert!(!loaded.comments[0].anchor.matches_lines(
|
||||
"working:new-snapshot",
|
||||
Path::new("日本語.rs"),
|
||||
Side::Before,
|
||||
&["changed".into()],
|
||||
true,
|
||||
));
|
||||
assert!(
|
||||
loaded.comments[0]
|
||||
.anchor
|
||||
.mapped_range(
|
||||
"working:new-snapshot",
|
||||
Path::new("日本語.rs"),
|
||||
Side::Before,
|
||||
&["changed".into()],
|
||||
true,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let path = root.join("export.json");
|
||||
loaded.save(&path).unwrap();
|
||||
let exported = fs::read(&path).unwrap();
|
||||
@@ -387,6 +392,92 @@ fn default_directory_uses_absolute_xdg_then_absolute_home() {
|
||||
assert!(resolve(None, Some("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_survives_unrelated_edit() {
|
||||
let anchor = Anchor {
|
||||
revision: "working:old".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start: crate::plugin::TextPosition { row: 1, column: 0 },
|
||||
end: crate::plugin::TextPosition { row: 1, column: 6 },
|
||||
content: "before\ntarget\nafter\n".into(),
|
||||
};
|
||||
let map = |text: &str| {
|
||||
anchor.mapped_range(
|
||||
"working:new",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&text
|
||||
.trim_end_matches('\n')
|
||||
.split('\n')
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>(),
|
||||
text.ends_with('\n'),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
map("changed\ntarget\nafter\n"),
|
||||
Some(anchor.start..anchor.end)
|
||||
);
|
||||
assert_eq!(
|
||||
map("inserted\nbefore\ntarget\nafter\n"),
|
||||
Some(
|
||||
crate::plugin::TextPosition { row: 2, column: 0 }..crate::plugin::TextPosition {
|
||||
row: 2,
|
||||
column: 6
|
||||
}
|
||||
)
|
||||
);
|
||||
assert!(map("before\ntargEt\nafter\n").is_none());
|
||||
assert!(map("before\nafter\n").is_none());
|
||||
assert!(map("before\ntarget\nafter\nbefore\ntarget\nafter\n").is_none());
|
||||
assert_eq!(map("target\nbefore\ntarget\nafter\n").unwrap().start.row, 2);
|
||||
assert_eq!(anchor.content, "before\ntarget\nafter\n");
|
||||
let partial = Anchor {
|
||||
content: "header\r\nprefix 👩💻e\u{301} suffix\r\nfooter\r\n".into(),
|
||||
start: crate::plugin::TextPosition { row: 1, column: 7 },
|
||||
end: crate::plugin::TextPosition { row: 1, column: 9 },
|
||||
..anchor.clone()
|
||||
};
|
||||
let moved = partial
|
||||
.mapped_range(
|
||||
"working:new",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&[
|
||||
"inserted\r".into(),
|
||||
"header\r".into(),
|
||||
"longer prefix 👩💻e\u{301} suffix\r".into(),
|
||||
"footer\r".into(),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved.start,
|
||||
crate::plugin::TextPosition { row: 2, column: 14 }
|
||||
);
|
||||
assert_eq!(
|
||||
moved.end,
|
||||
crate::plugin::TextPosition { row: 2, column: 16 }
|
||||
);
|
||||
assert!(
|
||||
partial
|
||||
.mapped_range(
|
||||
"other-commit",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&[
|
||||
"header\r".into(),
|
||||
"prefix 👩💻e\u{301} suffix\r".into(),
|
||||
"footer\r".into()
|
||||
],
|
||||
true
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
let directory = directory();
|
||||
@@ -413,27 +504,39 @@ fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.verdict, review.verdict);
|
||||
let anchor = &loaded.comments[0].anchor;
|
||||
assert!(!anchor.matches_lines(
|
||||
"working:other",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
));
|
||||
assert!(anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
));
|
||||
assert!(!anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
false
|
||||
));
|
||||
assert!(
|
||||
anchor
|
||||
.mapped_range(
|
||||
"working:other",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
anchor
|
||||
.mapped_range(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
anchor
|
||||
.mapped_range(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
false
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
loaded.summary = "Updated".into();
|
||||
loaded.save(&path).unwrap();
|
||||
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
||||
|
||||
+1
-1
@@ -3225,7 +3225,7 @@ fn local_review_markers_reject_stale_identity_text_and_rename_paths() {
|
||||
let anchor = &mut diff.local_review.document.comments[0].anchor;
|
||||
match mismatch {
|
||||
"revision" => anchor.revision.push_str("-newer"),
|
||||
"content" => anchor.content.push('\n'),
|
||||
"content" => anchor.content.replace_range(..1, "X"),
|
||||
"path" => anchor.path = "new.txt".into(),
|
||||
"side" => anchor.side = crate::review::Side::After,
|
||||
"root" => diff.local_review.document.root = "another-repository".into(),
|
||||
|
||||
Reference in New Issue
Block a user