1026 lines
37 KiB
Rust
1026 lines
37 KiB
Rust
use super::*;
|
||
use crate::review::{Anchor, Comment, Review, Side, Verdict};
|
||
|
||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||
enum ReviewText {
|
||
Comment(u64),
|
||
Summary,
|
||
Overview,
|
||
Reviews,
|
||
}
|
||
|
||
enum ReviewSource<'a> {
|
||
Stored(i64),
|
||
Json(&'a Path),
|
||
New,
|
||
}
|
||
|
||
pub(super) struct ReviewBuffer {
|
||
owner: usize,
|
||
kind: ReviewText,
|
||
title: String,
|
||
pending_comment: Option<Comment>,
|
||
}
|
||
|
||
impl Editor {
|
||
pub fn set_review_directory(&mut self, directory: PathBuf) {
|
||
self.review_directory = Some(directory);
|
||
}
|
||
|
||
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 {
|
||
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 reviews.len() == 1 {
|
||
self.review_diff_mut().unwrap().local_review.document = reviews.pop().unwrap();
|
||
} else if reviews.len() > 1 {
|
||
self.show_review_selector(reviews)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn open_review_selector(&mut self) -> Result<(), String> {
|
||
let diff = self.workspace_diff().ok_or("No comparison is open")?;
|
||
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)?;
|
||
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",
|
||
);
|
||
for review in reviews {
|
||
let id = review.storage_id.unwrap();
|
||
let label = review
|
||
.summary
|
||
.lines()
|
||
.find(|line| !line.trim().is_empty())
|
||
.map_or_else(|| format!("Review {id}"), |line| line.trim().to_owned());
|
||
use std::fmt::Write as _;
|
||
writeln!(text, "\n## Review #{id} · {label}").unwrap();
|
||
}
|
||
let row = text
|
||
.lines()
|
||
.position(|line| line.starts_with("## Review #"))
|
||
.unwrap_or(0);
|
||
self.open_review_buffer(
|
||
ReviewText::Reviews,
|
||
&text,
|
||
"Local reviews · Enter opens · :diff.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;
|
||
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.as_mut().unwrap().pending_comment = None;
|
||
buffer.saved_lines.clone_from(&buffer.lines);
|
||
buffer.dirty_cache.set(Some(false));
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(super) fn autosave_local_review(&mut self) -> Result<(), String> {
|
||
let Some(directory) = self.review_directory.as_deref() else {
|
||
return Ok(());
|
||
};
|
||
if !self
|
||
.workspace_diff()
|
||
.is_some_and(|diff| diff.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;
|
||
Ok(())
|
||
}
|
||
pub fn review_panel_title(&self) -> Option<&str> {
|
||
self.active
|
||
.review_buffer
|
||
.as_ref()
|
||
.map(|buffer| buffer.title.as_str())
|
||
}
|
||
|
||
pub fn review_panel(&self) -> Option<usize> {
|
||
self.review_pane
|
||
.filter(|pane| *pane == self.pane_id && self.workspace_diff().is_some())
|
||
}
|
||
|
||
pub(super) fn dismiss_review_panel(&mut self) -> bool {
|
||
let Some(panel) = self.review_panel() else {
|
||
return false;
|
||
};
|
||
let source = self.panes[&panel]
|
||
.origin
|
||
.expect("review panel has a source pane");
|
||
self.finish_pane_interaction();
|
||
if let Some(mut binding) = self.active.review_buffer.take() {
|
||
let text = self.active.saved_lines.join("\n");
|
||
let wrap = self.active.view.word_wrap;
|
||
let mut buffer = Buffer::from_text(self.active.id, None, None, &text, None)
|
||
.expect("saved review text is valid");
|
||
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;
|
||
}
|
||
buffer.review_buffer = Some(binding);
|
||
self.active = buffer;
|
||
}
|
||
self.select_pane(source);
|
||
self.bind_diff_buffer();
|
||
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))
|
||
|| 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.review_directory.is_some()
|
||
&& let Err(error) = self.autosave_local_review()
|
||
{
|
||
self.set_message(format!("Review not saved: {error}"));
|
||
return true;
|
||
}
|
||
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 replace_local_review(
|
||
&mut self,
|
||
root: PathBuf,
|
||
source: ReviewSource<'_>,
|
||
) -> Result<(), String> {
|
||
let (document, path, dirty) = match source {
|
||
ReviewSource::Stored(id) => {
|
||
let directory = self
|
||
.review_directory
|
||
.as_deref()
|
||
.ok_or("Review storage is not configured")?;
|
||
(
|
||
crate::review::Store::open(directory)?.load(&root, id)?,
|
||
None,
|
||
false,
|
||
)
|
||
}
|
||
ReviewSource::Json(path) => (
|
||
Review::load(path, &root)?,
|
||
Some(path.to_path_buf()),
|
||
self.review_directory.is_some(),
|
||
),
|
||
ReviewSource::New => (Review::new(root), None, true),
|
||
};
|
||
// Retire old drafts before comment IDs can be reused.
|
||
self.detach_review_buffers();
|
||
let state = &mut self.review_diff_mut().unwrap().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();
|
||
// Persistence failure leaves the identity change applied and unsaved.
|
||
self.autosave_local_review()?;
|
||
self.open_review_overview()
|
||
}
|
||
|
||
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;
|
||
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);
|
||
buffer.dirty_cache.set(Some(false));
|
||
}
|
||
}
|
||
self.autosave_local_review()?;
|
||
self.open_review_overview()
|
||
}
|
||
|
||
fn sync_review_text(&mut self) -> Vec<(ReviewText, Option<String>)> {
|
||
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
|
||
&& matches!(binding.kind, ReviewText::Comment(_) | ReviewText::Summary))
|
||
.then(|| {
|
||
(
|
||
binding.kind,
|
||
buffer.lines.join("\n"),
|
||
binding.pending_comment.clone(),
|
||
)
|
||
})
|
||
})
|
||
.collect();
|
||
let Some(diff) = self.review_diff_mut() else {
|
||
return Vec::new();
|
||
};
|
||
let mut previous = Vec::new();
|
||
for (kind, text, pending) in updates {
|
||
if let Some(mut comment) = pending
|
||
&& !diff
|
||
.local_review
|
||
.document
|
||
.comments
|
||
.iter()
|
||
.any(|entry| entry.id == comment.id)
|
||
{
|
||
previous.push((kind, None));
|
||
comment.body = text;
|
||
diff.local_review.document.comments.push(comment);
|
||
diff.local_review.dirty = true;
|
||
continue;
|
||
}
|
||
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,
|
||
ReviewText::Reviews => None,
|
||
};
|
||
if let Some(destination) = destination
|
||
&& *destination != text
|
||
{
|
||
let old = std::mem::replace(destination, text);
|
||
previous.push((kind, Some(old)));
|
||
diff.local_review.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 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;
|
||
for (kind, text) in previous {
|
||
match (kind, text) {
|
||
(ReviewText::Comment(id), None) => {
|
||
state.document.comments.retain(|comment| comment.id != id)
|
||
}
|
||
(ReviewText::Comment(id), Some(text)) => {
|
||
state
|
||
.document
|
||
.comments
|
||
.iter_mut()
|
||
.find(|comment| comment.id == id)
|
||
.unwrap()
|
||
.body = text;
|
||
}
|
||
(ReviewText::Summary, Some(text)) => state.document.summary = text,
|
||
_ => unreachable!("only editable review text is synchronized"),
|
||
}
|
||
}
|
||
state.dirty = dirty;
|
||
}
|
||
result
|
||
}
|
||
|
||
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.autosave_local_review()?;
|
||
self.mark_review_saved();
|
||
self.set_message(if path.is_some() {
|
||
"Review exported as JSON; autosave remains in SQLite"
|
||
} else {
|
||
"Local review saved to SQLite"
|
||
});
|
||
return Ok(());
|
||
}
|
||
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);
|
||
self.mark_review_saved();
|
||
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());
|
||
}
|
||
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();
|
||
if !matches!(
|
||
action,
|
||
Action::ReviewEdit
|
||
| Action::ReviewDelete
|
||
| Action::ReviewResolve
|
||
| Action::ReviewUnresolve
|
||
| 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 !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
|
||
self.autosave_local_review()?;
|
||
}
|
||
if action == Action::ReviewOpen {
|
||
if self.current_review_dirty() {
|
||
return Err("Save the current local review before opening another".into());
|
||
}
|
||
if args.is_empty() {
|
||
return self.open_review_selector();
|
||
}
|
||
let source = if self.review_directory.is_some() {
|
||
args.parse::<i64>()
|
||
.map(ReviewSource::Stored)
|
||
.unwrap_or_else(|_| ReviewSource::Json(Path::new(args)))
|
||
} else {
|
||
ReviewSource::Json(Path::new(args))
|
||
};
|
||
return self.replace_local_review(root, source);
|
||
}
|
||
match action {
|
||
Action::DiffComment => self.add_review_comment()?,
|
||
Action::ReviewNew => self.replace_local_review(root, ReviewSource::New)?,
|
||
Action::ReviewEdit => {
|
||
let id = self.review_comment_id(args)?;
|
||
self.show_review_comment(id)?;
|
||
}
|
||
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 comment = state
|
||
.document
|
||
.comments
|
||
.iter_mut()
|
||
.find(|comment| comment.id == id)
|
||
.unwrap();
|
||
if comment.resolved != resolved {
|
||
comment.resolved = resolved;
|
||
state.dirty = true;
|
||
}
|
||
let status = if resolved { "resolved" } else { "unresolved" };
|
||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||
if let Some(binding) = buffer.review_buffer.as_mut()
|
||
&& binding.owner == self.tab_id
|
||
&& binding.kind == ReviewText::Comment(id)
|
||
{
|
||
binding.title = binding.title.replacen(
|
||
if resolved {
|
||
" · unresolved · "
|
||
} else {
|
||
" · resolved · "
|
||
},
|
||
&format!(" · {status} · "),
|
||
1,
|
||
);
|
||
}
|
||
}
|
||
self.autosave_local_review()?;
|
||
self.open_review_overview()?;
|
||
}
|
||
Action::ReviewDelete => self.delete_local_review_comment(args)?,
|
||
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::ReviewCopy => {
|
||
let review = &self.workspace_diff().unwrap().local_review.document;
|
||
let count = review.comments.len();
|
||
if count == 0 {
|
||
return Err("No local review comments".into());
|
||
}
|
||
let text = review.format_text(true);
|
||
self.store_clipboard(
|
||
vec![text],
|
||
self.clipboard_mode == ClipboardMode::System,
|
||
false,
|
||
)?;
|
||
self.set_message(format!("Copied {count} review comments"));
|
||
}
|
||
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.autosave_local_review()?;
|
||
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 contiguous selection for a review comment".into());
|
||
}
|
||
let range = self.selections().into_iter().next().unwrap_or_else(|| {
|
||
let row = self.cursor().row;
|
||
Selection {
|
||
start: Cursor { row, column: 0 },
|
||
end: if row + 1 < self.active.lines.len() {
|
||
Cursor {
|
||
row: row + 1,
|
||
column: 0,
|
||
}
|
||
} else {
|
||
Cursor {
|
||
row,
|
||
column: grapheme_count(&self.active.lines[row]),
|
||
}
|
||
},
|
||
}
|
||
});
|
||
let diff = self.diff.as_ref().unwrap();
|
||
let comparison = diff.comparison.as_ref().unwrap();
|
||
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: TextPosition {
|
||
row: range.start.row,
|
||
column: range.start.column,
|
||
},
|
||
end: TextPosition {
|
||
row: range.end.row,
|
||
column: range.end.column,
|
||
},
|
||
content: self.active.lines.join("\n"),
|
||
};
|
||
let id = diff
|
||
.local_review
|
||
.document
|
||
.comments
|
||
.iter()
|
||
.map(|comment| comment.id)
|
||
.chain(
|
||
std::iter::once(&self.active)
|
||
.chain(self.parked.values())
|
||
.filter_map(|buffer| {
|
||
let binding = buffer.review_buffer.as_ref()?;
|
||
(binding.owner == self.tab_id)
|
||
.then_some(binding.pending_comment.as_ref()?.id)
|
||
}),
|
||
)
|
||
.max()
|
||
.unwrap_or(0)
|
||
.checked_add(1)
|
||
.ok_or("Review comment IDs exhausted")?;
|
||
self.prepare_command();
|
||
self.finish_command_mode();
|
||
let comment = Comment {
|
||
id,
|
||
anchor,
|
||
body: String::new(),
|
||
resolved: false,
|
||
};
|
||
self.open_review_comment(id, Some(&comment))?;
|
||
self.active.review_buffer.as_mut().unwrap().pending_comment = Some(comment);
|
||
Ok(())
|
||
}
|
||
|
||
fn show_review_comment(&mut self, id: u64) -> Result<(), String> {
|
||
self.open_review_comment(id, None)
|
||
}
|
||
|
||
fn open_review_comment(&mut self, id: u64, draft: Option<&Comment>) -> Result<(), String> {
|
||
let diff = self.workspace_diff().unwrap();
|
||
let comment = draft
|
||
.or_else(|| {
|
||
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 status = if comment.resolved {
|
||
"resolved"
|
||
} else {
|
||
"unresolved"
|
||
};
|
||
let mapped = diff.review_anchor_range(anchor);
|
||
let current = mapped.is_some();
|
||
let location = if current {
|
||
"current"
|
||
} else {
|
||
"original snapshot"
|
||
};
|
||
let range = mapped.unwrap_or(anchor.start..anchor.end);
|
||
let title = format!(
|
||
"Comment #{id} · {status} · {location} · {}:{}:{}–{}:{} · {:?} · :w saves review",
|
||
anchor.path.display(),
|
||
range.start.row + 1,
|
||
range.start.column + 1,
|
||
range.end.row + 1,
|
||
range.end.column + 1,
|
||
anchor.side
|
||
);
|
||
let target = diff.comparison.as_ref().and_then(|comparison| {
|
||
comparison.files.iter().position(|file| {
|
||
let path = if anchor.side == Side::Before {
|
||
file.old_path.as_ref().unwrap_or(&file.path)
|
||
} else {
|
||
&file.path
|
||
};
|
||
path == &anchor.path
|
||
})
|
||
});
|
||
let stale = !current
|
||
&& target.is_none_or(|index| diff.comparison.as_ref().unwrap().files[index].loaded);
|
||
let start = anchor.start;
|
||
let owner = self.diff_owner.unwrap();
|
||
self.select_pane(owner);
|
||
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);
|
||
}
|
||
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
|
||
if stale {
|
||
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;
|
||
}
|
||
let Some(range) = diff.review_anchor_range(&comment.anchor) else {
|
||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||
return;
|
||
};
|
||
let position = range.start;
|
||
let focus = if comment.anchor.side == Side::Before {
|
||
Focus::Before
|
||
} else {
|
||
Focus::After
|
||
};
|
||
diff.focus = focus;
|
||
diff.reveal_source_row(focus, position.row);
|
||
self.bind_diff_buffer();
|
||
self.place_navigation(position);
|
||
}
|
||
|
||
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 storage = if self.review_directory.is_some() {
|
||
state.document.storage_id.map_or_else(
|
||
|| "SQLite · uncommitted draft".into(),
|
||
|id| {
|
||
format!(
|
||
"SQLite · review #{id} · revision {}",
|
||
state.document.storage_revision
|
||
)
|
||
},
|
||
)
|
||
} else {
|
||
state
|
||
.path
|
||
.as_ref()
|
||
.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",
|
||
state.document.summary
|
||
);
|
||
for comment in &state.document.comments {
|
||
let anchor = &comment.anchor;
|
||
let mapped = diff.review_anchor_range(anchor);
|
||
let range = mapped.clone().unwrap_or(anchor.start..anchor.end);
|
||
let status = if mapped.is_some() {
|
||
"current"
|
||
} else if diff.comparison.as_ref().is_some_and(|comparison| {
|
||
comparison.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"
|
||
};
|
||
let resolution = if comment.resolved {
|
||
"resolved"
|
||
} else {
|
||
"unresolved"
|
||
};
|
||
use std::fmt::Write as _;
|
||
write!(
|
||
text,
|
||
"\n## Comment #{} · {resolution} · {status}\n{}:{}:{}–{}:{} · {:?}\nRevision: {}\n{}\n",
|
||
comment.id,
|
||
anchor.path.display(),
|
||
range.start.row + 1,
|
||
range.start.column + 1,
|
||
range.end.row + 1,
|
||
range.end.column + 1,
|
||
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 = if let Some(panel) = self.review_pane {
|
||
panel
|
||
} else {
|
||
let id = self.next_pane_id;
|
||
self.next_pane_id += 1;
|
||
self.panes.insert(
|
||
id,
|
||
Pane {
|
||
buffer_id: self.active.id,
|
||
origin: self.diff_owner,
|
||
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.select_pane(panel);
|
||
if let Some(id) = existing {
|
||
self.switch_buffer(id);
|
||
self.active.review_buffer.as_mut().unwrap().title = title;
|
||
if matches!(kind, ReviewText::Overview | ReviewText::Reviews) {
|
||
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.active.dirty_cache.set(Some(false));
|
||
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,
|
||
pending_comment: None,
|
||
});
|
||
buffer.resolver_readonly = matches!(kind, ReviewText::Overview | ReviewText::Reviews);
|
||
buffer.view.word_wrap = true;
|
||
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"
|
||
} else {
|
||
"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::Escape
|
||
&& self.active.view.mode == Mode::Normal
|
||
&& self.palette.is_none()
|
||
&& self.sequence_pending().is_none()
|
||
&& self.count.is_none()
|
||
&& self.dismiss_review_panel()
|
||
{
|
||
return true;
|
||
}
|
||
if *input == Input::Enter
|
||
&& self.active.view.mode == Mode::Normal
|
||
&& self.palette.is_none()
|
||
&& self.active.review_buffer.as_ref().is_some_and(|binding| {
|
||
matches!(binding.kind, ReviewText::Overview | ReviewText::Reviews)
|
||
})
|
||
{
|
||
let choice = if self.active.review_buffer.as_ref().unwrap().kind == ReviewText::Reviews
|
||
{
|
||
self.active.lines[..=self.cursor().row]
|
||
.iter()
|
||
.rev()
|
||
.find_map(|line| line.strip_prefix("## Review #")?.split_whitespace().next())
|
||
.map(str::to_owned)
|
||
.ok_or_else(|| "Place the caret on a review".to_string())
|
||
.and_then(|id| self.local_review_command(Action::ReviewOpen, &id))
|
||
} else {
|
||
self.local_review_command(Action::ReviewEdit, "")
|
||
};
|
||
if let Err(error) = choice {
|
||
self.set_message(error);
|
||
}
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
}
|