use crate::route::{Family, Route, Target, Url, code, issues};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::api::browse::Results;
use crate::api::housekeeping::{LabelChange, LabelInfo, MilestoneChange, RefKind};
use crate::api::model::RepoId;
use crate::git::local::Clone;
use crate::picker::entry as item;
pub(crate) use crate::ui::form::Field;
use crate::ui::form::Row;
use crate::ui::page::{Link, Page};
use crate::browse::{Build, Width};
use crate::chrome::Section;
use crate::data::DataKey;
use crate::follow::{Expectation, Shown, expect};
use crate::keymap::Action;
use crate::nav::{Doable, row};
use crate::state::{Cmd, Screen, State};
pub mod files;
pub mod git;
pub mod labels;
pub mod milestones;
pub mod refs;
pub mod repos;
pub const FEATURE: crate::features::Feature = crate::features::Feature {
owns: &[Action::NewHere, Action::Fork, Action::NewRepo],
takes,
act,
offers: |state, out| out.extend(change_doables(state)),
follow: refs::follow,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FormOf {
Label {
repo: RepoId,
name: Option<String>,
},
Milestone {
repo: RepoId,
number: Option<u64>,
},
Fork(RepoId),
NewRepo,
File {
repo: RepoId,
rev: String,
path: String,
change: FileChange,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FileChange {
Edit,
New,
Delete,
}
impl Family for FormOf {
fn url(&self) -> String {
use crate::ui::pages::url as links;
match self {
FormOf::Label { repo, .. } => format!("{}/labels", links::repo(repo)),
FormOf::Milestone { repo, number } => match number {
Some(n) => format!("{}/milestones/{n}/edit", links::repo(repo)),
None => format!("{}/milestones/new", links::repo(repo)),
},
FormOf::Fork(repo) => format!("{}/fork", links::repo(repo)),
FormOf::NewRepo => format!("{}/new", links::base()),
FormOf::File {
repo,
rev,
path,
change,
} => {
let verb = match change {
FileChange::Edit => "edit",
FileChange::New => "new",
FileChange::Delete => "delete",
};
let url = format!("{}/{verb}/{}", links::repo(repo), links::encode_path(rev));
if path.is_empty() {
url
} else {
format!("{url}/{}", links::encode_path(path))
}
}
}
}
fn form(&self) -> Option<&'static crate::forms::Kind> {
Some(&FORM)
}
fn title(&self) -> String {
match self {
FormOf::Label { repo, name: None } => format!("{repo} · New label"),
FormOf::Label {
repo,
name: Some(name),
} => format!("{repo} · Label {name}"),
FormOf::Milestone { repo, number: None } => format!("{repo} · New milestone"),
FormOf::Milestone {
repo,
number: Some(n),
} => format!("{repo} · Editing milestone {n}"),
FormOf::Fork(repo) => format!("{repo} · Fork"),
FormOf::NewRepo => "New repository".to_owned(),
FormOf::File {
repo, path, change, ..
} => match change {
FileChange::Edit => format!("{}/{path} · Editing", repo.name),
FileChange::Delete => format!("{}/{path} · Deleting", repo.name),
FileChange::New if path.is_empty() => format!("{} · New file", repo.name),
FileChange::New => format!("{}/{path} · New file", repo.name),
},
}
}
fn repo(&self) -> Option<&RepoId> {
match self {
FormOf::Label { repo, .. }
| FormOf::Milestone { repo, .. }
| FormOf::Fork(repo)
| FormOf::File { repo, .. } => Some(repo),
FormOf::NewRepo => None,
}
}
fn up(&self, _: &State) -> Option<Route> {
Some(match self {
FormOf::Label { repo, .. } => Route::Issues(issues::Labels(repo.clone())),
FormOf::Milestone { repo, .. } => Route::Issues(issues::Milestones {
repo: repo.clone(),
closed: false,
}),
FormOf::Fork(repo) => Route::Code(code::Repo(repo.clone())),
FormOf::NewRepo => return None,
FormOf::File {
repo,
rev,
path,
change: FileChange::New,
} => Route::Code(code::Tree {
repo: repo.clone(),
rev: rev.clone(),
path: path.clone(),
}),
FormOf::File {
repo, rev, path, ..
} => Route::blob(repo.clone(), rev.clone(), path.clone()),
})
}
fn needs(&self) -> Vec<DataKey> {
let header = self.repo().map(|repo| DataKey::Repo(repo.clone()));
let own = match self {
FormOf::Label { repo, .. } => vec![DataKey::Labels(repo.clone())],
FormOf::Milestone {
repo,
number: Some(n),
} => vec![DataKey::Milestone(repo.clone(), *n)],
FormOf::Fork(_) => vec![DataKey::Owners],
FormOf::NewRepo => vec![DataKey::Owners, DataKey::Templates],
FormOf::File {
repo, rev, path, ..
} => vec![
DataKey::Clone(repo.clone()),
DataKey::FileLook(repo.clone(), rev.clone(), path.clone()),
],
FormOf::Milestone { number: None, .. } => Vec::new(),
};
header.into_iter().chain(own).collect()
}
fn width(&self) -> Width {
Width::Reading
}
fn section(&self) -> Section {
match self {
FormOf::Label { .. } | FormOf::Milestone { .. } => Section::Issues,
_ => Section::Code,
}
}
fn build(&self, b: Build<'_>, page: &mut Page) {
let route = Route::Housekeeping(self.clone());
if !loaded(b, page, self) {
return;
}
match self {
FormOf::Label { .. } => labels::build(b.state, page, &route),
FormOf::Milestone { .. } => milestones::build(b.state, page, &route),
FormOf::Fork(_) | FormOf::NewRepo => repos::build(b.state, page, &route, self),
FormOf::File { .. } => files::build(b.state, page, &route, self),
}
}
}
impl FormOf {
fn parse(repo: &RepoId, rest: &[&str]) -> Option<FormOf> {
let repo = repo.clone();
Some(match rest {
["fork"] => FormOf::Fork(repo),
["milestones", "new"] => FormOf::Milestone { repo, number: None },
["milestones", n, "edit"] => FormOf::Milestone {
repo,
number: Some(n.parse().ok()?),
},
[verb @ ("edit" | "new" | "delete"), rev, path @ ..]
if !rev.is_empty() && !rev.contains(char::is_control) =>
{
let change = match *verb {
"edit" => FileChange::Edit,
"delete" => FileChange::Delete,
_ => FileChange::New,
};
let path = path.join("/");
if path.is_empty() && change != FileChange::New {
return None;
}
FormOf::File {
repo,
rev: (*rev).to_owned(),
path,
change,
}
}
_ => return None,
})
}
}
pub(crate) fn parse(url: &Url<'_>) -> Option<Target> {
(url.segments == ["new"]).then_some(Target::Page(Route::Housekeeping(FormOf::NewRepo)))
}
pub(crate) fn parse_repo(repo: &RepoId, rest: &[&str], _: &Url<'_>) -> Option<Target> {
FormOf::parse(repo, rest).map(|of| Target::Page(Route::Housekeeping(of)))
}
#[derive(Debug, Clone, Default)]
pub struct Form {
pub values: BTreeMap<Field, String>,
pub text: String,
pub started: bool,
}
impl crate::forms::Fields for Form {
fn text(&mut self, _: usize) -> Option<&mut String> {
Some(&mut self.text)
}
fn title(&self, route: &Route, _: usize) -> String {
match route {
Route::Housekeeping(FormOf::Milestone { .. }) => "The milestone's description".into(),
Route::Housekeeping(FormOf::File { path, change, .. }) => match change {
FileChange::New => "The new file's text".into(),
_ => format!("Editing {path}"),
},
_ => String::new(),
}
}
fn save(&self, route: &Route) -> &'static str {
match route {
Route::Housekeeping(FormOf::File { .. }) => "commit…",
Route::Housekeeping(FormOf::Milestone { number: None, .. }) => "create",
_ => "save",
}
}
}
impl Form {
pub fn get(&self, field: Field) -> &str {
self.values.get(&field).map_or("", String::as_str)
}
pub fn on(&self, field: Field) -> bool {
!self.get(field).is_empty()
}
fn set(&mut self, field: Field, value: impl Into<String>) {
self.values.insert(field, value.into());
}
fn toggle(&mut self, field: Field) {
let on = self.on(field);
self.set(field, if on { "" } else { "yes" });
}
}
#[derive(Debug, Default)]
pub struct Housekeeping {
pub deleting: Option<(RepoId, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Subject {
Label(RepoId, String),
Milestone(RepoId, u64),
Ref(RepoId, RefKind, String),
}
fn here(state: &State) -> Option<(Route, FormOf)> {
match state.route()? {
route @ Route::Housekeeping(of) => Some((route.clone(), of.clone())),
_ => None,
}
}
pub(crate) fn form<'a>(state: &'a State, route: &Route) -> Cow<'a, Form> {
state.forms.shown(route)
}
pub(crate) fn form_mut<'a>(state: &'a mut State, route: &Route) -> &'a mut Form {
state.forms.edit(route)
}
fn refused(state: &State, repo: &RepoId, what: &str) -> Option<String> {
let o = state.overview(repo)?;
if o.summary.archived {
return Some(format!("{repo} is archived: it can't be changed"));
}
(!o.can_write).then(|| format!("{what} takes write access to {repo}"))
}
#[must_use]
fn open(state: &mut State, of: FormOf) -> Vec<Cmd> {
let file = match &of {
FormOf::File {
repo, rev, path, ..
} => Some(DataKey::FileLook(repo.clone(), rev.clone(), path.clone())),
_ => None,
};
let route = Route::Housekeeping(of);
if state.route() == Some(&route) {
return compose(state);
}
state.forms.take::<Form>(&route);
let mut cmds = file.map_or_else(Vec::new, |file| state.ensure(file, true));
cmds.extend(state.push(route));
cmds.extend(follow(state));
cmds
}
fn clone_here(state: &mut State, repo: &RepoId) -> Result<Option<Clone>, Vec<Cmd>> {
if let Some(clone) = state.clone_of(repo) {
return Ok(clone.cloned());
}
state.info("Looking for your clone of the repository: a moment");
Err(state.ensure(DataKey::Clone(repo.clone()), false))
}
fn takes(state: &State, action: Action) -> bool {
let Some(route) = state.route() else {
return false;
};
let lists = matches!(
route,
Route::Issues(issues::Labels(_))
| Route::Issues(issues::Milestones { .. })
| Route::Issues(issues::Milestone { .. })
);
match action {
Action::EditComment => lists || matches!(route, Route::Code(code::Blob { .. })),
Action::DeleteComment => {
lists
|| matches!(
route,
Route::Code(code::Blob { .. })
| Route::Code(code::Branches(_))
| Route::Code(code::Tags(_))
)
}
Action::Close => matches!(
route,
Route::Issues(issues::Milestones { .. }) | Route::Issues(issues::Milestone { .. })
),
_ => false,
}
}
#[must_use]
fn act(state: &mut State, action: Action) -> Vec<Cmd> {
let route = state.route().cloned();
match (action, route) {
(Action::NewHere, Some(route)) => new_here(state, route),
(Action::Fork, _) => repos::open_fork(state),
(Action::NewRepo, _) => open(state, FormOf::NewRepo),
(Action::EditComment, Some(Route::Issues(issues::Labels(repo)))) => {
labels::edit(state, &repo)
}
(Action::DeleteComment, Some(Route::Issues(issues::Labels(repo)))) => {
labels::ask_delete(state, &repo)
}
(
Action::EditComment,
Some(
Route::Issues(issues::Milestones { repo, .. })
| Route::Issues(issues::Milestone { repo, .. }),
),
) => milestones::edit(state, &repo),
(
Action::DeleteComment,
Some(
Route::Issues(issues::Milestones { repo, .. })
| Route::Issues(issues::Milestone { repo, .. }),
),
) => milestones::ask_delete(state, &repo),
(
Action::Close,
Some(
Route::Issues(issues::Milestones { repo, .. })
| Route::Issues(issues::Milestone { repo, .. }),
),
) => milestones::toggle(state, &repo),
(Action::DeleteComment, Some(Route::Code(code::Branches(repo)))) => {
refs::ask_delete(state, &repo, RefKind::Branch)
}
(Action::DeleteComment, Some(Route::Code(code::Tags(repo)))) => {
refs::ask_delete(state, &repo, RefKind::Tag)
}
(
Action::EditComment | Action::DeleteComment,
Some(Route::Code(code::Blob {
repo, rev, path, ..
})),
) => {
let change = if action == Action::EditComment {
FileChange::Edit
} else {
FileChange::Delete
};
files::open(state, repo, rev, path, change)
}
_ => state.just_say(action.not_here()),
}
}
#[must_use]
fn new_here(state: &mut State, route: Route) -> Vec<Cmd> {
match route {
Route::Issues(issues::Labels(repo)) => labels::open(state, &repo, None),
Route::Issues(issues::Milestones { repo, .. })
| Route::Issues(issues::Milestone { repo, .. }) => milestones::open(state, &repo, None),
Route::Code(code::Branches(repo)) => refs::start(state, &repo, RefKind::Branch),
Route::Code(code::Tags(repo)) => refs::start(state, &repo, RefKind::Tag),
Route::Code(code::Tree { repo, rev, path }) => {
files::open(state, repo, rev, path, FileChange::New)
}
Route::Code(code::Blob {
repo, rev, path, ..
}) => {
let folder = path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
files::open(state, repo, rev, folder, FileChange::New)
}
Route::Code(code::Repo(repo)) => {
let Some(branch) = state.overview(&repo).and_then(|o| o.default_branch.clone()) else {
return state.just_say("The repository hasn't loaded yet");
};
files::open(state, repo, branch, String::new(), FileChange::New)
}
_ => {
state.just_say(
"New works on a repository's labels, milestones, branches, tags and files (a folder for a new file)",
)
}
}
}
pub const FORM: crate::forms::Kind = crate::forms::Kind {
compose: |state, _| compose(state),
row: |state, row| match (row, state.route().cloned()) {
(Row::Field(field), Some(route)) => open_field(state, &route, *field),
_ => Vec::new(),
},
fill,
submit,
follow,
offers: |state, out| {
if let Some((route, of)) = here(state) {
form_doables(state, &route, &of, out);
}
},
};
#[must_use]
fn submit(state: &mut State) -> Vec<Cmd> {
let Some((route, of)) = here(state) else {
return Vec::new();
};
match &of {
FormOf::Label { repo, name } => labels::submit(state, &route, repo, name.as_deref()),
FormOf::Milestone { repo, number } => milestones::submit(state, &route, repo, *number),
FormOf::Fork(repo) => repos::fork(state, &route, repo),
FormOf::NewRepo => repos::create(state, &route),
FormOf::File { .. } => files::ask_commit(state, &route),
}
}
fn selected<'a, T>(state: &State, items: &'a [T], url: impl Fn(&T) -> String) -> Option<&'a T> {
let Screen::Page(p) = state.screen() else {
return None;
};
let Some(Link::Url(link)) = p.selected_link() else {
return None;
};
items.iter().find(|item| url(item) == *link)
}
pub fn change_doables(state: &State) -> Vec<Doable> {
let Some(route) = state.route() else {
return Vec::new();
};
let mut mine = Vec::new();
page_doables(state, route, &mut mine);
if let Some(repo) = route.repo() {
let why = state
.overview(repo)
.is_none()
.then(|| "the repository hasn't loaded yet".to_owned());
mine.push(row("New", Action::Fork, format!("Fork {repo}"), "", why));
}
mine.push(row("New", Action::NewRepo, "New repository", "", None));
mine.sort_by_key(|d| d.section == "New");
mine
}
fn page_doables(state: &State, route: &Route, out: &mut Vec<Doable>) {
let repo = route.repo();
let can = |what: &str| repo.and_then(|r| refused(state, r, what));
match route {
Route::Issues(issues::Labels(_)) => {
let why = can("Changing labels");
out.push(row("New", Action::NewHere, "New label", "new", why.clone()));
let picked = labels::picked(state).map(|l| l.name.clone());
let none = || "no label is selected".to_owned();
let row_why = why.or_else(|| picked.is_none().then(none));
let name = picked.unwrap_or_else(|| "the label".into());
out.push(row(
"Edit",
Action::EditComment,
format!("Edit {name}"),
"edit",
row_why.clone(),
));
out.push(row(
"Edit",
Action::DeleteComment,
format!("Delete {name}"),
"",
row_why,
));
}
Route::Issues(issues::Milestones { .. }) | Route::Issues(issues::Milestone { .. }) => {
milestones::doables(state, out);
}
Route::Code(code::Branches(_)) | Route::Code(code::Tags(_)) => {
refs::doables(state, route, out);
}
Route::Code(code::Tree { .. })
| Route::Code(code::Blob { .. })
| Route::Code(code::Repo(_)) => {
files::doables(state, route, out);
}
_ => {}
}
}
fn form_doables(state: &State, route: &Route, of: &FormOf, out: &mut Vec<Doable>) {
let written = matches!(
of,
FormOf::Milestone { .. }
| FormOf::File {
change: FileChange::Edit | FileChange::New,
..
}
);
let write = written.then_some("Write the text");
let button = button(of, &form(state, route));
crate::forms::offer(
state,
write,
(&button, "save"),
problem(state, route, of),
out,
);
}
fn button(of: &FormOf, f: &Form) -> String {
match of {
FormOf::Label { name: None, .. } => "Create label".into(),
FormOf::Label { .. }
| FormOf::Milestone {
number: Some(_), ..
} => "Save changes".into(),
FormOf::Milestone { .. } => "Create milestone".into(),
FormOf::Fork(_) => "Create fork".into(),
FormOf::NewRepo => "Create repository".into(),
FormOf::File { change, .. } => {
let verb = if *change == FileChange::Delete {
"Delete the file"
} else {
"Commit changes"
};
if f.get(Field::Branch).is_empty() {
format!("{verb}…")
} else {
format!("{verb} and propose…")
}
}
}
}
fn problem(state: &State, route: &Route, of: &FormOf) -> Option<String> {
let f = form(state, route);
match of {
FormOf::Label { repo, .. } => {
refused(state, repo, "Changing labels").or_else(|| labels::change(&f).err())
}
FormOf::Milestone { repo, .. } => {
refused(state, repo, "Changing milestones").or_else(|| milestones::change(&f).err())
}
FormOf::Fork(repo) => repos::fork_problem(state, repo, &f),
FormOf::NewRepo => repos::repo_problem(&f),
FormOf::File { .. } => files::problem(state, route, of),
}
}
#[must_use]
fn follow(state: &mut State) -> Vec<Cmd> {
let Some((route, of)) = here(state) else {
return Vec::new();
};
match &of {
FormOf::Label { repo, name } => labels::follow(state, &route, repo, name.as_ref()),
FormOf::Milestone { repo, number } => milestones::follow(state, &route, repo, *number),
FormOf::Fork(repo) => repos::follow_fork(state, &route, repo),
FormOf::NewRepo => repos::follow_new(state, &route),
FormOf::File { .. } => files::follow(state, &route, &of),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expected {
Label {
repo: RepoId,
gone: Option<String>,
shown: Option<LabelChange>,
},
Milestone {
repo: RepoId,
number: u64,
list: Option<bool>,
closed: Option<bool>,
title: Option<String>,
},
Ref(RepoId, RefKind, String, bool),
Repo(RepoId),
File {
repo: RepoId,
rev: String,
path: String,
text: Option<String>,
},
}
impl Expectation for Expected {
fn waiting(&self) -> &'static str {
match self {
Expected::Label { .. } => "Waiting for GitHub to show the label",
Expected::Milestone { .. } => "Waiting for GitHub to show the milestone",
Expected::Ref(_, RefKind::Branch, ..) => "Waiting for GitHub to show the branch",
Expected::Ref(_, RefKind::Tag, ..) => "Waiting for GitHub to show the tag",
Expected::Repo(_) => "Waiting for GitHub to make the repository",
Expected::File { .. } => "Waiting for GitHub to show the file",
}
}
fn shown(&self, state: &State, _: &Route) -> Shown {
use crate::api::browse::{
Blob, BranchInfo, MilestoneDetail, MilestoneList, TagInfo, TreeEntry,
};
let shown = match self {
Expected::Label { repo, gone, shown } => {
let Some(list) = labels::list(state, repo) else {
return Shown::No;
};
let gone = gone
.as_ref()
.is_none_or(|name| list.items.iter().all(|l| l.name != *name));
let shown = shown.as_ref().is_none_or(|c| {
list.items.iter().any(|l| {
l.name == c.name
&& l.color.eq_ignore_ascii_case(&c.color)
&& l.description == c.description
})
});
gone && shown
}
Expected::Milestone {
repo,
number,
list,
closed,
title,
} => {
let titled = |t: &str| title.as_deref().is_none_or(|want| want == t);
match list {
Some(list) => {
let key = DataKey::Milestones(repo.clone(), *list);
state.picked::<MilestoneList>(&key).is_some_and(|l| {
let found = l.results.items.iter().find(|m| m.number == *number);
match found {
Some(m) => *closed == Some(*list) && titled(&m.title),
None => *closed != Some(*list),
}
})
}
None => {
let one = DataKey::Milestone(repo.clone(), *number);
state.picked::<MilestoneDetail>(&one).is_some_and(|m| {
Some(m.info.closed) == *closed && titled(&m.info.title)
})
}
}
}
Expected::Ref(repo, kind, name, there) => {
let (names, more) = match kind {
RefKind::Branch => {
let Some(l) =
state.picked::<Results<BranchInfo>>(&DataKey::Branches(repo.clone()))
else {
return Shown::No;
};
let names: Vec<&str> = l.items.iter().map(|b| b.name.as_str()).collect();
(names, l.next.is_some())
}
RefKind::Tag => {
let Some(l) =
state.picked::<Results<TagInfo>>(&DataKey::Tags(repo.clone()))
else {
return Shown::No;
};
let names: Vec<&str> = l.items.iter().map(|t| t.name.as_str()).collect();
(names, false)
}
};
let listed = names.contains(&name.as_str());
let later = more
&& *kind == RefKind::Branch
&& names.last().is_some_and(|last| *last < name.as_str());
if *there { listed || later } else { !listed }
}
Expected::Repo(repo) => state.overview(repo).is_some(),
Expected::File {
repo,
rev,
path,
text: Some(text),
} => state
.picked::<Blob>(&DataKey::Blob(repo.clone(), rev.clone(), path.clone()))
.is_some_and(|b| b.text.as_deref().map(str::trim_end) == Some(text.trim_end())),
Expected::File {
repo,
rev,
path,
text: None,
} => {
let (dir, name) = path.rsplit_once('/').unwrap_or(("", path));
let key = DataKey::Tree(repo.clone(), rev.clone(), dir.to_owned());
state
.picked::<[TreeEntry]>(&key)
.is_some_and(|entries| entries.iter().all(|e| e.name != name))
}
};
shown.into()
}
}
pub fn build(b: Build<'_>, page: &mut Page, repo: &RepoId) {
let list = b.get::<Results<LabelInfo>>(&DataKey::Labels(repo.clone()));
let keys = crate::ui::pages::LabelKeys {
new: b.state.first_key(Action::NewHere),
edit: b.state.first_key(Action::EditComment),
delete: b.state.first_key(Action::DeleteComment),
};
crate::ui::pages::labels_page(page, repo, list, &keys, b.cx.now);
}
fn loaded(b: Build<'_>, page: &mut Page, of: &FormOf) -> bool {
for key in of.needs() {
let what = match &key {
DataKey::Labels(_) => "the labels",
DataKey::Owners => "your account",
DataKey::Templates => "GitHub's templates",
DataKey::Milestone(..) => "the milestone",
_ => continue,
};
let fetched = crate::state::Remote::fetched(b.state.remote(&key), b.retry);
if fetched.show(page, what).is_none() {
return false;
}
}
true
}
#[must_use]
fn compose(state: &mut State) -> Vec<Cmd> {
let Some((route, of)) = here(state) else {
return Vec::new();
};
match of {
FormOf::Milestone { .. } => {}
FormOf::File { change, .. } if change != FileChange::Delete => {
if !form(state, &route).started {
return state.just_say("The file hasn't loaded yet");
}
}
_ => {
return Vec::new();
}
}
let start = [ratatui_textarea::CursorMove::Jump(0, 0)];
crate::forms::compose::<Form>(state, &route, 0, &start)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pick {
Field(Route, Field),
RefName(RepoId, RefKind),
RefFrom(RepoId, RefKind, String),
}
impl Pick {
pub fn title(&self) -> &'static str {
match self {
Pick::Field(_, field) => field.name(),
Pick::RefName(_, RefKind::Branch) => "New branch: its name",
Pick::RefName(_, RefKind::Tag) => "New tag: its name",
Pick::RefFrom(..) => "Where it starts",
}
}
pub fn placeholder(&self) -> &'static str {
match self {
Pick::Field(_, Field::Colour) => "Type a colour (#rrggbb), or choose one",
Pick::Field(_, Field::Due) => "Type the date: YYYY-MM-DD",
Pick::Field(_, Field::Owner | Field::Gitignore | Field::License) => "Type to filter",
Pick::Field(_, Field::Branch) => "Type a new branch's name, or choose",
Pick::Field(..) => "Type it",
Pick::RefName(..) => "Type the name",
Pick::RefFrom(..) => "Find a branch or tag, or type a commit's SHA",
}
}
pub fn need(&self) -> Option<DataKey> {
match self {
Pick::Field(_, Field::Owner) => Some(DataKey::Owners),
Pick::Field(_, Field::Gitignore | Field::License) => Some(DataKey::Templates),
Pick::RefName(repo, _) => Some(DataKey::Clone(repo.clone())),
Pick::RefFrom(repo, ..) => Some(DataKey::Branches(repo.clone())),
Pick::Field(..) => None,
}
}
pub fn start(&self, state: &State) -> Option<String> {
match self {
Pick::Field(route, field) if !field_lists(*field) => {
Some(form(state, route).get(*field).to_owned()).filter(|v| !v.is_empty())
}
_ => None,
}
}
}
fn field_lists(field: Field) -> bool {
matches!(
field,
Field::Owner | Field::Gitignore | Field::License | Field::Branch
)
}
fn setting(route: &Route, field: Field, value: &str) -> Option<crate::picker::Choice> {
Some(crate::forms::filling(route, Row::Field(field), value))
}
pub fn rows(state: &State, pick: &Pick, q: &str) -> crate::picker::Rows {
match pick {
Pick::Field(route, field) => field_rows(state, route, *field, q),
Pick::RefName(repo, kind) => refs::name_rows(repo, *kind, q),
Pick::RefFrom(repo, kind, name) => refs::from_rows(state, repo, *kind, name, q),
}
}
fn field_rows(state: &State, route: &Route, field: Field, q: &str) -> crate::picker::Rows {
let set = |value: &str| setting(route, field, value);
let q = q.trim();
match field {
Field::Colour => labels::colour_rows(route, q),
Field::Owner => repos::owner_rows(state, route, q),
Field::Gitignore | Field::License => repos::template_rows(state, route, field, q),
Field::Branch => files::branch_rows(state, route, q),
Field::Due => milestones::due_rows(route, q),
Field::Name | Field::Title | Field::Path | Field::Message if q.is_empty() => {
vec![item(
format!("Type the {}", field.name().to_lowercase()),
"",
None,
)]
}
_ => match check(route, field, q) {
Some(why) => vec![item(why, "", None)],
None if q.is_empty() => vec![item(
format!("No {}", field.name().to_lowercase()),
"↵",
set(""),
)],
None => vec![item(format!("{}: {q}", field.name()), "↵", set(q))],
},
}
}
fn check(route: &Route, field: Field, value: &str) -> Option<String> {
match (route, field) {
(Route::Housekeeping(FormOf::Label { .. }), Field::Name) if value.chars().count() > 50 => {
Some("A label's name is at most 50 characters".into())
}
(Route::Housekeeping(FormOf::Label { .. }), Field::Description)
if value.chars().count() > 100 =>
{
Some("A label's description is at most 100 characters".into())
}
(Route::Housekeeping(FormOf::Fork(_) | FormOf::NewRepo), Field::Name) => {
repos::name_problem(value)
}
(Route::Housekeeping(FormOf::File { .. }), Field::Path) => files::path_problem(value),
_ => None,
}
}
#[must_use]
pub fn open_field(state: &mut State, route: &Route, field: Field) -> Vec<Cmd> {
if field.toggles() {
form_mut(state, route).toggle(field);
return Vec::new();
}
let pick = crate::picker::Kind::Housekeeping(Pick::Field(route.clone(), field));
state.open_picker(pick)
}
fn fill(state: &mut State, route: &Route, row: &Row, value: String) -> Vec<Cmd> {
let Row::Field(field) = *row else {
return Vec::new();
};
let f = form_mut(state, route);
f.set(field, value);
let blank = f.text.is_empty();
let new_file = matches!(
route,
Route::Housekeeping(FormOf::File {
change: FileChange::New,
..
})
);
if field == Field::Path && new_file && blank && state.route() == Some(route) {
return compose(state);
}
Vec::new()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
Delete(Subject),
Push(RepoId, RefKind, String, String),
Commit,
Clone {
url: String,
dir: PathBuf,
},
Propose(RepoId, String, Option<String>),
}
impl Answer {
pub fn busy(&self) -> &'static str {
match self {
Answer::Delete(_) => "Deleting…",
Answer::Push(..) => "Pushing…",
Answer::Commit => "Committing…",
Answer::Clone { .. } => "Cloning…",
Answer::Propose(..) => "Opening a pull request…",
}
}
}
fn question(
route: Route,
title: String,
facts: Vec<(String, bool)>,
choices: Vec<(String, Answer)>,
) -> Box<crate::forms::Ask> {
let choices = (choices.into_iter())
.map(|(label, a)| (label, crate::forms::Answer::Housekeeping(a)))
.collect();
let about = crate::forms::About::Form(route);
Box::new(crate::forms::Ask::new(about, title, facts, choices))
}
fn ask(
state: &mut State,
title: String,
facts: Vec<(String, bool)>,
choices: Vec<(String, Answer)>,
) {
if let Some(route) = state.route().cloned() {
let ask = question(route, title, facts, choices);
state.overlay = Some(crate::state::Overlay::Ask(ask));
}
}
#[must_use]
pub fn answer(state: &mut State, route: &Route, answer: Answer) -> Vec<Cmd> {
match answer {
Answer::Commit => files::commit(state, route),
Answer::Delete(Subject::Label(repo, name)) => labels::delete(state, repo, name),
Answer::Delete(Subject::Milestone(repo, number)) => milestones::delete(state, repo, number),
Answer::Delete(Subject::Ref(repo, kind, name)) => refs::send(state, repo, kind, name, None),
Answer::Push(repo, kind, name, from) => refs::send(state, repo, kind, name, Some(from)),
Answer::Clone { url, dir } => {
crate::forms::sending(state);
crate::work::start(repos::CloneTo { url, dir })
}
Answer::Propose(repo, branch, base) => {
state.overlay = None;
files::propose(state, repo, branch, base)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MilestoneWrite {
Save(MilestoneChange),
Close(bool),
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefWrite {
Create(String),
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileWrite {
pub repo: RepoId,
pub base: Option<String>,
pub commit: git::Commit,
pub head: String,
pub clone: Option<Clone>,
}
fn form_row(f: &Form, field: Field, unset: &str) -> crate::ui::pages::FormRow {
let colour = (field == Field::Colour && !f.get(field).is_empty())
.then(|| format!("#{}", f.get(field).trim_start_matches('#')));
let value = colour.as_deref().unwrap_or(f.get(field));
let (value, unset) = if field.toggles() {
(toggle_text(field, f.on(field)).to_owned(), false)
} else if value.is_empty() {
(unset.to_owned(), true)
} else {
(value.to_owned(), false)
};
crate::ui::pages::FormRow {
name: field.name(),
value,
unset,
chips: Vec::new(),
link: Some(Row::Field(field)),
}
}
fn toggle_text(field: Field, on: bool) -> &'static str {
match (field, on) {
(Field::Private, true) => "Private: you choose who can see it",
(Field::Private, false) => "Public: anyone can see it",
(Field::Readme, true) => "Add a README file",
(Field::Readme, false) => "No README",
(Field::AllBranches, true) => "Copy all branches",
(Field::AllBranches, false) => "Copy the default branch only",
_ => "",
}
}
#[cfg(test)]
pub(crate) mod fixtures {
use super::*;
use crate::api::housekeeping::{Owners, Templates};
pub fn labels() -> Results<LabelInfo> {
let label = |name: &str, color: &str, description: &str, open| LabelInfo {
name: name.into(),
color: color.into(),
description: description.into(),
open,
};
Results {
total: 3,
items: vec![
label("bug", "d73a4a", "Something isn't working", 4),
label("documentation", "0075ca", "", 0),
label("good first issue", "7057ff", "Good for newcomers", 12),
],
next: None,
}
}
pub fn owners() -> Owners {
Owners {
viewer: "octocat".into(),
orgs: vec![("ghtui-org".into(), true), ("readers".into(), false)],
more: 0,
}
}
pub fn templates() -> Templates {
Templates {
gitignores: vec!["Go".into(), "Rust".into()],
licenses: vec![
("mit".into(), "MIT License".into()),
("apache-2.0".into(), "Apache License 2.0".into()),
],
}
}
}