feat: add local diff review workflow
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
use super::*;
|
||||
use crate::review::{Anchor, Comment, Review, Side, Verdict};
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum ReviewText {
|
||||
Comment(u64),
|
||||
Summary,
|
||||
Overview,
|
||||
}
|
||||
|
||||
pub(super) struct ReviewBuffer {
|
||||
owner: usize,
|
||||
kind: ReviewText,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn review_panel_title(&self) -> Option<&str> {
|
||||
self.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.map(|buffer| buffer.title.as_str())
|
||||
}
|
||||
|
||||
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))
|
||||
|| 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)
|
||||
|| std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.any(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
&& buffer.dirty()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn local_review_close_blocked(&mut self) -> bool {
|
||||
if self.current_review_dirty() {
|
||||
self.set_message("Unsaved local review; use :diff.review.save <path> before closing");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn detach_review_buffers(&mut self) {
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
{
|
||||
buffer.review_buffer = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_review_text(&mut self) {
|
||||
let owner = self.tab_id;
|
||||
let updates: Vec<_> = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.filter_map(|buffer| {
|
||||
let binding = buffer.review_buffer.as_ref()?;
|
||||
(binding.owner == owner && binding.kind != ReviewText::Overview)
|
||||
.then(|| (binding.kind, buffer.lines.join("\n")))
|
||||
})
|
||||
.collect();
|
||||
let Some(diff) = self.review_diff_mut() else {
|
||||
return;
|
||||
};
|
||||
for (kind, text) in updates {
|
||||
let destination = match kind {
|
||||
ReviewText::Comment(id) => diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter_mut()
|
||||
.find(|comment| comment.id == id)
|
||||
.map(|comment| &mut comment.body),
|
||||
ReviewText::Summary => Some(&mut diff.local_review.document.summary),
|
||||
ReviewText::Overview => None,
|
||||
};
|
||||
if let Some(destination) = destination
|
||||
&& *destination != text
|
||||
{
|
||||
*destination = text;
|
||||
diff.local_review.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
|
||||
self.sync_review_text();
|
||||
let state = &mut self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.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")?;
|
||||
state.document.save(&path)?;
|
||||
state.path = Some(path);
|
||||
state.dirty = false;
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
{
|
||||
buffer.saved_lines.clone_from(&buffer.lines);
|
||||
}
|
||||
}
|
||||
self.set_message("Local review saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn local_review_command(
|
||||
&mut self,
|
||||
action: Action,
|
||||
args: &str,
|
||||
) -> Result<(), String> {
|
||||
if self.resolver().is_some() {
|
||||
return Err("Local reviews require an ordinary diff comparison".into());
|
||||
}
|
||||
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();
|
||||
if !matches!(
|
||||
action,
|
||||
Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
) && !args.is_empty()
|
||||
{
|
||||
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 action == Action::ReviewOpen {
|
||||
if args.is_empty() {
|
||||
return Err("Usage: diff.review.open <path>".into());
|
||||
}
|
||||
if self.current_review_dirty() {
|
||||
return Err("Save the current local review before opening another".into());
|
||||
}
|
||||
let document = Review::load(Path::new(args), &root)?;
|
||||
self.detach_review_buffers();
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
state.document = document;
|
||||
state.path = Some(PathBuf::from(args));
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
self.open_review_overview()?;
|
||||
return Ok(());
|
||||
}
|
||||
self.sync_review_text();
|
||||
match action {
|
||||
Action::DiffComment => self.add_review_comment()?,
|
||||
Action::ReviewEdit => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
self.show_review_comment(id)?;
|
||||
}
|
||||
Action::ReviewDelete => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
state.document.comments.retain(|comment| comment.id != id);
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
state.dirty = true;
|
||||
// The deleted draft cannot later overwrite another comment with the same ID.
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer.review_buffer.as_ref().is_some_and(|binding| {
|
||||
binding.owner == self.tab_id && binding.kind == ReviewText::Comment(id)
|
||||
}) {
|
||||
buffer.review_buffer = None;
|
||||
buffer.saved_lines.clone_from(&buffer.lines);
|
||||
}
|
||||
}
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewNext | Action::ReviewPrevious => {
|
||||
let state = &self.workspace_diff().unwrap().local_review;
|
||||
let comments = &state.document.comments;
|
||||
if comments.is_empty() {
|
||||
return Err("No local review comments".into());
|
||||
}
|
||||
let current = state
|
||||
.selected
|
||||
.and_then(|id| comments.iter().position(|comment| comment.id == id));
|
||||
let index = match (current, action == Action::ReviewNext) {
|
||||
(Some(index), true) => (index + 1) % comments.len(),
|
||||
(Some(index), false) => (index + comments.len() - 1) % comments.len(),
|
||||
(None, true) => 0,
|
||||
(None, false) => comments.len() - 1,
|
||||
};
|
||||
self.show_review_comment(comments[index].id)?;
|
||||
}
|
||||
Action::ReviewOverview => self.open_review_overview()?,
|
||||
Action::ReviewSummary => {
|
||||
let text = self
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.summary
|
||||
.clone();
|
||||
self.open_review_buffer(
|
||||
ReviewText::Summary,
|
||||
&text,
|
||||
"Review summary · :w saves review".into(),
|
||||
)?;
|
||||
}
|
||||
Action::ReviewVerdict => {
|
||||
let verdict = match args {
|
||||
"looks_good" => Some(Verdict::LooksGood),
|
||||
"changes_requested" => Some(Verdict::ChangesRequested),
|
||||
"none" => None,
|
||||
_ => {
|
||||
return Err(
|
||||
"Usage: diff.review.verdict <looks_good|changes_requested|none>".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
if state.document.verdict != verdict {
|
||||
state.document.verdict = verdict;
|
||||
state.dirty = true;
|
||||
}
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewSave => {
|
||||
self.save_local_review((!args.is_empty()).then(|| Path::new(args)))?
|
||||
}
|
||||
_ => unreachable!("local review action"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn review_comment_id(&self, args: &str) -> Result<u64, String> {
|
||||
let state = &self
|
||||
.workspace_diff()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review;
|
||||
let under_cursor = self
|
||||
.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.filter(|binding| binding.kind == ReviewText::Overview)
|
||||
.and_then(|_| {
|
||||
self.active.lines[..=self.cursor().row]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("## Comment #")?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
})
|
||||
});
|
||||
let id = if args.is_empty() {
|
||||
under_cursor
|
||||
.or(state.selected)
|
||||
.ok_or("Select a comment or supply its ID")?
|
||||
} else {
|
||||
args.parse()
|
||||
.map_err(|_| "Comment ID must be a positive integer")?
|
||||
};
|
||||
state
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.any(|comment| comment.id == id)
|
||||
.then_some(id)
|
||||
.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);
|
||||
}
|
||||
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 line range for a review comment".into());
|
||||
}
|
||||
let selection = self.selections().into_iter().next();
|
||||
let (first, last) = selection.map_or((self.cursor().row, self.cursor().row), |selection| {
|
||||
let last = selection.end.row.saturating_sub(usize::from(
|
||||
selection.end.column == 0 && selection.end.row > selection.start.row,
|
||||
));
|
||||
(selection.start.row, last)
|
||||
});
|
||||
let 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
|
||||
} else {
|
||||
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(),
|
||||
side,
|
||||
start_line: first + 1,
|
||||
end_line: last + 1,
|
||||
content: self.active.lines.join("\n"),
|
||||
};
|
||||
let id = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.map(|comment| comment.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.ok_or("Review comment IDs exhausted")?;
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let state = &mut self.diff.as_mut().unwrap().local_review;
|
||||
state.document.comments.push(Comment {
|
||||
id,
|
||||
anchor,
|
||||
body: String::new(),
|
||||
});
|
||||
state.dirty = true;
|
||||
self.show_review_comment(id)
|
||||
}
|
||||
|
||||
fn show_review_comment(&mut self, id: u64) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().unwrap();
|
||||
let comment = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.find(|comment| comment.id == id)
|
||||
.ok_or("Comment no longer exists")?;
|
||||
let text = comment.body.clone();
|
||||
let anchor = &comment.anchor;
|
||||
let title = format!(
|
||||
"Comment #{id} · {}:{}–{} · {:?} · :w saves review",
|
||||
anchor.path.display(),
|
||||
anchor.start_line,
|
||||
anchor.end_line,
|
||||
anchor.side
|
||||
);
|
||||
let target = diff.comparison.as_ref().and_then(|comparison| {
|
||||
(comparison.revisions[anchor.side.index()] == anchor.revision)
|
||||
.then(|| {
|
||||
comparison.files.iter().position(|file| {
|
||||
let path = if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
};
|
||||
path == &anchor.path
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
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();
|
||||
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
|
||||
if target.is_none() {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn finish_review_navigation(&mut self) {
|
||||
let Some(diff) = self.diff.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(id) = diff.local_review.pending_navigation else {
|
||||
return;
|
||||
};
|
||||
if !diff.selected_file_loaded() {
|
||||
return;
|
||||
}
|
||||
diff.local_review.pending_navigation = None;
|
||||
let Some(comment) = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.find(|comment| comment.id == id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let selected_path = diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(diff.file))
|
||||
.map(|file| {
|
||||
if comment.anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
}
|
||||
});
|
||||
if selected_path != Some(&comment.anchor.path) {
|
||||
return;
|
||||
}
|
||||
if !diff.review_anchor_current(&comment.anchor) {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
return;
|
||||
}
|
||||
let row = comment.anchor.start_line - 1;
|
||||
let focus = if comment.anchor.side == Side::Before {
|
||||
Focus::Before
|
||||
} else {
|
||||
Focus::After
|
||||
};
|
||||
diff.focus = focus;
|
||||
diff.reveal_source_row(focus, row);
|
||||
self.bind_diff_buffer();
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
}
|
||||
|
||||
fn open_review_overview(&mut self) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().unwrap();
|
||||
let state = &diff.local_review;
|
||||
let verdict = match state.document.verdict {
|
||||
Some(Verdict::LooksGood) => "Looks good",
|
||||
Some(Verdict::ChangesRequested) => "Changes requested",
|
||||
None => "Not set",
|
||||
};
|
||||
let mut text = format!(
|
||||
"# Local review\n\nVerdict: {verdict}\nFile: {}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
|
||||
state
|
||||
.path
|
||||
.as_ref()
|
||||
.map_or_else(|| "not saved".into(), |path| path.display().to_string()),
|
||||
state.document.summary
|
||||
);
|
||||
for comment in &state.document.comments {
|
||||
let anchor = &comment.anchor;
|
||||
let status = if diff.review_anchor_current(anchor) {
|
||||
"current"
|
||||
} else if diff.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison.revisions[anchor.side.index()] == anchor.revision
|
||||
&& comparison.files.iter().any(|file| {
|
||||
!file.loaded
|
||||
&& if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
|
||||
} else {
|
||||
file.path == anchor.path
|
||||
}
|
||||
})
|
||||
}) {
|
||||
"not checked (file not loaded)"
|
||||
} else {
|
||||
"stale"
|
||||
};
|
||||
use std::fmt::Write as _;
|
||||
write!(
|
||||
text,
|
||||
"\n## Comment #{} · {status}\n{}:{}–{} · {:?}\nRevision: {}\n{}\n",
|
||||
comment.id,
|
||||
anchor.path.display(),
|
||||
anchor.start_line,
|
||||
anchor.end_line,
|
||||
anchor.side,
|
||||
anchor.revision,
|
||||
comment.body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
self.open_review_buffer(
|
||||
ReviewText::Overview,
|
||||
&text,
|
||||
"Local review overview · Enter edits · :diff.review.next".into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_review_buffer(
|
||||
&mut self,
|
||||
kind: ReviewText,
|
||||
text: &str,
|
||||
title: String,
|
||||
) -> Result<(), String> {
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let existing = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id && binding.kind == kind)
|
||||
})
|
||||
.map(|buffer| buffer.id);
|
||||
let panel = self.panes.iter().find_map(|(id, pane)| {
|
||||
(self.pane_layout.contains(*id)
|
||||
&& self.buffer(pane.buffer_id).is_some_and(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
}))
|
||||
.then_some(*id)
|
||||
});
|
||||
if let Some(panel) = panel {
|
||||
self.select_pane(panel);
|
||||
} else {
|
||||
self.select_pane(self.diff_owner.unwrap());
|
||||
self.split_pane(SplitAxis::Vertical);
|
||||
}
|
||||
if let Some(id) = existing {
|
||||
self.switch_buffer(id);
|
||||
if kind == ReviewText::Overview {
|
||||
self.active.lines = split_lines(text);
|
||||
self.active.saved_lines.clone_from(&self.active.lines);
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.normalize_targets();
|
||||
}
|
||||
} else {
|
||||
let mut buffer = Buffer::from_text(self.next_buffer_id, None, None, text, None)
|
||||
.map_err(|error| error.to_string())?;
|
||||
buffer.review_buffer = Some(ReviewBuffer {
|
||||
owner: self.tab_id,
|
||||
kind,
|
||||
title,
|
||||
});
|
||||
buffer.resolver_readonly = kind == ReviewText::Overview;
|
||||
self.add_buffer(buffer);
|
||||
}
|
||||
self.set_message("Local review · :w saves JSON; first choose :diff.review.save <path>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_review_overview(&mut self, input: &Input) -> bool {
|
||||
if *input == Input::Enter
|
||||
&& self.active.view.mode == Mode::Normal
|
||||
&& self.palette.is_none()
|
||||
&& self
|
||||
.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.kind == ReviewText::Overview)
|
||||
{
|
||||
if let Err(error) = self.local_review_command(Action::ReviewEdit, "") {
|
||||
self.set_message(error);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user