feat: support comments in ordinary source buffers

This commit is contained in:
2026-10-22 22:30:16 +02:00
parent ac5c64981d
commit e6d94dcb41
16 changed files with 1005 additions and 600 deletions
+32 -34
View File
@@ -641,7 +641,6 @@ pub struct DiffState {
pub loading: bool,
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub local_review: crate::review::ReviewState,
pub comparison_id: Option<u64>,
pub reviewed_files: BTreeSet<usize>,
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
@@ -708,7 +707,6 @@ impl DiffState {
loading: true,
error: None,
comparison: None,
local_review: Default::default(),
comparison_id: None,
reviewed_files: BTreeSet::new(),
files: None,
@@ -1988,7 +1986,10 @@ impl DiffState {
})
}
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
pub(crate) fn toggle_reviewed(
&mut self,
review: &mut crate::review::ReviewState,
) -> Result<bool, &'static str> {
let comparison = self
.comparison
.as_ref()
@@ -2030,10 +2031,10 @@ impl DiffState {
.iter()
.all(|revision| !revision.is_empty())
{
if self.local_review.document.root.as_os_str().is_empty() {
self.local_review.document = crate::review::Review::new(comparison.root.clone());
if review.document.root.as_os_str().is_empty() {
review.document = crate::review::Review::new(comparison.root.clone());
}
let records = &mut self.local_review.document.reviewed_files;
let records = &mut review.document.reviewed_files;
records.retain(|record| {
record.revisions
!= *file
@@ -2057,21 +2058,20 @@ impl DiffState {
.then(|| file.after.join("\n")),
});
}
self.local_review.dirty = true;
review.dirty = true;
}
Ok(reviewed)
}
pub(crate) fn restore_reviewed_files(&mut self) {
pub(crate) fn restore_reviewed_files(&mut self, review: &crate::review::ReviewState) {
self.reviewed_files.clear();
let Some(comparison) = self.comparison.as_ref() else {
return;
};
if self.local_review.document.root != comparison.root {
if review.document.root != comparison.root {
return;
}
let records: BTreeMap<_, _> = self
.local_review
let records: BTreeMap<_, _> = review
.document
.reviewed_files
.iter()
@@ -2369,12 +2369,13 @@ impl DiffState {
pub fn review_anchor_range(
&self,
anchor: &crate::review::Anchor,
review: &crate::review::ReviewState,
) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
use crate::review::Side;
let Some(comparison) = &self.comparison else {
return None;
};
if self.local_review.document.root != comparison.root {
if review.document.root != comparison.root {
return None;
}
comparison
@@ -2481,7 +2482,12 @@ impl DiffState {
}
}
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
pub fn refresh_after(
&mut self,
lines: &[String],
revision: u64,
review: &mut crate::review::ReviewState,
) {
if !self.live_after_available() {
return;
}
@@ -2510,29 +2516,21 @@ impl DiffState {
self.modified_after.insert(self.file);
}
let comparison = self.comparison.as_ref().unwrap();
let record = (self.local_review.document.root == comparison.root)
let record = (review.document.root == comparison.root)
.then(|| {
self.local_review
.document
.reviewed_files
.iter()
.position(|record| {
record.revisions
== *file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
&& record.path == file.path
&& record.old_path == file.old_path
})
review.document.reviewed_files.iter().position(|record| {
record.revisions
== *file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
&& record.path == file.path
&& record.old_path == file.old_path
})
})
.flatten();
let matches = record
.and_then(|position| {
self.local_review.document.reviewed_files[position]
.after
.as_ref()
})
.and_then(|position| review.document.reviewed_files[position].after.as_ref())
.map(|text| text.split('\n').eq(lines.iter().map(String::as_str)));
if matches == Some(true) {
self.reviewed_files.insert(self.file);
@@ -2540,8 +2538,8 @@ impl DiffState {
if self.reviewed_files.contains(&self.file) && !matches.unwrap_or(unchanged) {
self.reviewed_files.remove(&self.file);
if let Some(position) = record {
self.local_review.document.reviewed_files.remove(position);
self.local_review.dirty = true;
review.document.reviewed_files.remove(position);
review.dirty = true;
}
}
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
+99 -59
View File
@@ -990,96 +990,91 @@ core_actions![
true,
),
(
"core.diff.comment",
Action::DiffComment,
"core.comment",
Action::Comment,
&[Mode::Normal, Mode::Visual],
true
),
(
"core.diff.review.edit",
"core.review.edit",
Action::ReviewEdit,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.delete",
"core.review.delete",
Action::ReviewDelete,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.resolve",
"core.review.resolve",
Action::ReviewResolve,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.unresolve",
"core.review.unresolve",
Action::ReviewUnresolve,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.next",
"core.review.next",
Action::ReviewNext,
&[Mode::Normal],
true
),
(
"core.diff.review.previous",
"core.review.previous",
Action::ReviewPrevious,
&[Mode::Normal],
true
),
(
"core.diff.review.overview",
"core.review.overview",
Action::ReviewOverview,
&[Mode::Normal],
true
),
(
"core.diff.review.copy",
"core.review.copy",
Action::ReviewCopy,
&[Mode::Normal],
true
),
(
"core.diff.review.summary",
"core.review.summary",
Action::ReviewSummary,
&[Mode::Normal],
true
),
(
"core.diff.review.verdict",
"core.review.verdict",
Action::ReviewVerdict,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.save",
"core.review.save",
Action::ReviewSave,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.open",
"core.review.open",
Action::ReviewOpen,
&[Mode::Normal],
true,
true
),
(
"core.diff.review.new",
Action::ReviewNew,
&[Mode::Normal],
true
),
("core.review.new", Action::ReviewNew, &[Mode::Normal], true),
(
"core.word_wrap.toggle",
Action::WordWrapToggle,
@@ -1193,6 +1188,20 @@ const COMMAND_ALIASES: &[(&str, Action)] = &[
("bprev", Action::BufferPrevious),
("bdelete", Action::BufferDelete),
("bdelete!", Action::BufferForceDelete),
("diff.comment", Action::Comment),
("diff.review.edit", Action::ReviewEdit),
("diff.review.delete", Action::ReviewDelete),
("diff.review.resolve", Action::ReviewResolve),
("diff.review.unresolve", Action::ReviewUnresolve),
("diff.review.next", Action::ReviewNext),
("diff.review.previous", Action::ReviewPrevious),
("diff.review.overview", Action::ReviewOverview),
("diff.review.copy", Action::ReviewCopy),
("diff.review.summary", Action::ReviewSummary),
("diff.review.verdict", Action::ReviewVerdict),
("diff.review.save", Action::ReviewSave),
("diff.review.open", Action::ReviewOpen),
("diff.review.new", Action::ReviewNew),
];
fn core_command(name: &str) -> Option<&'static CoreAction> {
@@ -1893,6 +1902,7 @@ struct Tab {
pane_id: usize,
pane_layout: PaneTree,
review_pane: Option<usize>,
local_review: crate::review::ReviewState,
file_picker: Option<FilePicker>,
diff: Option<DiffState>,
parked_diff: Option<DiffState>,
@@ -2200,6 +2210,7 @@ pub struct Editor {
panes: BTreeMap<usize, Pane>,
pane_layout: PaneTree,
review_pane: Option<usize>,
pub(crate) local_review: crate::review::ReviewState,
tab_id: usize,
next_tab_id: usize,
tab_order: Vec<usize>,
@@ -2313,6 +2324,7 @@ impl Editor {
)]),
pane_layout: PaneTree::new(1),
review_pane: None,
local_review: Default::default(),
tab_id: 1,
next_tab_id: 2,
tab_order: vec![1],
@@ -3663,6 +3675,7 @@ impl Editor {
pane_id: std::mem::replace(&mut self.pane_id, next.pane_id),
pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout),
review_pane: std::mem::replace(&mut self.review_pane, next.review_pane),
local_review: std::mem::replace(&mut self.local_review, next.local_review),
file_picker: std::mem::replace(&mut self.file_picker, next.file_picker),
diff: std::mem::replace(&mut self.diff, next.diff),
parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff),
@@ -3712,6 +3725,7 @@ impl Editor {
pane_id,
pane_layout: PaneTree::new(pane_id),
review_pane: None,
local_review: Default::default(),
file_picker: None,
diff: None,
parked_diff: None,
@@ -3814,11 +3828,6 @@ impl Editor {
// No input dispatch or document copies: source documents stay unique in active/parked.
fn select_pane(&mut self, id: usize) {
let id = if self.review_pane == Some(id) && self.workspace_diff().is_none() {
self.panes[&id].origin.unwrap_or(id)
} else {
id
};
if id == self.pane_id {
return;
}
@@ -4223,7 +4232,12 @@ impl Editor {
return;
}
self.finish_pane_interaction();
if self.diff_owner == Some(id) && !force && self.local_review_close_blocked() {
let review_source =
self.review_pane.and_then(|panel| self.panes[&panel].origin) == Some(id);
if (self.diff_owner == Some(id) || review_source)
&& !force
&& self.local_review_close_blocked()
{
return;
}
if self.diff_owner == Some(id) {
@@ -4236,6 +4250,9 @@ impl Editor {
}
let next = self.pane_layout.close(id).expect("another pane exists");
self.select_pane(next);
if review_source && let Some(panel) = self.review_pane {
self.panes.get_mut(&panel).unwrap().origin = Some(next);
}
self.panes.remove(&id);
for buffer in buffers {
self.forget_buffer(buffer);
@@ -4627,11 +4644,17 @@ impl Editor {
view.clamp(&self.active.lines);
self.active.view = view;
}
if let Err(error) = self.initialize_local_review() {
if self.review_directory.is_some()
&& self.resolver().is_none()
&& self
.workspace_diff()
.is_some_and(|diff| diff.phase == Phase::View)
&& let Err(error) = self.initialize_local_review()
{
self.set_message(format!("Local review: {error}"));
}
if restore_reviewed && let Some(diff) = &mut self.diff {
diff.restore_reviewed_files();
diff.restore_reviewed_files(&self.local_review);
}
let reviewed = self.diff.as_mut().and_then(|diff| {
(refresh
@@ -5190,16 +5213,19 @@ impl Editor {
pub(crate) fn refresh_live_diff(&mut self) {
self.refresh_resolver_comparison();
for diff in self
.diff
.iter_mut()
.chain(self.parked_diff.iter_mut())
.chain(
self.tabs
.values_mut()
.flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())),
)
{
for (diff, parked, review) in std::iter::once((
&mut self.diff,
&mut self.parked_diff,
&mut self.local_review,
))
.chain(
self.tabs
.values_mut()
.map(|tab| (&mut tab.diff, &mut tab.parked_diff, &mut tab.local_review)),
) {
let Some(diff) = diff.as_mut().or(parked.as_mut()) else {
continue;
};
let buffer = diff.live_buffer.and_then(|id| {
if id == self.active.id {
Some(&self.active)
@@ -5211,7 +5237,7 @@ impl Editor {
&& diff.live_after_available()
&& diff.live_revision != Some(buffer.text_revision)
{
diff.refresh_after(&buffer.lines, buffer.text_revision);
diff.refresh_after(&buffer.lines, buffer.text_revision, review);
diff.follow_caret = true;
}
}
@@ -5485,7 +5511,7 @@ impl Editor {
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::DiffReviewToggle
| Action::DiffComment
| Action::Comment
| Action::ReviewEdit
| Action::ReviewDelete
| Action::ReviewResolve
@@ -8917,7 +8943,7 @@ impl Editor {
| Action::DiffViewToggle
| Action::DiffOrientationToggle
| Action::DiffReviewToggle
| Action::DiffComment
| Action::Comment
| Action::ReviewEdit
| Action::ReviewDelete
| Action::ReviewResolve
@@ -9559,15 +9585,22 @@ impl Editor {
.map(|buffer| buffer.id);
if let Some(id) = loaded {
self.switch_buffer(id);
return Ok(());
} else {
let buffer = Buffer::open(
self.next_buffer_id,
Some(path),
Some(identity),
allow_missing,
)?;
self.add_buffer(buffer);
}
if self.review_directory.is_some()
&& self.workspace_diff().is_none()
&& self.resolver().is_none()
&& let Err(error) = self.initialize_local_review()
{
self.set_message(format!("Local review: {error}"));
}
let buffer = Buffer::open(
self.next_buffer_id,
Some(path),
Some(identity),
allow_missing,
)?;
self.add_buffer(buffer);
Ok(())
}
@@ -9745,7 +9778,7 @@ impl Editor {
fn restart(&mut self, force: bool) {
if !force && self.local_reviews_dirty() {
self.set_message("Unsaved local review; use :diff.review.save <path>");
self.set_message("Unsaved local review; use :review.save <path>");
return;
}
if force || !self.any_dirty() {
@@ -9876,7 +9909,7 @@ impl Editor {
}
if matches!(
entry.action,
Action::DiffComment
Action::Comment
| Action::ReviewEdit
| Action::ReviewDelete
| Action::ReviewResolve
@@ -10102,15 +10135,15 @@ impl Editor {
self.diff_return_buffer = Some(self.active.id);
return None;
}
let Some(diff) = self
.picker_diff_mut()
.filter(|diff| diff.phase == Phase::View)
else {
self.set_message("No comparison is open");
return None;
};
if entry.action == Action::DiffReviewToggle {
match diff.toggle_reviewed() {
let mut review = std::mem::take(&mut self.local_review);
let result = self
.picker_diff_mut()
.filter(|diff| diff.phase == Phase::View)
.ok_or("No comparison is open")
.and_then(|diff| diff.toggle_reviewed(&mut review));
self.local_review = review;
match result {
Ok(reviewed) => {
if self.resolver().is_none()
&& let Err(error) = self.autosave_local_review()
@@ -10128,6 +10161,13 @@ impl Editor {
}
return None;
}
let Some(diff) = self
.picker_diff_mut()
.filter(|diff| diff.phase == Phase::View)
else {
self.set_message("No comparison is open");
return None;
};
if entry.action == Action::DiffViewToggle {
diff.set_view_mode(match diff.view_mode {
ViewMode::Split => ViewMode::Unified,
+296 -202
View File
@@ -25,58 +25,81 @@ pub(super) struct ReviewBuffer {
impl Editor {
pub fn set_review_directory(&mut self, directory: PathBuf) {
self.review_directory = Some(directory);
if self.review_root().is_ok()
&& let Err(error) = self.initialize_local_review()
{
self.set_message(format!("Local review: {error}"));
}
}
pub(super) fn initialize_local_review(&mut self) -> Result<(), String> {
let Some(directory) = self.review_directory.as_ref() else {
return Ok(());
};
let Some(diff) = self
.workspace_diff()
.filter(|diff| diff.phase == Phase::View)
else {
return Ok(());
};
if diff.local_review.initialized {
let root = self.review_root()?;
if self.local_review.document.root == root {
self.local_review.initialized = true;
return Ok(());
}
let root = diff
.comparison
.as_ref()
.ok_or("Comparison is loading")?
.root
.clone();
let directory = directory.clone();
self.review_diff_mut().unwrap().local_review.initialized = true;
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
let store = crate::review::Store::open(&directory)?;
let mut reviews = store.list(&root)?;
if self.current_review_dirty() {
return Err("Save the current review before changing repositories".into());
}
let reviews = match self.review_directory.as_deref() {
Some(directory) => crate::review::Store::open_read_only(directory)?
.map(|store| store.list(&root))
.transpose()?
.unwrap_or_default(),
None => Vec::new(),
};
self.detach_review_buffers();
self.local_review = crate::review::ReviewState {
document: Review::new(root),
initialized: true,
..Default::default()
};
let mut reviews = reviews;
if reviews.len() == 1 {
self.review_diff_mut().unwrap().local_review.document = reviews.pop().unwrap();
self.local_review.document = reviews.pop().unwrap();
} else if reviews.len() > 1 {
self.show_review_selector(reviews)?;
}
Ok(())
}
fn review_root(&self) -> Result<PathBuf, String> {
if let Some(diff) = self.workspace_diff() {
return diff
.comparison
.as_ref()
.map(|comparison| {
fs::canonicalize(&comparison.root).unwrap_or_else(|_| comparison.root.clone())
})
.ok_or_else(|| "Comparison is still loading".into());
}
if self.active.review_buffer.is_some()
|| (self.active.path.is_none() && self.local_review.initialized)
{
return Ok(self.local_review.document.root.clone());
}
let path = self
.active
.identity
.as_deref()
.or(self.active.path.as_deref())
.ok_or("Save the source file before commenting")?;
crate::repository::discover_root(path.parent().ok_or("Source has no parent directory")?)
}
fn open_review_selector(&mut self) -> Result<(), String> {
let diff = self.workspace_diff().ok_or("No comparison is open")?;
let root = self.review_root()?;
let directory = self
.review_directory
.as_deref()
.ok_or("Review storage is not configured")?;
let root = &diff
.comparison
.as_ref()
.ok_or("Comparison is loading")?
.root;
let reviews = crate::review::Store::open(directory)?.list(root)?;
let reviews = crate::review::Store::open(directory)?.list(&root)?;
self.show_review_selector(reviews)
}
fn show_review_selector(&mut self, reviews: Vec<Review>) -> Result<(), String> {
let mut text = String::from(
"# Local reviews\n\nEnter opens the review under the caret.\n:diff.review.new starts a separate review.\n",
"# Local reviews\n\nEnter opens the review under the caret.\n:review.new starts a separate review.\n",
);
for review in reviews {
let id = review.storage_id.unwrap();
@@ -95,14 +118,14 @@ impl Editor {
self.open_review_buffer(
ReviewText::Reviews,
&text,
"Local reviews · Enter opens · :diff.review.new".into(),
"Local reviews · Enter opens · :review.new".into(),
)?;
self.place_navigation(TextPosition { row, column: 0 });
Ok(())
}
fn mark_review_saved(&mut self) {
self.review_diff_mut().unwrap().local_review.dirty = false;
self.local_review.dirty = false;
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
if buffer
.review_buffer
@@ -120,21 +143,12 @@ impl Editor {
let Some(directory) = self.review_directory.as_deref() else {
return Ok(());
};
if !self
.workspace_diff()
.is_some_and(|diff| diff.local_review.dirty)
{
if !self.local_review.dirty {
return Ok(());
}
let mut store = crate::review::Store::open(directory)?;
store.save(
&mut self
.review_diff_mut()
.ok_or("No comparison is open")?
.local_review
.document,
)?;
self.review_diff_mut().unwrap().local_review.dirty = false;
store.save(&mut self.local_review.document)?;
self.local_review.dirty = false;
Ok(())
}
pub fn review_panel_title(&self) -> Option<&str> {
@@ -145,8 +159,7 @@ impl Editor {
}
pub fn review_panel(&self) -> Option<usize> {
self.review_pane
.filter(|pane| *pane == self.pane_id && self.workspace_diff().is_some())
self.review_pane.filter(|pane| *pane == self.pane_id)
}
pub(super) fn dismiss_review_panel(&mut self) -> bool {
@@ -165,7 +178,7 @@ impl Editor {
buffer.view.word_wrap = wrap;
buffer.resolver_readonly = self.active.resolver_readonly;
if binding.pending_comment.take().is_some() {
self.review_diff_mut().unwrap().local_review.selected = None;
self.local_review.selected = None;
}
buffer.review_buffer = Some(binding);
self.active = buffer;
@@ -175,25 +188,16 @@ impl Editor {
true
}
fn review_diff_mut(&mut self) -> Option<&mut DiffState> {
self.diff.as_mut().or(self.parked_diff.as_mut())
}
pub(super) fn local_reviews_dirty(&self) -> bool {
self.workspace_diff()
.is_some_and(|diff| diff.local_review.dirty)
|| self
.tabs
.values()
.any(|tab| tab.diff().is_some_and(|diff| diff.local_review.dirty))
self.local_review.dirty
|| self.tabs.values().any(|tab| tab.local_review.dirty)
|| std::iter::once(&self.active)
.chain(self.parked.values())
.any(|buffer| buffer.review_buffer.is_some() && buffer.dirty())
}
pub(super) fn current_review_dirty(&self) -> bool {
self.workspace_diff()
.is_some_and(|diff| diff.local_review.dirty)
self.local_review.dirty
|| std::iter::once(&self.active)
.chain(self.parked.values())
.any(|buffer| {
@@ -213,7 +217,7 @@ impl Editor {
return true;
}
if self.current_review_dirty() {
self.set_message("Unsaved local review; use :diff.review.save <path> before closing");
self.set_message("Unsaved local review; use :review.save <path> before closing");
true
} else {
false
@@ -258,13 +262,15 @@ impl Editor {
};
// Retire old drafts before comment IDs can be reused.
self.detach_review_buffers();
let state = &mut self.review_diff_mut().unwrap().local_review;
let state = &mut self.local_review;
state.document = document;
state.path = path;
state.selected = None;
state.pending_navigation = None;
state.dirty = dirty;
self.review_diff_mut().unwrap().restore_reviewed_files();
if let Some(diff) = self.diff.as_mut().or(self.parked_diff.as_mut()) {
diff.restore_reviewed_files(&self.local_review);
}
// Persistence failure leaves the identity change applied and unsaved.
self.autosave_local_review()?;
self.open_review_overview()
@@ -272,7 +278,7 @@ impl Editor {
fn delete_local_review_comment(&mut self, args: &str) -> Result<(), String> {
let id = self.review_comment_id(args)?;
let state = &mut self.review_diff_mut().unwrap().local_review;
let state = &mut self.local_review;
state.document.comments.retain(|comment| comment.id != id);
state.selected = None;
state.pending_navigation = None;
@@ -308,14 +314,11 @@ impl Editor {
})
})
.collect();
let Some(diff) = self.review_diff_mut() else {
return Vec::new();
};
let state = &mut self.local_review;
let mut previous = Vec::new();
for (kind, text, pending) in updates {
if let Some(mut comment) = pending
&& !diff
.local_review
&& !state
.document
.comments
.iter()
@@ -323,19 +326,18 @@ impl Editor {
{
previous.push((kind, None));
comment.body = text;
diff.local_review.document.comments.push(comment);
diff.local_review.dirty = true;
state.document.comments.push(comment);
state.dirty = true;
continue;
}
let destination = match kind {
ReviewText::Comment(id) => diff
.local_review
ReviewText::Comment(id) => state
.document
.comments
.iter_mut()
.find(|comment| comment.id == id)
.map(|comment| &mut comment.body),
ReviewText::Summary => Some(&mut diff.local_review.document.summary),
ReviewText::Summary => Some(&mut state.document.summary),
ReviewText::Overview => None,
ReviewText::Reviews => None,
};
@@ -344,22 +346,18 @@ impl Editor {
{
let old = std::mem::replace(destination, text);
previous.push((kind, Some(old)));
diff.local_review.dirty = true;
state.dirty = true;
}
}
previous
}
pub(super) fn save_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
let dirty = self
.review_diff_mut()
.ok_or("No comparison is open")?
.local_review
.dirty;
let dirty = self.local_review.dirty;
let previous = self.sync_review_text();
let result = self.write_local_review(path);
if result.is_err() {
let state = &mut self.review_diff_mut().unwrap().local_review;
let state = &mut self.local_review;
for (kind, text) in previous {
match (kind, text) {
(ReviewText::Comment(id), None) => {
@@ -386,12 +384,8 @@ impl Editor {
fn write_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
if self.review_directory.is_some() {
if let Some(path) = path {
self.review_diff_mut()
.ok_or("No comparison is open")?
.local_review
.document
.save(path)?;
self.review_diff_mut().unwrap().local_review.path = Some(path.to_path_buf());
self.local_review.document.save(path)?;
self.local_review.path = Some(path.to_path_buf());
}
self.autosave_local_review()?;
self.mark_review_saved();
@@ -402,14 +396,11 @@ impl Editor {
});
return Ok(());
}
let state = &mut self
.review_diff_mut()
.ok_or("No comparison is open")?
.local_review;
let state = &mut self.local_review;
let path = path
.map(Path::to_path_buf)
.or_else(|| state.path.clone())
.ok_or("Use :diff.review.save <path> to choose a local JSON review file")?;
.ok_or("Use :review.save <path> to choose a local JSON review file")?;
state.document.save(&path)?;
state.path = Some(path);
self.mark_review_saved();
@@ -423,19 +414,10 @@ impl Editor {
args: &str,
) -> Result<(), String> {
if self.resolver().is_some() {
return Err("Local reviews require an ordinary diff comparison".into());
return Err("Local reviews are unavailable in conflict resolution workspaces".into());
}
self.initialize_local_review()?;
let diff = self
.workspace_diff()
.filter(|diff| diff.phase == Phase::View)
.ok_or("No comparison is open")?;
let root = diff
.comparison
.as_ref()
.ok_or("Comparison is still loading")?
.root
.clone();
let root = self.review_root()?;
if !matches!(
action,
Action::ReviewEdit
@@ -449,16 +431,8 @@ impl Editor {
{
return Err("This review command takes no arguments".into());
}
if self
.workspace_diff()
.unwrap()
.local_review
.document
.root
.as_os_str()
.is_empty()
{
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
if self.local_review.document.root.as_os_str().is_empty() {
self.local_review.document = Review::new(root.clone());
}
if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
self.autosave_local_review()?;
@@ -480,7 +454,7 @@ impl Editor {
return self.replace_local_review(root, source);
}
match action {
Action::DiffComment => self.add_review_comment()?,
Action::Comment => self.add_review_comment()?,
Action::ReviewNew => self.replace_local_review(root, ReviewSource::New)?,
Action::ReviewEdit => {
let id = self.review_comment_id(args)?;
@@ -489,7 +463,7 @@ impl Editor {
Action::ReviewResolve | Action::ReviewUnresolve => {
let id = self.review_comment_id(args)?;
let resolved = action == Action::ReviewResolve;
let state = &mut self.review_diff_mut().unwrap().local_review;
let state = &mut self.local_review;
let comment = state
.document
.comments
@@ -522,7 +496,7 @@ impl Editor {
}
Action::ReviewDelete => self.delete_local_review_comment(args)?,
Action::ReviewNext | Action::ReviewPrevious => {
let state = &self.workspace_diff().unwrap().local_review;
let state = &self.local_review;
let comments = &state.document.comments;
if comments.is_empty() {
return Err("No local review comments".into());
@@ -540,7 +514,7 @@ impl Editor {
}
Action::ReviewOverview => self.open_review_overview()?,
Action::ReviewCopy => {
let review = &self.workspace_diff().unwrap().local_review.document;
let review = &self.local_review.document;
let count = review.comments.len();
if count == 0 {
return Err("No local review comments".into());
@@ -554,13 +528,7 @@ impl Editor {
self.set_message(format!("Copied {count} review comments"));
}
Action::ReviewSummary => {
let text = self
.workspace_diff()
.unwrap()
.local_review
.document
.summary
.clone();
let text = self.local_review.document.summary.clone();
self.open_review_buffer(
ReviewText::Summary,
&text,
@@ -574,11 +542,11 @@ impl Editor {
"none" => None,
_ => {
return Err(
"Usage: diff.review.verdict <looks_good|changes_requested|none>".into(),
"Usage: review.verdict <looks_good|changes_requested|none>".into()
);
}
};
let state = &mut self.review_diff_mut().unwrap().local_review;
let state = &mut self.local_review;
if state.document.verdict != verdict {
state.document.verdict = verdict;
state.dirty = true;
@@ -595,10 +563,7 @@ impl Editor {
}
fn review_comment_id(&self, args: &str) -> Result<u64, String> {
let state = &self
.workspace_diff()
.ok_or("No comparison is open")?
.local_review;
let state = &self.local_review;
let under_cursor = self
.active
.review_buffer
@@ -633,16 +598,65 @@ impl Editor {
.ok_or_else(|| format!("No local comment #{id}"))
}
fn add_review_comment(&mut self) -> Result<(), String> {
let owner = self.diff_owner.ok_or("No comparison is open")?;
if self.pane_id != owner {
self.select_pane(owner);
pub(crate) fn source_review_anchor_range(
&self,
anchor: &Anchor,
) -> Option<std::ops::Range<TextPosition>> {
if self.active.review_buffer.is_some() || !anchor.revision.starts_with("working:") {
return None;
}
let path = self
.active
.identity
.as_ref()
.or(self.active.path.as_ref())?;
let relative = path.strip_prefix(&self.local_review.document.root).ok()?;
anchor.mapped_range(
&anchor.revision,
relative,
Side::After,
&self.active.lines,
false,
)
}
fn review_anchor_range(&self, anchor: &Anchor) -> Option<std::ops::Range<TextPosition>> {
if let Some(diff) = self.workspace_diff() {
return diff.review_anchor_range(anchor, &self.local_review);
}
let path = self.local_review.document.root.join(&anchor.path);
let buffer = std::iter::once(&self.active)
.chain(self.parked.values())
.find(|buffer| {
buffer.identity.as_ref() == Some(&path) && buffer.review_buffer.is_none()
})?;
if !anchor.revision.starts_with("working:") {
return None;
}
anchor.mapped_range(
&anchor.revision,
&anchor.path,
Side::After,
&buffer.lines,
false,
)
}
fn add_review_comment(&mut self) -> Result<(), String> {
if let Some(owner) = self.diff_owner {
if self.pane_id != owner {
self.select_pane(owner);
}
self.bind_diff_buffer();
if self
.diff_buffer_focus()
.filter(|_| self.diff_interacting())
.is_none()
{
return Err("Focus a diff text pane to add a comment".into());
}
} else if self.active.review_buffer.is_some() || self.active.resolver_readonly {
return Err("Focus a source text pane to add a comment".into());
}
self.bind_diff_buffer();
let focus = self
.diff_buffer_focus()
.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 contiguous selection for a review comment".into());
}
@@ -663,22 +677,45 @@ impl Editor {
},
}
});
let diff = self.diff.as_ref().unwrap();
let comparison = diff.comparison.as_ref().unwrap();
let file = &comparison.files[diff.file];
let side = if focus == Focus::Before {
Side::Before
let content = self.active.lines.join("\n");
let (revision, path, side) = if let Some(diff) = self.diff.as_ref() {
let comparison = diff.comparison.as_ref().ok_or("Comparison is loading")?;
let file = &comparison.files[diff.file];
let side = if self.diff_buffer_focus() == Some(Focus::Before) {
Side::Before
} else {
Side::After
};
(
comparison.revisions[side.index()].clone(),
if side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
}
.clone(),
side,
)
} else {
Side::After
let root = &self.local_review.document.root;
let path = self
.active
.identity
.as_ref()
.or(self.active.path.as_ref())
.ok_or("Save the source file before commenting")?
.strip_prefix(root)
.map_err(|_| "Source is outside the review repository")?
.to_path_buf();
(
crate::repository::source_snapshot_revision(root, &content)?,
path,
Side::After,
)
};
let anchor = Anchor {
revision: comparison.revisions[side.index()].clone(),
path: if side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
}
.clone(),
revision,
path,
side,
start: TextPosition {
row: range.start.row,
@@ -688,9 +725,9 @@ impl Editor {
row: range.end.row,
column: range.end.column,
},
content: self.active.lines.join("\n"),
content,
};
let id = diff
let id = self
.local_review
.document
.comments
@@ -727,10 +764,10 @@ impl Editor {
}
fn open_review_comment(&mut self, id: u64, draft: Option<&Comment>) -> Result<(), String> {
let diff = self.workspace_diff().unwrap();
let state = &self.local_review;
let comment = draft
.or_else(|| {
diff.local_review
state
.document
.comments
.iter()
@@ -744,7 +781,7 @@ impl Editor {
} else {
"unresolved"
};
let mapped = diff.review_anchor_range(anchor);
let mapped = self.review_anchor_range(anchor);
let current = mapped.is_some();
let location = if current {
"current"
@@ -761,31 +798,80 @@ impl Editor {
range.end.column + 1,
anchor.side
);
let target = diff.comparison.as_ref().and_then(|comparison| {
comparison.files.iter().position(|file| {
let path = if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path)
let anchor = anchor.clone();
let mut stale = !current;
self.local_review.selected = Some(id);
if let Some(owner) = self.diff_owner {
let target = self
.workspace_diff()
.and_then(|diff| diff.comparison.as_ref())
.and_then(|comparison| {
comparison.files.iter().position(|file| {
if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
} else {
file.path == anchor.path
}
})
});
stale = !current
&& target.is_none_or(|index| {
self.workspace_diff()
.unwrap()
.comparison
.as_ref()
.unwrap()
.files[index]
.loaded
});
self.select_pane(owner);
if let Some(index) = target {
self.diff.as_mut().unwrap().select_file_index(index);
self.local_review.pending_navigation = Some(id);
}
self.finish_review_navigation();
if draft.is_some() {
self.place_navigation(anchor.start);
}
} else {
if let Some(panel) = self.review_panel() {
self.select_pane(self.panes[&panel].origin.unwrap());
}
let path = self.local_review.document.root.join(&anchor.path);
if draft.is_none() {
let opened = self.edit_buffer(path.clone(), false).is_ok();
let mapped = opened.then(|| self.review_anchor_range(&anchor)).flatten();
stale = mapped.is_none();
if let Some(range) = mapped {
self.place_navigation(range.start);
} 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);
let diff = self.diff.as_mut().unwrap();
diff.local_review.selected = Some(id);
if let Some(index) = target {
diff.select_file_index(index);
diff.local_review.pending_navigation = Some(id);
}
self.finish_review_navigation();
if draft.is_some() {
self.place_navigation(start);
let mut snapshot = Buffer::from_text(
self.next_buffer_id,
Some(path),
None,
&anchor.content,
None,
)
.map_err(|error| error.to_string())?;
snapshot.resolver_readonly = true;
self.add_buffer(snapshot);
self.place_navigation(anchor.start);
}
}
}
let title = title.replacen(
if stale {
" · current · "
} else {
" · original snapshot · "
},
if stale {
" · original snapshot · "
} else {
" · current · "
},
1,
);
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
if stale {
self.set_message("Comment is stale; original snapshot anchor preserved");
@@ -794,18 +880,18 @@ impl Editor {
}
pub(super) fn finish_review_navigation(&mut self) {
let state = &mut self.local_review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let Some(id) = diff.local_review.pending_navigation else {
let Some(id) = state.pending_navigation else {
return;
};
if !diff.selected_file_loaded() {
return;
}
diff.local_review.pending_navigation = None;
let Some(comment) = diff
.local_review
state.pending_navigation = None;
let Some(comment) = state
.document
.comments
.iter()
@@ -827,7 +913,7 @@ impl Editor {
if selected_path != Some(&comment.anchor.path) {
return;
}
let Some(range) = diff.review_anchor_range(&comment.anchor) else {
let Some(range) = diff.review_anchor_range(&comment.anchor, state) else {
self.set_message("Comment is stale; original snapshot anchor preserved");
return;
};
@@ -844,8 +930,7 @@ impl Editor {
}
fn open_review_overview(&mut self) -> Result<(), String> {
let diff = self.workspace_diff().unwrap();
let state = &diff.local_review;
let state = &self.local_review;
let verdict = match state.document.verdict {
Some(Verdict::LooksGood) => "Looks good",
Some(Verdict::ChangesRequested) => "Changes requested",
@@ -868,25 +953,29 @@ impl Editor {
.map_or_else(|| "not saved".into(), |path| path.display().to_string())
};
let mut text = format!(
"# Local review\n\nVerdict: {verdict}\nStorage: {storage}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
"# Local review\n\nVerdict: {verdict}\nStorage: {storage}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:review.summary · :review.verdict · :review.save\n",
state.document.summary
);
for comment in &state.document.comments {
let anchor = &comment.anchor;
let mapped = diff.review_anchor_range(anchor);
let mapped = self.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.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
}
} else if self
.workspace_diff()
.and_then(|diff| diff.comparison.as_ref())
.is_some_and(|comparison| {
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 {
"stale"
@@ -915,7 +1004,7 @@ impl Editor {
self.open_review_buffer(
ReviewText::Overview,
&text,
"Local review overview · Enter edits · :diff.review.next".into(),
"Local review overview · Enter edits · :review.next".into(),
)
}
@@ -927,6 +1016,11 @@ impl Editor {
) -> Result<(), String> {
self.prepare_command();
self.finish_command_mode();
let source = if self.review_panel().is_some() {
self.panes[&self.pane_id].origin.unwrap()
} else {
self.pane_id
};
let existing = std::iter::once(&self.active)
.chain(self.parked.values())
.find(|buffer| {
@@ -945,14 +1039,14 @@ impl Editor {
id,
Pane {
buffer_id: self.active.id,
origin: self.diff_owner,
origin: Some(source),
views: BTreeMap::from([(self.active.id, self.active.view.inherit())]),
},
);
self.review_pane = Some(id);
id
};
self.panes.get_mut(&panel).unwrap().origin = self.diff_owner;
self.panes.get_mut(&panel).unwrap().origin = Some(source);
self.select_pane(panel);
if let Some(id) = existing {
self.switch_buffer(id);
@@ -978,9 +1072,9 @@ impl Editor {
self.add_buffer(buffer);
}
self.set_message(if self.review_directory.is_some() {
"Local review · :w saves · Escape discards unsaved text · :diff.review.save path exports JSON"
"Local review · :w saves · Escape discards unsaved text · :review.save path exports JSON"
} else {
"Local review · :w saves JSON; first choose :diff.review.save <path>"
"Local review · :w saves JSON; first choose :review.save <path>"
});
Ok(())
}
+7 -5
View File
@@ -96,13 +96,15 @@ 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 anchors exact Visual text, Visual Line rows, or the current line
Local reviews: :comment anchors exact Visual text, Visual Line rows, or the current line
Works in ordinary repository source buffers and diff text panes
Selected comments highlight their precise text
: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.copy copies all saved comments in CLI format using the configured clipboard
:review.edit/delete [ID], :review.next/previous, :review.overview
:review.summary; :review.verdict looks_good|changes_requested|none
:review.copy copies all saved comments in CLI format using the configured clipboard
:w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
:review.save PATH exports JSON; :review.open [ID|PATH] selects/imports; :review.new
Existing :diff.comment and :diff.review.* spellings remain available
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
+18
View File
@@ -1093,6 +1093,24 @@ pub fn discover_root(path: &Path) -> Result<PathBuf, String> {
discover_root_with_cancellation(path, &AtomicBool::new(false))
}
/// Identify the exact source bytes without writing objects or snapshotting the checkout.
pub(crate) fn source_snapshot_revision(root: &Path, content: &str) -> Result<String, String> {
let mut command = if root.join(".jj").exists() {
git_command(&store_for_jj(root)?)
} else {
let mut command = Command::new("git");
command.arg("-C").arg(root);
command
};
command.args(["hash-object", "--no-filters", "--stdin"]);
let id = text(checked(run(
command,
content.as_bytes().to_vec(),
&AtomicBool::new(false),
)?)?)?;
Ok(format!("working:{}", id.trim()))
}
fn discover_root_with_cancellation(path: &Path, cancelled: &AtomicBool) -> Result<PathBuf, String> {
let path = fs::canonicalize(path).map_err(|error| error.to_string())?;
let root = path
+1 -1
View File
@@ -43,7 +43,7 @@ pub enum Verdict {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Anchor {
/// Frozen comparison context; `content` additionally captures any live buffer edits.
/// Frozen repository context; source comments use the exact buffer's Git blob identity.
pub revision: String,
pub path: PathBuf,
pub side: Side,
+80 -9
View File
@@ -807,6 +807,7 @@ impl BufferViewports {
editor.file_navigation_hint(),
&mut self.panels,
Some(editor),
Some(&editor.local_review),
);
} else {
draw_overlays(
@@ -1009,6 +1010,7 @@ impl BufferViewports {
view.whitespace_scope(),
view.resolver(),
true,
None,
);
if view.render_pane_focused()
&& !view.file_picker().is_some_and(|picker| picker.focused)
@@ -1823,7 +1825,14 @@ fn chrome_items<'a>(
tab: None,
accent: !matches!(
name.as_str(),
"path" | "language" | "diagnostics" | "selections" | "count" | "pending" | "buffer" | "diff"
"path"
| "language"
| "diagnostics"
| "selections"
| "count"
| "pending"
| "buffer"
| "diff"
),
bold: false,
first: true,
@@ -2236,6 +2245,7 @@ fn draw_document(
editor.file_navigation_hint(),
panels,
Some(editor),
Some(&editor.local_review),
);
return;
}
@@ -2275,6 +2285,17 @@ fn draw_document(
let mut document_caret = None;
let carets = editor.carets();
let selections = editor.selections();
let review_comments: Vec<_> = editor
.local_review
.document
.comments
.iter()
.filter_map(|comment| {
editor
.source_review_anchor_range(&comment.anchor)
.map(|range| (comment, range))
})
.collect();
let wrapped = editor.word_wrap();
let presentation = (editor.buffer_id(), wrapped, width, height);
let identity = (editor.buffer_id(), editor.text_revision(), cursor);
@@ -2576,6 +2597,25 @@ fn draw_document(
})
.collect();
frame.render_widget(Paragraph::new(numbers), gutter);
for (screen, &(row, continuation, _)) in document.visible.iter().enumerate() {
if continuation != 0 {
continue;
}
if let Some((comment, _)) = review_comments
.iter()
.filter(|(_, range)| {
let end = range.end.row
- usize::from(range.end.column == 0 && range.end.row > range.start.row);
(range.start.row..=end).contains(&row)
})
.max_by_key(|(comment, _)| editor.local_review.selected == Some(comment.id))
{
let selected = editor.local_review.selected == Some(comment.id);
frame.buffer_mut()[(gutter.right() - 1, gutter.y + screen as u16)]
.set_symbol(if selected { "●" } else { "○" })
.set_fg(Color::Magenta);
}
}
}
let lines: Vec<Line> = document
.visible
@@ -2627,6 +2667,33 @@ fn draw_document(
if editor.highlight_trailing_whitespace() {
paint_trailing_whitespace(frame, line, area, segment.start);
}
if let Some((_, range)) = review_comments
.iter()
.find(|(comment, _)| editor.local_review.selected == Some(comment.id))
{
let end_row = range.end.row
- usize::from(range.end.column == 0 && range.end.row > range.start.row);
if (range.start.row..=end_row).contains(&row) {
let start = if row == range.start.row {
range.start.column
} else {
0
};
let end = if row == range.end.row {
range.end.column
} else {
usize::MAX
};
paint_grapheme_range(
frame,
area,
segment.start,
line,
start..end,
Style::default().bg(Color::Magenta),
);
}
}
paint_editor_line(
frame,
editor,
@@ -3337,6 +3404,7 @@ fn draw_diff_popups(
}
}
#[allow(clippy::too_many_arguments)]
fn draw_diff(
frame: &mut Frame,
diff: &DiffState,
@@ -3345,6 +3413,7 @@ fn draw_diff(
file_navigation_hint: &str,
panels: &mut WheelPanels,
editor: Option<&Editor>,
review: Option<&crate::review::ReviewState>,
) {
if let Some(editor) = editor {
panels.picker_scroll.distance = editor.scroll_distance();
@@ -3814,7 +3883,7 @@ fn draw_diff(
comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)),
);
}
let document_caret = panels.draw_documents(frame, diff, panes, navigator, editor);
let document_caret = panels.draw_documents(frame, diff, panes, navigator, editor, review);
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, document_caret, panels);
}
@@ -3839,6 +3908,7 @@ impl WheelPanels {
panes: Rect,
navigator: Option<Rect>,
editor: Option<&Editor>,
review: Option<&crate::review::ReviewState>,
) -> Option<(Rect, Position)> {
self.cached_diff_error(Some(diff));
let selected = diff
@@ -4087,6 +4157,7 @@ impl WheelPanels {
editor.map_or(WhitespaceScope::All, Editor::whitespace_scope),
resolver,
false,
review,
);
text_widths[0] = text_area.width;
if let Some(editor) =
@@ -4127,6 +4198,7 @@ impl WheelPanels {
editor.map_or(WhitespaceScope::All, Editor::whitespace_scope),
resolver,
false,
review,
);
text_widths[1] = text_area.width;
if let Some(editor) =
@@ -4464,6 +4536,7 @@ fn draw_diff_pane(
whitespace_scope: WhitespaceScope,
resolver: Option<&ResolverWorkspace>,
content_only: bool,
review: Option<&crate::review::ReviewState>,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
@@ -4487,11 +4560,9 @@ fn draw_diff_pane(
*left = diff.bound_horizontal(focus, *left, width);
}
let left = *left;
let review_comments = diff
.local_review
.document
.comments
.iter()
let review_comments = review
.into_iter()
.flat_map(|review| review.document.comments.iter())
.filter_map(|comment| {
let before = comment.anchor.side == crate::review::Side::Before;
let path = if before {
@@ -4502,7 +4573,7 @@ fn draw_diff_pane(
if !(unified || before == (focus == Focus::Before)) || comment.anchor.path != *path {
return None;
}
diff.review_anchor_range(&comment.anchor)
diff.review_anchor_range(&comment.anchor, review.unwrap())
.map(|range| (comment, range))
});
paint_diff_content(
@@ -4518,7 +4589,7 @@ fn draw_diff_pane(
whitespace_characters,
whitespace_scope,
review_comments,
diff.local_review.selected,
review.and_then(|review| review.selected),
)
}