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