Skip to main content

kimun_notes/components/dialogs/
update_dialog.rs

1use ratatui::Frame;
2use ratatui::crossterm::event::KeyCode;
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::widgets::Paragraph;
6
7use crate::components::Component;
8use crate::components::event_state::EventState;
9use crate::components::events::{AppEvent, AppTx, UpdateFlow};
10use crate::components::panel::{ModalSpec, modal_chrome};
11use crate::settings::themes::Theme;
12use crate::update::UpdateStatus;
13
14/// Dialog shown when a newer release is available. On self-update-eligible
15/// channels it offers an in-place update; otherwise it shows the package
16/// manager's upgrade command. Either way the user can skip the version.
17///
18/// ```text
19/// ┌─ Update Available ───────────────────────────────────┐
20/// │                                                      │
21/// │  kimün 0.17.0  →  0.18.0                             │
22/// │                                                      │
23/// │  [U] Update now      [S] Skip this version           │
24/// │                                                      │
25/// │  Release notes: https://github.com/nico2sh/kimun/... │
26/// │  [Esc] Close                                          │
27/// └──────────────────────────────────────────────────────┘
28/// ```
29pub struct UpdateAvailableDialog {
30    current: String,
31    latest: String,
32    /// Whether this channel can self-update in place.
33    eligible: bool,
34    /// Upgrade command for package-manager channels (e.g. `brew upgrade kimun`).
35    upgrade_hint: Option<String>,
36}
37
38impl UpdateAvailableDialog {
39    pub fn new(status: &UpdateStatus) -> Self {
40        Self {
41            current: status.current.clone(),
42            latest: status.latest.clone(),
43            eligible: status.channel.self_update_eligible(),
44            upgrade_hint: status.channel.upgrade_hint().map(str::to_string),
45        }
46    }
47
48    pub fn handle_key(
49        &mut self,
50        key: ratatui::crossterm::event::KeyEvent,
51        tx: &AppTx,
52    ) -> EventState {
53        match key.code {
54            KeyCode::Char('u') | KeyCode::Char('U') if self.eligible => {
55                tx.send(AppEvent::Update(UpdateFlow::Apply)).ok();
56                tx.send(AppEvent::CloseOverlay).ok();
57                EventState::Consumed
58            }
59            KeyCode::Char('s') | KeyCode::Char('S') => {
60                tx.send(AppEvent::Update(UpdateFlow::Dismiss(self.latest.clone())))
61                    .ok();
62                tx.send(AppEvent::CloseOverlay).ok();
63                EventState::Consumed
64            }
65            KeyCode::Esc => {
66                tx.send(AppEvent::CloseOverlay).ok();
67                EventState::Consumed
68            }
69            _ => EventState::Consumed, // swallow other keys while open
70        }
71    }
72}
73
74impl Component for UpdateAvailableDialog {
75    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
76        let popup_area = super::fixed_centered_rect(58, 11, rect);
77
78        let inner = modal_chrome(
79            f,
80            popup_area,
81            theme,
82            ModalSpec {
83                title: Some(" Update Available "),
84                border: Some(Style::default().fg(theme.accent.to_ratatui())),
85                ..Default::default()
86            },
87        );
88
89        let rows = Layout::default()
90            .direction(Direction::Vertical)
91            .constraints([
92                Constraint::Length(1), // 0: spacer
93                Constraint::Length(1), // 1: version line
94                Constraint::Length(1), // 2: separator
95                Constraint::Length(1), // 3: action row
96                Constraint::Length(1), // 4: spacer
97                Constraint::Length(1), // 5: release notes / hint
98                Constraint::Length(1), // 6: Esc hint
99                Constraint::Min(0),    // 7: remainder
100            ])
101            .split(inner);
102
103        let bg = theme.bg_panel.to_ratatui();
104        let fg = theme.fg.to_ratatui();
105        let gray = theme.gray.to_ratatui();
106        let key_fg = theme.selection_fg.to_ratatui();
107        let accent = theme.accent.to_ratatui();
108
109        // Row 1: version transition.
110        f.render_widget(
111            Paragraph::new(format!("  kimün {}  →  {}", self.current, self.latest)).style(
112                Style::default()
113                    .fg(accent)
114                    .bg(bg)
115                    .add_modifier(Modifier::BOLD),
116            ),
117            rows[1],
118        );
119
120        // Row 2: separator.
121        super::render_separator(f, rows[2], gray, bg);
122
123        // Row 3: actions.
124        let key_style = Style::default()
125            .fg(key_fg)
126            .bg(bg)
127            .add_modifier(Modifier::BOLD);
128        let label_style = Style::default().fg(fg).bg(bg);
129        if self.eligible {
130            let cols = Layout::default()
131                .direction(Direction::Horizontal)
132                .constraints([Constraint::Length(24), Constraint::Min(1)])
133                .split(rows[3]);
134            render_action(f, cols[0], "  [U]", " Update now", key_style, label_style);
135            render_action(
136                f,
137                cols[1],
138                "[S]",
139                " Skip this version",
140                key_style,
141                label_style,
142            );
143        } else {
144            // Package-manager channel: show the upgrade command instead.
145            let hint = self
146                .upgrade_hint
147                .clone()
148                .unwrap_or_else(|| "Download the latest release manually.".to_string());
149            f.render_widget(
150                Paragraph::new(format!("  Run: {hint}")).style(label_style),
151                rows[3],
152            );
153            render_action(
154                f,
155                rows[4],
156                "  [S]",
157                " Skip this version",
158                key_style,
159                label_style,
160            );
161        }
162
163        // Row 5: release notes URL.
164        f.render_widget(
165            Paragraph::new(format!("  Releases: {}", crate::update::releases_url()))
166                .style(Style::default().fg(gray).bg(bg)),
167            rows[5],
168        );
169
170        // Row 6: close hint.
171        f.render_widget(
172            Paragraph::new("  [Esc] Close").style(Style::default().fg(gray).bg(bg)),
173            rows[6],
174        );
175    }
176}
177
178fn render_action(
179    f: &mut Frame,
180    area: Rect,
181    key: &str,
182    label: &str,
183    key_style: Style,
184    label_style: Style,
185) {
186    let chunks = Layout::default()
187        .direction(Direction::Horizontal)
188        .constraints([Constraint::Length(key.len() as u16), Constraint::Min(1)])
189        .split(area);
190    f.render_widget(Paragraph::new(key.to_string()).style(key_style), chunks[0]);
191    f.render_widget(
192        Paragraph::new(label.to_string()).style(label_style),
193        chunks[1],
194    );
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::update::InstallChannel;
201    use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
202    use tokio::sync::mpsc;
203
204    fn status(channel: InstallChannel) -> UpdateStatus {
205        UpdateStatus {
206            current: "0.17.0".into(),
207            latest: "0.18.0".into(),
208            channel,
209            update_available: true,
210            dismissed: false,
211        }
212    }
213
214    #[test]
215    fn skip_sends_dismiss_and_close() {
216        let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
217        let mut d = UpdateAvailableDialog::new(&status(InstallChannel::Direct));
218        let state = d.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), &tx);
219        assert_eq!(state, EventState::Consumed);
220        assert!(
221            matches!(rx.try_recv(), Ok(AppEvent::Update(UpdateFlow::Dismiss(v))) if v == "0.18.0")
222        );
223        assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
224    }
225
226    #[test]
227    fn update_now_only_on_eligible_channel() {
228        let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
229        // Eligible: 'u' applies.
230        let mut d = UpdateAvailableDialog::new(&status(InstallChannel::Script));
231        d.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), &tx);
232        assert!(matches!(
233            rx.try_recv(),
234            Ok(AppEvent::Update(UpdateFlow::Apply))
235        ));
236
237        // Not eligible: 'u' is swallowed, no apply.
238        let (tx2, mut rx2) = mpsc::unbounded_channel::<AppEvent>();
239        let mut d2 = UpdateAvailableDialog::new(&status(InstallChannel::Brew));
240        let state = d2.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), &tx2);
241        assert_eq!(state, EventState::Consumed);
242        assert!(rx2.try_recv().is_err());
243    }
244}