use std::collections::HashMap;
use crate::api::model::{NewThread, ReviewEvent, ReviewThread, Side as ApiSide};
use crate::api::model::{NodeId, PrRef};
use crate::diff::anchor::{AnchorError, LinePos, Side};
use crate::store::{DraftComment, DraftSide, ReviewState};
use crate::theme::{Bg, Theme};
use crate::ui::annotations::{Annotation, AnnotationComment, AnnotationKey, Place};
use crate::ui::diff_doc::{Doc, Pos};
use crate::ui::text::short_sha;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui_textarea::TextArea;
use crate::api::change::Change;
use crate::diff_screen::{self, DiffOf, LastReview};
use crate::keymap::Action;
use crate::picker::{self, PickItem};
use crate::state::{Api, Cmd, Git, OutdatedThread, Overlay, Screen, State};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComposeTarget {
Line {
path: String,
start: LinePos,
end: LinePos,
},
File {
path: String,
reason: Option<String>,
},
Reply { thread_id: NodeId },
Draft { id: u64 },
Conversation { subject_id: NodeId, name: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Preview {
pub start_line: u32,
pub original: Vec<String>,
pub suggested: Vec<String>,
}
pub struct Compose {
pub target: ComposeTarget,
pub input: TextArea<'static>,
pub preview: Option<Preview>,
pub sending: bool,
pub error: Option<String>,
pub discard_armed: Option<&'static str>,
}
impl Compose {
pub fn new(theme: &Theme, target: ComposeTarget, text: &str) -> Self {
Self {
target,
input: editor(theme, text),
preview: None,
sending: false,
error: None,
discard_armed: None,
}
}
pub fn text(&self) -> String {
self.input.lines().join("\n")
}
pub fn title(&self) -> String {
let side = |s: Side| match s {
Side::Left => "old",
Side::Right => "new",
};
match &self.target {
ComposeTarget::Line { path, start, end } if start == end => {
format!("Comment on {path}:{} ({})", end.line, side(end.side))
}
ComposeTarget::Line { path, start, end } => format!(
"Comment on {path}, lines {}–{} ({})",
start.line,
end.line,
side(end.side)
),
ComposeTarget::File { path, .. } => format!("Comment on the file {path}"),
ComposeTarget::Reply { .. } => "Reply".to_owned(),
ComposeTarget::Draft { .. } => "Edit draft".to_owned(),
ComposeTarget::Conversation { name, .. } => format!("Comment on {name}"),
}
}
}
pub struct SubmitDialog {
pub pr: PrRef,
pub event: ReviewEvent,
pub input: TextArea<'static>,
pub sending: bool,
pub error: Option<String>,
pub quick: bool,
pub saved: Option<ReviewState>,
pub discard_armed: Option<&'static str>,
pub on_github: Option<u64>,
}
impl SubmitDialog {
pub fn new(theme: &Theme, pr: PrRef) -> Self {
let mut input = editor(theme, "");
input.set_placeholder_text("Summary (optional for comments and approvals)");
input.set_placeholder_style(theme.meta(Bg::ContainerHigh));
Self {
pr,
event: ReviewEvent::Comment,
input,
sending: false,
error: None,
quick: false,
saved: None,
discard_armed: None,
on_github: None,
}
}
pub fn drafts<'a>(&'a self, state: &'a State) -> Option<&'a [DraftComment]> {
match state.diffs.get(&DiffOf::Pr(self.pr.clone())) {
Some(diff) => Some(&diff.inputs().review.pending),
None => self.saved.as_ref().map(|s| s.pending.as_slice()),
}
}
pub fn cycle(&mut self, forward: bool) {
const ORDER: [ReviewEvent; 3] = [
ReviewEvent::Comment,
ReviewEvent::Approve,
ReviewEvent::RequestChanges,
];
let i = ORDER.iter().position(|e| *e == self.event).unwrap_or(0);
let steps = if forward { 1 } else { ORDER.len() - 1 };
if let Some(&next) = ORDER.iter().cycle().nth(i + steps) {
self.event = next;
}
}
}
fn editor(theme: &Theme, text: &str) -> TextArea<'static> {
let mut input = crate::nav::new_input(theme, Bg::ContainerHigh);
input.insert_str(text.trim_end_matches('\n'));
input
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditPurpose {
Compose,
Suggest {
path: String,
start: LinePos,
end: LinePos,
original: Vec<String>,
},
Summary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SubmitOutcome {
pub accepted: Vec<u64>,
pub rejected: Vec<(u64, String)>,
pub submitted: bool,
pub error: Option<String>,
pub on_github: Option<u64>,
}
pub fn suggestion_body(suggested: &str) -> String {
format!("```suggestion\n{}\n```", suggested.trim_end_matches('\n'))
}
pub fn target(
doc: &Doc,
cursor: Pos,
half: Side,
selection: Option<Pos>,
) -> Result<ComposeTarget, String> {
let file = doc.files().get(cursor.file).ok_or("No file here")?;
let path = file.meta.path().to_owned();
let (from, to) = match selection {
Some(anchor) if anchor.file == cursor.file => {
let (a, b) = (doc.to_global(anchor), doc.to_global(cursor));
(a.min(b), a.max(b))
}
Some(_) => return Err("A selection can't span files".into()),
None => {
let g = doc.to_global(cursor);
(g, g)
}
};
let lines: Vec<LinePos> = (from..=to)
.filter_map(|g| doc.line_at(doc.to_pos(g), half))
.collect();
let (Some(&start), Some(&end)) = (lines.first(), lines.last()) else {
return Ok(ComposeTarget::File { path, reason: None });
};
let Some(commentable) = doc.commentable(cursor.file) else {
return Err("This file's diff hasn't loaded yet".into());
};
match commentable.validate(start, end) {
Ok(()) => Ok(ComposeTarget::Line { path, start, end }),
Err(AnchorError::NotCommentable) => Ok(ComposeTarget::File {
path,
reason: Some(
"That line isn't part of GitHub's diff, so this will be a comment on the file."
.into(),
),
}),
Err(err) => Err(format!("Can't comment there: {err}")),
}
}
fn draft_side(side: Side) -> DraftSide {
match side {
Side::Left => DraftSide::Left,
Side::Right => DraftSide::Right,
}
}
fn side_of(side: DraftSide) -> Side {
match side {
DraftSide::Left => Side::Left,
DraftSide::Right => Side::Right,
}
}
pub fn draft(target: &ComposeTarget, body: &str, id: u64, commit: &str) -> Option<DraftComment> {
let base = |path: &str| DraftComment {
id,
path: path.to_owned(),
body: body.to_owned(),
side: DraftSide::Right,
line: None,
start_line: None,
start_side: None,
commit: commit.to_owned(),
error: None,
};
match target {
ComposeTarget::Line { path, start, end } => {
let ranged = start != end;
Some(DraftComment {
side: draft_side(end.side),
line: Some(end.line),
start_line: ranged.then_some(start.line),
start_side: ranged.then_some(draft_side(start.side)),
..base(path)
})
}
ComposeTarget::File { path, .. } => Some(base(path)),
ComposeTarget::Reply { .. }
| ComposeTarget::Draft { .. }
| ComposeTarget::Conversation { .. } => None,
}
}
pub fn new_thread(draft: &DraftComment) -> NewThread {
let side = |s: DraftSide| match s {
DraftSide::Left => ApiSide::Left,
DraftSide::Right => ApiSide::Right,
};
NewThread {
path: draft.path.clone(),
body: draft.body.clone(),
line: draft.line,
side: side(draft.side),
start_line: draft.start_line,
start_side: draft.start_side.map(side),
}
}
pub fn annotations(
threads: &[ReviewThread],
mapped: &HashMap<NodeId, Option<u32>>,
drafts: &[DraftComment],
) -> Vec<Annotation> {
let mut out: Vec<Annotation> = threads
.iter()
.map(|t| {
let side = match t.side {
ApiSide::Left => Side::Left,
ApiSide::Right => Side::Right,
};
let moved_to = mapped.get(&t.id).copied().flatten();
let place = match (t.file_level, t.outdated, t.line, moved_to) {
(true, outdated, ..) => Place::File { outdated },
(_, false, Some(line), _) => Place::Line {
at: LinePos { side, line },
start: t.start_line,
},
(.., Some(line)) => Place::Moved(LinePos { side, line }),
_ => Place::Outdated(t.original_line),
};
Annotation {
key: AnnotationKey::Thread(t.id.clone()),
path: t.path.clone(),
place,
resolved: t.resolved,
comments: t
.comments
.iter()
.map(|c| AnnotationComment {
author: c.author.clone(),
body: c.body.clone(),
created_at: c.created_at.clone(),
pending: c.pending,
})
.collect(),
left_out: t.comments.left_out(),
error: None,
can_reply: t.can_reply,
can_resolve: t.can_resolve,
can_unresolve: t.can_unresolve,
}
})
.collect();
out.extend(drafts.iter().map(|d| Annotation {
key: AnnotationKey::Draft(d.id),
path: d.path.clone(),
place: match d.line {
Some(line) => Place::Line {
at: LinePos {
side: side_of(d.side),
line,
},
start: d.start_line,
},
None => Place::File { outdated: false },
},
resolved: false,
comments: vec![AnnotationComment {
author: "You".into(),
body: d.body.clone(),
created_at: String::new(),
pending: false,
}],
left_out: 0,
error: d.error.clone(),
can_reply: false,
can_resolve: false,
can_unresolve: false,
}));
out
}
pub fn outdated_to_map(threads: &[ReviewThread]) -> Vec<OutdatedThread> {
threads
.iter()
.filter(|t| t.outdated && !t.file_level && t.side == ApiSide::Right)
.filter_map(|t| {
Some(OutdatedThread {
thread: t.id.clone(),
path: t.path.clone(),
commit: t.comments.first()?.original_commit.clone()?,
line: t.original_line?,
})
})
.collect()
}
#[must_use]
pub(crate) fn on_submitted(state: &mut State, pr: &PrRef, outcome: &SubmitOutcome) -> Vec<Cmd> {
let apply = |review: &mut ReviewState, head: Option<String>| {
review.pending.retain(|d| !outcome.accepted.contains(&d.id));
for draft in &mut review.pending {
draft.error = outcome
.rejected
.iter()
.find(|(id, _)| *id == draft.id)
.map(|(_, reason)| reason.clone());
}
if outcome.submitted {
review.last_reviewed_head = head;
}
};
let mut cmds = vec![Cmd::Api(Api::FetchThreads(pr.clone()))];
if let Some(diff) = state.diffs.get_mut(&DiffOf::Pr(pr.clone())) {
let head = diff.head().map(|h| h.to_string());
cmds.extend(diff.edit_review(pr, |i| apply(&mut i.review, head)));
} else if let Some(Overlay::Submit(dialog)) = &mut state.overlay
&& let Some(saved) = &mut dialog.saved
{
let head = (state.prs.get(pr))
.and_then(|r| r.data.as_ref())
.map(|d| d.head_oid.clone());
apply(saved, head);
cmds.push(Cmd::SaveReview(pr.clone(), saved.clone()));
}
if outcome.submitted {
let approved = matches!(
&state.overlay,
Some(Overlay::Submit(d)) if d.event == ReviewEvent::Approve
);
state.overlay = None;
state.info(if approved {
format!("Approved {pr}")
} else {
"Review submitted".to_owned()
});
cmds.extend(state.load_visible(false));
} else if let Some(Overlay::Submit(dialog)) = &mut state.overlay {
dialog.sending = false;
dialog.on_github = outcome.on_github.or(dialog.on_github);
dialog.error = Some(match (&outcome.error, outcome.rejected.len()) {
(Some(err), _) => err.clone(),
(None, n) => format!(
"GitHub rejected {n} comment{}; they're marked in the diff. The review stays pending on GitHub until you submit again.",
if n == 1 { "" } else { "s" }
),
});
}
cmds
}
#[must_use]
pub(crate) fn on_edited(
state: &mut State,
purpose: EditPurpose,
result: Result<String, crate::state::Failure>,
) -> Vec<Cmd> {
let text = match result {
Ok(text) => text,
Err(err) => {
state.error(format!("Couldn't run the editor: {err}"));
return Vec::new();
}
};
let theme = &state.theme;
match purpose {
EditPurpose::Compose => {
if let Some(Overlay::Compose(compose)) = &mut state.overlay {
let target = compose.target.clone();
let preview = compose.preview.clone();
**compose = Compose::new(theme, target, &text);
compose.preview = preview;
}
}
EditPurpose::Summary => {
if let Some(Overlay::Submit(dialog)) = &mut state.overlay {
dialog.input = editor(theme, text.trim_end());
}
}
EditPurpose::Suggest {
path,
start,
end,
original,
} => {
let suggested: Vec<String> = text
.trim_end_matches('\n')
.lines()
.map(str::to_owned)
.collect();
if suggested == original {
state.info("No change suggested");
return Vec::new();
}
let body = suggestion_body(&suggested.join("\n"));
let mut compose = Compose::new(theme, ComposeTarget::Line { path, start, end }, &body);
compose.preview = Some(Preview {
start_line: start.line,
original,
suggested,
});
state.overlay = Some(Overlay::Compose(Box::new(compose)));
}
}
Vec::new()
}
pub(crate) fn all_changes(state: &State) -> String {
let key = state.first_key(Action::PickCommits);
format!("pick “All changes” ({key})")
}
#[must_use]
pub(crate) fn review_action(state: &mut State, action: Action) -> Vec<Cmd> {
let viewer = state.viewer.clone();
let Some((screen, diff)) = state.diff_parts() else {
state.info("The diff hasn't loaded yet");
return Vec::new();
};
let Some(pr) = screen.of.pr().cloned() else {
state.info(format!("{}: on pull requests", action.description()));
return Vec::new();
};
let (cursor, half) = (screen.cursor, screen.half);
let at = diff.doc.annotation_under(cursor, half);
let annotation = at
.and_then(|i| diff.doc.annotations().get(i as usize))
.cloned();
let info = |state: &mut State, text: &str| {
state.info(text);
Vec::new()
};
let error = |state: &mut State, text: &str| {
state.error(text);
Vec::new()
};
let in_range = diff.range().is_some();
if in_range
&& matches!(
action,
Action::Comment | Action::Suggest | Action::FileComment | Action::SubmitReview
)
{
let all = all_changes(state);
return error(
state,
&format!("Comments anchor to the whole PR: {all} to comment"),
);
}
match action {
Action::ToggleSinceReview => {
if in_range {
let all = all_changes(state);
return error(state, &format!("First {all}"));
}
let active = !diff.doc.since_active();
if diff_screen::preserving_position(screen, diff, |d| d.doc.show_since(active)) {
state.info(if active {
"Showing changes since your last review"
} else {
"Showing all changes"
});
return Vec::new();
}
diff.since_requested = true;
match (&diff.inputs().last_review, viewer) {
(LastReview::Unknown | LastReview::Failed(_), Some(login)) => {
diff.edit(|i| i.last_review = LastReview::Unknown);
state.info("Looking up your last review…");
vec![Cmd::Api(Api::FetchLastReview { pr, login })]
}
(LastReview::Unknown | LastReview::Failed(_), None) => {
diff.edit(|i| i.last_review = LastReview::None);
Vec::new()
}
(LastReview::None | LastReview::At(_), _) => {
if !diff.listed() {
state.info("Comparing once the diff has loaded");
}
Vec::new()
}
}
}
Action::PickCommits => {
if diff.inputs().commits.is_empty() {
state.info("Listing commits…");
return vec![Cmd::Git(Git::ListCommits(pr))];
}
state.open_picker(picker::Kind::Commits { mark: None })
}
Action::Comment
if let Some(Annotation {
key: AnnotationKey::Thread(thread_id),
can_reply,
..
}) = &annotation =>
{
if !can_reply {
return info(state, "You can't reply to this one");
}
let thread_id = thread_id.clone();
state.compose(ComposeTarget::Reply { thread_id }, "")
}
Action::Comment => {
let selection = screen.selection.take();
match target(&diff.doc, cursor, half, selection) {
Ok(target) => {
if let ComposeTarget::File {
reason: Some(reason),
..
} = &target
{
state.info(reason.clone());
}
state.compose(target, "")
}
Err(err) => error(state, &err),
}
}
Action::FileComment => {
if let Some(ann) = &annotation
&& let AnnotationKey::Draft(id) = ann.key
{
let save = diff.edit_review(&pr, |i| {
if let Some(d) = i.review.pending.iter_mut().find(|d| d.id == id) {
d.line = None;
d.start_line = None;
d.start_side = None;
d.error = None;
}
});
state.info("Now a comment on the file");
return save;
}
let Some(file) = diff.doc.files().get(cursor.file) else {
return Vec::new();
};
let path = file.meta.path().to_owned();
state.compose(ComposeTarget::File { path, reason: None }, "")
}
Action::Suggest => {
let selection = screen.selection.take();
let target = match target(&diff.doc, cursor, half, selection) {
Ok(target) => target,
Err(err) => return error(state, &err),
};
let ComposeTarget::Line { path, start, end } = target else {
return error(state, "Suggestions need lines GitHub can comment on");
};
if start.side != Side::Right || end.side != Side::Right {
return error(state, "Suggestions apply to new lines (not deleted ones)");
}
let Some(text) = diff.doc.files().get(cursor.file).and_then(|f| f.text()) else {
return error(state, "Suggestions need the file's text");
};
let original: Vec<String> = (start.line..=end.line)
.map(|line| text.line(LinePos { line, ..start }).to_owned())
.collect();
vec![Cmd::Edit {
text: original.join("\n") + "\n",
purpose: EditPurpose::Suggest {
path,
start,
end,
original,
},
}]
}
Action::ResolveThread => {
let Some(ann) = annotation else {
return info(state, "Move to a thread to resolve it");
};
let AnnotationKey::Thread(thread_id) = ann.key else {
return info(state, "Drafts can't be resolved");
};
let resolved = !ann.resolved;
if (resolved && !ann.can_resolve) || (!resolved && !ann.can_unresolve) {
return info(state, "You can't change this thread");
}
diff.edit(|i| i.set_resolved(&thread_id, resolved));
vec![Cmd::Api(Api::SetResolved {
pr,
thread_id,
resolved,
})]
}
Action::Delete => {
let Some(AnnotationKey::Draft(id)) = annotation.map(|a| a.key) else {
return info(state, "Move to a draft comment to delete it");
};
let Some(at) = diff.inputs().review.pending.iter().position(|d| d.id == id) else {
return Vec::new();
};
let save = diff.edit_review(&pr, |i| i.deleted = Some(i.review.pending.remove(at)));
let undo = state.first_key(Action::UndoDelete);
state.info(format!("Draft deleted · {undo} brings it back"));
save
}
Action::UndoDelete => {
let Some(mut draft) = diff.edit(|i| i.deleted.take()) else {
return info(state, "No deleted draft to bring back");
};
let save = diff.edit_review(&pr, |i| {
if i.review.pending.iter().any(|d| d.id == draft.id) {
draft.id = i.review.next_draft_id();
}
i.review.pending.push(draft);
});
state.info("Draft restored");
save
}
Action::Open => {
let pending = &diff.inputs().review.pending;
let draft = annotation.and_then(|a| match a.key {
AnnotationKey::Draft(id) => pending.iter().find(|d| d.id == id),
AnnotationKey::Thread(_) => None,
});
if let Some(draft) = draft {
let (id, body) = (draft.id, draft.body.clone());
return state.compose(ComposeTarget::Draft { id }, &body);
}
if let Some(at) = at {
diff.doc.toggle_thread(at);
}
Vec::new()
}
Action::SubmitReview => {
if diff.refs.is_none() {
return error(state, "The diff hasn't loaded yet");
}
state.overlay = Some(Overlay::Submit(Box::new(SubmitDialog::new(
&state.theme,
pr.clone(),
))));
vec![Cmd::Api(Api::FetchPendingReview(pr))]
}
_ => info(state, &action.not_here()),
}
}
#[must_use]
pub(crate) fn apply_commit_choice(
state: &mut State,
choice: PickItem,
mark: Option<usize>,
) -> Vec<Cmd> {
let pr = match state.screen() {
Screen::Diff(screen) => match screen.of.pr() {
Some(pr) => pr.clone(),
None => return Vec::new(),
},
Screen::Page(_) => return Vec::new(),
};
let Some(base) = state.pr_base(&pr) else {
return Vec::new();
};
let Some((screen, diff)) = state.diff_parts() else {
return Vec::new();
};
let since = choice == PickItem::SinceReview;
let range = match choice {
PickItem::All | PickItem::SinceReview if diff.range().is_none() => {
if diff.doc.since_active() == since {
return Vec::new();
}
return review_action(state, Action::ToggleSinceReview);
}
PickItem::All | PickItem::SinceReview => None,
PickItem::Commit(i) => {
let j = mark.unwrap_or(i);
let (first, last) = (i.min(j), i.max(j));
let commits = &diff.inputs().commits;
let (Some(from), Some(to)) = (commits.get(first), commits.get(last)) else {
return Vec::new();
};
let (from, to) = (&from.oid, &to.oid);
let label = if first == last {
short_sha(to).to_owned()
} else {
format!("{}..{}", short_sha(from), short_sha(to))
};
Some(diff_screen::RangeView {
label,
from: format!("{from}^"),
to: to.to_string(),
})
}
};
let cmd_range = range.as_ref().map(|r| (r.from.clone(), r.to.clone()));
diff.restart(range);
diff.since_requested = since;
let job = diff.job;
screen.restart();
vec![Cmd::Git(Git::LoadDiff {
of: pr.into(),
job,
base,
range: cmd_range,
})]
}
#[must_use]
pub(crate) fn on_compose_key(state: &mut State, key: KeyEvent) -> Vec<Cmd> {
let Some(Overlay::Compose(compose)) = &mut state.overlay else {
return Vec::new();
};
if compose.sending {
return Vec::new();
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
if may_discard(state, "esc", "discard") {
state.overlay = None;
}
}
KeyCode::Char('e') if ctrl => {
return vec![Cmd::Edit {
purpose: EditPurpose::Compose,
text: compose.text(),
}];
}
KeyCode::Char('s') if ctrl => return save_compose(state),
_ => {
compose.discard_armed = None;
compose.error = None;
compose.input.input(key);
}
}
Vec::new()
}
pub(crate) fn may_discard(state: &mut State, key: &'static str, verb: &str) -> bool {
let (text, armed, error, what) = match &mut state.overlay {
Some(Overlay::Compose(c)) => (c.text(), &mut c.discard_armed, &mut c.error, "comment"),
Some(Overlay::Submit(d)) => (
d.input.lines().join("\n"),
&mut d.discard_armed,
&mut d.error,
"summary",
),
_ => return true,
};
if text.trim().is_empty() || *armed == Some(key) {
return true;
}
*armed = Some(key);
*error = Some(format!("Press {key} again to {verb} this {what}"));
false
}
#[must_use]
pub(crate) fn save_compose(state: &mut State) -> Vec<Cmd> {
let Some(Overlay::Compose(compose)) = &mut state.overlay else {
return Vec::new();
};
let body = compose.text();
if body.trim().is_empty() {
compose.error = Some("Write something first".into());
return Vec::new();
}
let target = compose.target.clone();
if let ComposeTarget::Conversation { subject_id, .. } = target {
compose.sending = true;
let change = Change::Comment {
subject: subject_id,
body,
};
return vec![Cmd::Api(Api::Change(change, crate::act::By::Compose))];
}
let Some((screen, diff)) = state.diff_parts() else {
return Vec::new();
};
let Some(pr) = screen.of.pr().cloned() else {
return Vec::new();
};
match target {
ComposeTarget::Reply { thread_id } => {
if let Some(Overlay::Compose(compose)) = &mut state.overlay {
compose.sending = true;
}
let reply = Change::Reply {
thread: thread_id,
body,
};
vec![Cmd::Api(Api::Change(reply, crate::act::By::Reply(pr)))]
}
ComposeTarget::Draft { id } => {
let save = diff.edit_review(&pr, |i| {
if let Some(d) = i.review.pending.iter_mut().find(|d| d.id == id) {
d.body = body;
d.error = None;
}
});
state.overlay = None;
save
}
ComposeTarget::Conversation { .. } => Vec::new(),
target @ (ComposeTarget::Line { .. } | ComposeTarget::File { .. }) => {
let Some(head) = diff.head() else {
return Vec::new();
};
let id = diff.inputs().review.next_draft_id();
let Some(draft) = draft(&target, &body, id, &head) else {
return Vec::new();
};
let save = diff.edit_review(&pr, |i| i.review.pending.push(draft));
let count = diff.inputs().review.pending.len();
state.overlay = None;
state.info(format!(
"Added to your review ({count} pending). Submit with {}",
state.first_key(Action::SubmitReview)
));
save
}
}
}
#[must_use]
pub(crate) fn on_submit_key(state: &mut State, key: KeyEvent) -> Vec<Cmd> {
let Some(Overlay::Submit(dialog)) = &mut state.overlay else {
return Vec::new();
};
if dialog.sending {
return Vec::new();
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
if may_discard(state, "esc", "discard") {
state.overlay = None;
}
}
KeyCode::Tab => dialog.cycle(true),
KeyCode::BackTab => dialog.cycle(false),
KeyCode::Char('e') if ctrl => {
return vec![Cmd::Edit {
purpose: EditPurpose::Summary,
text: dialog.input.lines().join("\n"),
}];
}
KeyCode::Char('s') if ctrl => return submit(state),
KeyCode::Enter if dialog.quick => return submit(state),
_ => {
dialog.discard_armed = None;
dialog.error = None;
dialog.input.input(key);
}
}
Vec::new()
}
#[must_use]
fn submit(state: &mut State) -> Vec<Cmd> {
let Some(Overlay::Submit(dialog)) = &state.overlay else {
return Vec::new();
};
let (event, body, pr, seen) = (
dialog.event,
dialog.input.lines().join("\n"),
dialog.pr.clone(),
dialog.on_github.unwrap_or(0),
);
let drafts = dialog.drafts(state).map(<[DraftComment]>::to_vec);
let head = match state.diffs.get(&DiffOf::Pr(pr.clone())) {
Some(diff) => diff.head(),
None => (state.prs.get(&pr))
.and_then(|r| r.data.as_ref())
.and_then(|d| crate::git::Oid::parse(&d.head_oid).ok()),
};
let problem = match (&drafts, &head) {
_ if event == ReviewEvent::RequestChanges && body.trim().is_empty() => {
Some("Requesting changes needs a summary")
}
(None, _) => Some("Still reading your saved drafts; try again in a moment"),
(_, None) => Some("The pull request hasn't loaded yet"),
_ => None,
};
let Some(Overlay::Submit(dialog)) = &mut state.overlay else {
return Vec::new();
};
let (None, Some(drafts), Some(head)) = (problem, drafts, head) else {
dialog.error = problem.map(Into::into);
return Vec::new();
};
dialog.sending = true;
dialog.error = None;
vec![Cmd::Api(Api::SubmitReview {
pr,
head,
drafts,
event,
body,
seen,
})]
}
#[must_use]
pub(crate) fn open_approve(state: &mut State, pr: PrRef) -> Vec<Cmd> {
let mut dialog = SubmitDialog::new(&state.theme, pr.clone());
dialog.event = ReviewEvent::Approve;
dialog.quick = true;
state.overlay = Some(Overlay::Submit(Box::new(dialog)));
let mut cmds = vec![Cmd::Api(Api::FetchPendingReview(pr.clone()))];
if !state.diffs.contains_key(&DiffOf::Pr(pr.clone())) {
cmds.push(Cmd::LoadReview(pr));
}
cmds
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::thread;
#[test]
fn outdated_threads_move_when_mapped() {
let threads = vec![
thread("current", Some(3), false, false),
thread("mapped", None, false, true),
thread("lost", None, false, true),
];
let mapped = HashMap::from([
(NodeId::new("mapped"), Some(12)),
(NodeId::new("lost"), None),
]);
let anns = annotations(&threads, &mapped, &[]);
assert!(matches!(anns[0].place, Place::Line { at, .. } if at.line == 3));
assert!(matches!(anns[1].place, Place::Moved(at) if at.line == 12));
assert_eq!(anns[2].place, Place::Outdated(Some(3)));
assert_eq!(outdated_to_map(&threads).len(), 2);
}
#[test]
fn drafts_become_ranges_or_file_comments() {
let right = |line| LinePos {
side: Side::Right,
line,
};
let range = ComposeTarget::Line {
path: "a.rs".into(),
start: right(3),
end: right(5),
};
let d = draft(&range, "x", 1, "head").unwrap();
assert_eq!(
(d.line, d.start_line, d.start_side),
(Some(5), Some(3), Some(DraftSide::Right))
);
let thread = new_thread(&d);
assert_eq!((thread.line, thread.start_line), (Some(5), Some(3)));
let single = ComposeTarget::Line {
path: "a.rs".into(),
start: right(4),
end: right(4),
};
let d = draft(&single, "x", 2, "head").unwrap();
assert_eq!(
(d.start_line, d.start_side),
(None, None),
"single lines have no start"
);
let file = ComposeTarget::File {
path: "a.rs".into(),
reason: None,
};
assert_eq!(draft(&file, "x", 3, "head").unwrap().line, None);
assert!(
draft(
&ComposeTarget::Reply {
thread_id: NodeId::new("t")
},
"x",
4,
"h"
)
.is_none()
);
let anns = annotations(&[], &HashMap::new(), &[d_with_error()]);
assert!(anns[0].is_draft());
assert_eq!(anns[0].error.as_deref(), Some("nope"));
}
fn d_with_error() -> DraftComment {
DraftComment {
id: 9,
path: "a.rs".into(),
body: "b".into(),
side: DraftSide::Right,
line: Some(1),
start_line: None,
start_side: None,
commit: "h".into(),
error: Some("nope".into()),
}
}
#[test]
fn suggestion_fences() {
assert_eq!(
suggestion_body("let x = 1;\n"),
"```suggestion\nlet x = 1;\n```"
);
}
#[test]
fn submit_events_cycle() {
let theme = Theme::new(
crate::theme::DEFAULT_SEED,
crate::theme::Mode::Dark,
crate::theme::ColorDepth::TrueColor,
);
let mut dialog = SubmitDialog::new(&theme, PrRef::parse("o/r#1").unwrap());
dialog.cycle(true);
assert_eq!(dialog.event, ReviewEvent::Approve);
dialog.cycle(true);
assert_eq!(dialog.event, ReviewEvent::RequestChanges);
dialog.cycle(true);
assert_eq!(dialog.event, ReviewEvent::Comment);
dialog.cycle(false);
assert_eq!(dialog.event, ReviewEvent::RequestChanges);
}
}