mdbook-plotly 0.3.0-alpha

A mdbook preprocessor that renders plot code blocks (e.g., ```plot) into interactive or static charts during book build.
Documentation
//! Self-update view: current→target version comparison, release notes,
//! download progress, checksum verification, and the replace confirmation.

use std::path::PathBuf;
use std::time::Duration;

use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, Paragraph};

use crate::tui::theme;
use crate::tui::update::{ReleaseInfo, UpdateMsg};
use crate::tui::widget::{Spinner, view_header};

/// Space reserved for header + version + hint rows inside the update view.
const VERSION_BLOCK_LINES: u16 = 3;
const HINT_LINES: u16 = 1;

pub struct UpdateView {
    pub phase: Phase,
    pub spinner: Spinner,
}

#[derive(Debug)]
pub enum Phase {
    Idle,
    Working,
    Available {
        current: String,
        release: ReleaseInfo,
        dry_run: bool,
    },
    AlreadyLatest {
        current: String,
    },
    Downloading {
        downloaded: u64,
        total: u64,
    },
    Downloaded,
    Verified {
        sha: String,
    },
    ReadyToConfirm {
        archive: PathBuf,
    },
    Replaced,
    Error(String),
}

impl Default for UpdateView {
    fn default() -> Self {
        Self::new()
    }
}

impl UpdateView {
    pub fn new() -> Self {
        Self {
            phase: Phase::Idle,
            spinner: Spinner::new(),
        }
    }

    pub fn tick(&mut self, dt: Duration) {
        self.spinner.tick(dt);
    }

    pub fn on_msg(&mut self, msg: UpdateMsg) {
        match msg {
            UpdateMsg::CheckStarted => self.phase = Phase::Working,
            UpdateMsg::Available {
                release,
                current,
                dry_run,
            } => {
                self.phase = Phase::Available {
                    current,
                    release,
                    dry_run,
                };
            }
            UpdateMsg::AlreadyLatest { current } => {
                self.phase = Phase::AlreadyLatest { current };
            }
            UpdateMsg::DownloadProgress { downloaded, total } => {
                self.phase = Phase::Downloading { downloaded, total };
            }
            UpdateMsg::Downloaded { .. } => self.phase = Phase::Downloaded,
            UpdateMsg::Verified { sha } => self.phase = Phase::Verified { sha },
            UpdateMsg::WaitingConfirm { archive } => {
                self.phase = Phase::ReadyToConfirm { archive };
            }
            UpdateMsg::Replaced => self.phase = Phase::Replaced,
            UpdateMsg::Failed(msg) => self.phase = Phase::Error(msg),
        }
    }

    pub fn is_confirming(&self) -> bool {
        matches!(self.phase, Phase::ReadyToConfirm { .. })
    }

    pub fn is_working(&self) -> bool {
        matches!(
            self.phase,
            Phase::Working | Phase::Downloading { .. } | Phase::Downloaded | Phase::Verified { .. }
        )
    }

    pub fn render(&self, frame: &mut ratatui::Frame, area: Rect) {
        let [header, version_area, body, hint] = Layout::vertical([
            Constraint::Length(1),
            Constraint::Length(VERSION_BLOCK_LINES),
            Constraint::Min(3),
            Constraint::Length(HINT_LINES),
        ])
        .areas(area);

        view_header(
            frame,
            header,
            " Self-update ",
            &format!("current v{}", env!("CARGO_PKG_VERSION")),
        );

        self.render_version_block(frame, version_area);
        self.render_body(frame, body);

        let hint_text = match &self.phase {
            Phase::Idle => "c  check for updates     Esc  back",
            Phase::Working => "checking…",
            Phase::Available { dry_run, .. } if *dry_run => {
                "dry-run: nothing was downloaded.    Esc  back"
            }
            Phase::Available { .. } => "downloading…",
            Phase::Downloading { .. } | Phase::Downloaded | Phase::Verified { .. } => {
                "please wait…"
            }
            Phase::ReadyToConfirm { .. } => "y  replace binary     n  cancel",
            Phase::Replaced => "q  quit and restart",
            Phase::AlreadyLatest { .. } => "Esc  back",
            Phase::Error(_) => "c  retry     Esc  back",
        };
        frame.render_widget(
            Paragraph::new(Line::from(Span::styled(hint_text, theme::dim()))),
            hint,
        );
    }

    fn info_from_phase(&self) -> Option<(&String, &ReleaseInfo)> {
        match &self.phase {
            Phase::Available {
                current, release, ..
            } => Some((current, release)),
            _ => None,
        }
    }

    fn render_version_block(&self, frame: &mut ratatui::Frame, area: Rect) {
        let Some((current, release)) = self.info_from_phase() else {
            let text = match &self.phase {
                Phase::Idle => "Press c to check for a newer release.",
                Phase::Working => "Checking GitHub for the latest release…",
                Phase::AlreadyLatest { current } => {
                    return frame.render_widget(
                        Paragraph::new(vec![
                            Line::from(Span::styled("You are up to date.", theme::success())),
                            Line::from(Span::styled(
                                format!("v{current} is the latest release."),
                                theme::dim(),
                            )),
                        ]),
                        area,
                    );
                }
                Phase::Error(_) => "",
                _ => "",
            };
            return frame.render_widget(Paragraph::new(text).style(theme::fg()), area);
        };
        frame.render_widget(
            Paragraph::new(Line::from(vec![
                Span::styled(format!("v{current}"), theme::fg()),
                Span::styled("", theme::accent_bold()),
                Span::styled(format!("v{}", release.version), theme::accent_bold()),
                Span::styled(format!("   (tag {})", release.tag_name), theme::dim()),
            ])),
            area,
        );
    }

    fn render_body(&self, frame: &mut ratatui::Frame, area: Rect) {
        let spinner = self.spinner.ch();
        let (lines, style): (Vec<Line>, Style) = match &self.phase {
            Phase::Idle => (vec![Line::from("")], theme::fg()),
            Phase::Working => (
                vec![Line::from(Span::styled(
                    format!("{spinner} Contacting GitHub…"),
                    theme::dim(),
                ))],
                theme::fg(),
            ),
            Phase::Available {
                release, dry_run, ..
            } => {
                let mut lines = vec![];
                if *dry_run {
                    lines.push(Line::from(Span::styled(
                        "DRY-RUN — no download or replacement will happen.",
                        theme::accent_bold(),
                    )));
                    lines.push(Line::from(""));
                }
                lines.push(Line::from(Span::styled(
                    "Release notes:",
                    theme::accent_bold(),
                )));
                let max = area.height.saturating_sub(4) as usize;
                for note in release.body.lines().take(max) {
                    lines.push(Line::from(Span::raw(note.to_string())));
                }
                (lines, theme::dim())
            }
            Phase::AlreadyLatest { .. } => (vec![], theme::dim()),
            Phase::Downloading { downloaded, total } => {
                let ratio = if *total > 0 {
                    *downloaded as f64 / *total as f64
                } else {
                    0.0
                };
                let pct = (ratio * 100.0) as u16;
                let gauge = Gauge::default()
                    .ratio(ratio)
                    .label(format!(
                        "{spinner} downloading {downloaded} / {total} bytes ({pct}%)"
                    ))
                    .gauge_style(theme::accent());
                frame.render_widget(gauge, area);
                return;
            }
            Phase::Downloaded => (
                vec![Line::from(Span::styled(
                    format!("{spinner} verifying SHA-256 checksum…"),
                    theme::dim(),
                ))],
                theme::fg(),
            ),
            Phase::Verified { sha } => (
                vec![
                    Line::from(Span::styled("✓ SHA-256 verified", theme::success())),
                    Line::from(Span::raw(sha.clone())),
                ],
                theme::dim(),
            ),
            Phase::ReadyToConfirm { archive } => (
                vec![
                    Line::from(Span::styled(
                        "The new binary is downloaded and verified.",
                        theme::fg(),
                    )),
                    Line::from(Span::styled(
                        "Replacing the running binary is irreversible.",
                        theme::fg(),
                    )),
                    Line::from(""),
                    Line::from(Span::styled(
                        format!("Archive: {}", archive.display()),
                        theme::dim(),
                    )),
                ],
                theme::fg(),
            ),
            Phase::Replaced => (
                vec![
                    Line::from(Span::styled(
                        "✓ Update complete. The new binary is in place.",
                        theme::success(),
                    )),
                    Line::from(Span::styled(
                        "Quit and restart mdbook-plotly to run the new version.",
                        theme::dim(),
                    )),
                ],
                theme::fg(),
            ),
            Phase::Error(msg) => (
                msg.lines()
                    .map(|l| Line::from(Span::styled(l.to_string(), theme::error())))
                    .collect(),
                theme::fg(),
            ),
        };
        frame.render_widget(Paragraph::new(lines).style(style), area);
    }
}