use std::borrow::Cow;
use crate::api::browse::WikiPage;
use crate::api::model::RepoId;
use crate::create::templates;
use crate::route::{Route, wiki};
use crate::ui::page::{Link, Page};
use crate::ui::pages::{Mark, TextBox, WriteFormView};
use crate::data::DataKey;
use crate::follow::{Expectation, Shown};
use crate::forms::{Fields, say};
use crate::keymap::Action;
use crate::nav::{Doable, row};
use crate::state::{Cmd, State};
use crate::work::{self, Cx, Failure, Job};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Save {
pub route: Route,
pub save: crate::wiki::Save,
}
#[derive(Debug, Clone, Default)]
pub struct Form {
pub text: String,
pub page: Option<(String, String, String)>,
pub started: bool,
}
impl Fields for Form {
fn text(&mut self, _: usize) -> Option<&mut String> {
Some(&mut self.text)
}
fn title(&self, _: &Route, _: usize) -> String {
match &self.page {
Some((title, ..)) => format!("Edit the wiki page {title}"),
None => "New wiki page: its title on the first line, then its text".into(),
}
}
fn save(&self, _: &Route) -> &'static str {
"save page"
}
}
fn here(state: &State) -> Option<(Route, RepoId, Option<String>, Cow<'_, Form>)> {
let route = state.route()?;
let Route::Wiki(wiki::WikiEdit { repo, page }) = route else {
return None;
};
let form = state.forms.shown(route);
Some((route.clone(), repo.clone(), page.clone(), form))
}
pub const FEATURE: crate::features::Feature = crate::features::Feature {
owns: &[Action::NewWikiPage],
takes: |state, action| {
action == Action::EditComment
&& matches!(state.route(), Some(Route::Wiki(wiki::Wiki { .. })))
},
act: |state, action| match action {
Action::NewWikiPage => open_new(state),
_ => open_edit(state),
},
offers: doables,
..crate::features::Feature::NONE
};
pub fn key(repo: &RepoId, page: Option<&String>) -> DataKey {
let page = page.cloned().unwrap_or_else(|| "_pages".into());
DataKey::Wiki(repo.clone(), Some(page))
}
fn shown(state: &State) -> Option<(RepoId, &WikiPage)> {
let Some(Route::Wiki(wiki::Wiki { repo, page })) = state.route() else {
return None;
};
let w = state.picked::<WikiPage>(&DataKey::Wiki(repo.clone(), page.clone()))?;
Some((repo.clone(), w))
}
fn uneditable(state: &State) -> Option<String> {
let Some((_, w)) = shown(state) else {
return Some("The page hasn't loaded yet".into());
};
match (&w.title, &w.file) {
(Some(_), Some(_)) => None,
_ => Some("This is the list of pages: open one to edit it".into()),
}
}
#[must_use]
pub fn open_edit(state: &mut State) -> Vec<Cmd> {
if let Some(why) = uneditable(state) {
return state.just_say(why);
}
let Some((repo, w)) = shown(state) else {
return Vec::new();
};
let page = w.title.as_deref().map(crate::wiki::slug);
let route = Route::Wiki(wiki::WikiEdit { repo, page });
state.forms.take::<Form>(&route);
let mut cmds = state.push(route);
cmds.extend(follow(state));
cmds
}
#[must_use]
pub fn open_new(state: &mut State) -> Vec<Cmd> {
let repo = match state.route() {
Some(Route::Wiki(wiki::WikiEdit { page: None, .. })) => return compose(state),
Some(route) => route.repo().cloned(),
None => None,
};
let Some(repo) = repo else {
return state.just_say("Open a repository's wiki first");
};
if state.overview(&repo).is_some_and(|o| !o.has_wiki) {
return state.just_say(format!("{repo} has its wiki turned off"));
}
let mut cmds = state.push(Route::Wiki(wiki::WikiEdit { repo, page: None }));
cmds.extend(follow(state));
cmds
}
#[must_use]
fn follow(state: &mut State) -> Vec<Cmd> {
let Some((route, repo, page, form)) = here(state) else {
return Vec::new();
};
if form.started {
return Vec::new();
}
let Some(w) = state
.picked::<WikiPage>(&key(&repo, page.as_ref()))
.cloned()
else {
return Vec::new();
};
let form = state.forms.edit::<Form>(&route);
form.started = true;
if page.is_some()
&& let (Some(title), Some(file), Some(commit)) = (w.title, w.file, w.commit)
{
form.text = w.text.unwrap_or_default();
form.page = Some((title, file, commit));
}
if state.overlay.is_none() {
return compose(state);
}
Vec::new()
}
#[must_use]
fn compose(state: &mut State) -> Vec<Cmd> {
let Some((route, _, page, form)) = here(state) else {
return Vec::new();
};
if page.is_some() && form.page.is_none() {
return state.just_say("The page hasn't loaded yet");
}
let top = [ratatui_textarea::CursorMove::Top];
crate::forms::compose::<Form>(state, &route, 0, &top)
}
fn to_save(state: &State, repo: &RepoId, form: &Form) -> Result<crate::wiki::Save, String> {
let login = state.viewer.clone();
match &form.page {
Some((title, file, commit)) => Ok(crate::wiki::Save {
repo: repo.clone(),
title: title.clone(),
file: Some(file.clone()),
base: Some(commit.clone()),
text: form.text.clone(),
login,
}),
None => {
let (title, text) = templates::split(&form.text);
if title.is_empty() {
return Err("Write the page's title first: it's the first line".into());
}
if title.contains(['/', '\\']) || title.chars().any(char::is_control) {
return Err("A page's title can't have / or \\ in it".into());
}
Ok(crate::wiki::Save {
repo: repo.clone(),
title,
file: None,
base: None,
text,
login,
})
}
}
}
#[must_use]
fn submit(state: &mut State) -> Vec<Cmd> {
let Some((route, repo, page, form)) = here(state) else {
return Vec::new();
};
if page.is_some() && form.page.is_none() {
return say(state, "The page hasn't loaded yet".into());
}
let save = match to_save(state, &repo, &form) {
Ok(save) => save,
Err(why) => {
return say(state, why);
}
};
crate::forms::form_sending(state, &route);
state.info(format!("Saving {}…", save.title));
work::start(Save { route, save })
}
#[derive(Debug)]
struct Saved(DataKey, String);
impl Expectation for Saved {
fn waiting(&self) -> &'static str {
"Waiting for GitHub to show the page"
}
fn shown(&self, state: &State, _: &Route) -> Shown {
let text = |w: &WikiPage| w.text.as_deref().map(str::trim_end) == Some(self.1.trim_end());
state.picked::<WikiPage>(&self.0).is_some_and(text).into()
}
}
impl Job for Save {
type Out = String;
async fn run(&self, cx: &Cx) -> Result<String, Failure> {
crate::wiki::save(&cx.git, &self.save)
.await
.map_err(Failure::msg)
}
fn done(self, state: &mut State, result: Result<String, Failure>) -> Vec<Cmd> {
let route = &self.route;
let Route::Wiki(wiki::WikiEdit { repo, .. }) = route else {
return Vec::new();
};
match result {
Ok(slug) => {
let form: Form = crate::forms::done(state, route);
let text = match &form.page {
Some(_) => form.text.clone(),
None => templates::split(&form.text).1,
};
state.info(match &form.page {
Some((title, ..)) => format!("Saved {title}"),
None => format!("Created {}", slug.replace('-', " ")),
});
if state.route() != Some(route) {
return Vec::new();
}
let below = state.screens.below().and_then(|s| match s {
crate::state::Screen::Page(p) => match &p.route {
Route::Wiki(wiki::Wiki { repo: r, page }) if r == repo => {
let title = (state
.picked::<WikiPage>(&DataKey::Wiki(r.clone(), page.clone())))
.and_then(|w| w.title.as_deref().map(crate::wiki::slug));
(title.as_deref() == Some(slug.as_str())).then(|| p.route.clone())
}
_ => None,
},
crate::state::Screen::Diff(_) => None,
});
let page = below.clone().unwrap_or_else(|| {
Route::Wiki(wiki::Wiki {
repo: repo.clone(),
page: Some(slug),
})
});
let Route::Wiki(wiki::Wiki { page: name, .. }) = &page else {
return Vec::new();
};
let shows = Saved(DataKey::Wiki(repo.clone(), name.clone()), text);
crate::follow::expect(state, page.clone(), shows);
if below.is_some() {
let mut cmds = state.back();
state.forward.clear();
cmds.extend(state.ensure_route(&page, true));
cmds
} else {
state.replace(page, true, None)
}
}
Err(why) => crate::forms::failed(state, route, why.to_string()),
}
}
}
pub const FORM: crate::forms::Kind = crate::forms::Kind {
compose: |state, _| compose(state),
row: |_, _| Vec::new(),
fill: |_, _, _, _| Vec::new(),
submit,
follow,
offers,
};
fn offers(state: &State, out: &mut Vec<Doable>) {
if let Some((_, repo, _, form)) = here(state) {
let why = to_save(state, &repo, &form).err();
let save = ("Save the page", "save");
crate::forms::offer(state, Some("Write the page"), save, why, out);
}
}
fn doables(state: &State, out: &mut Vec<Doable>) {
if let Some(Route::Wiki(wiki::Wiki { .. })) = state.route() {
out.push(row(
"Edit",
Action::EditComment,
"Edit this page",
"edit",
uneditable(state),
));
out.push(row("New", Action::NewWikiPage, "New wiki page", "", None));
}
}
pub fn build(state: &State, page: &mut Page, route: &Route) {
let Route::Wiki(wiki::WikiEdit { repo, .. }) = route else {
return;
};
let form = state.forms.shown::<Form>(route);
let composing = crate::forms::composing(state, route, 0);
let (heading, title, body, hint) = match &form.page {
Some((title, file, _)) => (
format!("Editing {title} in {repo}'s wiki"),
title.clone(),
form.text.clone(),
format!("Saved as {file}: committed and pushed with your git credentials"),
),
None => {
let (title, body) = templates::split(&form.text);
(
format!("New page in {repo}'s wiki"),
title,
body,
"The first line is the title; then the page, in Markdown (ctrl-e for $EDITOR)"
.to_owned(),
)
}
};
let markdown = form
.page
.as_ref()
.is_none_or(|(_, file, _)| crate::ui::pages::is_markdown(file));
let mut status = Vec::new();
if let Some((_, _, commit)) = &form.page {
let at = crate::ui::text::short_sha(commit);
status.push((
format!("As the wiki had it at {at}: if it changes meanwhile, saving says so"),
Mark::Fine,
None,
));
}
let write = state.first_key(Action::Comment);
let save_key = state.first_key(Action::Create);
let body = if composing { String::new() } else { body };
let view = WriteFormView {
heading,
base: Some(crate::ui::markdown::LinkBase::wiki(repo)),
rows: Vec::new(),
status,
boxes: vec![TextBox {
title: if composing { String::new() } else { title },
untitled: "Title",
body: &body,
markdown,
empty: "Nothing written yet.",
hint: &hint,
link: Link::Form(crate::ui::form::Row::Text(0)),
actions: Vec::new(),
}],
after: Vec::new(),
write: &write,
button: "Save page".into(),
save_key: &save_key,
why_not: to_save(state, repo, &form).err(),
error: state.forms.sent(route).error.as_deref(),
};
crate::ui::pages::write_form(page, &view);
}