ghtui 0.2.0

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! A release's assets, beyond listing them on its page (with their
//! sizes and how often they were downloaded): `↵` on one saves it where
//! you say, never over a file without asking (as a run's artifacts
//! are); `,U` uploads a file from a path; `,D` on one deletes it, after
//! asking. The page follows GitHub until it shows an upload or a
//! deletion.

use crate::route::{Route, releases};
use std::path::{Path, PathBuf};

use crate::act::Step;
use crate::api::browse::{Asset, Release};
use crate::api::model::RepoId;
use crate::data::DataKey;
use crate::forms::Answer;
use crate::keymap::Action;
use crate::picker::{Choice, entry};
use crate::state::{Cmd, Screen, State};
use crate::ui::page::Link;
use crate::work::{self, Cx, Failure, Job};

/// An asset of a release, by REST's ID for the release and the asset's
/// name (unique on a release).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Of {
    pub repo: RepoId,
    pub tag: String,
    pub id: u64,
    pub name: String,
}

/// Saving an asset to `path` (over a file there, with `replace`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Save {
    pub of: Of,
    pub path: PathBuf,
    pub replace: bool,
}

/// Uploading the file at `path` as the asset.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upload {
    pub of: Of,
    pub path: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delete(pub Of);

impl Job for Save {
    /// Its size.
    type Out = u64;
    const WRITES: bool = false;

    async fn run(&self, cx: &Cx) -> Result<u64, Failure> {
        let Save { of, path, replace } = self;
        let saved = cx
            .gh
            .download_asset(&of.repo, of.id, &of.name, path, *replace);
        Ok(saved.await?)
    }

    fn done(self, state: &mut State, result: Result<u64, Failure>) -> Vec<Cmd> {
        match result {
            Ok(size) => {
                let size = crate::ui::text::size(size);
                let (name, path) = (self.of.name, self.path.display());
                state.info(format!("Saved {name} to {path} ({size})"));
            }
            Err(why) => state.error(format!("Couldn't save {}: {why}", self.of.name)),
        }
        Vec::new()
    }
}

impl Job for Upload {
    type Out = ();

    async fn run(&self, cx: &Cx) -> Result<(), Failure> {
        let of = &self.of;
        Ok(cx
            .gh
            .upload_asset(&of.repo, of.id, &self.path, &of.name)
            .await?)
    }

    fn done(self, state: &mut State, result: Result<(), Failure>) -> Vec<Cmd> {
        if let Err(why) = result {
            state.error(format!("GitHub didn't take {}: {why}", self.of.name));
            return Vec::new();
        }
        state.info(format!("Uploaded {}", self.of.name));
        follow(state, self.of, true)
    }
}

impl Job for Delete {
    type Out = ();

    async fn run(&self, cx: &Cx) -> Result<(), Failure> {
        let of = &self.0;
        Ok(cx.gh.delete_asset(&of.repo, of.id, &of.name).await?)
    }

    fn done(self, state: &mut State, result: Result<(), Failure>) -> Vec<Cmd> {
        let of = self.0;
        if let Err(why) = result {
            return crate::forms::say(state, format!("GitHub didn't delete {}: {why}", of.name));
        }
        let route = Route::Releases(releases::Release {
            repo: of.repo.clone(),
            tag: of.tag.clone(),
        });
        crate::forms::close_sender(state, &route);
        state.info(format!("Deleted {}", of.name));
        follow(state, of, false)
    }
}

/// The release on screen's asset named `name`, as loaded.
fn asset<'a>(r: &'a Release, name: &str) -> Option<&'a Asset> {
    r.assets.iter().find(|a| a.name == name)
}

/// The asset `name` of the release on screen.
fn of(state: &State, name: &str) -> Option<Of> {
    let (repo, tag, r) = super::shown(state)?;
    Some(Of {
        repo,
        tag,
        id: r.id?,
        name: name.to_owned(),
    })
}

/// The asset selected on the release's page, by its name.
pub fn selected(state: &State) -> Option<String> {
    let Screen::Page(p) = state.screen() else {
        return None;
    };
    match p.selected_link()? {
        Link::Asset(name) => Some(name.clone()),
        _ => None,
    }
}

/// `↵` on an asset: where to save it.
#[must_use]
pub fn open(state: &mut State, name: &str) -> Vec<Cmd> {
    let Some(of) = of(state, name) else {
        return state.just_say("The release hasn't loaded yet");
    };
    state.open_picker(crate::picker::Kind::SaveAsset(of))
}

/// `,U`: a file to upload.
#[must_use]
pub fn open_upload(state: &mut State) -> Vec<Cmd> {
    if !matches!(
        state.route(),
        Some(Route::Releases(releases::Release { .. }))
    ) {
        return state.just_say("Assets are uploaded to a release: open its page");
    }
    if let Some(why) = super::unchangeable(state) {
        return state.just_say(why);
    }
    let Some(of) = of(state, "") else {
        return Vec::new();
    };
    state.open_picker(crate::picker::Kind::Upload(of))
}

/// The saving picker's row.
pub fn save_rows(of: &Of, q: &str) -> crate::picker::Rows {
    crate::picker::save_rows(q, &of.name, "", |path, replace| {
        let of = of.clone();
        Save { of, path, replace }.into()
    })
}

/// How many files the upload picker lists of a folder.
const LISTED: usize = 50;

/// The upload picker's rows: the file typed, else the files in the
/// folder typed (or beside what's typed) whose names start as typed.
pub fn upload_rows(state: &State, of: &Of, q: &str) -> crate::picker::Rows {
    let typed = q.trim();
    let release = (super::shown(state)).map(|(_, _, r)| r);
    let row = |path: &Path| {
        let name = path
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        let size = std::fs::metadata(path).map_or(0, |m| m.len());
        let shown = path.display().to_string();
        if release.is_some_and(|r| asset(r, &name).is_some()) {
            let delete = state.first_key(Action::DeleteComment);
            return entry(
                format!("{name} is on the release already"),
                format!("{delete} on it deletes it"),
                None,
            );
        }
        let up = Of { name, ..of.clone() };
        entry(
            format!("Upload {shown}"),
            crate::ui::text::size(size),
            Some(Choice::Step(Step::Start(
                format!("Uploading {shown}…"),
                Upload {
                    of: up,
                    path: path.to_owned(),
                }
                .into(),
            ))),
        )
    };
    let path = PathBuf::from(if typed.is_empty() { "." } else { typed });
    if path.is_file() {
        return vec![row(&path)];
    }
    let (dir, start) = if typed.is_empty() || typed.ends_with('/') || path.is_dir() {
        (path, String::new())
    } else {
        let dir = path
            .parent()
            .filter(|d| !d.as_os_str().is_empty())
            .map_or_else(|| PathBuf::from("."), Path::to_owned);
        let start = path
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        (dir, start)
    };
    let Ok(entries) = std::fs::read_dir(&dir) else {
        return vec![entry(format!("No file or folder {typed}"), "", None)];
    };
    let mut files: Vec<PathBuf> = entries
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| p.is_file())
        .filter(|p| {
            p.file_name()
                .is_some_and(|n| n.to_string_lossy().starts_with(&start))
        })
        .collect();
    files.sort();
    let more = files.len().saturating_sub(LISTED);
    let mut rows: Vec<_> = files.iter().take(LISTED).map(|p| row(p)).collect();
    if rows.is_empty() {
        rows.push(entry(
            format!("No files in {} start with “{start}”", dir.display()),
            "",
            None,
        ));
    }
    if more > 0 {
        rows.push(entry(
            format!("{more} more: type more of the name"),
            "",
            None,
        ));
    }
    rows
}

/// Choosing a row: saving, or uploading.
/// `,D` on an asset: asks first, as GitHub does.
#[must_use]
pub fn ask_delete(state: &mut State, name: &str) -> Vec<Cmd> {
    let Some((repo, tag, r)) = super::shown(state) else {
        return Vec::new();
    };
    let Some(a) = asset(r, name) else {
        return Vec::new();
    };
    let facts = vec![(
        format!(
            "{}, downloaded {}: it can't be brought back",
            crate::ui::text::size(a.size),
            if a.downloads == 1 {
                "once".to_owned()
            } else {
                format!("{} times", a.downloads)
            }
        ),
        true,
    )];
    crate::forms::ask(
        state,
        Route::Releases(releases::Release { repo, tag }),
        format!("Delete {name}?"),
        facts,
        vec![("Delete asset".into(), Answer::DeleteAsset(name.to_owned()))],
    );
    Vec::new()
}

/// Deletes the asset asked about.
#[must_use]
pub fn delete(state: &mut State, name: &str) -> Vec<Cmd> {
    let Some(of) = of(state, name) else {
        return Vec::new();
    };
    crate::forms::sending(state);
    work::start(Delete(of))
}

/// The release's page, if it's on screen, follows GitHub until it shows
/// the asset there (or gone).
fn follow(state: &mut State, of: Of, there: bool) -> Vec<Cmd> {
    let route = Route::Releases(releases::Release {
        repo: of.repo.clone(),
        tag: of.tag.clone(),
    });
    if state.route() != Some(&route) {
        return Vec::new();
    }
    let what = super::Expected::Asset {
        repo: of.repo,
        tag: of.tag,
        name: of.name,
        there,
    };
    crate::follow::expect(state, route.clone(), what);
    state.ensure_route(&route, true)
}

/// Whether the release's page shows asset `name` there (or gone).
pub fn shows(state: &State, repo: &RepoId, tag: &str, name: &str, there: bool) -> bool {
    let key = DataKey::Release(repo.clone(), tag.to_owned());
    state
        .picked::<Release>(&key)
        .is_some_and(|r| asset(r, name).is_some() == there)
}