pub mod keep;
mod page;
pub mod queue;
use crate::act::By;
use crate::api::ApiError;
use crate::api::change::Change;
use crate::api::model::PrRef;
use crate::keymap::Action;
use crate::route::{Dest, Route, app};
use crate::state::{Cmd as Do, Overlay, Screen, State, Timer};
use crate::ui::page::Link;
pub use page::{build, queued_here};
#[derive(Debug)]
pub enum Msg {
Reach(Option<u64>),
Probed(bool),
Queue(queue::Told),
Kept(Vec<keep::Kept>, Option<u64>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cmd {
Probe,
Queue(queue::Op),
Keep(keep::Op),
}
#[derive(Debug, Default)]
pub struct Offline {
pub since: Option<u64>,
pub queue: Vec<queue::Entry>,
pub kept: Vec<keep::Kept>,
pub cache: Option<u64>,
pub limits: keep::Limits,
probe: Option<u64>,
discarded: Option<queue::Entry>,
}
const PROBE_FIRST: u64 = 2_000;
const PROBE_MOST: u64 = 30_000;
impl Offline {
pub fn waiting(&self) -> usize {
(self.queue.iter())
.filter(|e| matches!(e.status, queue::Status::Queued | queue::Status::Sending))
.count()
}
pub fn unsent(&self) -> usize {
self.queue.len() - self.waiting()
}
pub fn keeps(&self, repo: &crate::api::model::RepoId) -> Option<&keep::Kept> {
self.kept.iter().find(|k| k.repo == *repo)
}
}
#[must_use]
pub fn on_msg(state: &mut State, msg: Msg) -> Vec<Do> {
if !matches!(msg, Msg::Probed(_)) {
state.shown_gen += 1;
}
match msg {
Msg::Reach(Some(since)) => {
state.offline.since = Some(since);
return schedule(state);
}
Msg::Reach(None) => {
if state.offline.since.take().is_none() {
return Vec::new();
}
state.info("GitHub is back");
let mut cmds = vec![
Do::Offline(Cmd::Queue(queue::Op::Flush)),
Do::Offline(Cmd::Keep(keep::Op::Now)),
];
cmds.extend(state.load_visible(false));
return cmds;
}
Msg::Probed(true) => state.offline.probe = None,
Msg::Probed(false) => {
let off = &mut state.offline;
let wait = off.probe.map_or(PROBE_FIRST, |w| (w * 2).min(PROBE_MOST));
off.probe = Some(wait);
if off.since.is_some() {
return vec![Do::Timer(Timer::Probe, wait)];
}
}
Msg::Queue(queue::Told::All(entries)) => state.offline.queue = entries,
Msg::Queue(queue::Told::Taken(entry)) => {
state.info(format!(
"Sent to GitHub: {} on {}",
entry.write.what(),
entry.title
));
return state.load_visible(false);
}
Msg::Kept(kept, cache) => (state.offline.kept, state.offline.cache) = (kept, cache),
}
Vec::new()
}
fn schedule(state: &mut State) -> Vec<Do> {
if state.offline.probe.is_some() {
return Vec::new();
}
state.offline.probe = Some(PROBE_FIRST);
vec![Do::Timer(Timer::Probe, PROBE_FIRST)]
}
#[must_use]
pub fn on_timer(state: &mut State) -> Vec<Do> {
if state.offline.since.is_none() {
state.offline.probe = None;
return Vec::new();
}
vec![Do::Offline(Cmd::Probe)]
}
#[must_use]
pub fn queue_change(
state: &mut State,
change: &Change,
by: &By,
err: &crate::work::Failure,
sender: bool,
) -> Option<Vec<Do>> {
let Some(ApiError::Offline(_)) = err.api() else {
return None;
};
let write = queue::Write::of(change)?;
let route = match by {
By::Reply(pr) => Some(Route::pr(pr.clone())),
_ => here(state),
};
if sender && matches!(state.overlay, Some(Overlay::Compose(_) | Overlay::Ask(_))) {
state.overlay = None;
}
state.info(format!(
"GitHub can't be reached: {} is queued, to send when it's back",
write.what().to_lowercase()
));
Some(vec![add(state, write, route)])
}
#[must_use]
pub fn queue_review(state: &mut State, review: &queue::Review) -> Do {
let route = Route::pr(review.pr.clone());
state.info("GitHub can't be reached: your review is queued, to send when it's back");
add(state, queue::Write::Review(review.clone()), Some(route))
}
fn add(state: &State, write: queue::Write, route: Option<Route>) -> Do {
let (page, title) = route.map_or_else(
|| (String::new(), "GitHub".to_owned()),
|r| (r.url(), r.title()),
);
let entry = queue::Entry {
id: 0,
at: (state.clock)(),
write,
page,
title,
status: queue::Status::Queued,
};
Do::Offline(Cmd::Queue(queue::Op::Add(Box::new(entry))))
}
fn here(state: &State) -> Option<Route> {
match state.screen() {
Screen::Page(p) => Some(p.route.clone()),
Screen::Diff(d) => match &d.of {
crate::diff_screen::DiffOf::Pr(pr) => Some(Route::pr(PrRef::clone(pr))),
_ => None,
},
}
}
pub fn header(state: &State) -> Vec<String> {
let off = &state.offline;
let waiting = off.waiting();
let mut said = Vec::new();
if off.since.is_some() {
said.push("⚠ Offline".to_owned());
}
match (off.since, waiting, off.unsent()) {
(Some(_), 0, _) | (None, 0, 0) => {}
(Some(_), n, _) => said.push(format!("{n} queued")),
(None, 0, n) => said.push(format!("⚠ {n} not sent")),
(None, n, _) => said.push(format!("Sending {n}")),
}
said
}
pub fn banner(state: &State, when: &str) -> Option<String> {
state
.offline
.since
.map(|_| format!("Offline: showing what was loaded {when}."))
}
fn selected(state: &State) -> Option<&queue::Entry> {
let Screen::Page(p) = state.screen() else {
return None;
};
let Some(Link::Queued(id)) = p.selected_link() else {
return None;
};
state.offline.queue.iter().find(|e| e.id == *id)
}
pub const FEATURE: crate::features::Feature = crate::features::Feature {
owns: &[Action::Offline, Action::KeepOffline],
takes,
act,
offers: doables,
..crate::features::Feature::NONE
};
fn takes(state: &State, action: Action) -> bool {
match action {
Action::Refresh => state.offline.since.is_some(),
Action::UndoDelete => state.offline.discarded.as_ref().is_some_and(|e| {
let here = state.route();
here == Some(&Route::App(app::Offline)) || here.is_some_and(|r| r.url() == e.page)
}),
Action::Rerun | Action::Delete | Action::Copy => selected(state).is_some(),
_ => false,
}
}
#[must_use]
pub fn act(state: &mut State, action: Action) -> Vec<Do> {
match action {
Action::Offline if state.route() == Some(&Route::App(app::Offline)) => {
state.just_say("This is the Offline page")
}
Action::Offline => state.push(Route::App(app::Offline)),
Action::KeepOffline => keep(state),
Action::Refresh => {
let mut cmds = state.load_visible(true);
cmds.push(Do::Offline(Cmd::Probe));
state.info("Asking whether GitHub's back…");
cmds
}
Action::UndoDelete => match state.offline.discarded.take() {
Some(entry) => {
state.info(format!("Brought back: {}", entry.write.what()));
vec![Do::Offline(Cmd::Queue(queue::Op::Add(Box::new(entry))))]
}
None => Vec::new(),
},
_ => {
let Some(entry) = selected(state).cloned() else {
return Vec::new();
};
on_entry(state, action, entry)
}
}
}
fn on_entry(state: &mut State, action: Action, entry: queue::Entry) -> Vec<Do> {
let what = entry.write.what();
match action {
Action::Copy => match entry.write.text() {
Some(text) => {
state.info(format!("Copied the text of {}", what.to_lowercase()));
vec![Do::Copy(text)]
}
None => state.just_say(format!("{what} has no text to copy")),
},
Action::Rerun => match entry.status {
queue::Status::Refused(_) | queue::Status::Unsure(_) => {
state.info(format!("Sending again: {what}"));
vec![Do::Offline(Cmd::Queue(queue::Op::Retry(entry.id)))]
}
queue::Status::Queued | queue::Status::Sending => {
state.just_say(format!("{what} is queued already"))
}
},
Action::Delete if entry.status == queue::Status::Sending => {
state.just_say(format!("{what} is on its way to GitHub"))
}
Action::Delete => {
let undo = state.first_key(Action::UndoDelete);
state.info(format!("Threw away: {what} · {undo} brings it back"));
let id = entry.id;
state.offline.discarded = Some(entry);
vec![Do::Offline(Cmd::Queue(queue::Op::Discard(id)))]
}
_ => Vec::new(),
}
}
fn keep(state: &mut State) -> Vec<Do> {
let Some(repo) = state.route().and_then(Route::repo).cloned() else {
return state.just_say("Keep offline works on a repository's pages");
};
if state.offline.keeps(&repo).is_some() {
state.offline.kept.retain(|k| k.repo != repo);
state.info(format!(
"No longer keeping {repo} offline (what the cache has stays until it's replaced)"
));
} else {
state.info(format!("Keeping {repo} to read offline: fetching it ahead"));
}
vec![Do::Offline(Cmd::Keep(keep::Op::Toggle(repo)))]
}
#[must_use]
pub fn follow(state: &mut State, id: u64) -> Vec<Do> {
let Some(entry) = state.offline.queue.iter().find(|e| e.id == id) else {
return Vec::new();
};
let here = state.route().map(Route::url);
if entry.page.is_empty() || here.as_deref() == Some(entry.page.as_str()) {
let (retry, copy) = (
state.first_key(Action::Rerun),
state.first_key(Action::Copy),
);
return state.just_say(format!(
"{} · {retry} sends a refused one again · {copy} copies its text",
entry.write.what()
));
}
let page = entry.page.clone();
state.go(Dest::from_url(&page))
}
pub fn doables(state: &State, out: &mut Vec<crate::nav::Doable>) {
let row = |action, label| crate::nav::row("Offline", action, label, "", None);
if let Some(entry) = selected(state) {
let what = entry.write.what().to_lowercase();
let mut again = row(Action::Rerun, format!("Send {what} again"));
if matches!(entry.status, queue::Status::Queued | queue::Status::Sending) {
again.unavailable = Some("it's queued".into());
}
out.push(again);
out.push(row(Action::Delete, format!("Throw away {what}")));
let mut copy = row(Action::Copy, format!("Copy the text of {what}"));
if entry.write.text().is_none() {
copy.unavailable = Some("it has no text".into());
}
out.push(copy);
}
if let Some(repo) = state.route().and_then(Route::repo) {
let label = match state.offline.keeps(repo) {
Some(_) => format!("Stop keeping {repo} offline"),
None => format!("Keep {repo} to read offline"),
};
out.push(row(Action::KeepOffline, label));
}
if state.route() != Some(&Route::App(app::Offline)) {
let label = "Offline: what's queued for GitHub, and what's kept";
out.push(crate::nav::row("Go", Action::Offline, label, "", None));
}
}