kimun_notes/components/dialogs/
update_dialog.rs1use 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
14pub struct UpdateAvailableDialog {
30 current: String,
31 latest: String,
32 eligible: bool,
34 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, }
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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
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 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 super::render_separator(f, rows[2], gray, bg);
122
123 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 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 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 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 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 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}