use crate::action::{Action, DeclarationState};
use crate::components::centered;
use crate::provider::Declaration;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
pub struct ConsentPopup {
decl: Declaration,
state: Option<DeclarationState>,
}
impl ConsentPopup {
pub fn new(decl: Declaration) -> Self {
ConsentPopup { decl, state: None }
}
pub fn declaration(&self) -> &Declaration {
&self.decl
}
pub fn set_state(&mut self, state: DeclarationState) {
self.state = Some(state);
}
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
match (key.code, &self.state) {
(KeyCode::Char('y'), None) => Action::DeclarationAccept,
(KeyCode::Char('n') | KeyCode::Esc, _) => Action::DeclarationDecline,
_ => Action::Noop,
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let sem = &theme.semantic;
let area = centered(area, 64, 40);
let decl = &self.decl;
let mut lines = vec![
Line::from(vec![
Span::styled("config declares ", Style::default().fg(sem.subtext0)),
Span::styled(
decl.name.clone(),
Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
),
Span::styled(
" — not installed on this machine",
Style::default().fg(sem.subtext0),
),
]),
Line::from(vec![
Span::styled("source ", Style::default().fg(sem.subtext0)),
Span::styled(decl.repo.clone(), Style::default().fg(sem.text)),
]),
];
if decl.tag.is_some() || decl.sha.is_some() {
let pins = [
decl.tag.clone().map(|t| format!("tag {t}")),
decl.sha.is_some().then(|| "sha256".to_string()),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · ");
lines.push(Line::from(vec![
Span::styled("pins ", Style::default().fg(sem.subtext0)),
Span::styled(pins, Style::default().fg(sem.warning)),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
format!(
"you are trusting {} — the tarball is sha256-verified{} before anything runs",
decl.repo,
if decl.sha.is_some() {
" against your config's pin and its sidecar"
} else {
" against its release sidecar"
}
),
Style::default().fg(sem.hint),
)));
match &self.state {
None => lines.push(Line::from(vec![
Span::styled(
"y",
Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
),
Span::styled(" install · ", Style::default().fg(sem.subtext0)),
Span::styled(
"n",
Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
),
Span::styled(" browse github instead", Style::default().fg(sem.subtext0)),
])),
Some(DeclarationState::Installing) => lines.push(Line::from(Span::styled(
"installing — verified download running…",
Style::default().fg(sem.hint),
))),
Some(DeclarationState::Failed(e)) => {
lines.push(Line::from(Span::styled(
format!("failed: {e}"),
Style::default().fg(sem.error),
)));
lines.push(Line::from(Span::styled(
"esc — browse github (the declaration stays in your config)",
Style::default().fg(sem.subtext0),
)));
}
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type())
.border_style(Style::default().fg(sem.border_focused))
.title(Span::styled(
" install provider? ",
Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
));
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: false }),
area,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn decl() -> Declaration {
Declaration {
name: "gitlab".into(),
repo: "rootledev/rootle-gitlab".into(),
tag: None,
sha: None,
}
}
fn screen(popup: &mut ConsentPopup) -> String {
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| popup.render(f, f.area(), &Theme::catppuccin_mocha()))
.unwrap();
let buf = terminal.backend().buffer();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, ratatui::crossterm::event::KeyModifiers::NONE)
}
#[test]
fn asks_with_trust_line_and_maps_keys() {
let mut popup = ConsentPopup::new(decl());
let s = screen(&mut popup);
assert!(s.contains("config declares gitlab"), "{s}");
assert!(s.contains("rootledev/rootle-gitlab"), "{s}");
assert!(
s.contains("you are trusting rootledev/rootle-gitlab"),
"{s}"
);
assert!(s.contains("y install"), "{s}");
assert!(s.contains("n browse github"), "{s}");
assert_eq!(
popup.handle_key(key(KeyCode::Char('y'))),
Action::DeclarationAccept
);
assert_eq!(
popup.handle_key(key(KeyCode::Char('n'))),
Action::DeclarationDecline
);
assert_eq!(
popup.handle_key(key(KeyCode::Esc)),
Action::DeclarationDecline
);
assert_eq!(popup.handle_key(key(KeyCode::Char('q'))), Action::Noop);
}
#[test]
fn pins_surface_in_the_popup() {
let mut popup = ConsentPopup::new(Declaration {
tag: Some("v0.2.1".into()),
sha: Some("deadbeef".into()),
..decl()
});
let s = screen(&mut popup);
assert!(s.contains("pins"), "{s}");
assert!(s.contains("tag v0.2.1"), "{s}");
assert!(s.contains("sha256"), "{s}");
assert!(s.contains("against your config's pin"), "{s}");
}
#[test]
fn installing_and_failed_states_render() {
let mut popup = ConsentPopup::new(decl());
popup.set_state(DeclarationState::Installing);
assert!(screen(&mut popup).contains("installing — verified download running"));
popup.set_state(DeclarationState::Failed("network: refused".into()));
let s = screen(&mut popup);
assert!(s.contains("failed: network: refused"), "{s}");
assert!(s.contains("esc — browse github"), "{s}");
assert_eq!(
popup.handle_key(key(KeyCode::Esc)),
Action::DeclarationDecline
);
}
}