use crate::api::lists::{ListPage, MyLists, StarList};
use crate::api::model::{Capped, NodeId, RepoId};
use crate::data::Picked;
use crate::follow::{Expectation, Shown};
use crate::keymap::Action;
use crate::nav::{Doable, row};
use crate::picker::fuzzy_score;
use crate::picker::{Choice, entry};
use crate::route::{Route, people};
use crate::state::{Cmd, Remote, State};
use crate::work::{self, Cx, Failure, Job};
use super::Page;
use crate::data::DataKey;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetLists {
pub repo: RepoId,
pub item: NodeId,
pub lists: Vec<NodeId>,
pub create: Option<String>,
pub list: String,
pub add: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteList {
pub login: String,
pub slug: String,
pub id: NodeId,
pub name: String,
}
impl SetLists {
fn said(&self) -> String {
match (&self.create, self.add) {
(Some(name), _) => format!("Made the list “{name}” and added {} to it", self.repo),
(None, true) => format!("Added {} to “{}”", self.repo, self.list),
(None, false) => format!("Took {} out of “{}”", self.repo, self.list),
}
}
fn chosen(self) -> Choice {
Choice::Step(crate::act::Step::Start(
format!("{}…", self.said()),
self.into(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expected {
Membership {
repo: RepoId,
list: String,
add: bool,
},
Gone { login: String, slug: String },
}
impl Expectation for Expected {
fn needs(&self, _: &State, _: &Route) -> Vec<DataKey> {
match self {
Expected::Membership { repo, .. } => {
vec![DataKey::MyLists(repo.clone())]
}
Expected::Gone { login, .. } => {
vec![DataKey::StarLists(login.to_lowercase())]
}
}
}
fn waiting(&self) -> &'static str {
match self {
Expected::Membership { .. } => "Waiting for GitHub to show the list",
Expected::Gone { .. } => "Waiting for GitHub to show the list gone",
}
}
fn shown(&self, state: &State, _: &Route) -> Shown {
match self {
Expected::Membership { repo, list, add } => state
.picked::<MyLists>(&DataKey::MyLists(repo.clone()))
.is_some_and(|m| {
m.lists
.iter()
.any(|l| l.name == *list && l.has == Some(*add))
|| (!add && m.lists.iter().all(|l| l.name != *list))
}),
Expected::Gone { login, slug } => state
.picked::<Capped<StarList>>(&DataKey::StarLists(login.to_lowercase()))
.is_some_and(|lists| lists.iter().all(|l| l.slug != *slug)),
}
.into()
}
}
fn subject(state: &State) -> Option<RepoId> {
state.route()?.repo().cloned()
}
#[must_use]
pub fn open(state: &mut State) -> Vec<Cmd> {
let Some(repo) = subject(state) else {
return state.just_say(Action::Lists.not_here());
};
let mut cmds = state.ensure(DataKey::MyLists(repo.clone()), true);
let pick = crate::picker::Kind::Site(super::Pick::Lists(repo));
cmds.extend(state.open_picker(pick));
cmds
}
pub fn rows(state: &State, repo: &RepoId, q: &str) -> crate::picker::Rows {
let key = DataKey::MyLists(repo.clone());
#[expect(clippy::disallowed_methods, reason = "said as the picker's row")]
let fetched = Remote::fetched(state.remote(&key), "")
.pick(<MyLists as Picked>::pick)
.text("your lists");
let mine = match fetched {
Ok(m) => m,
Err(why) => return vec![entry(why, "", None)],
};
let ids = |except: Option<&NodeId>| -> Vec<NodeId> {
(mine.lists.iter())
.filter(|l| l.has == Some(true) && Some(&l.id) != except)
.map(|l| l.id.clone())
.collect()
};
let mut rows: Vec<_> = (mine.lists.iter())
.filter(|l| fuzzy_score(q, &l.name).is_some())
.map(|l| {
let mark = if l.has == Some(true) { "✓" } else { " " };
let noun = crate::ui::text::noun(l.count, "repository");
let mut hint = format!("{} {noun}", l.count);
if l.private {
hint.push_str(" · private");
}
let label = format!("{mark} {}", l.name);
let Some(has) = l.has else {
let hint = "too long for ghtui to say whether it's in it";
return entry(label, hint, None);
};
let mut lists = ids(Some(&l.id));
if !has {
lists.push(l.id.clone());
}
let work = SetLists {
repo: repo.clone(),
item: mine.repo.clone(),
lists,
create: None,
list: l.name.clone(),
add: !has,
};
entry(label, hint, Some(work.chosen()))
})
.collect();
let name = q.trim();
if !name.is_empty()
&& mine
.lists
.iter()
.all(|l| !l.name.eq_ignore_ascii_case(name))
{
let work = SetLists {
repo: repo.clone(),
item: mine.repo.clone(),
lists: ids(None),
create: Some(name.to_owned()),
list: name.to_owned(),
add: true,
};
let label = format!("+ Make the list “{name}” and add it");
rows.push(entry(label, "↵", Some(work.chosen())));
}
if mine.lists.left_out() > 0 {
let n = mine.lists.left_out();
rows.push(entry(
format!("{n} more lists on GitHub, not listed"),
"",
None,
));
}
if rows.is_empty() {
rows.push(entry(
"You have no lists: type a name to make one",
"",
None,
));
}
rows
}
impl Job for SetLists {
type Out = ();
async fn run(&self, cx: &Cx) -> Result<(), Failure> {
let gh = &cx.gh;
let mut lists = self.lists.clone();
if let Some(name) = &self.create {
lists.push(gh.create_list(name).await?);
}
Ok(gh.set_lists(&self.item, &lists).await?)
}
fn done(self, state: &mut State, result: Result<(), Failure>) -> Vec<Cmd> {
let work = self;
match result {
Ok(()) => {
state.info(work.said());
if let Some(route) = state.route().cloned() {
let what = Expected::Membership {
repo: work.repo.clone(),
list: work.list.clone(),
add: work.add,
};
crate::follow::expect(state, route, what);
}
let mut cmds = state.ensure(DataKey::MyLists(work.repo), true);
cmds.extend(state.load_visible(false));
cmds
}
Err(why) => {
state.error(refused("change your lists", &why));
Vec::new()
}
}
}
}
fn refused(what: &str, why: &Failure) -> String {
let why = why.to_string();
if why.contains("['user']") {
crate::api::scope_missing("Changing your lists", "user")
} else {
format!("GitHub didn't {what}: {why}")
}
}
fn yours<'a>(state: &'a State, login: &str) -> Result<&'a ListPage, String> {
let route = state.route().cloned();
let list = route
.and_then(|r| r.family().paged())
.and_then(|k| state.picked::<ListPage>(&k));
let Some(list) = list else {
return Err("it hasn't loaded yet".into());
};
match &state.viewer {
Some(me) if me.eq_ignore_ascii_case(login) => Ok(list),
_ => Err(format!("it's @{login}'s list, not yours")),
}
}
#[must_use]
pub fn ask_delete(state: &mut State) -> Vec<Cmd> {
let Some(route @ Route::Site(Page::StarList { .. })) = state.route().cloned() else {
return Vec::new();
};
let Route::Site(Page::StarList { login, .. }) = &route else {
return Vec::new();
};
let list = match yours(state, login) {
Ok(l) => l.list.clone(),
Err(why) => {
return state.just_say(format!("Can't delete the list: {why}"));
}
};
let n = list.count;
let facts = vec![(
format!(
"It has {n} {}; they stay starred. It can't be brought back",
crate::ui::text::noun(n, "repository")
),
true,
)];
crate::forms::ask(
state,
route,
format!("Delete the list “{}”?", list.name),
facts,
vec![(
"Delete list".into(),
crate::forms::Answer::Site(super::Answer::DeleteList),
)],
);
Vec::new()
}
#[must_use]
pub fn delete(state: &mut State, route: &Route) -> Vec<Cmd> {
let Route::Site(Page::StarList { login, slug }) = route else {
return Vec::new();
};
let Ok(list) = yours(state, login).map(|l| l.list.clone()) else {
return Vec::new();
};
crate::forms::sending(state);
let delete = DeleteList {
login: login.clone(),
slug: slug.clone(),
id: list.id,
name: list.name,
};
work::start(delete)
}
impl Job for DeleteList {
type Out = ();
async fn run(&self, cx: &Cx) -> Result<(), Failure> {
Ok(cx.gh.delete_list(&self.id).await?)
}
fn done(self, state: &mut State, result: Result<(), Failure>) -> Vec<Cmd> {
let d = self;
let route = Route::Site(Page::StarList {
login: d.login.clone(),
slug: d.slug.clone(),
});
match result {
Ok(()) => {
crate::forms::close_sender(state, &route);
state.info(format!("Deleted the list “{}”", d.name));
if state.route() != Some(&route) {
return Vec::new();
}
let stars = Route::People(people::User {
login: d.login.clone(),
tab: crate::ui::pages::ProfileTab::Stars,
});
let gone = Expected::Gone {
login: d.login,
slug: d.slug,
};
crate::follow::expect(state, stars.clone(), gone);
state.replace(stars, true, None)
}
Err(why) => crate::forms::say(state, refused("delete the list", &why)),
}
}
}
pub fn doable(state: &State) -> Option<Doable> {
subject(state)?;
Some(row(
"Change",
Action::Lists,
"Add the repository to your lists, or take it out",
"",
None,
))
}
pub fn doables(state: &State, route: &Route, out: &mut Vec<Doable>) {
if let Route::Site(Page::StarList { login, .. }) = route {
let why = yours(state, login).err();
out.push(row(
"Edit",
Action::DeleteComment,
"Delete the list",
"",
why,
));
}
}