use crate::route::Route;
use std::path::PathBuf;
use crate::api::housekeeping::{MadeRepo, NewFork, NewRepo, Owners, Templates};
use crate::api::model::RepoId;
use crate::picker::{fuzzy_score, listed};
use crate::ui::page::Page;
use crate::ui::pages::{Mark, WriteFormView};
use super::{Expected, Field, Form, FormOf, ask, form, form_mut, form_row, item, setting};
use crate::data::DataKey;
use crate::state::{Cmds, State};
use crate::work::{self, Cx, Failure, Job};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fork {
pub route: Route,
pub new: NewFork,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Make {
pub route: Route,
pub new: NewRepo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneTo {
pub url: String,
pub dir: PathBuf,
}
fn owners(state: &State) -> Option<&Owners> {
state.picked(&DataKey::Owners)
}
pub(super) fn open_fork(state: &mut State) -> Cmds {
let Some(repo) = state.route().and_then(Route::repo).cloned() else {
return state.just_say("Open a repository first: a fork is of one");
};
super::open(state, FormOf::Fork(repo))
}
pub(super) fn follow(state: &mut State, route: &Route, fork: Option<&RepoId>) -> Cmds {
if form(state, route).started {
return Cmds::new();
}
let Some(viewer) = owners(state).map(|o| o.viewer.clone()) else {
return Cmds::new();
};
let f = form_mut(state, route);
f.started = true;
f.set(Field::Owner, viewer);
match fork {
Some(repo) => f.set(Field::Name, &*repo.name),
None if state.overlay.is_none() => return super::open_field(state, route, Field::Name),
None => {}
}
Cmds::new()
}
pub(super) fn name_problem(name: &str) -> Option<String> {
let ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
if name.is_empty() {
Some("A repository needs a name".into())
} else if !name.chars().all(ok) {
Some("A repository's name is letters, digits, -, _ and .".into())
} else if name == "." || name == ".." || name.len() > 100 {
Some("That can't be a repository's name".into())
} else {
None
}
}
fn org(state: &State, f: &Form) -> Option<String> {
let viewer = owners(state).map(|o| o.viewer.as_str());
let owner = f.get(Field::Owner);
(Some(owner) != viewer && !owner.is_empty()).then(|| owner.to_owned())
}
pub(super) fn repo_problem(f: &Form) -> Option<String> {
if f.get(Field::Owner).is_empty() {
return Some("Still asking GitHub who you are".into());
}
name_problem(f.get(Field::Name).trim())
}
pub(super) fn fork_problem(state: &State, repo: &RepoId, f: &Form) -> Option<String> {
let owner = f.get(Field::Owner);
if owner.eq_ignore_ascii_case(&repo.owner) {
let parent = state.overview(repo).and_then(|o| o.parent.clone());
return Some(match parent {
Some(parent) => {
format!("{repo} is {owner}'s fork of {parent} already: choose another owner")
}
None => format!("{owner} owns {repo}: fork it to another owner"),
});
}
repo_problem(f)
}
pub(super) fn owner_rows(state: &State, route: &Route, q: &str) -> crate::picker::Rows {
let Some(o) = owners(state) else {
return vec![item("Asking GitHub who you are…", "", None)];
};
let set = |login: &str| setting(route, Field::Owner, login);
let mut rows = Vec::new();
if fuzzy_score(q, &o.viewer).is_some() {
rows.push(item(o.viewer.clone(), "your account", set(&o.viewer)));
}
for (login, can) in &o.orgs {
if fuzzy_score(q, login).is_none() {
continue;
}
rows.push(if *can {
item(login.clone(), "organization", set(login))
} else {
item(
login.clone(),
"the organization doesn't let you make repositories",
None,
)
});
}
if o.more > 0 {
rows.push(item(
format!("{} more organizations GitHub didn't list", o.more),
"",
None,
));
}
rows
}
fn licence_row(state: &State, f: &Form) -> crate::ui::pages::FormRow {
let mut row = form_row(f, Field::License, "None");
let templates = state.picked::<Templates>(&DataKey::Templates);
let named = templates.and_then(|t| {
(t.licenses.iter())
.find(|(key, _)| *key == row.value)
.map(|(_, name)| name.clone())
});
if let Some(name) = named {
row.value = name;
}
row
}
pub(super) fn template_rows(
state: &State,
route: &Route,
field: Field,
q: &str,
) -> crate::picker::Rows {
let set = |value: &str| setting(route, field, value);
let key = DataKey::Templates;
let t = or_return!(listed::<Templates, _>(state, &key, "GitHub's templates"));
let mut rows = Vec::new();
if fuzzy_score(q, "none").is_some() {
rows.push(item("None", "", set("")));
}
let mut hits: Vec<(usize, &str, &str)> = match field {
Field::Gitignore => (t.gitignores.iter())
.filter_map(|g| Some((fuzzy_score(q, g)?, g.as_str(), g.as_str())))
.collect(),
_ => (t.licenses.iter())
.filter_map(|(key, name)| Some((fuzzy_score(q, name)?, name.as_str(), key.as_str())))
.collect(),
};
hits.sort_by_key(|(score, ..)| *score);
rows.extend(
hits.into_iter()
.map(|(_, label, value)| item(label, "", set(value))),
);
rows
}
pub(super) fn fork(state: &mut State, route: &Route, repo: &RepoId) -> Cmds {
let f = form(state, route);
if let Some(why) = fork_problem(state, repo, &f) {
return crate::forms::say(state, why);
}
let new = NewFork {
repo: repo.clone(),
org: org(state, &f),
name: f.get(Field::Name).trim().to_owned(),
default_branch_only: !f.on(Field::AllBranches),
};
crate::forms::form_sending(state, route);
state.info(format!("Forking {repo}…"));
work::start(Fork {
route: route.clone(),
new,
})
}
pub(super) fn create(state: &mut State, route: &Route) -> Cmds {
let f = form(state, route);
if let Some(why) = repo_problem(&f) {
return crate::forms::say(state, why);
}
let template = |field| Some(f.get(field).to_owned()).filter(|v| !v.is_empty());
let new = NewRepo {
org: org(state, &f),
name: f.get(Field::Name).trim().to_owned(),
description: f.get(Field::Description).trim().to_owned(),
private: f.on(Field::Private),
readme: f.on(Field::Readme),
gitignore: template(Field::Gitignore),
license: template(Field::License),
};
crate::forms::form_sending(state, route);
state.info(format!("Creating {}…", new.name));
work::start(Make {
route: route.clone(),
new,
})
}
#[must_use]
fn opened(state: &mut State, route: &Route, made: &MadeRepo) -> Option<Cmds> {
crate::forms::done::<Form>(state, route);
let repo = made.repo()?;
if state.route() != Some(route) {
return Some(Cmds::new());
}
let page = Route::code(repo.clone());
Some(crate::follow::replace(state, page, Expected::Repo(repo)))
}
impl Job for Fork {
type Out = MadeRepo;
async fn run(&self, cx: &Cx) -> Result<MadeRepo, Failure> {
Ok(cx.gh.fork(&self.new).await?)
}
fn done(self, state: &mut State, result: Result<MadeRepo, Failure>) -> Cmds {
let Fork { route, .. } = &self;
let made = or_return!(result, |why| {
crate::forms::failed(state, route, why.to_string())
});
state.info(format!(
"Forked as {}: GitHub may take a moment to copy it",
made.full_name
));
opened(state, route, &made).unwrap_or_default()
}
}
impl Job for Make {
type Out = MadeRepo;
async fn run(&self, cx: &Cx) -> Result<MadeRepo, Failure> {
Ok(cx.gh.create_repo(&self.new).await?)
}
fn done(self, state: &mut State, result: Result<MadeRepo, Failure>) -> Cmds {
let Make { route, .. } = &self;
let made = or_return!(result, |why| {
crate::forms::failed(state, route, why.to_string())
});
state.info(format!("Created {}", made.full_name));
let on_form = state.route() == Some(route);
let mut cmds = opened(state, route, &made).unwrap_or_default();
let Some(repo) = made.repo().filter(|_| on_form) else { return cmds };
let dir = PathBuf::from(&*repo.name);
let fact = format!(
"Into ./{} here, with your own git: git clone {}",
dir.display(),
made.clone_url
);
let url = made.clone_url.clone();
let clone = super::Answer::Clone { url, dir };
cmds.extend(ask(
state,
format!("Clone {repo}?"),
vec![(fact, false)],
vec![("Clone it".into(), clone)],
));
cmds
}
}
impl Job for CloneTo {
type Out = ();
const WRITES: bool = false;
async fn run(&self, _: &Cx) -> Result<(), Failure> {
super::git::clone(&self.url, &self.dir)
.await
.map_err(Failure::msg)
}
fn done(self, state: &mut State, result: Result<(), Failure>) -> Cmds {
if let Err(why) = result {
return crate::forms::say(state, why.to_string());
}
state.overlay = None;
state.just_say(format!("Cloned into ./{}", self.dir.display()))
}
}
pub(super) fn build(state: &State, page: &mut Page, route: &Route, of: &FormOf) {
let f = form(state, route);
let (heading, rows, mut status) = match of {
FormOf::Fork(repo) => (
format!("Create a new fork of {repo}"),
vec![
form_row(&f, Field::Owner, "Asking GitHub who you are…"),
form_row(&f, Field::Name, "Repository name"),
form_row(&f, Field::AllBranches, ""),
],
vec![(
"A fork is a copy of the repository, under the owner you choose".to_owned(),
Mark::Fine,
None,
)],
),
_ => (
"Create a new repository".to_owned(),
vec![
form_row(&f, Field::Owner, "Asking GitHub who you are…"),
form_row(&f, Field::Name, "Repository name"),
form_row(&f, Field::Description, "Description (optional)"),
form_row(&f, Field::Private, ""),
form_row(&f, Field::Readme, ""),
form_row(&f, Field::Gitignore, "None"),
licence_row(state, &f),
],
Vec::new(),
),
};
let owner = f.get(Field::Owner);
let name = f.get(Field::Name).trim();
if !owner.is_empty() && !name.is_empty() {
status.push((format!("It will be {owner}/{name}"), Mark::Fine, None));
}
let view = WriteFormView {
heading,
rows,
status,
button: super::button(of, &f),
why_not: super::problem(state, route, of),
..WriteFormView::default()
};
crate::forms::write_form(state, route, page, view);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repository_names_are_githubs() {
assert_eq!(name_problem("ghtui-live-1"), None);
assert_eq!(name_problem("a.b_c"), None);
assert!(name_problem("has space").is_some());
assert!(name_problem("..").is_some());
assert!(name_problem("").is_some());
}
}