pub mod assets;
use std::borrow::Cow;
use crate::api::browse::{BranchInfo, Release, Results, TagInfo};
use crate::api::model::RepoId;
use crate::api::release::{Made, NewRelease, Notes, TagLook};
use crate::create::templates;
use crate::route::{Route, releases};
use crate::ui::form::Row;
use crate::ui::page::{Link, Page};
use crate::ui::pages::{FormRow, Mark, TextBox, WriteFormView};
use crate::data::DataKey;
use crate::follow::{Expectation, Shown};
use crate::forms::Answer;
use crate::forms::{Fields, filling, say};
use crate::keymap::Action;
use crate::nav::{Doable, row};
use crate::picker::entry;
use crate::picker::fuzzy_score;
use crate::state::{Cmd, State};
use crate::work::{self, Cx, Failure, Job};
#[derive(Debug, Clone)]
pub struct Form {
pub text: String,
pub filled: String,
pub started: bool,
pub tag: Option<String>,
pub target: Option<String>,
pub prerelease: bool,
pub latest: bool,
pub editing: Option<Editing>,
pub generating: bool,
}
impl 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::Releases(releases::EditRelease { tag, .. }) => {
format!("Editing {tag}: its title on the first line, then its notes")
}
_ => "New release: its title on the first line, then its notes".into(),
}
}
fn save(&self, route: &Route) -> &'static str {
match route {
Route::Releases(releases::EditRelease { .. }) => "save…",
_ => "publish…",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing {
pub id: u64,
pub tag: String,
pub draft: bool,
}
impl Default for Form {
fn default() -> Self {
Self {
text: String::new(),
filled: String::new(),
started: false,
tag: None,
target: None,
prerelease: false,
latest: true,
editing: None,
generating: false,
}
}
}
impl Form {
fn of(r: &Release, id: u64) -> Self {
let text = templates::join(&r.name, r.notes.as_deref().unwrap_or_default());
Self {
text,
started: true,
tag: Some(r.tag.clone()),
prerelease: r.prerelease,
latest: r.latest,
editing: Some(Editing {
id,
tag: r.tag.clone(),
draft: r.draft,
}),
..Self::default()
}
}
fn tag_look<'a>(
&self,
state: &'a State,
repo: &RepoId,
) -> Option<Result<&'a TagLook, &'a str>> {
static OWN: TagLook = TagLook {
exists: true,
release: None,
};
if self.own_tag() {
return Some(Ok(&OWN));
}
state.answer(&DataKey::TagLook(repo.clone(), self.tag.clone()?))
}
fn new_tag(&self, state: &State, repo: &RepoId) -> bool {
matches!(
self.tag_look(state, repo),
Some(Ok(TagLook { exists: false, .. }))
)
}
fn own_tag(&self) -> bool {
(self.editing.as_ref()).is_some_and(|e| self.tag.as_ref() == Some(&e.tag))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Generate {
pub repo: RepoId,
pub tag: String,
pub target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Save {
pub route: Route,
pub id: Option<u64>,
pub new: NewRelease,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delete {
pub repo: RepoId,
pub tag: String,
pub id: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteTag {
pub repo: RepoId,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expected {
Release(RepoId, String),
Edited {
repo: RepoId,
tag: String,
name: String,
notes: String,
prerelease: bool,
},
ReleaseGone(RepoId, String),
Asset {
repo: RepoId,
tag: String,
name: String,
there: bool,
},
Draft(RepoId, String),
}
impl Expectation for Expected {
fn waiting(&self) -> &'static str {
match self {
Expected::Release(..) | Expected::Draft(..) | Expected::Edited { .. } => {
"Waiting for GitHub to show the release"
}
Expected::ReleaseGone(..) => "Waiting for GitHub to show the release gone",
Expected::Asset { there: true, .. } => "Waiting for GitHub to show the asset",
Expected::Asset { .. } => "Waiting for GitHub to show the asset gone",
}
}
fn shown(&self, state: &State, _: &Route) -> Shown {
let release = |repo: &RepoId, tag: &String| {
state.picked::<Release>(&DataKey::Release(repo.clone(), tag.clone()))
};
let releases =
|repo: &RepoId| state.picked::<Results<Release>>(&DataKey::Releases(repo.clone()));
match self {
Expected::Release(repo, tag) => release(repo, tag).is_some(),
Expected::Edited {
repo,
tag,
name,
notes,
prerelease,
} => release(repo, tag).is_some_and(|r| {
r.name == *name
&& r.prerelease == *prerelease
&& r.notes.as_deref().unwrap_or_default().trim() == notes.trim()
}),
Expected::ReleaseGone(repo, tag) => {
releases(repo).is_some_and(|r| r.items.iter().all(|r| r.tag != *tag))
}
Expected::Asset {
repo,
tag,
name,
there,
} => assets::shows(state, repo, tag, name, *there),
Expected::Draft(repo, tag) => {
releases(repo).is_some_and(|r| r.items.iter().any(|r| r.tag == *tag && r.draft))
}
}
.into()
}
}
fn here(state: &State) -> Option<(Route, RepoId, Cow<'_, Form>)> {
let route = state.route()?;
let repo = form_repo(route)?;
Some((route.clone(), repo.clone(), state.forms.shown(route)))
}
pub const FEATURE: crate::features::Feature = crate::features::Feature {
owns: &[Action::NewRelease, Action::UploadAsset],
takes: |state, action| {
matches!(action, Action::EditComment | Action::DeleteComment)
&& matches!(
state.route(),
Some(Route::Releases(releases::Release { .. }))
)
},
act: |state, action| match action {
Action::NewRelease => open(state),
Action::UploadAsset => assets::open_upload(state),
Action::EditComment => open_edit(state),
_ => ask_delete(state),
},
offers: doables,
..crate::features::Feature::NONE
};
pub(crate) fn refused(state: &State, repo: &RepoId) -> Option<String> {
let o = state.overview(repo)?;
if o.summary.archived {
return Some(format!("{repo} is archived: it takes nothing new"));
}
(!o.can_write).then(|| format!("Releases take write access to {repo}"))
}
#[must_use]
pub fn open(state: &mut State) -> Vec<Cmd> {
let Some(repo) = state.route().and_then(Route::repo).cloned() else {
return state.just_say("Open a repository first: a release is of one");
};
if let Some(why) = refused(state, &repo) {
return state.just_say(why);
}
let route = Route::Releases(releases::NewRelease(repo));
if state.route() == Some(&route) {
return compose(state);
}
let mut cmds = state.push(route);
cmds.extend(follow(state));
cmds
}
pub(crate) fn shown(state: &State) -> Option<(RepoId, String, &Release)> {
let Some(Route::Releases(releases::Release { repo, tag })) = state.route() else {
return None;
};
let r = state.picked::<Release>(&DataKey::Release(repo.clone(), tag.clone()))?;
Some((repo.clone(), tag.clone(), r))
}
pub(crate) fn unchangeable(state: &State) -> Option<String> {
let Some((repo, _, r)) = shown(state) else {
return Some("The release hasn't loaded yet".into());
};
if let Some(why) = refused(state, &repo) {
return Some(why);
}
r.id.is_none()
.then(|| "GitHub didn't say the release's ID, which changing it takes".into())
}
#[must_use]
pub fn open_edit(state: &mut State) -> Vec<Cmd> {
if let Some(why) = unchangeable(state) {
return state.just_say(why);
}
let Some((repo, tag, _)) = shown(state) else {
return Vec::new();
};
let mut cmds = state.push(Route::Releases(releases::EditRelease { repo, tag }));
cmds.extend(follow(state));
cmds
}
#[must_use]
fn follow(state: &mut State) -> Vec<Cmd> {
let Some((route, repo, form)) = here(state) else {
return Vec::new();
};
if let Route::Releases(releases::EditRelease { tag, .. }) = &route
&& !form.started
&& let Some(r) = state.picked::<Release>(&DataKey::Release(repo.clone(), tag.clone()))
&& let Some(id) = r.id
{
*state.forms.edit(&route) = Form::of(r, id);
}
let mut cmds = Vec::new();
let loaded = state.overview(&repo).is_some();
let form = state.forms.shown::<Form>(&route);
let (started, tag) = (form.started, form.tag.clone().filter(|_| !form.own_tag()));
if let Some(tag) = tag {
cmds.extend(state.ensure(DataKey::TagLook(repo.clone(), tag), false));
}
let new = matches!(route, Route::Releases(releases::NewRelease(_)));
if new && loaded && !started && state.overlay.is_none() && refused(state, &repo).is_none() {
state.forms.edit::<Form>(&route).started = true;
cmds.extend(state.open_picker(crate::picker::Kind::Tag(route)));
}
cmds
}
#[must_use]
fn compose(state: &mut State) -> Vec<Cmd> {
let Some((route, ..)) = here(state) else {
return Vec::new();
};
crate::forms::compose::<Form>(state, &route, 0, crate::forms::TITLE)
}
pub const FORM: crate::forms::Kind = crate::forms::Kind {
compose: |state, _| compose(state),
row: on_row,
fill,
submit,
follow,
offers,
};
#[must_use]
fn on_row(state: &mut State, link: &Row) -> Vec<Cmd> {
let Some((route, repo, form)) = here(state) else {
return Vec::new();
};
match link {
Row::Tag => state.open_picker(crate::picker::Kind::Tag(route)),
Row::Target if form.new_tag(state, &repo) => {
state.open_picker(crate::picker::Kind::Target(route))
}
Row::Target => state.just_say("The tag is GitHub's already: it's where it is"),
Row::Notes => generate(state),
Row::Prerelease => {
let form = state.forms.edit::<Form>(&route);
form.prerelease = !form.prerelease;
Vec::new()
}
Row::Latest if form.prerelease => {
state.just_say("A pre-release is never the latest release")
}
Row::Latest => {
let form = state.forms.edit::<Form>(&route);
form.latest = !form.latest;
Vec::new()
}
_ => Vec::new(),
}
}
#[must_use]
fn generate(state: &mut State) -> Vec<Cmd> {
let Some((route, repo, form)) = here(state) else {
return Vec::new();
};
let Some(tag) = form.tag.clone() else {
return state
.just_say("Choose a tag first: GitHub writes the notes since the release before it");
};
if form.generating {
return Vec::new();
}
let target = form.target.clone().filter(|_| form.new_tag(state, &repo));
state.forms.edit::<Form>(&route).generating = true;
state.info("Asking GitHub for release notes…");
work::start(Generate { repo, tag, target })
}
fn tag_problem(tag: &str) -> Option<&'static str> {
let bad = |c: char| c.is_whitespace() || c.is_control() || "~^:?*[\\".contains(c);
if tag.is_empty() {
Some("A tag needs a name")
} else if tag.chars().any(bad) || tag.contains("..") || tag.contains("@{") {
Some("A tag's name can't have spaces, .., or any of ~^:?*[\\")
} else if tag.starts_with(['/', '.', '-'])
|| tag.ends_with(['/', '.'])
|| tag.ends_with(".lock")
{
Some("A tag's name can't start with / . or -, or end with / . or .lock")
} else {
None
}
}
fn form_repo(route: &Route) -> Option<&RepoId> {
match route {
Route::Releases(releases::NewRelease(repo))
| Route::Releases(releases::EditRelease { repo, .. }) => Some(repo),
_ => None,
}
}
pub fn tag_rows(state: &State, route: &Route, q: &str) -> crate::picker::Rows {
let Some(repo) = form_repo(route) else {
return Vec::new();
};
let tags = state.picked::<Results<TagInfo>>(&DataKey::Tags(repo.clone()));
let mut rows = Vec::new();
let known = tags.is_some_and(|t| t.items.iter().any(|t| t.name == q));
if !q.is_empty() && !known {
rows.push(match tag_problem(q) {
Some(why) => entry(why, "", None),
None => entry(
format!("Create new tag: {q}"),
"on publish",
Some(filling(route, Row::Tag, q)),
),
});
}
let Some(tags) = tags else {
rows.push(entry("Loading the tags…", "", None));
return rows;
};
let mut hits: Vec<(usize, &TagInfo)> = (tags.items.iter())
.filter_map(|t| Some((fuzzy_score(q, &t.name)?, t)))
.collect();
hits.sort_by_key(|(score, _)| *score);
for (_, t) in hits {
let hint = t
.date
.as_deref()
.map_or("tag", |d| d.get(..10).unwrap_or(d));
rows.push(entry(
t.name.clone(),
hint,
Some(filling(route, Row::Tag, &t.name)),
));
}
let more = tags.total.saturating_sub(tags.items.len() as u64);
if more > 0 {
let what = crate::ui::text::noun(more, "tag");
rows.push(entry(
format!("{more} older {what} on GitHub: type one's name"),
"",
None,
));
}
rows
}
pub fn target_rows(state: &State, route: &Route, q: &str) -> crate::picker::Rows {
let Some(repo) = form_repo(route) else {
return Vec::new();
};
let branches = state.picked::<Results<BranchInfo>>(&DataKey::Branches(repo.clone()));
let mut rows = Vec::new();
let known = branches.is_some_and(|b| b.items.iter().any(|b| b.name == q));
if !q.is_empty() && !known {
rows.push(entry(
format!("Use {q}"),
"a branch or a commit's SHA",
Some(filling(route, Row::Target, q)),
));
}
let Some(branches) = branches else {
rows.push(entry("Loading the branches…", "", None));
return rows;
};
let mut hits: Vec<(usize, &BranchInfo)> = (branches.items.iter())
.filter_map(|b| Some((fuzzy_score(q, &b.name)?, b)))
.collect();
hits.sort_by_key(|(score, b)| (!b.default, *score));
for (_, b) in hits {
let hint = if b.default {
"default branch"
} else {
"branch"
};
rows.push(entry(
b.name.clone(),
hint,
Some(filling(route, Row::Target, &b.name)),
));
}
rows
}
#[must_use]
fn fill(state: &mut State, route: &Route, row: &Row, value: String) -> Vec<Cmd> {
let form = state.forms.edit::<Form>(route);
match row {
Row::Tag => {
form.tag = Some(value);
let blank = form.text.trim().is_empty();
let mut cmds = follow(state);
if blank && state.overlay.is_none() {
cmds.extend(compose(state));
}
cmds
}
Row::Target => {
form.target = Some(value);
Vec::new()
}
_ => Vec::new(),
}
}
fn default_branch(state: &State, repo: &RepoId) -> String {
(state.overview(repo))
.and_then(|o| o.default_branch.clone())
.unwrap_or_else(|| "the default branch".into())
}
fn problem(state: &State, repo: &RepoId, form: &Form) -> Option<String> {
if let Some(why) = refused(state, repo) {
return Some(why);
}
let tag = form.tag.as_deref()?;
match form.tag_look(state, repo) {
None => Some(format!("Still looking for {tag} on GitHub")),
Some(Err(why)) => Some(format!("Couldn't look for {tag} on GitHub: {why}")),
Some(Ok(_)) if form.own_tag() => None,
Some(Ok(TagLook {
release: Some((true, _)),
..
})) => Some(format!(
"{tag} has a draft release already: publish or delete that one"
)),
Some(Ok(TagLook {
release: Some(_), ..
})) => Some(format!("{tag} has a release already")),
Some(Ok(_)) => None,
}
}
#[must_use]
fn submit(state: &mut State) -> Vec<Cmd> {
let Some((route, repo, form)) = here(state) else {
return Vec::new();
};
let Some(tag) = form.tag.clone() else {
return say(state, "Choose a tag first (Enter on Tag)".into());
};
if let Some(why) = problem(state, &repo, &form) {
return say(state, why);
}
let (title, notes) = templates::split(&form.text);
let mut facts = vec![(
if form.new_tag(state, &repo) {
let at = form
.target
.clone()
.unwrap_or_else(|| default_branch(state, &repo));
format!("A new tag, {tag}, made at {at}")
} else {
format!("Tag {tag}, as GitHub has it")
},
false,
)];
if let Some(was) = form.editing.as_ref().filter(|e| e.tag != tag) {
facts.push((format!("Its tag was {}: that tag stays", was.tag), true));
}
facts.push((
if title.is_empty() {
format!("Titled {tag}: it has no title of its own")
} else {
format!("“{title}”")
},
false,
));
if notes.is_empty() {
facts.push(("It has no notes".into(), true));
}
facts.push((
match (form.prerelease, form.latest) {
(true, _) => "A pre-release".to_owned(),
(false, true) => "It becomes the latest release".to_owned(),
(false, false) => "It doesn't become the latest release".to_owned(),
},
false,
));
let (question, choices) = match &form.editing {
None => (
format!("Publish {tag} in {repo}?"),
vec![
("Publish release".into(), Answer::Release { draft: false }),
("Save draft".into(), Answer::Release { draft: true }),
],
),
Some(e) if e.draft => (
format!("Save the draft of {tag}?"),
vec![
("Publish release".into(), Answer::Release { draft: false }),
("Save draft".into(), Answer::Release { draft: true }),
],
),
Some(_) => (
format!("Update {tag}?"),
vec![
("Update release".into(), Answer::Release { draft: false }),
(
"Make it a draft again".into(),
Answer::Release { draft: true },
),
],
),
};
crate::forms::ask(state, route, question, facts, choices);
Vec::new()
}
#[must_use]
pub fn create(state: &mut State, route: &Route, draft: bool) -> Vec<Cmd> {
let Some(repo) = form_repo(route).cloned() else {
return Vec::new();
};
let Some(form) = state.forms.get::<Form>(route) else {
return Vec::new();
};
let Some(tag) = form.tag.clone() else {
return Vec::new();
};
let (name, notes) = templates::split(&form.text);
let new = NewRelease {
target: form.target.clone().filter(|_| form.new_tag(state, &repo)),
repo,
tag,
name,
notes,
draft,
prerelease: form.prerelease,
latest: form.latest,
};
let id = form.editing.as_ref().map(|e| e.id);
crate::forms::form_sending(state, route);
work::start(Save {
route: route.clone(),
id,
new,
})
}
#[must_use]
pub fn ask_delete(state: &mut State) -> Vec<Cmd> {
if let Some(why) = unchangeable(state) {
return state.just_say(why);
}
if let Some(name) = assets::selected(state) {
return assets::ask_delete(state, &name);
}
let Some((repo, tag, r)) = shown(state) else {
return Vec::new();
};
let mut facts = Vec::new();
let n = r.assets.total();
if n > 0 {
let what = crate::ui::text::noun(n, "asset");
facts.push((format!("Its {n} {what} go with it"), true));
}
facts.push((
format!("Its tag, {tag}, stays: you're asked about it next"),
false,
));
let title = format!("Delete the release {}?", r.name);
let route = Route::Releases(releases::Release { repo, tag });
let choices = vec![("Delete release".into(), Answer::DeleteRelease)];
crate::forms::ask(state, route, title, facts, choices);
Vec::new()
}
#[must_use]
pub fn delete(state: &mut State, route: &Route) -> Vec<Cmd> {
let Route::Releases(releases::Release { repo, tag }) = route else {
return Vec::new();
};
let key = DataKey::Release(repo.clone(), tag.clone());
let Some(id) = state.picked::<Release>(&key).and_then(|r| r.id) else {
return Vec::new();
};
crate::forms::sending(state);
work::start(Delete {
repo: repo.clone(),
tag: tag.clone(),
id,
})
}
#[must_use]
pub fn delete_tag(state: &mut State, route: &Route) -> Vec<Cmd> {
let Route::Releases(releases::Release { repo, tag }) = route else {
return Vec::new();
};
crate::forms::sending(state);
work::start(DeleteTag {
repo: repo.clone(),
tag: tag.clone(),
})
}
impl Job for Generate {
type Out = Notes;
const WRITES: bool = false;
async fn run(&self, cx: &Cx) -> Result<Notes, Failure> {
let notes = cx
.gh
.release_notes(&self.repo, &self.tag, self.target.as_deref());
Ok(notes.await?)
}
fn done(self, state: &mut State, result: Result<Notes, Failure>) -> Vec<Cmd> {
let Generate { repo, tag, .. } = self;
let Some(route) = state
.route()
.cloned()
.filter(|r| form_repo(r) == Some(&repo))
else {
return Vec::new();
};
let Some(form) = state.forms.get_mut::<Form>(&route) else {
return Vec::new();
};
form.generating = false;
match result {
Ok(notes) => {
let (title, _) = templates::split(&form.text);
if form.text.trim().is_empty() || form.text == form.filled {
let title = if title.is_empty() { notes.name } else { title };
form.text = templates::join(&title, ¬es.body);
form.filled = form.text.clone();
} else {
form.text = format!("{}\n\n{}", form.text.trim_end(), notes.body);
}
let write = state.first_key(Action::Comment);
state.info(format!(
"GitHub wrote the notes for {tag}: {write} edits them"
));
}
Err(why) => state.error(format!("GitHub didn't write the notes: {why}")),
}
Vec::new()
}
}
impl Job for DeleteTag {
type Out = ();
async fn run(&self, cx: &Cx) -> Result<(), Failure> {
Ok(cx.gh.delete_tag(&self.repo, &self.tag).await?)
}
fn done(self, state: &mut State, result: Result<(), Failure>) -> Vec<Cmd> {
let DeleteTag { repo, tag } = self;
let route = Route::Releases(releases::Release {
repo: repo.clone(),
tag: tag.clone(),
});
match result {
Ok(()) => {
crate::forms::close_sender(state, &route);
state.info(format!("Deleted the tag {tag} on GitHub"));
state.ensure(DataKey::Tags(repo), true)
}
Err(why) => say(state, format!("GitHub didn't delete the tag: {why}")),
}
}
}
impl Job for Save {
type Out = Made;
async fn run(&self, cx: &Cx) -> Result<Made, Failure> {
let gh = &cx.gh;
Ok(match self.id {
Some(id) => gh.update_release(id, &self.new).await?,
None => gh.create_release(&self.new).await?,
})
}
fn done(self, state: &mut State, result: Result<Made, Failure>) -> Vec<Cmd> {
let route = &self.route;
let Some(repo) = form_repo(route).cloned() else {
return Vec::new();
};
match result {
Ok(made) => {
let form: Form = crate::forms::done(state, route);
let tag = made.tag_name;
let editing = form.editing.is_some();
let (page, what, said) = if made.draft {
let said = format!("Saved a draft release of {tag}");
(
Route::Releases(releases::Releases(repo.clone())),
Expected::Draft(repo, tag),
said,
)
} else {
let (name, notes) = templates::split(&form.text);
let what = if editing {
Expected::Edited {
repo: repo.clone(),
tag: tag.clone(),
name: if name.is_empty() { tag.clone() } else { name },
notes,
prerelease: form.prerelease,
}
} else {
Expected::Release(repo.clone(), tag.clone())
};
let said = if editing {
format!("Updated {tag}")
} else {
format!("Published {tag}")
};
(Route::Releases(releases::Release { repo, tag }), what, said)
};
state.info(said);
if state.route() != Some(route) {
return Vec::new();
}
crate::follow::expect(state, page.clone(), what);
state.replace(page, true, None)
}
Err(why) => {
let why = format!("GitHub didn't save the release: {why}");
crate::forms::failed(state, route, why)
}
}
}
}
impl Job for Delete {
type Out = bool;
async fn run(&self, cx: &Cx) -> Result<bool, Failure> {
let (gh, repo) = (&cx.gh, &self.repo);
gh.delete_release(repo, self.id).await?;
Ok(gh.tag_look(repo, &self.tag).await.is_ok_and(|l| l.exists))
}
fn done(self, state: &mut State, result: Result<bool, Failure>) -> Vec<Cmd> {
let Delete { repo, tag, .. } = &self;
let route = Route::Releases(releases::Release {
repo: repo.clone(),
tag: tag.to_owned(),
});
let tagged = match result {
Ok(tagged) => tagged,
Err(why) => {
return say(state, format!("GitHub didn't delete the release: {why}"));
}
};
crate::forms::close_sender(state, &route);
state.info(format!("Deleted the release of {tag}"));
let mut cmds = Vec::new();
if state.route() == Some(&route) {
let list = Route::Releases(releases::Releases(repo.clone()));
let gone = Expected::ReleaseGone(repo.clone(), tag.to_owned());
crate::follow::expect(state, list.clone(), gone);
cmds = state.replace(list, true, None);
}
if !tagged {
return cmds;
}
crate::forms::ask(
state,
route,
format!("Delete the tag {tag} as well?"),
vec![
(
format!(
"The release is deleted; the tag {tag} stays on GitHub unless you delete it"
),
false,
),
("A deleted tag can't be brought back from here".into(), true),
],
vec![
(format!("Delete the tag {tag}"), Answer::DeleteTag),
("Keep the tag".into(), Answer::KeepTag),
],
);
cmds
}
}
fn offers(state: &State, out: &mut Vec<Doable>) {
if let Some((_, repo, form)) = here(state) {
let why = match &form.tag {
None => Some("choose a tag first".to_owned()),
Some(_) => problem(state, &repo, &form),
};
let publish = if form.editing.is_some() {
"Save the release"
} else {
"Publish the release, or save a draft"
};
let write = Some("Write the title and notes");
crate::forms::offer(state, write, (publish, "publish"), why, out);
}
}
fn doables(state: &State, out: &mut Vec<Doable>) {
let Some(route) = state.route().filter(|_| here(state).is_none()) else {
return;
};
let Some(repo) = route.repo() else {
return;
};
if matches!(route, Route::Releases(releases::Release { .. })) {
let why = unchangeable(state);
out.push(row(
"Edit",
Action::EditComment,
"Edit the release",
"edit",
why.clone(),
));
let delete = if assets::selected(state).is_some() {
"Delete the asset"
} else {
"Delete the release"
};
out.push(row(
"Edit",
Action::DeleteComment,
delete,
"delete",
why.clone(),
));
out.push(row(
"Change",
Action::UploadAsset,
"Upload an asset",
"upload",
why,
));
}
out.push(row(
"New",
Action::NewRelease,
"New release",
"",
refused(state, repo),
));
}
pub fn build(state: &State, page: &mut Page, route: &Route, repo: &RepoId) {
let form = state.forms.shown::<Form>(route);
let form = form.as_ref();
let composing = crate::forms::composing(state, route, 0);
let mut rows = vec![FormRow {
name: "Tag",
value: form
.tag
.as_ref()
.map_or_else(|| "Choose a tag ▾".into(), |t| format!("{t} ▾")),
unset: form.tag.is_none(),
chips: Vec::new(),
link: Some(Row::Tag),
}];
let mut status = Vec::new();
if let Some(tag) = &form.tag {
let (text, mark) = match form.tag_look(state, repo) {
None => (format!("Looking for {tag} on GitHub…"), Mark::Waiting),
Some(Err(why)) => (format!("Couldn't look for {tag}: {why}"), Mark::Problem),
Some(Ok(_)) if form.own_tag() => (format!("{tag} is this release's tag"), Mark::Fine),
Some(Ok(TagLook {
release: Some(_), ..
})) => (
problem(state, repo, form).unwrap_or_default(),
Mark::Problem,
),
Some(Ok(TagLook { exists: true, .. })) => {
(format!("{tag} is on GitHub already"), Mark::Fine)
}
Some(Ok(_)) => (
format!("{tag} is a new tag: GitHub makes it when the release is published"),
Mark::Fine,
),
};
status.push((text, mark, None));
}
if form.new_tag(state, repo) {
let target = (form.target.clone())
.unwrap_or_else(|| format!("{} (default branch)", default_branch(state, repo)));
rows.push(FormRow {
name: "Target",
value: format!("{target} ▾"),
unset: false,
chips: Vec::new(),
link: Some(Row::Target),
});
}
let check = |on: bool| if on { "☑" } else { "☐" };
let notes = if form.generating {
"GitHub is writing them…"
} else {
"From the pull requests since the last release"
};
let after = vec![
FormRow {
name: "Notes",
value: format!("Generate release notes · {notes}"),
unset: false,
chips: Vec::new(),
link: Some(Row::Notes),
},
FormRow {
name: "Pre-release",
value: format!("{} Set as a pre-release", check(form.prerelease)),
unset: false,
chips: Vec::new(),
link: Some(Row::Prerelease),
},
FormRow {
name: "Latest",
value: if form.prerelease {
"☐ A pre-release isn't the latest".to_owned()
} else {
format!("{} Set as the latest release", check(form.latest))
},
unset: form.prerelease,
chips: Vec::new(),
link: Some(Row::Latest),
},
];
let (title, notes) = templates::split(&form.text);
let write = state.first_key(Action::Comment);
let save_key = state.first_key(Action::Create);
let why_not = match &form.tag {
None => Some("Choose a tag first".to_owned()),
Some(_) => problem(state, repo, form),
};
let (title, notes) = if composing {
(String::new(), String::new())
} else {
(title, notes)
};
let (heading, button) = match &form.editing {
Some(e) => (
format!("Editing {} in {repo}", e.tag),
if e.draft {
"Save the draft…"
} else {
"Update release…"
},
),
None => (format!("New release in {repo}"), "Publish release…"),
};
let view = WriteFormView {
heading,
base: Some(crate::ui::markdown::LinkBase::new(repo, "HEAD", "")),
rows,
status,
boxes: vec![TextBox {
title,
untitled: "Release title (the tag's name, if none)",
body: ¬es,
markdown: true,
empty: "No notes yet.",
hint: "The first line is the title; then the notes, in Markdown (ctrl-e for $EDITOR)",
link: Link::Form(crate::ui::form::Row::Text(0)),
actions: Vec::new(),
}],
after,
write: &write,
button: button.into(),
save_key: &save_key,
why_not,
error: state.forms.sent(route).error.as_deref(),
};
crate::ui::pages::write_form(page, &view);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_names_follow_gits_rules() {
assert_eq!(tag_problem("v1.2.0"), None);
assert_eq!(tag_problem("release/2026-10"), None);
assert!(tag_problem("v1 2").is_some());
assert!(tag_problem("v1..2").is_some());
assert!(tag_problem("v1.lock").is_some());
assert!(tag_problem("-v1").is_some());
assert!(tag_problem("").is_some());
}
}