1use crate::config::Config;
2use crate::github::cache::{CacheConfig, DiskCache};
3use crate::github::types::PullRequest;
4use crate::scoring::ScoreResult;
5use crate::snooze::SnoozeState;
6use crate::version_check::VersionStatus;
7use chrono::{DateTime, Utc};
8use std::collections::VecDeque;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Instant;
12
13const MAX_UNDO: usize = 50;
14
15#[derive(Debug, Clone, PartialEq)]
16pub enum View {
17 Active,
18 Snoozed,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum InputMode {
23 Normal,
24 SnoozeInput,
25 Help,
26 ScoreBreakdown,
27}
28
29#[derive(Debug, Clone)]
30pub enum UndoAction {
31 Snoozed {
32 url: String,
33 title: String,
34 },
35 Unsnoozed {
36 url: String,
37 title: String,
38 until: Option<DateTime<Utc>>,
39 },
40 Resnooze {
41 url: String,
42 title: String,
43 previous_until: Option<DateTime<Utc>>,
44 },
45}
46
47pub struct App {
48 pub active_prs: Vec<(PullRequest, ScoreResult)>,
49 pub snoozed_prs: Vec<(PullRequest, ScoreResult)>,
50 pub table_state: ratatui::widgets::TableState,
51 pub current_view: View,
52 pub snooze_state: SnoozeState,
53 pub snooze_path: PathBuf,
54 pub input_mode: InputMode,
55 pub snooze_input: String,
56 pub flash_message: Option<(String, Instant)>,
57 pub undo_stack: VecDeque<UndoAction>,
58 pub last_refresh: Instant,
59 pub needs_refresh: bool,
60 pub force_refresh: bool,
61 pub should_quit: bool,
62 pub config: Config,
63 pub cache_config: CacheConfig,
64 pub cache_handle: Option<Arc<DiskCache>>,
65 pub verbose: bool,
66 pub is_loading: bool,
67 pub spinner_frame: usize,
68 pub rate_limit_remaining: Option<u64>,
69 pub auth_username: Option<String>,
70 pub version_status: VersionStatus,
71 pub no_version_check: bool,
72}
73
74impl App {
75 #[allow(clippy::too_many_arguments)]
76 pub fn new(
77 active_prs: Vec<(PullRequest, ScoreResult)>,
78 snoozed_prs: Vec<(PullRequest, ScoreResult)>,
79 snooze_state: SnoozeState,
80 snooze_path: PathBuf,
81 config: Config,
82 cache_config: CacheConfig,
83 cache_handle: Option<Arc<DiskCache>>,
84 verbose: bool,
85 auth_username: Option<String>,
86 no_version_check: bool,
87 ) -> Self {
88 let mut table_state = ratatui::widgets::TableState::default();
89 if !active_prs.is_empty() {
90 table_state.select(Some(0));
91 }
92
93 Self {
94 active_prs,
95 snoozed_prs,
96 table_state,
97 current_view: View::Active,
98 snooze_state,
99 snooze_path,
100 input_mode: InputMode::Normal,
101 snooze_input: String::new(),
102 flash_message: None,
103 undo_stack: VecDeque::new(),
104 last_refresh: Instant::now(),
105 needs_refresh: false,
106 force_refresh: false,
107 should_quit: false,
108 config,
109 cache_config,
110 cache_handle,
111 verbose,
112 is_loading: false,
113 spinner_frame: 0,
114 rate_limit_remaining: None,
115 auth_username,
116 version_status: VersionStatus::Unknown,
117 no_version_check,
118 }
119 }
120
121 #[allow(clippy::too_many_arguments)]
124 pub fn new_loading(
125 snooze_state: SnoozeState,
126 snooze_path: PathBuf,
127 config: Config,
128 cache_config: CacheConfig,
129 cache_handle: Option<Arc<DiskCache>>,
130 verbose: bool,
131 auth_username: Option<String>,
132 no_version_check: bool,
133 ) -> Self {
134 Self {
135 active_prs: Vec::new(),
136 snoozed_prs: Vec::new(),
137 table_state: ratatui::widgets::TableState::default(),
138 current_view: View::Active,
139 snooze_state,
140 snooze_path,
141 input_mode: InputMode::Normal,
142 snooze_input: String::new(),
143 flash_message: None,
144 undo_stack: VecDeque::new(),
145 last_refresh: Instant::now(),
146 needs_refresh: false,
147 force_refresh: false,
148 should_quit: false,
149 config,
150 cache_config,
151 cache_handle,
152 verbose,
153 is_loading: true,
154 spinner_frame: 0,
155 rate_limit_remaining: None,
156 auth_username,
157 version_status: VersionStatus::Unknown,
158 no_version_check,
159 }
160 }
161
162 pub fn current_prs(&self) -> &[(PullRequest, ScoreResult)] {
163 match self.current_view {
164 View::Active => &self.active_prs,
165 View::Snoozed => &self.snoozed_prs,
166 }
167 }
168
169 pub fn next_row(&mut self) {
170 let prs = self.current_prs();
171 if prs.is_empty() {
172 return;
173 }
174 let i = match self.table_state.selected() {
175 Some(i) => {
176 if i >= prs.len() - 1 {
177 0
178 } else {
179 i + 1
180 }
181 }
182 None => 0,
183 };
184 self.table_state.select(Some(i));
185 }
186
187 pub fn previous_row(&mut self) {
188 let prs = self.current_prs();
189 if prs.is_empty() {
190 return;
191 }
192 let i = match self.table_state.selected() {
193 Some(i) => {
194 if i == 0 {
195 prs.len() - 1
196 } else {
197 i - 1
198 }
199 }
200 None => 0,
201 };
202 self.table_state.select(Some(i));
203 }
204
205 pub fn selected_pr(&self) -> Option<&PullRequest> {
206 let prs = self.current_prs();
207 self.table_state
208 .selected()
209 .and_then(|i| prs.get(i).map(|(pr, _)| pr))
210 }
211
212 pub fn push_undo(&mut self, action: UndoAction) {
213 self.undo_stack.push_front(action);
214 if self.undo_stack.len() > MAX_UNDO {
215 self.undo_stack.pop_back();
216 }
217 }
218
219 pub fn update_flash(&mut self) {
220 if let Some((_, timestamp)) = self.flash_message {
221 if timestamp.elapsed().as_secs() >= 3 {
222 self.flash_message = None;
223 }
224 }
225 }
226
227 pub fn show_flash(&mut self, msg: String) {
228 self.flash_message = Some((msg, Instant::now()));
229 }
230
231 pub fn auto_refresh_interval(&self) -> std::time::Duration {
232 std::time::Duration::from_secs(self.config.auto_refresh_interval)
233 }
234
235 pub fn open_selected(&self) -> anyhow::Result<()> {
237 if let Some(pr) = self.selected_pr() {
238 crate::browser::open_url(&pr.url)?;
239 }
240 Ok(())
241 }
242
243 pub fn start_snooze_input(&mut self) {
245 if self.selected_pr().is_some() {
246 self.input_mode = InputMode::SnoozeInput;
247 self.snooze_input.clear();
248 }
249 }
250
251 pub fn confirm_snooze_input(&mut self) {
253 let (url, title) = match self.selected_pr() {
255 Some(pr) => (pr.url.clone(), pr.title.clone()),
256 None => {
257 self.input_mode = InputMode::Normal;
258 return;
259 }
260 };
261
262 let computed_until = if self.snooze_input.trim().is_empty() {
264 None
266 } else {
267 match humantime::parse_duration(&self.snooze_input) {
269 Ok(duration) => {
270 let until =
271 Utc::now() + chrono::Duration::from_std(duration).unwrap_or_default();
272 Some(until)
273 }
274 Err(_) => {
275 self.show_flash(format!("Invalid duration: '{}'", self.snooze_input));
276 self.input_mode = InputMode::Normal;
277 self.snooze_input.clear();
278 return;
279 }
280 }
281 };
282
283 let old_until = self
285 .snooze_state
286 .snoozed_entries()
287 .get(&url)
288 .and_then(|entry| entry.snooze_until);
289
290 self.snooze_state.snooze(url.clone(), computed_until);
292
293 if let Err(e) = crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state) {
295 self.show_flash(format!("Failed to save snooze state: {}", e));
296 self.input_mode = InputMode::Normal;
297 return;
298 }
299
300 match self.current_view {
302 View::Active => {
303 self.push_undo(UndoAction::Snoozed {
305 url: url.clone(),
306 title: title.clone(),
307 });
308
309 self.move_pr_between_lists(&url, true);
311
312 self.show_flash(format!("Snoozed: {} (z to undo)", title));
314 }
315 View::Snoozed => {
316 self.push_undo(UndoAction::Resnooze {
318 url: url.clone(),
319 title: title.clone(),
320 previous_until: old_until,
321 });
322
323 self.show_flash(format!("Re-snoozed: {} (z to undo)", title));
325 }
326 }
327
328 self.input_mode = InputMode::Normal;
330 self.snooze_input.clear();
331 }
332
333 pub fn cancel_snooze_input(&mut self) {
335 self.input_mode = InputMode::Normal;
336 self.snooze_input.clear();
337 }
338
339 pub fn unsnooze_selected(&mut self) {
341 if !matches!(self.current_view, View::Snoozed) {
342 return;
343 }
344
345 let (url, title, until) = match self.selected_pr() {
346 Some(pr) => {
347 let url = pr.url.clone();
348 let title = pr.title.clone();
349 let until = self
351 .snooze_state
352 .snoozed_entries()
353 .get(&url)
354 .and_then(|entry| entry.snooze_until);
355 (url, title, until)
356 }
357 None => return,
358 };
359
360 self.snooze_state.unsnooze(&url);
362
363 if let Err(e) = crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state) {
365 self.show_flash(format!("Failed to save snooze state: {}", e));
366 return;
367 }
368
369 self.push_undo(UndoAction::Unsnoozed {
371 url: url.clone(),
372 title: title.clone(),
373 until,
374 });
375
376 self.move_pr_between_lists(&url, false);
378
379 self.show_flash(format!("Unsnoozed: {} (z to undo)", title));
381 }
382
383 pub fn undo_last(&mut self) {
385 let action = match self.undo_stack.pop_front() {
386 Some(action) => action,
387 None => {
388 self.show_flash("Nothing to undo".to_string());
389 return;
390 }
391 };
392
393 match action {
394 UndoAction::Snoozed { url, title } => {
395 self.snooze_state.unsnooze(&url);
397
398 if let Err(e) =
400 crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state)
401 {
402 self.show_flash(format!("Failed to save snooze state: {}", e));
403 return;
404 }
405
406 self.move_pr_between_lists(&url, false);
408
409 self.show_flash(format!("Undid snooze: {}", title));
410 }
411 UndoAction::Unsnoozed { url, title, until } => {
412 self.snooze_state.snooze(url.clone(), until);
414
415 if let Err(e) =
417 crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state)
418 {
419 self.show_flash(format!("Failed to save snooze state: {}", e));
420 return;
421 }
422
423 self.move_pr_between_lists(&url, true);
425
426 self.show_flash(format!("Undid unsnooze: {}", title));
427 }
428 UndoAction::Resnooze {
429 url,
430 title,
431 previous_until,
432 } => {
433 self.snooze_state.snooze(url.clone(), previous_until);
435
436 if let Err(e) =
438 crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state)
439 {
440 self.show_flash(format!("Failed to save snooze state: {}", e));
441 return;
442 }
443
444 self.show_flash(format!("Undid re-snooze: {}", title));
446 }
447 }
448 }
449
450 fn move_pr_between_lists(&mut self, url: &str, from_active_to_snoozed: bool) {
456 let (source_list, dest_list) = if from_active_to_snoozed {
457 (&mut self.active_prs, &mut self.snoozed_prs)
458 } else {
459 (&mut self.snoozed_prs, &mut self.active_prs)
460 };
461
462 if let Some(pos) = source_list.iter().position(|(pr, _)| pr.url == url) {
464 let pr_entry = source_list.remove(pos);
465
466 let insert_pos = dest_list
468 .iter()
469 .position(|(_, score)| score.score < pr_entry.1.score)
470 .unwrap_or(dest_list.len());
471 dest_list.insert(insert_pos, pr_entry);
472
473 let current_list = self.current_prs();
475 if current_list.is_empty() {
476 self.table_state.select(None);
477 } else if let Some(selected) = self.table_state.selected() {
478 if selected >= current_list.len() {
479 self.table_state.select(Some(current_list.len() - 1));
480 }
481 }
482 }
483 }
484
485 pub fn toggle_view(&mut self) {
487 self.current_view = match self.current_view {
488 View::Active => View::Snoozed,
489 View::Snoozed => View::Active,
490 };
491
492 let prs = self.current_prs();
494 if prs.is_empty() {
495 self.table_state.select(None);
496 } else {
497 self.table_state.select(Some(0));
498 }
499 }
500
501 pub fn show_help(&mut self) {
503 self.input_mode = InputMode::Help;
504 }
505
506 pub fn dismiss_help(&mut self) {
508 self.input_mode = InputMode::Normal;
509 }
510
511 pub fn show_score_breakdown(&mut self) {
513 if self.selected_pr().is_some() {
514 self.input_mode = InputMode::ScoreBreakdown;
515 }
516 }
517
518 pub fn dismiss_score_breakdown(&mut self) {
520 self.input_mode = InputMode::Normal;
521 }
522
523 pub fn selected_score_result(&self) -> Option<&crate::scoring::ScoreResult> {
525 let prs = self.current_prs();
526 self.table_state
527 .selected()
528 .and_then(|i| prs.get(i).map(|(_, sr)| sr))
529 }
530
531 pub fn update_prs(
533 &mut self,
534 active: Vec<(PullRequest, ScoreResult)>,
535 snoozed: Vec<(PullRequest, ScoreResult)>,
536 rate_limit_remaining: Option<u64>,
537 ) {
538 self.active_prs = active;
540 self.snoozed_prs = snoozed;
541
542 self.rate_limit_remaining = rate_limit_remaining;
544
545 let current_list = self.current_prs();
547 if current_list.is_empty() {
548 self.table_state.select(None);
549 } else if let Some(selected) = self.table_state.selected() {
550 if selected >= current_list.len() {
552 self.table_state.select(Some(current_list.len() - 1));
553 }
554 } else {
555 self.table_state.select(Some(0));
557 }
558
559 if let Ok(loaded_state) = crate::snooze::load_snooze_state(&self.snooze_path) {
561 self.snooze_state = loaded_state;
562 }
563
564 self.last_refresh = Instant::now();
566
567 let active_count = self.active_prs.len();
569 let snoozed_count = self.snoozed_prs.len();
570 self.show_flash(format!(
571 "Refreshed ({} active, {} snoozed)",
572 active_count, snoozed_count
573 ));
574 }
575
576 pub fn advance_spinner(&mut self) {
578 self.spinner_frame = self.spinner_frame.wrapping_add(1);
579 }
580
581 pub fn set_version_status(&mut self, status: VersionStatus) {
583 self.version_status = status;
584 }
585
586 pub fn dismiss_update_banner(&mut self) {
588 if let VersionStatus::UpdateAvailable { latest, .. } = &self.version_status {
589 crate::version_check::dismiss_version(latest);
590 self.version_status = VersionStatus::UpToDate;
591 self.show_flash("Update notice dismissed".to_string());
592 }
593 }
594
595 pub fn has_update_banner(&self) -> bool {
597 matches!(self.version_status, VersionStatus::UpdateAvailable { .. })
598 }
599}