use crate::api::browse::Results;
use crate::api::housekeeping::{LabelChange, LabelInfo};
use crate::api::model::RepoId;
use crate::route::{Route, issues};
use crate::ui::page::Page;
use crate::ui::pages::{Mark, WriteFormView};
use super::{
Expected, Field, Form, FormOf, Subject, ask, expect, form, form_mut, form_row, item, refused,
selected, setting,
};
use crate::data::DataKey;
use crate::keymap::Action;
use crate::state::{Cmd, State};
use crate::work::{self, Cx, Failure, Job};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Save {
pub repo: RepoId,
pub was: Option<String>,
pub change: Option<LabelChange>,
}
const COLOURS: &[(&str, &str)] = &[
("b60205", "red"),
("d93f0b", "orange"),
("fbca04", "yellow"),
("0e8a16", "green"),
("006b75", "teal"),
("1d76db", "blue"),
("0052cc", "dark blue"),
("5319e7", "purple"),
("e99695", "pink"),
("f9d0c4", "peach"),
("fef2c0", "cream"),
("c2e0c6", "mint"),
("bfdadc", "pale teal"),
("c5def5", "pale blue"),
("bfd4f2", "lavender"),
("d4c5f9", "lilac"),
];
pub(super) fn list<'a>(state: &'a State, repo: &RepoId) -> Option<&'a Results<LabelInfo>> {
state.picked(&DataKey::Labels(repo.clone()))
}
pub(super) fn picked(state: &State) -> Option<&LabelInfo> {
let Some(Route::Issues(issues::Labels(repo))) = state.route() else {
return None;
};
let list = list(state, repo)?;
selected(state, &list.items, |l| {
crate::ui::pages::url::label(repo, &l.name)
})
}
#[must_use]
pub(super) fn open(state: &mut State, repo: &RepoId, name: Option<String>) -> Vec<Cmd> {
if let Some(why) = refused(state, repo, "Changing labels") {
return state.just_say(why);
}
super::open(
state,
FormOf::Label {
repo: repo.clone(),
name,
},
)
}
#[must_use]
pub(super) fn edit(state: &mut State, repo: &RepoId) -> Vec<Cmd> {
let Some(name) = picked(state).map(|l| l.name.clone()) else {
return state.just_say("Select a label first");
};
open(state, repo, Some(name))
}
#[must_use]
pub(super) fn follow(
state: &mut State,
route: &Route,
repo: &RepoId,
name: Option<&String>,
) -> Vec<Cmd> {
if form(state, route).started {
return Vec::new();
}
let label = match name {
Some(name) => {
let Some(l) = list(state, repo).and_then(|l| l.items.iter().find(|l| l.name == *name))
else {
return Vec::new();
};
Some(l.clone())
}
None => None,
};
let n = usize::try_from((state.clock)() / 1000).unwrap_or_default();
let colour = COLOURS.get(n % COLOURS.len()).map_or("ededed", |c| c.0);
let f = form_mut(state, route);
f.started = true;
match label {
Some(l) => {
f.set(Field::Name, l.name);
f.set(Field::Colour, l.color.to_lowercase());
f.set(Field::Description, l.description);
}
None => f.set(Field::Colour, colour),
}
if name.is_none() && state.overlay.is_none() {
return super::open_field(state, route, Field::Name);
}
Vec::new()
}
fn colour(text: &str) -> Option<String> {
let hex = text.trim().trim_start_matches('#');
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
match hex.len() {
6 => Some(hex.to_lowercase()),
3 => Some(
hex.chars()
.flat_map(|c| [c, c])
.collect::<String>()
.to_lowercase(),
),
_ => None,
}
}
pub(super) fn colour_rows(
route: &Route,
q: &str,
) -> Vec<(
crate::ui::overlays::PaletteItem,
Option<crate::picker::Choice>,
)> {
let set = |c: &str| setting(route, Field::Colour, c);
let mut rows = Vec::new();
if !q.is_empty() {
rows.push(match colour(q) {
Some(c) => item(format!("#{c}"), "↵", set(&c)),
None => item("A colour is #rrggbb: six hexadecimal digits", "", None),
});
}
let q = q.to_lowercase();
for (hex, name) in COLOURS {
if q.is_empty() || name.contains(q.as_str()) || hex.starts_with(q.trim_start_matches('#')) {
rows.push(item(format!("#{hex}"), *name, set(hex)));
}
}
rows
}
pub(super) fn change(f: &Form) -> Result<LabelChange, String> {
let name = f.get(Field::Name).trim();
if name.is_empty() {
return Err("A label needs a name".into());
}
let Some(color) = colour(f.get(Field::Colour)) else {
return Err("A label's colour is #rrggbb".into());
};
Ok(LabelChange {
name: name.to_owned(),
color,
description: f.get(Field::Description).trim().to_owned(),
})
}
#[must_use]
pub(super) fn submit(
state: &mut State,
route: &Route,
repo: &RepoId,
was: Option<&str>,
) -> Vec<Cmd> {
if let Some(why) = refused(state, repo, "Changing labels") {
return crate::forms::say(state, why);
}
let change = match change(&form(state, route)) {
Ok(c) => c,
Err(why) => {
return crate::forms::say(state, why);
}
};
let taken = list(state, repo).is_some_and(|l| {
(l.items.iter())
.any(|l| l.name.eq_ignore_ascii_case(&change.name) && Some(l.name.as_str()) != was)
});
if taken {
return crate::forms::say(
state,
format!("{repo} has a label named {} already", change.name),
);
}
crate::forms::form_sending(state, route);
state.info(format!("Saving the label {}…", change.name));
work::start(Save {
repo: repo.clone(),
was: was.map(str::to_owned),
change: Some(change),
})
}
#[must_use]
pub(super) fn ask_delete(state: &mut State, repo: &RepoId) -> Vec<Cmd> {
if let Some(why) = refused(state, repo, "Changing labels") {
return state.just_say(why);
}
let Some(l) = picked(state).cloned() else {
return state.just_say("Select a label first");
};
let on = match l.open {
0 => "No open issue or pull request has it".to_owned(),
1 => "It comes off its open issue or pull request (and any closed ones)".to_owned(),
n => format!("It comes off its {n} open issues and pull requests (and any closed ones)"),
};
let subject = Subject::Label(repo.clone(), l.name.clone());
ask(
state,
format!("Delete the label {}?", l.name),
vec![(on, l.open > 0), ("It can't be brought back".into(), true)],
vec![("Delete label".into(), super::Answer::Delete(subject))],
);
Vec::new()
}
#[must_use]
pub(super) fn delete(state: &mut State, repo: RepoId, name: String) -> Vec<Cmd> {
crate::forms::sending(state);
work::start(Save {
repo,
was: Some(name),
change: None,
})
}
impl Job for Save {
type Out = ();
async fn run(&self, cx: &Cx) -> Result<(), Failure> {
let (gh, repo) = (&cx.gh, &self.repo);
match (&self.was, &self.change) {
(None, Some(c)) => gh.create_label(repo, c).await?,
(Some(was), Some(c)) => gh.update_label(repo, was, c).await?,
(Some(was), None) => gh.delete_label(repo, was).await?,
(None, None) => {}
}
Ok(())
}
fn done(self, state: &mut State, result: Result<(), Failure>) -> Vec<Cmd> {
let work = self;
let Save { repo, was, change } = work;
let form = Route::Housekeeping(FormOf::Label {
repo: repo.clone(),
name: was.clone().filter(|_| change.is_some()),
});
let page = Route::Issues(issues::Labels(repo.clone()));
if let Err(why) = result {
return crate::forms::failed(state, &form, why.to_string());
}
crate::forms::close_sender(state, &page);
crate::forms::done::<Form>(state, &form);
let said = match (&change, &was) {
(Some(c), None) => format!("Created the label {}", c.name),
(Some(c), Some(_)) => format!("Saved the label {}", c.name),
(None, Some(was)) => format!("Deleted the label {was}"),
(None, None) => return Vec::new(),
};
state.info(said);
let mut cmds = Vec::new();
if state.route() == Some(&form) {
cmds.extend(state.back());
state.forward.clear();
if state.route() != Some(&page) {
cmds.extend(state.replace(page.clone(), true, None));
}
}
if state.route() == Some(&page) {
let gone = was.filter(|was| change.as_ref().is_none_or(|c| c.name != *was));
expect(
state,
page.clone(),
Expected::Label {
repo,
gone,
shown: change,
},
);
cmds.extend(state.ensure_route(&page, true));
}
cmds
}
}
pub(super) fn build(state: &State, page: &mut Page, route: &Route) {
let Route::Housekeeping(FormOf::Label { repo, name }) = route else {
return;
};
let f = form(state, route);
let heading = match name {
Some(name) => format!("Edit the label {name} in {repo}"),
None => format!("New label in {repo}"),
};
let mut status = Vec::new();
if let Ok(c) = change(&f) {
status.push((
format!("Shows as {} (#{})", c.name, c.color),
Mark::Fine,
None,
));
}
let save_key = state.first_key(Action::Create);
let view = WriteFormView {
heading,
base: None,
rows: vec![
form_row(&f, Field::Name, "Label name"),
form_row(&f, Field::Description, "Description (optional)"),
form_row(&f, Field::Colour, "#rrggbb"),
],
status,
boxes: Vec::new(),
after: Vec::new(),
write: "",
button: super::button(&route_of(route), &f),
save_key: &save_key,
why_not: super::problem(state, route, &route_of(route)),
error: state.forms.sent(route).error.as_deref(),
};
crate::ui::pages::write_form(page, &view);
}
fn route_of(route: &Route) -> FormOf {
match route {
Route::Housekeeping(of) => of.clone(),
_ => FormOf::NewRepo,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colours_are_githubs_hex() {
assert_eq!(colour("#D93F0B").as_deref(), Some("d93f0b"));
assert_eq!(colour("abc").as_deref(), Some("aabbcc"));
assert_eq!(colour("#12345"), None);
assert_eq!(colour("red"), None);
}
}