feat: support comments in ordinary source buffers
This commit is contained in:
+32
-34
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user