feat: anchor review comments to precise text ranges
This commit is contained in:
+2
-2
@@ -5033,8 +5033,8 @@ mod tests {
|
||||
revision: "working:snapshot".into(),
|
||||
path: "file".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 1,
|
||||
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
||||
end: crate::plugin::TextPosition { row: 1, column: 0 },
|
||||
content: "new\n".into(),
|
||||
};
|
||||
assert!(state.review_anchor_current(&anchor));
|
||||
|
||||
+78
-1
@@ -15990,6 +15990,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
|
||||
for focus in [Focus::Before, Focus::After] {
|
||||
for keys in ["lvl", "llvh", "llvj"] {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&root,
|
||||
vec!["p界e\u{301}z".into(), "before".into()],
|
||||
vec!["q界e\u{301}z".into(), "after!".into()],
|
||||
);
|
||||
editor.diff.as_mut().unwrap().focus = focus;
|
||||
editor.bind_diff_buffer();
|
||||
let owner = editor.pane_id();
|
||||
type_text(&mut editor, keys);
|
||||
type_text(&mut editor, ":diff.comment");
|
||||
editor.handle(Input::Enter);
|
||||
let diff = editor.workspace_diff().unwrap();
|
||||
let anchor = &diff.local_review.document.comments[0].anchor;
|
||||
let (start, end) = if keys == "llvj" {
|
||||
(
|
||||
TextPosition { row: 0, column: 2 },
|
||||
TextPosition { row: 1, column: 3 },
|
||||
)
|
||||
} else {
|
||||
(
|
||||
TextPosition { row: 0, column: 1 },
|
||||
TextPosition { row: 0, column: 3 },
|
||||
)
|
||||
};
|
||||
assert_eq!(anchor.start, start);
|
||||
assert_eq!(anchor.end, end);
|
||||
assert!(diff.review_anchor_current(anchor));
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.pane_id(), owner);
|
||||
assert_eq!(
|
||||
editor.cursor(),
|
||||
Cursor {
|
||||
row: start.row,
|
||||
column: start.column
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
for text in ["last", "one\nlast", "one\n", ""] {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut editor = editor();
|
||||
historical_diff(&mut editor, &root, vec!["before".into()], split_lines(text));
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
editor.execute("diff.comment");
|
||||
let anchor = &editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.anchor;
|
||||
assert_eq!(anchor.start, TextPosition { row: 0, column: 0 });
|
||||
assert_eq!(
|
||||
anchor.end,
|
||||
if text.contains('\n') {
|
||||
TextPosition { row: 1, column: 0 }
|
||||
} else {
|
||||
TextPosition {
|
||||
row: 0,
|
||||
column: grapheme_count(text),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_commands_preserve_directed_ranges_on_cancel_and_plugin_dispatch() {
|
||||
for shape in ["vll", "vhh", "Vj"] {
|
||||
@@ -16059,7 +16135,8 @@ mod tests {
|
||||
editor.handle(Input::Enter);
|
||||
let diff = editor.workspace_diff().unwrap();
|
||||
let anchor = diff.local_review.document.comments[0].anchor.clone();
|
||||
assert_eq!((anchor.start_line, anchor.end_line), (1, 2));
|
||||
assert_eq!(anchor.start, TextPosition { row: 0, column: 0 });
|
||||
assert_eq!(anchor.end, TextPosition { row: 2, column: 0 });
|
||||
assert_eq!(
|
||||
anchor.side,
|
||||
if focus == Focus::Before {
|
||||
|
||||
+38
-18
@@ -341,14 +341,24 @@ impl Editor {
|
||||
.filter(|_| self.diff_interacting())
|
||||
.ok_or("Focus a diff text pane to add a comment")?;
|
||||
if self.selection_count() != 1 {
|
||||
return Err("Use one caret or one line range for a review comment".into());
|
||||
return Err("Use one caret or one contiguous selection for a review comment".into());
|
||||
}
|
||||
let selection = self.selections().into_iter().next();
|
||||
let (first, last) = selection.map_or((self.cursor().row, self.cursor().row), |selection| {
|
||||
let last = selection.end.row.saturating_sub(usize::from(
|
||||
selection.end.column == 0 && selection.end.row > selection.start.row,
|
||||
));
|
||||
(selection.start.row, last)
|
||||
let range = self.selections().into_iter().next().unwrap_or_else(|| {
|
||||
let row = self.cursor().row;
|
||||
Selection {
|
||||
start: Cursor { row, column: 0 },
|
||||
end: if row + 1 < self.active.lines.len() {
|
||||
Cursor {
|
||||
row: row + 1,
|
||||
column: 0,
|
||||
}
|
||||
} else {
|
||||
Cursor {
|
||||
row,
|
||||
column: grapheme_count(&self.active.lines[row]),
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
let diff = self.diff.as_ref().unwrap();
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
@@ -367,8 +377,14 @@ impl Editor {
|
||||
}
|
||||
.clone(),
|
||||
side,
|
||||
start_line: first + 1,
|
||||
end_line: last + 1,
|
||||
start: TextPosition {
|
||||
row: range.start.row,
|
||||
column: range.start.column,
|
||||
},
|
||||
end: TextPosition {
|
||||
row: range.end.row,
|
||||
column: range.end.column,
|
||||
},
|
||||
content: self.active.lines.join("\n"),
|
||||
};
|
||||
let id = diff
|
||||
@@ -405,10 +421,12 @@ impl Editor {
|
||||
let text = comment.body.clone();
|
||||
let anchor = &comment.anchor;
|
||||
let title = format!(
|
||||
"Comment #{id} · {}:{}–{} · {:?} · :w saves review",
|
||||
"Comment #{id} · {}:{}:{}–{}:{} · {:?} · :w saves review",
|
||||
anchor.path.display(),
|
||||
anchor.start_line,
|
||||
anchor.end_line,
|
||||
anchor.start.row + 1,
|
||||
anchor.start.column + 1,
|
||||
anchor.end.row + 1,
|
||||
anchor.end.column + 1,
|
||||
anchor.side
|
||||
);
|
||||
let target = diff.comparison.as_ref().and_then(|comparison| {
|
||||
@@ -479,16 +497,16 @@ impl Editor {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
return;
|
||||
}
|
||||
let row = comment.anchor.start_line - 1;
|
||||
let position = comment.anchor.start;
|
||||
let focus = if comment.anchor.side == Side::Before {
|
||||
Focus::Before
|
||||
} else {
|
||||
Focus::After
|
||||
};
|
||||
diff.focus = focus;
|
||||
diff.reveal_source_row(focus, row);
|
||||
diff.reveal_source_row(focus, position.row);
|
||||
self.bind_diff_buffer();
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
self.place_navigation(position);
|
||||
}
|
||||
|
||||
fn open_review_overview(&mut self) -> Result<(), String> {
|
||||
@@ -529,11 +547,13 @@ impl Editor {
|
||||
use std::fmt::Write as _;
|
||||
write!(
|
||||
text,
|
||||
"\n## Comment #{} · {status}\n{}:{}–{} · {:?}\nRevision: {}\n{}\n",
|
||||
"\n## Comment #{} · {status}\n{}:{}:{}–{}:{} · {:?}\nRevision: {}\n{}\n",
|
||||
comment.id,
|
||||
anchor.path.display(),
|
||||
anchor.start_line,
|
||||
anchor.end_line,
|
||||
anchor.start.row + 1,
|
||||
anchor.start.column + 1,
|
||||
anchor.end.row + 1,
|
||||
anchor.end.column + 1,
|
||||
anchor.side,
|
||||
anchor.revision,
|
||||
comment.body
|
||||
|
||||
+2
-1
@@ -83,7 +83,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
Local reviews: :diff.comment at caret or on a Visual/Visual Line selection
|
||||
Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the current line
|
||||
Selected comments highlight their precise text; old line-only JSON reviews migrate on load
|
||||
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
|
||||
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
|
||||
:diff.review.save PATH / :diff.review.open PATH; :w in floating review editors saves local JSON
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ pub enum DiagnosticSeverity {
|
||||
}
|
||||
|
||||
/// Zero-based extended-grapheme positions, independent of any server encoding.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, Ord, PartialEq, PartialOrd)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TextPosition {
|
||||
pub row: usize,
|
||||
|
||||
+290
-21
@@ -10,6 +10,7 @@ use std::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
const LIMIT: usize = 64 * 1024 * 1024;
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -43,13 +44,19 @@ pub struct Anchor {
|
||||
pub revision: String,
|
||||
pub path: PathBuf,
|
||||
pub side: Side,
|
||||
pub start_line: usize,
|
||||
pub end_line: usize,
|
||||
/// Zero-based extended-grapheme bounds; `end` is exclusive.
|
||||
pub start: crate::plugin::TextPosition,
|
||||
pub end: crate::plugin::TextPosition,
|
||||
/// Exact whole-side text, including its final newline when present.
|
||||
// ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters.
|
||||
pub content: String,
|
||||
}
|
||||
impl Anchor {
|
||||
pub fn rows(&self) -> std::ops::RangeInclusive<usize> {
|
||||
let end = self.end.row - usize::from(self.end.column == 0 && self.end.row > self.start.row);
|
||||
self.start.row..=end
|
||||
}
|
||||
|
||||
pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool {
|
||||
self.revision == revision && self.path == path && self.side == side && self.content == text
|
||||
}
|
||||
@@ -91,6 +98,91 @@ pub struct Review {
|
||||
#[serde(skip)]
|
||||
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LegacyReview {
|
||||
version: u32,
|
||||
root: PathBuf,
|
||||
summary: String,
|
||||
verdict: Option<Verdict>,
|
||||
comments: Vec<LegacyComment>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LegacyComment {
|
||||
id: u64,
|
||||
anchor: LegacyAnchor,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LegacyAnchor {
|
||||
revision: String,
|
||||
path: PathBuf,
|
||||
side: Side,
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl LegacyReview {
|
||||
fn migrate(self) -> Result<Review, String> {
|
||||
if self.version != 1 {
|
||||
return Err(format!("Unsupported review version {}", self.version));
|
||||
}
|
||||
let comments = self
|
||||
.comments
|
||||
.into_iter()
|
||||
.map(|comment| {
|
||||
let old = comment.anchor;
|
||||
if old.start_line == 0 || old.end_line < old.start_line {
|
||||
return Err("Legacy review ranges must be inclusive and nonzero".into());
|
||||
}
|
||||
let mut lines = old.content.split('\n');
|
||||
let last = lines
|
||||
.nth(old.end_line - 1)
|
||||
.ok_or("Legacy review range is outside the anchor text")?;
|
||||
let end = if lines.next().is_some() {
|
||||
crate::plugin::TextPosition {
|
||||
row: old.end_line,
|
||||
column: 0,
|
||||
}
|
||||
} else {
|
||||
crate::plugin::TextPosition {
|
||||
row: old.end_line - 1,
|
||||
column: last.graphemes(true).count(),
|
||||
}
|
||||
};
|
||||
Ok(Comment {
|
||||
id: comment.id,
|
||||
anchor: Anchor {
|
||||
revision: old.revision,
|
||||
path: old.path,
|
||||
side: old.side,
|
||||
start: crate::plugin::TextPosition {
|
||||
row: old.start_line - 1,
|
||||
column: 0,
|
||||
},
|
||||
end,
|
||||
content: old.content,
|
||||
},
|
||||
body: comment.body,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
Ok(Review {
|
||||
version: 2,
|
||||
root: self.root,
|
||||
summary: self.summary,
|
||||
verdict: self.verdict,
|
||||
comments,
|
||||
saved: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Default for Review {
|
||||
fn default() -> Self {
|
||||
Self::new(PathBuf::new())
|
||||
@@ -114,7 +206,7 @@ impl Review {
|
||||
fs::canonicalize(&root).unwrap_or(root)
|
||||
};
|
||||
Self {
|
||||
version: 1,
|
||||
version: 2,
|
||||
root,
|
||||
summary: String::new(),
|
||||
verdict: None,
|
||||
@@ -124,7 +216,7 @@ impl Review {
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 1 {
|
||||
if self.version != 2 {
|
||||
return Err(format!("Unsupported review version {}", self.version));
|
||||
}
|
||||
if !self.root.is_absolute() || !self.root.is_dir() {
|
||||
@@ -143,13 +235,28 @@ impl Review {
|
||||
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
||||
return Err("Review anchors require an immutable revision identity".into());
|
||||
}
|
||||
if anchor.start_line == 0
|
||||
|| anchor.end_line < anchor.start_line
|
||||
|| anchor.end_line > anchor.content.split('\n').count()
|
||||
{
|
||||
return Err(
|
||||
"Review ranges must be inclusive, nonzero and within the anchor text".into(),
|
||||
);
|
||||
if anchor.start > anchor.end {
|
||||
return Err("Review ranges must be ordered and end-exclusive".into());
|
||||
}
|
||||
let mut end_exists = false;
|
||||
for (row, line) in anchor.content.split('\n').enumerate() {
|
||||
if row == anchor.start.row || row == anchor.end.row {
|
||||
let columns = line.graphemes(true).count();
|
||||
if (row == anchor.start.row && anchor.start.column > columns)
|
||||
|| (row == anchor.end.row && anchor.end.column > columns)
|
||||
{
|
||||
return Err(
|
||||
"Review columns must be within the anchor's grapheme text".into()
|
||||
);
|
||||
}
|
||||
}
|
||||
if row == anchor.end.row {
|
||||
end_exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !end_exists {
|
||||
return Err("Review rows must be within the anchor text".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -157,8 +264,20 @@ impl Review {
|
||||
|
||||
pub fn load(path: &Path, root: &Path) -> Result<Self, String> {
|
||||
let bytes = read_file(path)?.ok_or("Review file does not exist")?;
|
||||
let mut review: Self = serde_json::from_slice(&bytes)
|
||||
#[derive(Deserialize)]
|
||||
struct Version {
|
||||
version: u32,
|
||||
}
|
||||
let Version { version } = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
||||
let mut review: Self = match version {
|
||||
1 => serde_json::from_slice::<LegacyReview>(&bytes)
|
||||
.map_err(|error| format!("Invalid review JSON: {error}"))?
|
||||
.migrate()?,
|
||||
2 => serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("Invalid review JSON: {error}"))?,
|
||||
_ => return Err(format!("Unsupported review version {version}")),
|
||||
};
|
||||
review.validate()?;
|
||||
let expected = fs::canonicalize(root).map_err(|error| error.to_string())?;
|
||||
let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?;
|
||||
@@ -278,14 +397,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
fn directory() -> Directory {
|
||||
let directory = Directory(std::env::temp_dir().join(format!(
|
||||
"vamp-review-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
||||
)));
|
||||
fs::create_dir(&directory.0).unwrap();
|
||||
directory
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let mut review = Review::new(root.clone());
|
||||
@@ -297,8 +421,8 @@ mod tests {
|
||||
revision: "working:immutable".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 2,
|
||||
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
||||
end: crate::plugin::TextPosition { row: 2, column: 0 },
|
||||
content: "first\nlast\n".into(),
|
||||
},
|
||||
body: "Please adjust this".into(),
|
||||
@@ -340,7 +464,7 @@ mod tests {
|
||||
assert!(Review::new(root.clone()).save(&path).is_err());
|
||||
let valid = fs::read(&path).unwrap();
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(2)),
|
||||
("version", serde_json::json!(3)),
|
||||
("root", serde_json::json!(root.join("other"))),
|
||||
(
|
||||
"comments",
|
||||
@@ -356,9 +480,9 @@ mod tests {
|
||||
("path", serde_json::json!("../outside")),
|
||||
("path", serde_json::json!("/absolute")),
|
||||
("path", serde_json::json!(".git/config")),
|
||||
("start_line", serde_json::json!(0)),
|
||||
("start_line", serde_json::json!(3)),
|
||||
("end_line", serde_json::json!(4)),
|
||||
("start", serde_json::json!({"row": 2, "column": 1})),
|
||||
("start", serde_json::json!({"row": 3, "column": 0})),
|
||||
("end", serde_json::json!({"row": 3, "column": 0})),
|
||||
("revision", serde_json::json!("")),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
@@ -374,7 +498,7 @@ mod tests {
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
assert_eq!(loaded.summary, "Updated");
|
||||
let invalid = root.join("invalid.json");
|
||||
loaded.comments[0].anchor.start_line = 0;
|
||||
loaded.comments[0].anchor.start.row = usize::MAX;
|
||||
assert!(loaded.save(&invalid).is_err());
|
||||
assert!(!invalid.exists());
|
||||
let large = root.join("large.json");
|
||||
@@ -392,4 +516,149 @@ mod tests {
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_lines_migrate_losslessly_and_save_exact_v2_ranges() {
|
||||
use crate::plugin::TextPosition;
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let cases = [
|
||||
("first\nlast\n", 1, 2, (0, 0), (2, 0), 0..=1),
|
||||
("first\nlast", 2, 2, (1, 0), (1, 4), 1..=1),
|
||||
("é👩🔬e\u{301}", 1, 1, (0, 0), (0, 3), 0..=0),
|
||||
("first\n\n", 2, 2, (1, 0), (2, 0), 1..=1),
|
||||
("first\n", 2, 2, (1, 0), (1, 0), 1..=1),
|
||||
("", 1, 1, (0, 0), (0, 0), 0..=0),
|
||||
];
|
||||
let comments: Vec<_> = cases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, case)| {
|
||||
serde_json::json!({
|
||||
"id": index + 11,
|
||||
"anchor": {
|
||||
"revision": "base:immutable",
|
||||
"path": "old/name.rs",
|
||||
"side": "before",
|
||||
"start_line": case.1,
|
||||
"end_line": case.2,
|
||||
"content": case.0,
|
||||
},
|
||||
"body": format!("Comment {index}"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let original = serde_json::json!({
|
||||
"version": 1,
|
||||
"root": root,
|
||||
"summary": "Keep this summary",
|
||||
"verdict": "looks_good",
|
||||
"comments": comments,
|
||||
});
|
||||
fs::write(&path, serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
let migrated = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(migrated.version, 2);
|
||||
assert_eq!(migrated.root, root);
|
||||
assert_eq!(migrated.summary, "Keep this summary");
|
||||
assert_eq!(migrated.verdict, Some(Verdict::LooksGood));
|
||||
for (index, (comment, case)) in migrated.comments.iter().zip(&cases).enumerate() {
|
||||
assert_eq!(comment.id, index as u64 + 11);
|
||||
assert_eq!(comment.body, format!("Comment {index}"));
|
||||
assert_eq!(comment.anchor.revision, "base:immutable");
|
||||
assert_eq!(comment.anchor.path, Path::new("old/name.rs"));
|
||||
assert_eq!(comment.anchor.side, Side::Before);
|
||||
assert_eq!(comment.anchor.content, case.0);
|
||||
assert_eq!(
|
||||
comment.anchor.start,
|
||||
TextPosition {
|
||||
row: case.3.0,
|
||||
column: case.3.1
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
comment.anchor.end,
|
||||
TextPosition {
|
||||
row: case.4.0,
|
||||
column: case.4.1
|
||||
}
|
||||
);
|
||||
assert_eq!(comment.anchor.rows(), case.5);
|
||||
}
|
||||
migrated.save(&path).unwrap();
|
||||
let saved: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["version"], 2);
|
||||
assert!(saved["comments"][0]["anchor"].get("start_line").is_none());
|
||||
assert!(saved["comments"][0]["anchor"].get("end_line").is_none());
|
||||
assert_eq!(
|
||||
Review::load(&path, &root).unwrap().comments,
|
||||
migrated.comments
|
||||
);
|
||||
|
||||
for (start, end) in [(0, 1), (2, 1), (1, 4)] {
|
||||
let mut invalid = original.clone();
|
||||
invalid["comments"][0]["anchor"]["start_line"] = start.into();
|
||||
invalid["comments"][0]["anchor"]["end_line"] = end.into();
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
let mut mixed = original.clone();
|
||||
mixed["comments"][0]["anchor"]["start"] = serde_json::json!({"row": 0, "column": 0});
|
||||
fs::write(&path, serde_json::to_vec(&mixed).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
let mut wrong_version = original;
|
||||
wrong_version["version"] = 2.into();
|
||||
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
|
||||
use crate::plugin::TextPosition;
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let mut review = Review::new(root.clone());
|
||||
review.comments.push(Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "working:immutable".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start: TextPosition { row: 0, column: 1 },
|
||||
end: TextPosition { row: 1, column: 1 },
|
||||
content: "a👩🔬e\u{301}\n界x\n".into(),
|
||||
},
|
||||
body: "Exact text".into(),
|
||||
});
|
||||
review.save(&path).unwrap();
|
||||
let mut loaded = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.comments[0].anchor.rows(), 0..=1);
|
||||
let valid = serde_json::to_value(&review).unwrap();
|
||||
for (field, value) in [
|
||||
("start", serde_json::json!({"row": 0, "column": 4})),
|
||||
("end", serde_json::json!({"row": 1, "column": 3})),
|
||||
("end", serde_json::json!({"row": 2, "column": 1})),
|
||||
("end", serde_json::json!({"row": 3, "column": 0})),
|
||||
("end", serde_json::json!({"row": 0, "column": 0})),
|
||||
("start", serde_json::json!({"row": -1, "column": 0})),
|
||||
(
|
||||
"start",
|
||||
serde_json::json!({"row": 0, "column": 0, "extra": 0}),
|
||||
),
|
||||
("start_line", serde_json::json!(1)),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid["comments"][0]["anchor"][field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err(), "{field}");
|
||||
}
|
||||
let mut wrong_version = valid;
|
||||
wrong_version["version"] = 1.into();
|
||||
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
|
||||
assert!(loaded.save(&root.join("backwards.json")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+265
-24
@@ -4396,6 +4396,25 @@ fn draw_diff_pane(
|
||||
&& comment.anchor.path == *path
|
||||
&& diff.review_anchor_current(&comment.anchor)
|
||||
});
|
||||
let selected_anchor = review_comments
|
||||
.clone()
|
||||
.find(|comment| diff.local_review.selected == Some(comment.id))
|
||||
.map(|comment| &comment.anchor);
|
||||
let review_source_row = |side, display| {
|
||||
diff.display_source_row(side, display).or_else(|| {
|
||||
let DisplayRow::Line(index) = diff.display_rows[display] else {
|
||||
return None;
|
||||
};
|
||||
let row = &diff.rows[index];
|
||||
(unified && row.kind == RowKind::Equal)
|
||||
.then_some(match side {
|
||||
Focus::Before => row.before,
|
||||
Focus::After => row.after,
|
||||
Focus::Files => None,
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||
let row = &diff.display_rows[display_row];
|
||||
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
|
||||
@@ -4566,6 +4585,34 @@ fn draw_diff_pane(
|
||||
};
|
||||
paint_trailing_whitespace(frame, &text, text_area, left);
|
||||
}
|
||||
if let Some(anchor) = selected_anchor {
|
||||
let side = match anchor.side {
|
||||
crate::review::Side::Before => Focus::Before,
|
||||
crate::review::Side::After => Focus::After,
|
||||
};
|
||||
if let Some(source_row) = review_source_row(side, display_row)
|
||||
.filter(|source_row| anchor.rows().contains(source_row))
|
||||
{
|
||||
let start = if source_row == anchor.start.row {
|
||||
anchor.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = if source_row == anchor.end.row {
|
||||
anchor.end.column
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
paint_grapheme_range(
|
||||
frame,
|
||||
text_area,
|
||||
left,
|
||||
source_text,
|
||||
start..end,
|
||||
Style::default().bg(Color::Magenta),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if gutter > 0 {
|
||||
for comment in review_comments {
|
||||
@@ -4575,23 +4622,9 @@ fn draw_diff_pane(
|
||||
};
|
||||
let selected = diff.local_review.selected == Some(comment.id);
|
||||
for (screen, display, _) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||
let source = diff.display_source_row(side, display).or_else(|| {
|
||||
let DisplayRow::Line(index) = diff.display_rows[display] else {
|
||||
return None;
|
||||
};
|
||||
let row = &diff.rows[index];
|
||||
(unified && row.kind == RowKind::Equal)
|
||||
.then_some(match side {
|
||||
Focus::Before => row.before,
|
||||
Focus::After => row.after,
|
||||
Focus::Files => None,
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
let source = review_source_row(side, display);
|
||||
if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0)
|
||||
|| !source.is_some_and(|row| {
|
||||
(comment.anchor.start_line..=comment.anchor.end_line).contains(&(row + 1))
|
||||
})
|
||||
|| !source.is_some_and(|row| comment.anchor.rows().contains(&row))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -9349,8 +9382,14 @@ mod tests {
|
||||
|
||||
fn commented_diff() -> DiffState {
|
||||
use crate::review::{Anchor, Comment, Side};
|
||||
let before = format!("{}\nold second\nkeep", "old ".repeat(24));
|
||||
let after = format!("{}\nnew second\nkeep", "new ".repeat(24));
|
||||
let before = format!(
|
||||
"{}\nold e\u{301}界 second\nkeep",
|
||||
"old e\u{301}界 ".repeat(12)
|
||||
);
|
||||
let after = format!(
|
||||
"{}\nnew e\u{301}界 second\nkeep",
|
||||
"new e\u{301}界 ".repeat(12)
|
||||
);
|
||||
let mut file = syntax_diff_file("new.txt", &before, &after);
|
||||
file.hunks =
|
||||
crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
|
||||
@@ -9365,8 +9404,8 @@ mod tests {
|
||||
revision: "before-snapshot".into(),
|
||||
path: "old.txt".into(),
|
||||
side: Side::Before,
|
||||
start_line: 1,
|
||||
end_line: 2,
|
||||
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
||||
end: crate::plugin::TextPosition { row: 2, column: 0 },
|
||||
content: before,
|
||||
},
|
||||
body: "Before range".into(),
|
||||
@@ -9377,8 +9416,8 @@ mod tests {
|
||||
revision: "after-snapshot".into(),
|
||||
path: "new.txt".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 1,
|
||||
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
||||
end: crate::plugin::TextPosition { row: 1, column: 0 },
|
||||
content: after,
|
||||
},
|
||||
body: "After line".into(),
|
||||
@@ -9388,6 +9427,155 @@ mod tests {
|
||||
diff
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations() {
|
||||
use crate::plugin::TextPosition;
|
||||
use crate::review::Side;
|
||||
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for side in [Side::Before, Side::After] {
|
||||
for (wrapping, horizontal) in [(false, 0), (false, 6), (true, 0)] {
|
||||
for (start, end) in [((0, 4), (0, 6)), ((0, 5), (1, 5)), ((0, 84), (1, 0))] {
|
||||
let mut diff = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.top = 0;
|
||||
diff.word_wrap = wrapping;
|
||||
diff.left_before = horizontal;
|
||||
diff.left_after = horizontal;
|
||||
let comment = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter_mut()
|
||||
.find(|comment| comment.anchor.side == side)
|
||||
.unwrap();
|
||||
comment.anchor.start = TextPosition {
|
||||
row: start.0,
|
||||
column: start.1,
|
||||
};
|
||||
comment.anchor.end = TextPosition {
|
||||
row: end.0,
|
||||
column: end.1,
|
||||
};
|
||||
let anchor = comment.anchor.clone();
|
||||
diff.local_review.selected = Some(comment.id);
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||
let wrapped = panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.map(|viewport| viewport.rows.as_slice());
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (area, focus) in
|
||||
panels.diff.as_ref().unwrap().regions.into_iter().skip(1)
|
||||
{
|
||||
let content = diff_inner(area);
|
||||
let gutter = diff_gutter(&diff, file, content.width as usize, focus);
|
||||
let width = content.width as usize - gutter;
|
||||
for (screen, display, segments) in
|
||||
diff_screen_rows(&diff, 0, content.height, wrapped)
|
||||
{
|
||||
let mut expected = vec![false; width];
|
||||
if let DisplayRow::Line(aligned) = diff.display_rows[display] {
|
||||
let row = &diff.rows[aligned];
|
||||
let actual_side = if mode == ViewMode::Unified {
|
||||
diff.display_focus(display).unwrap_or(focus)
|
||||
} else {
|
||||
focus
|
||||
};
|
||||
let anchor_side = if side == Side::Before {
|
||||
Focus::Before
|
||||
} else {
|
||||
Focus::After
|
||||
};
|
||||
if actual_side == anchor_side
|
||||
|| (mode == ViewMode::Unified && row.kind == RowKind::Equal)
|
||||
{
|
||||
let source_row = if side == Side::Before {
|
||||
row.before
|
||||
} else {
|
||||
row.after
|
||||
};
|
||||
if let Some(source_row) = source_row {
|
||||
let line = if side == Side::Before {
|
||||
&file.before[source_row]
|
||||
} else {
|
||||
&file.after[source_row]
|
||||
};
|
||||
let segment = segments.and_then(|segments| {
|
||||
segments[usize::from(actual_side == Focus::After)]
|
||||
});
|
||||
let left =
|
||||
segment.map_or(horizontal, |segment| segment.start);
|
||||
let paint_width = segment.map_or(width, |segment| {
|
||||
segment_paint_width(
|
||||
segment,
|
||||
wrapped.unwrap()[screen].final_segments
|
||||
[usize::from(actual_side == Focus::After)],
|
||||
width,
|
||||
)
|
||||
});
|
||||
for column in 0..=line.graphemes(true).count() {
|
||||
let position = TextPosition {
|
||||
row: source_row,
|
||||
column,
|
||||
};
|
||||
let cell_start = display_column(line, column);
|
||||
let cell_end =
|
||||
if column == line.graphemes(true).count() {
|
||||
cell_start + 1
|
||||
} else {
|
||||
display_column(line, column + 1)
|
||||
};
|
||||
if anchor.start <= position
|
||||
&& position < anchor.end
|
||||
&& cell_start >= left
|
||||
&& cell_end <= left + paint_width
|
||||
{
|
||||
expected[cell_start - left..cell_end - left]
|
||||
.fill(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut continuation = 0;
|
||||
for (column, expected) in expected.into_iter().enumerate() {
|
||||
if continuation > 0 {
|
||||
continuation -= 1;
|
||||
continue;
|
||||
}
|
||||
continuation = UnicodeWidthStr::width(
|
||||
buffer[(
|
||||
content.x + gutter as u16 + column as u16,
|
||||
content.y + screen as u16,
|
||||
)]
|
||||
.symbol(),
|
||||
)
|
||||
.saturating_sub(1);
|
||||
assert_eq!(
|
||||
buffer[(
|
||||
content.x + gutter as u16 + column as u16,
|
||||
content.y + screen as u16,
|
||||
)]
|
||||
.bg
|
||||
== Color::Magenta,
|
||||
expected,
|
||||
"{mode:?} {side:?} wrap={wrapping} left={horizontal} \
|
||||
{start:?}..{end:?}, screen={screen} column={column}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
@@ -9472,6 +9660,15 @@ mod tests {
|
||||
.all(|cell| !matches!(cell.symbol(), "●" | "○")),
|
||||
"{mode:?} {mismatch}"
|
||||
);
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.all(|cell| cell.bg != Color::Magenta),
|
||||
"{mode:?} {mismatch}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9486,8 +9683,8 @@ mod tests {
|
||||
.comments
|
||||
.retain(|comment| comment.anchor.side == side);
|
||||
let comment = &mut diff.local_review.document.comments[0];
|
||||
comment.anchor.start_line = 3;
|
||||
comment.anchor.end_line = 3;
|
||||
comment.anchor.start = crate::plugin::TextPosition { row: 2, column: 1 };
|
||||
comment.anchor.end = crate::plugin::TextPosition { row: 2, column: 3 };
|
||||
diff.local_review.selected = Some(comment.id);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||
@@ -9496,6 +9693,20 @@ mod tests {
|
||||
"{side:?}: {}",
|
||||
screen_text(&terminal)
|
||||
);
|
||||
let buffer = terminal.backend().buffer();
|
||||
let marker = buffer
|
||||
.content
|
||||
.iter()
|
||||
.position(|cell| cell.symbol() == "●")
|
||||
.unwrap();
|
||||
for offset in 1..=4 {
|
||||
assert_eq!(
|
||||
buffer.content[marker + offset].bg == Color::Magenta,
|
||||
(2..=3).contains(&offset),
|
||||
"{side:?}: source column {}",
|
||||
offset - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14096,6 +14307,36 @@ end
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_visual_selection_overrides_selected_review_range_background() {
|
||||
for unified in [false, true] {
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file(
|
||||
"file.txt",
|
||||
"old e\u{301}界 text",
|
||||
"new e\u{301}界 text",
|
||||
));
|
||||
if unified {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.execute("diff.comment");
|
||||
editor.handle(Input::Escape);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
let source = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(source.x, source.y)].bg,
|
||||
Color::Magenta
|
||||
);
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Right);
|
||||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(source.x, source.y)].bg, Color::Blue);
|
||||
assert!(buffer.content.iter().any(|cell| cell.bg == Color::Magenta));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
|
||||
for unified in [false, true] {
|
||||
|
||||
Reference in New Issue
Block a user