Files
vamp/src/editor_review.rs
T
2026-10-20 16:28:53 +02:00

1026 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}