use std::collections::BTreeSet;
use crate::api::change::Change;
use crate::api::edit::{Edit, Editable, Named, ReactionContent, Sidebar, SidebarPermits, Written};
use crate::api::model::{Capped, Label, MilestoneRef, NodeId, RepoId};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::act::{By, Step, Subject, issue};
use crate::compose;
use crate::data::DataKey;
use crate::discuss::same_text;
use crate::keymap::Action;
use crate::nav::Doable;
use crate::picker::{self, Choice};
use crate::state::{Api, Cmd, Cmds, Overlay, Remote, Screen, State};
use crate::ui::annotations::{AnnotationKey, ThreadRowKind};
use crate::ui::diff_doc::{Pos, Row};
use crate::ui::overlays::PaletteItem;
pub const ACTIONS: [Action; 8] = [
Action::EditComment,
Action::DeleteComment,
Action::React,
Action::EditTitle,
Action::EditLabels,
Action::EditAssignees,
Action::RequestReviews,
Action::EditMilestone,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing {
pub of: Written,
pub id: NodeId,
pub about: Subject,
pub at: String,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
Labels,
Assignees,
Reviewers,
}
impl Field {
fn name(self) -> &'static str {
match self {
Field::Labels => "Labels",
Field::Assignees => "Assignees",
Field::Reviewers => "Reviewers",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Choosing {
Many {
field: Field,
about: Subject,
chosen: Option<BTreeSet<String>>,
},
Milestone {
about: Subject,
},
React {
about: Subject,
target: Editable,
},
Title {
about: Subject,
id: NodeId,
was: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pick {
Toggle(String),
Milestone(Option<String>),
React(ReactionContent),
Title(String),
}
impl Choosing {
pub fn title(&self) -> &'static str {
match self {
Choosing::Many { field, .. } => field.name(),
Choosing::Milestone { .. } => "Milestone",
Choosing::React { .. } => "React",
Choosing::Title { .. } => "Edit the title",
}
}
pub fn placeholder(&self) -> &'static str {
match self {
Choosing::Many { .. } => "Filter · ↵ checks · ctrl-s applies · esc cancels",
Choosing::Milestone { .. } => "Filter · ↵ sets it · esc cancels",
Choosing::React { .. } => "↵ gives or takes back your reaction",
Choosing::Title { .. } => "The title",
}
}
pub fn need(&self) -> Option<DataKey> {
match self {
Choosing::Many { about, .. } | Choosing::Milestone { about } => {
let (repo, number) = numbered(about)?;
Some(DataKey::Sidebar(repo, number))
}
Choosing::React { .. } | Choosing::Title { .. } => None,
}
}
pub fn filters(&self) -> bool {
!matches!(self, Choosing::React { .. })
}
}
fn numbered(about: &Subject) -> Option<(RepoId, u64)> {
match about {
Subject::Pr(pr) => Some((pr.repo.clone(), pr.number)),
Subject::Issue(repo, number) => Some((repo.clone(), *number)),
_ => None,
}
}
struct Spot<'a> {
of: Written,
editable: &'a Editable,
body: &'a str,
author: &'a str,
at: &'a str,
}
impl Spot<'_> {
fn name(&self) -> String {
match self.of {
Written::Issue | Written::Pr => "the description".to_owned(),
Written::IssueComment | Written::ReviewComment => format!("{}'s comment", self.author),
Written::Review => format!("{}'s review", self.author),
}
}
}
fn subject_here(state: &State) -> Option<Subject> {
Subject::on_screen(state).filter(|s| matches!(s, Subject::Pr(_) | Subject::Issue(..)))
}
struct Detail<'a> {
description: Spot<'a>,
title: &'a str,
labels: &'a Capped<Label>,
assignees: &'a Capped<String>,
milestone: Option<&'a MilestoneRef>,
sidebar: SidebarPermits,
open: bool,
}
fn detail<'a>(state: &'a State, about: &Subject) -> Option<Detail<'a>> {
macro_rules! detail {
($d:ident, $of:expr, $open:expr) => {
Detail {
description: Spot {
of: $of,
editable: &$d.editable,
body: &$d.body,
author: &$d.author,
at: &$d.created_at,
},
title: &$d.title,
labels: &$d.labels,
assignees: &$d.assignees,
milestone: $d.milestone.as_ref(),
sidebar: $d.sidebar,
open: $open,
}
};
}
match about {
Subject::Issue(repo, number) => {
issue(state, repo, *number).map(|d| detail!(d, Written::Issue, true))
}
Subject::Pr(pr) => state
.pr(pr)
.map(|d| detail!(d, Written::Pr, d.state.is_open())),
_ => None,
}
}
fn timeline<'a>(state: &'a State, about: &Subject) -> Option<&'a [crate::api::timeline::Item]> {
match about {
Subject::Issue(repo, number) => Some(&issue(state, repo, *number)?.timeline.items),
Subject::Pr(pr) => Some(&state.activity(pr)?.timeline.items),
_ => None,
}
}
fn description<'a>(state: &'a State, about: &Subject) -> Result<Spot<'a>, String> {
let d = detail(state, about).ok_or("It's still loading")?;
Ok(d.description)
}
fn page_spot<'a>(state: &'a State, about: &Subject, fallback: bool) -> Result<Spot<'a>, String> {
let p = state.screen().page().ok_or_else(String::new)?;
let Some(crate::ui::page::Link::Quote { author, at, .. }) = p.selected_link() else {
if fallback {
return description(state, about);
}
let next = state.first_key(Action::Down);
return Err(format!(
"Select a comment first: {next} goes comment by comment"
));
};
let said = description(state, about)?;
if said.at == at && said.author == author {
return Ok(said);
}
let timeline = timeline(state, about).unwrap_or_default();
let review = timeline.iter().find_map(|i| match i {
crate::api::timeline::Item::Review(r) if r.at == *at && r.author == *author => Some(r),
_ => None,
});
if let Some(r) = review {
return Ok(Spot {
of: Written::Review,
editable: &r.editable,
body: &r.body,
author: &r.author,
at: &r.at,
});
}
let found = (timeline.iter())
.find_map(|i| match i {
crate::api::timeline::Item::Comment(c)
if c.created_at == *at && c.author == *author =>
{
Some(c)
}
_ => None,
})
.and_then(|c| Some((c, c.editable.as_ref()?)));
match found {
Some((c, editable)) => Ok(Spot {
of: Written::IssueComment,
editable,
body: &c.body,
author: &c.author,
at: &c.created_at,
}),
None => Err("That's a review's summary: ghtui edits comments and descriptions".into()),
}
}
fn diff_spot(state: &State) -> Result<Spot<'_>, String> {
let (Screen::Diff(screen), Some(diff)) = (state.screen(), state.diff()) else {
return Err("The diff hasn't loaded yet".into());
};
let doc = &diff.doc;
let cursor = screen.cursor;
let move_to = "Move to a comment in a thread";
let ann = doc.annotation_under(cursor, screen.half).ok_or(move_to)?;
let annotation = doc.annotations().get(ann as usize).ok_or(move_to)?;
if let AnnotationKey::Draft(_) = annotation.key {
let (open, delete) = (
state.first_key(Action::Open),
state.first_key(Action::Delete),
);
return Err(format!(
"That's your draft: {open} edits it, {delete} deletes it"
));
}
let file = doc.files().get(cursor.file).ok_or(move_to)?;
let mut comment = None;
for row in (0..=cursor.row).rev() {
let Some(Row::Thread(t)) = doc.row(Pos { row, ..cursor }) else { break };
let Some(r) = file.thread_row(t).filter(|r| r.ann == ann) else { break };
match r.kind {
ThreadRowKind::Head { comment: c, .. } => {
comment = Some(c);
break;
}
ThreadRowKind::Body | ThreadRowKind::Reactions { .. } => {}
_ => break,
}
}
let c = comment
.and_then(|c| annotation.comments.get(c))
.ok_or(move_to)?;
Ok(Spot {
of: Written::ReviewComment,
editable: c.editable.as_ref().ok_or(move_to)?,
body: &c.body,
author: &c.author,
at: "",
})
}
fn spot<'a>(state: &'a State, about: &Subject, fallback: bool) -> Result<Spot<'a>, String> {
match (state.screen(), about) {
(Screen::Diff(_), Subject::Pr(_)) => diff_spot(state),
_ => page_spot(state, about, fallback),
}
}
fn elsewhere(action: Action) -> String {
match action {
Action::EditComment | Action::DeleteComment | Action::React => {
"That works on a comment, an issue or a pull request".into()
}
_ => "That works on an issue or a pull request".into(),
}
}
fn plan(state: &State, action: Action) -> Result<Step, String> {
let about = subject_here(state).ok_or_else(|| elsewhere(action))?;
let diff = state.screen().diff().is_some();
match action {
Action::EditComment => {
let spot = spot(state, &about, true)?;
if !spot.editable.can_update {
return Err(format!(
"GitHub doesn't let you edit {}: it takes its author or write access",
spot.name()
));
}
let editing = Editing {
of: spot.of,
id: spot.editable.id.clone(),
about,
at: spot.at.to_owned(),
name: spot.name(),
};
Ok(Step::Compose(
compose::Target::Edit(editing),
spot.body.to_owned(),
))
}
Action::DeleteComment => {
let spot = spot(state, &about, false)?;
if matches!(spot.of, Written::Issue | Written::Pr) {
return Err("A description can't be deleted, only edited".into());
}
if !spot.editable.can_delete {
return Err(format!(
"GitHub doesn't let you delete {}: it takes its author or write access",
spot.name()
));
}
let first = spot.body.lines().find(|l| !l.trim().is_empty());
let mut facts = Vec::new();
if let Some(line) = first {
let line = crate::ui::text::truncate(line.trim(), 60);
facts.push((format!("“{line}”"), false));
}
facts.push(("Deleting it can't be undone".to_owned(), true));
let title = format!("Delete {}?", spot.name());
let delete = Change::Edit(Edit::Delete {
of: spot.of,
id: spot.editable.id.clone(),
});
Ok(crate::act::ask_one(
action, about, title, facts, "Delete", delete,
))
}
Action::React => {
let spot = spot(state, &about, true)?;
if !spot.editable.can_react {
return Err(format!("GitHub doesn't let you react to {}", spot.name()));
}
choose(Choosing::React {
target: spot.editable.clone(),
about,
})
}
_ if diff => Err(format!("{}: on the conversation", action.description())),
Action::EditTitle => {
let d = detail(state, &about).ok_or("It's still loading")?;
let editable = d.description.editable;
if !editable.can_update {
return Err(
"GitHub doesn't let you edit its title: it takes its author or triage access"
.into(),
);
}
choose(Choosing::Title {
id: editable.id.clone(),
was: d.title.to_owned(),
about,
})
}
Action::EditLabels
| Action::EditAssignees
| Action::RequestReviews
| Action::EditMilestone => {
let d = detail(state, &about).ok_or("It's still loading")?;
let (may, open) = (d.sidebar, d.open);
let triage = "it takes triage access";
match action {
Action::EditLabels if !may.labels => {
Err(format!("GitHub doesn't let you label it: {triage}"))
}
Action::EditAssignees if !may.assignees => {
Err(format!("GitHub doesn't let you assign it: {triage}"))
}
Action::EditMilestone if !may.milestone => Err(format!(
"GitHub doesn't let you set its milestone: {triage}"
)),
Action::RequestReviews if !matches!(about, Subject::Pr(_)) => {
Err("Reviews are asked for on pull requests".into())
}
Action::RequestReviews if !open => {
Err("It isn't open: reviews are asked for on open pull requests".into())
}
Action::RequestReviews if !may.reviewers => {
Err(format!("GitHub doesn't let you ask for reviews: {triage}"))
}
Action::EditMilestone => choose(Choosing::Milestone { about }),
_ => {
let field = match action {
Action::EditLabels => Field::Labels,
Action::EditAssignees => Field::Assignees,
_ => Field::Reviewers,
};
choose(Choosing::Many {
field,
about,
chosen: None,
})
}
}
}
_ => Err(elsewhere(action)),
}
}
pub const FEATURE: crate::features::Feature = crate::features::Feature {
owns: &ACTIONS,
act,
offers: |state, out| out.extend(doables(state)),
..crate::features::Feature::NONE
};
pub fn act(state: &mut State, action: Action) -> Cmds {
let planned = plan(state, action).and_then(|step| crate::act::settled(state).map(|()| step));
crate::act::take(state, planned)
}
#[expect(clippy::unnecessary_wraps, reason = "a plan's step")]
fn choose(choosing: Choosing) -> Result<Step, String> {
Ok(Step::Pick(picker::Kind::Edit(choosing)))
}
pub fn doables(state: &State) -> Vec<Doable> {
let Some(about) = subject_here(state) else { return Vec::new() };
let diff = state.screen().diff().is_some();
let what = spot(state, &about, true).map_or_else(|_| "the comment".to_owned(), |s| s.name());
ACTIONS
.into_iter()
.filter(|a| {
!diff
|| matches!(
a,
Action::EditComment | Action::DeleteComment | Action::React
)
})
.filter(|a| *a != Action::RequestReviews || matches!(about, Subject::Pr(_)))
.filter(|a| !crate::review::takes(state, *a))
.map(|action| {
let label = match action {
Action::EditComment => format!("Edit {what}"),
Action::DeleteComment if what != "the description" => format!("Delete {what}"),
Action::React => format!("React to {what}"),
Action::EditTitle => "Edit the title".into(),
Action::EditLabels => Field::Labels.name().into(),
Action::EditAssignees => Field::Assignees.name().into(),
Action::RequestReviews => Field::Reviewers.name().into(),
Action::EditMilestone => "Milestone".into(),
_ => action.description().into(),
};
crate::nav::row("Edit", action, label, "", plan(state, action).err())
})
.collect()
}
use crate::picker::Rows;
fn row(
label: impl Into<String>,
hint: impl Into<String>,
pick: Option<Pick>,
) -> (PaletteItem, Option<Choice>) {
let item = crate::picker::item(label, hint);
(item, pick.map(Choice::Edit))
}
fn sidebar<'a>(state: &'a State, c: &Choosing) -> Result<&'a Sidebar, Rows> {
let Some(key) = c.need() else { return Err(Vec::new()) };
#[expect(clippy::disallowed_methods, reason = "said as the picker's row")]
let listed = Remote::fetched(state.remote(&key), "")
.pick(<Sidebar as crate::data::Picked>::pick)
.text("the sidebar");
listed.map_err(|why| vec![row(why, "", None)])
}
fn teams(s: &Sidebar) -> impl Iterator<Item = &Named> {
s.teams.iter().flatten().flat_map(|t| t.iter())
}
fn field_names(s: &Sidebar, field: Field) -> (Vec<&Named>, Vec<&Named>) {
let r = &s.requested;
match field {
Field::Labels => (s.labels.iter().collect(), s.all_labels.iter().collect()),
Field::Assignees => (s.assignees.iter().collect(), s.users.iter().collect()),
Field::Reviewers => {
let set = (r.users.iter()).chain(&r.teams).chain(&r.bots).collect();
let users = (s.users.iter()).filter(|u| s.author.as_deref() != Some(u.name.as_str()));
(set, users.chain(teams(s)).collect())
}
}
}
pub fn rows(state: &State, c: &Choosing, q: &str) -> Rows {
let fits = |name: &str| picker::fuzzy_score(q, name).is_some();
match c {
Choosing::React { target, .. } => ReactionContent::ALL
.into_iter()
.map(|content| {
let n = (target.reactions.iter())
.find(|r| r.content == content)
.map_or(0, |r| r.count);
let mine = target.reacted(content);
let mark = if mine { "✓" } else { " " };
let hint = match (mine, n) {
(true, n) => format!("yours · {n}"),
(false, 0) => String::new(),
(false, n) => n.to_string(),
};
let label = format!("{mark} {} {}", content.emoji(), content.label());
row(label, hint, Some(Pick::React(content)))
})
.collect(),
Choosing::Title { was, .. } => {
if q.is_empty() {
vec![row("Type a title", "", None)]
} else if q == was.trim() {
vec![row("That's its title now", "", None)]
} else {
vec![row(
format!("Rename it “{q}”"),
"↵",
Some(Pick::Title(q.to_owned())),
)]
}
}
Choosing::Milestone { .. } => {
let s = or_return!(sidebar(state, c));
let now = s.milestone.as_ref().map(|m| m.name.as_str());
let mark = |on: bool| if on { "✓" } else { " " };
let mut rows = Vec::new();
if fits("No milestone") {
rows.push(row(
format!("{} No milestone", mark(now.is_none())),
"",
Some(Pick::Milestone(None)),
));
}
let mut names: Vec<&str> = now.into_iter().collect();
names.extend(
s.milestones
.iter()
.map(|m| m.name.as_str())
.filter(|m| Some(*m) != now),
);
for name in names.into_iter().filter(|n| fits(n)) {
let hint = if Some(name) == now { "current" } else { "" };
rows.push(row(
format!("{} {name}", mark(Some(name) == now)),
hint,
Some(Pick::Milestone(Some(name.to_owned()))),
));
}
more(
&mut rows,
s.milestones.left_out(),
"milestone",
"milestones",
);
rows
}
Choosing::Many { field, chosen, .. } => {
let s = or_return!(sidebar(state, c));
let (set, options) = field_names(s, *field);
let set: Vec<&str> = set.iter().map(|n| n.name.as_str()).collect();
let chosen: BTreeSet<&str> = match chosen {
Some(chosen) => chosen.iter().map(String::as_str).collect(),
None => set.iter().copied().collect(),
};
let mut names: Vec<&str> = set.clone();
names.extend(
options
.iter()
.map(|n| n.name.as_str())
.filter(|n| !set.contains(n)),
);
let mut rows: Rows = names
.into_iter()
.filter(|n| fits(n))
.map(|name| {
let (on, was) = (chosen.contains(name), set.contains(&name));
let hint = match (on, was) {
(true, false) => "adding",
(false, true) => "removing",
_ => "",
};
let mark = if on { "✓" } else { " " };
row(
format!("{mark} {name}"),
hint,
Some(Pick::Toggle(name.to_owned())),
)
})
.collect();
let (left_out, one, many) = match field {
Field::Labels => (s.all_labels.left_out(), "label", "labels"),
Field::Assignees => (s.users.left_out(), "person", "people"),
Field::Reviewers => {
let teams: u64 = s.teams.iter().flatten().map(Capped::left_out).sum();
let left_out = s.users.left_out().saturating_add(teams);
(left_out, "person or team", "people and teams")
}
};
more(&mut rows, left_out, one, many);
if *field == Field::Reviewers
&& let Some(Err(why)) = &s.teams
{
rows.push(row(format!("Teams aren't listed: {why}"), "", None));
}
if rows.is_empty() {
rows.push(row("Nothing matches", "", None));
}
rows
}
}
}
fn more(rows: &mut Rows, left_out: u64, one: &str, many: &str) {
if left_out > 0 {
let what = if left_out == 1 { one } else { many };
rows.push(row(
format!("{left_out} more {what} on GitHub, not listed"),
"",
None,
));
}
}
pub fn on_picker_key(state: &mut State, key: KeyEvent, rows: Rows) -> Cmds {
let set: Option<BTreeSet<String>> = match &state.overlay {
Some(Overlay::Picker(p)) => match &p.kind {
picker::Kind::Edit(
c @ Choosing::Many {
field,
chosen: None,
..
},
) => sidebar(state, c)
.ok()
.map(|s| names(&field_names(s, *field).0)),
_ => None,
},
_ => None,
};
let Some(Overlay::Picker(p)) = &mut state.overlay else { return Cmds::new() };
let picker::Kind::Edit(c) = &mut p.kind else { return Cmds::new() };
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => state.overlay = None,
KeyCode::Char('s') if ctrl => return apply(state),
KeyCode::Enter => {
let Some((_, Some(Choice::Edit(pick)))) = rows.into_iter().nth(p.selected) else {
return Cmds::new();
};
if let (Pick::Toggle(name), Choosing::Many { chosen, .. }) = (&pick, c) {
let chosen = chosen.get_or_insert_with(|| set.unwrap_or_default());
if !chosen.remove(name) {
chosen.insert(name.clone());
}
return Cmds::new();
}
return send_pick(state, pick);
}
_ if c.filters() => {
crate::nav::type_into(&mut p.input, key);
p.selected = 0;
}
_ => {}
}
Cmds::new()
}
fn names(list: &[&Named]) -> BTreeSet<String> {
list.iter().map(|n| n.name.clone()).collect()
}
fn node(state: &State, about: &Subject) -> Option<NodeId> {
detail(state, about).map(|d| d.description.editable.id.clone())
}
fn send_pick(state: &mut State, pick: Pick) -> Cmds {
let Some(Overlay::Picker(p)) = &state.overlay else { return Cmds::new() };
let picker::Kind::Edit(c) = &p.kind else { return Cmds::new() };
let edit = match (pick, c) {
(Pick::React(content), Choosing::React { about, target, .. }) => Some((
about.clone(),
Edit::React {
subject: target.id.clone(),
content,
on: !target.reacted(content),
},
)),
(Pick::Title(title), Choosing::Title { about, id, .. }) => Some((
about.clone(),
Edit::Title {
pr: matches!(about, Subject::Pr(_)),
id: id.clone(),
title,
},
)),
(Pick::Milestone(name), Choosing::Milestone { about }) => {
let Ok(s) = sidebar(state, c) else { return Cmds::new() };
if s.milestone.as_ref().map(|m| &m.name) == name.as_ref() {
state.overlay = None;
return state.just_say("That's its milestone now");
}
let milestone = name.and_then(|name| {
(s.milestones.iter())
.chain(&s.milestone)
.find(|m| m.name == name)
.cloned()
});
node(state, about).map(|id| {
let pr = matches!(about, Subject::Pr(_));
(about.clone(), Edit::Milestone { pr, id, milestone })
})
}
_ => None,
};
let Some((about, edit)) = edit else { return Cmds::new() };
send(state, about, edit)
}
fn send(state: &mut State, about: Subject, edit: Edit) -> Cmds {
state.overlay = None;
state.info(format!("{}…", said(&edit).0));
crate::act::send(Change::Edit(edit), By::Edit(about)).into()
}
fn apply(state: &mut State) -> Cmds {
let Some(Overlay::Picker(p)) = &state.overlay else { return Cmds::new() };
let picker::Kind::Edit(
c @ Choosing::Many {
field,
about,
chosen,
},
) = &p.kind
else {
return Cmds::new();
};
let (Ok(s), Some(subject)) = (sidebar(state, c), node(state, about)) else {
return Cmds::new();
};
let (set, options) = field_names(s, *field);
let was = names(&set);
let now = chosen.clone().unwrap_or_else(|| was.clone());
if now == was {
state.overlay = None;
return state.just_say("Nothing changed");
}
let mut all: Vec<&Named> = set.clone();
all.extend(options.iter().filter(|n| !set.contains(n)));
let named = |names: Vec<&String>| -> Vec<Named> {
(names.into_iter())
.filter_map(|name| all.iter().find(|n| n.name == *name).map(|n| (*n).clone()))
.collect()
};
let add = named(now.difference(&was).collect());
let remove = named(was.difference(&now).collect());
let edit = match field {
Field::Labels => Edit::Labels {
subject,
add,
remove,
},
Field::Assignees => Edit::Assignees {
subject,
add,
remove,
},
Field::Reviewers => {
if s.requested.left_out > 0 {
return state
.just_say("GitHub listed only some of its reviewers: change them on GitHub");
}
let r = &s.requested;
let listed: Vec<&Named> = teams(s).collect();
let (teams, rest): (Vec<Named>, Vec<Named>) = named(now.iter().collect())
.into_iter()
.partition(|n| r.teams.contains(n) || listed.contains(&n));
let (bots, users) = rest.into_iter().partition(|n| r.bots.contains(n));
Edit::Reviewers {
pr: subject,
users,
teams,
bots,
}
}
};
let about = about.clone();
send(state, about, edit)
}
fn written<'a>(state: &'a State, about: &Subject) -> Option<Vec<(&'a Editable, &'a str)>> {
let d = detail(state, about)?;
let written = timeline(state, about)?.iter().filter_map(|i| match i {
crate::api::timeline::Item::Comment(c) => Some((c.editable.as_ref()?, c.body.as_str())),
crate::api::timeline::Item::Review(r) => Some((&r.editable, r.body.as_str())),
crate::api::timeline::Item::Event(_) => None,
});
let description = (d.description.editable, d.description.body);
Some(std::iter::once(description).chain(written).collect())
}
fn has(list: impl Fn(&str) -> bool, want: &[Named], gone: &[Named]) -> bool {
want.iter().all(|n| list(&n.name)) && !gone.iter().any(|n| list(&n.name))
}
pub fn shown(state: &State, edit: &Edit, about: &Subject) -> bool {
let find =
|id: &NodeId| written(state, about).map(|all| all.into_iter().find(|(e, _)| e.id == *id));
let Some(d) = detail(state, about) else { return numbered(about).is_none() };
match edit {
Edit::Body {
of: Written::ReviewComment,
..
}
| Edit::Delete {
of: Written::ReviewComment,
..
} => true,
Edit::Body { id, body, .. } => match find(id) {
Some(Some((_, now))) => same_text(now, body),
Some(None) => true,
None => false,
},
Edit::Delete { id, .. } => matches!(find(id), Some(None)),
Edit::React {
subject,
content,
on,
} => match find(subject) {
Some(Some((e, _))) => e.reacted(*content) == *on,
Some(None) => true,
None => false,
},
Edit::Title { title: new, .. } => same_text(d.title, new),
Edit::Labels { add, remove, .. } => {
has(|name| d.labels.iter().any(|l| l.name == name), add, remove)
}
Edit::Assignees { add, remove, .. } => {
has(|name| d.assignees.iter().any(|a| a == name), add, remove)
}
Edit::Milestone {
milestone: want, ..
} => d.milestone.map(|m| m.title.as_str()) == want.as_ref().map(|m| m.name.as_str()),
Edit::Reviewers {
users, teams, bots, ..
} => {
let Subject::Pr(pr) = about else { return true };
let Some(a) = state.activity(pr) else { return false };
let asked: BTreeSet<&str> = (a.requested.iter())
.map(|r| match &r.who {
crate::api::browse::Reviewer::User(n)
| crate::api::browse::Reviewer::Team(n) => n.as_str(),
})
.collect();
let wanted: BTreeSet<&str> = (users.iter().chain(teams).chain(bots))
.map(|n| n.name.as_str())
.collect();
asked == wanted
}
}
}
pub fn polls(about: &Subject) -> Option<DataKey> {
match about {
Subject::Pr(pr) => Some(DataKey::PrActivity(pr.clone())),
_ => None,
}
}
pub fn after(state: &State) -> Option<Cmd> {
let pr = state.screen().diff()?.of.pr()?;
Some(Cmd::Api(Api::FetchThreads(pr.clone())))
}
pub fn said(edit: &Edit) -> (&'static str, &'static str) {
match edit {
Edit::Body { .. } => ("Saving", "the edit"),
Edit::Delete { .. } => ("Deleting it", "deleting it"),
Edit::Title { .. } => ("Renaming it", "the new title"),
Edit::React { on: true, .. } => ("Reacting", "the reaction"),
Edit::React { .. } => ("Taking back your reaction", "the reaction"),
Edit::Labels { .. } => ("Changing the labels", "the labels"),
Edit::Assignees { .. } => ("Changing the assignees", "the assignees"),
Edit::Reviewers { .. } => ("Asking for reviews", "the review request"),
Edit::Milestone { .. } => ("Setting the milestone", "the milestone"),
}
}
pub fn done(edit: &Edit) -> &'static str {
match edit {
Edit::Body { .. } => "Saved",
Edit::Delete { .. } => "Deleted",
Edit::Title { .. } => "Renamed",
Edit::React { on: true, .. } => "Reacted",
Edit::React { .. } => "Reaction taken back",
Edit::Labels { .. } => "Labels changed",
Edit::Assignees { .. } => "Assignees changed",
Edit::Reviewers { .. } => "Reviewers asked",
Edit::Milestone {
milestone: None, ..
} => "Milestone cleared",
Edit::Milestone { .. } => "Milestone set",
}
}