feat: copy diff review comments to configured clipboard
This commit is contained in:
@@ -1041,6 +1041,12 @@ core_actions![
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.copy",
|
||||
Action::ReviewCopy,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.summary",
|
||||
Action::ReviewSummary,
|
||||
@@ -5436,6 +5442,7 @@ impl Editor {
|
||||
| Action::ReviewNext
|
||||
| Action::ReviewPrevious
|
||||
| Action::ReviewOverview
|
||||
| Action::ReviewCopy
|
||||
| Action::ReviewSummary
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
@@ -8867,6 +8874,7 @@ impl Editor {
|
||||
| Action::ReviewNext
|
||||
| Action::ReviewPrevious
|
||||
| Action::ReviewOverview
|
||||
| Action::ReviewCopy
|
||||
| Action::ReviewSummary
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
@@ -9825,6 +9833,7 @@ impl Editor {
|
||||
| Action::ReviewNext
|
||||
| Action::ReviewPrevious
|
||||
| Action::ReviewOverview
|
||||
| Action::ReviewCopy
|
||||
| Action::ReviewSummary
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
|
||||
+15
-1
@@ -460,7 +460,7 @@ impl Editor {
|
||||
{
|
||||
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||
}
|
||||
if !matches!(action, Action::ReviewSave) {
|
||||
if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
|
||||
self.autosave_local_review()?;
|
||||
}
|
||||
if action == Action::ReviewOpen {
|
||||
@@ -539,6 +539,20 @@ impl Editor {
|
||||
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()
|
||||
|
||||
@@ -100,6 +100,7 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
|
||||
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
|
||||
: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
|
||||
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{
|
||||
cell::RefCell,
|
||||
collections::BTreeSet,
|
||||
ffi::OsStr,
|
||||
fmt::Write as _,
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
@@ -59,6 +60,22 @@ impl Anchor {
|
||||
self.start.row..=end
|
||||
}
|
||||
|
||||
pub fn excerpt(&self) -> &str {
|
||||
let offset = |position: crate::plugin::TextPosition| {
|
||||
let line = self.content.split('\n').nth(position.row).unwrap_or("");
|
||||
self.content
|
||||
.split('\n')
|
||||
.take(position.row)
|
||||
.map(|line| line.len() + 1)
|
||||
.sum::<usize>()
|
||||
+ line
|
||||
.grapheme_indices(true)
|
||||
.nth(position.column)
|
||||
.map_or(line.len(), |(index, _)| index)
|
||||
};
|
||||
&self.content[offset(self.start)..offset(self.end)]
|
||||
}
|
||||
|
||||
pub fn matches_lines(
|
||||
&self,
|
||||
revision: &str,
|
||||
@@ -148,6 +165,69 @@ impl Review {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summary_text(&self) -> String {
|
||||
let resolved = self
|
||||
.comments
|
||||
.iter()
|
||||
.filter(|comment| comment.resolved)
|
||||
.count();
|
||||
format!(
|
||||
"Review {} (revision {}): {}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
|
||||
self.storage_id
|
||||
.map_or_else(|| "null".into(), |id| id.to_string()),
|
||||
self.storage_revision,
|
||||
self.summary,
|
||||
match self.verdict {
|
||||
Some(Verdict::LooksGood) => "looks_good",
|
||||
Some(Verdict::ChangesRequested) => "changes_requested",
|
||||
None => "none",
|
||||
},
|
||||
self.comments.len() - resolved,
|
||||
resolved,
|
||||
self.comments.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// CLI show format, including the repository, summary, and selected comments.
|
||||
pub fn format_text(&self, all: bool) -> String {
|
||||
let mut text = format!(
|
||||
"Repository: {}\n{}",
|
||||
self.root.display(),
|
||||
self.summary_text()
|
||||
);
|
||||
for comment in self
|
||||
.comments
|
||||
.iter()
|
||||
.filter(|comment| all || !comment.resolved)
|
||||
{
|
||||
let anchor = &comment.anchor;
|
||||
writeln!(
|
||||
text,
|
||||
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}\n{}\n{}",
|
||||
comment.id,
|
||||
if comment.resolved {
|
||||
"resolved"
|
||||
} else {
|
||||
"unresolved"
|
||||
},
|
||||
anchor.path.display(),
|
||||
match anchor.side {
|
||||
Side::Before => "before",
|
||||
Side::After => "after",
|
||||
},
|
||||
anchor.start.row,
|
||||
anchor.start.column,
|
||||
anchor.end.row,
|
||||
anchor.end.column,
|
||||
anchor.revision,
|
||||
anchor.excerpt(),
|
||||
comment.body,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 4 {
|
||||
return Err(format!("Unsupported review version {}", self.version));
|
||||
|
||||
+23
-77
@@ -3,10 +3,9 @@ use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use vamp::{
|
||||
repository,
|
||||
review::{Anchor, Review, Store},
|
||||
review::{Review, Store},
|
||||
ruby,
|
||||
};
|
||||
|
||||
@@ -79,24 +78,6 @@ impl Request {
|
||||
}
|
||||
}
|
||||
|
||||
fn excerpt(anchor: &Anchor) -> String {
|
||||
// Positions use extended graphemes, not bytes or Unicode scalar values.
|
||||
let offset = |position: vamp::plugin::TextPosition| {
|
||||
let line = anchor.content.split('\n').nth(position.row).unwrap_or("");
|
||||
anchor
|
||||
.content
|
||||
.split('\n')
|
||||
.take(position.row)
|
||||
.map(|line| line.len() + 1)
|
||||
.sum::<usize>()
|
||||
+ line
|
||||
.grapheme_indices(true)
|
||||
.nth(position.column)
|
||||
.map_or(line.len(), |(index, _)| index)
|
||||
};
|
||||
anchor.content[offset(anchor.start)..offset(anchor.end)].to_owned()
|
||||
}
|
||||
|
||||
fn envelope(review: &Review, all: bool, comments: bool) -> Value {
|
||||
let resolved = review
|
||||
.comments
|
||||
@@ -116,7 +97,7 @@ fn envelope(review: &Review, all: bool, comments: bool) -> Value {
|
||||
json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved,
|
||||
"anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path,
|
||||
"side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end },
|
||||
"excerpt": excerpt(&comment.anchor)})
|
||||
"excerpt": comment.anchor.excerpt()})
|
||||
}).collect();
|
||||
}
|
||||
value
|
||||
@@ -131,7 +112,7 @@ pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
let root = repository::discover_root(request.repo.as_deref().unwrap_or(&cwd))?;
|
||||
let store = Store::open_read_only(&directory)?;
|
||||
let output = if let Some(id) = request.id {
|
||||
let reviews = if let Some(id) = request.id {
|
||||
let mut review = store
|
||||
.as_ref()
|
||||
.ok_or("Review does not exist in this repository")?
|
||||
@@ -145,76 +126,41 @@ pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
|
||||
review.comments.remove(index);
|
||||
Store::open(&directory)?.save(&mut review)?;
|
||||
}
|
||||
envelope(&review, request.all, true)
|
||||
vec![review]
|
||||
} else {
|
||||
let reviews = match store {
|
||||
Some(store) => store
|
||||
.list(&root)?
|
||||
.into_iter()
|
||||
.map(|review| envelope(&review, true, false))
|
||||
.collect::<Vec<_>>(),
|
||||
match store {
|
||||
Some(store) => store.list(&root)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
json!({"repository": root, "reviews": reviews})
|
||||
}
|
||||
};
|
||||
if request.json {
|
||||
let output = if request.id.is_some() {
|
||||
envelope(&reviews[0], request.all, true)
|
||||
} else {
|
||||
let reviews = reviews
|
||||
.iter()
|
||||
.map(|review| envelope(review, true, false))
|
||||
.collect::<Vec<_>>();
|
||||
json!({"repository": root, "reviews": reviews})
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&output).map_err(|error| error.to_string())?
|
||||
);
|
||||
} else if request.id.is_some() {
|
||||
print!("{}", reviews[0].format_text(request.all));
|
||||
} else {
|
||||
println!("Repository: {}", root.display());
|
||||
if let Some(reviews) = output["reviews"].as_array() {
|
||||
if reviews.is_empty() {
|
||||
println!("No stored reviews.");
|
||||
}
|
||||
for review in reviews {
|
||||
print_review(review);
|
||||
}
|
||||
} else {
|
||||
print_review(&output);
|
||||
for comment in output["comments"].as_array().unwrap() {
|
||||
let anchor = &comment["anchor"];
|
||||
println!(
|
||||
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}",
|
||||
comment["id"],
|
||||
if comment["resolved"] == true {
|
||||
"resolved"
|
||||
} else {
|
||||
"unresolved"
|
||||
},
|
||||
anchor["path"].as_str().unwrap(),
|
||||
anchor["side"].as_str().unwrap(),
|
||||
anchor["start"]["row"],
|
||||
anchor["start"]["column"],
|
||||
anchor["end"]["row"],
|
||||
anchor["end"]["column"],
|
||||
anchor["revision"].as_str().unwrap()
|
||||
);
|
||||
println!(
|
||||
"{}\n{}",
|
||||
comment["excerpt"].as_str().unwrap(),
|
||||
comment["body"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
if reviews.is_empty() {
|
||||
println!("No stored reviews.");
|
||||
}
|
||||
for review in reviews {
|
||||
print!("{}", review.summary_text());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_review(review: &Value) {
|
||||
println!(
|
||||
"Review {} (revision {}): {}\n Verdict: {}; {} unresolved, {} resolved ({} total)",
|
||||
review["id"],
|
||||
review["revision"],
|
||||
review["summary"].as_str().unwrap(),
|
||||
review["verdict"].as_str().unwrap_or("none"),
|
||||
review["counts"]["unresolved"],
|
||||
review["counts"]["resolved"],
|
||||
review["counts"]["total"]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/unit/review_cli.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user