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}
76
77impl App {
78 #[allow(clippy::too_many_arguments)]
79 pub fn new(
80 active_prs: Vec<(PullRequest, ScoreResult)>,
81 snoozed_prs: Vec<(PullRequest, ScoreResult)>,
82 snooze_state: SnoozeState,
83 snooze_path: PathBuf,
84 config: Config,
85 cache_config: CacheConfig,
86 cache_handle: Option<Arc<DiskCache>>,
87 verbose: bool,
88 auth_username: Option<String>,
89 no_version_check: bool,
90 theme: Theme,
91 ) -> Self {
92 let mut table_state = ratatui::widgets::TableState::default();
93 if !active_prs.is_empty() {
94 table_state.select(Some(0));
95 }
96
97 Self {
98 active_prs,
99 snoozed_prs,
100 table_state,
101 current_view: View::Active,
102 snooze_state,
103 snooze_path,
104 input_mode: InputMode::Normal,
105 snooze_input: String::new(),
106 flash_message: None,
107 undo_stack: VecDeque::new(),
108 last_refresh: Instant::now(),
109 needs_refresh: false,
110 force_refresh: false,
111 should_quit: false,
112 config,
113 cache_config,
114 cache_handle,
115 verbose,
116 is_loading: false,
117 spinner_frame: 0,
118 rate_limit_remaining: None,
119 auth_username,
120 version_status: VersionStatus::Unknown,
121 no_version_check,
122 theme,
123 theme_colors: ThemeColors::new(theme),
124 }
125 }
126
127 #[allow(clippy::too_many_arguments)]
130 pub fn new_loading(
131 snooze_state: SnoozeState,
132 snooze_path: PathBuf,
133 config: Config,
134 cache_config: CacheConfig,
135 cache_handle: Option<Arc<DiskCache>>,
136 verbose: bool,
137 auth_username: Option<String>,
138 no_version_check: bool,
139 theme: Theme,
140 ) -> Self {
141 Self {
142 active_prs: Vec::new(),
143 snoozed_prs: Vec::new(),
144 table_state: ratatui::widgets::TableState::default(),
145 current_view: View::Active,
146 snooze_state,
147 snooze_path,
148 input_mode: InputMode::Normal,
149 snooze_input: String::new(),
150 flash_message: None,
151 undo_stack: VecDeque::new(),
152 last_refresh: Instant::now(),
153 needs_refresh: false,
154 force_refresh: false,
155 should_quit: false,
156 config,
157 cache_config,
158 cache_handle,
159 verbose,
160 is_loading: true,
161 spinner_frame: 0,
162 rate_limit_remaining: None,
163 auth_username,
164 version_status: VersionStatus::Unknown,
165 no_version_check,
166 theme,
167 theme_colors: ThemeColors::new(theme),
168 }
169 }
170
171 pub fn current_prs(&self) -> &[(PullRequest, ScoreResult)] {
172 match self.current_view {
173 View::Active => &self.active_prs,
174 View::Snoozed => &self.snoozed_prs,
175 }
176 }
177
178 pub fn next_row(&mut self) {
179 let prs = self.current_prs();
180 if prs.is_empty() {
181 return;
182 }
183 let i = match self.table_state.selected() {
184 Some(i) => {
185 if i >= prs.len() - 1 {
186 0
187 } else {
188 i + 1
189 }
190 }
191 None => 0,
192 };
193 self.table_state.select(Some(i));
194 }
195
196 pub fn previous_row(&mut self) {
197 let prs = self.current_prs();
198 if prs.is_empty() {
199 return;
200 }
201 let i = match self.table_state.selected() {
202 Some(i) => {
203 if i == 0 {
204 prs.len() - 1
205 } else {
206 i - 1
207 }
208 }
209 None => 0,
210 };
211 self.table_state.select(Some(i));
212 }
213
214 pub fn selected_pr(&self) -> Option<&PullRequest> {
215 let prs = self.current_prs();
216 self.table_state
217 .selected()
218 .and_then(|i| prs.get(i).map(|(pr, _)| pr))
219 }
220
221 pub fn push_undo(&mut self, action: UndoAction) {
222 self.undo_stack.push_front(action);
223 if self.undo_stack.len() > MAX_UNDO {
224 self.undo_stack.pop_back();
225 }
226 }
227
228 pub fn update_flash(&mut self) {
229 if let Some((_, timestamp)) = self.flash_message {
230 if timestamp.elapsed().as_secs() >= 3 {
231 self.flash_message = None;
232 }
233 }
234 }
235
236 pub fn show_flash(&mut self, msg: String) {
237 self.flash_message = Some((msg, Instant::now()));
238 }
239
240 pub fn auto_refresh_interval(&self) -> std::time::Duration {
241 std::time::Duration::from_secs(self.config.auto_refresh_interval)
242 }
243
244 pub fn open_selected(&self) -> anyhow::Result<()> {
246 if let Some(pr) = self.selected_pr() {
247 crate::browser::open_url(&pr.url)?;
248 }
249 Ok(())
250 }
251
252 pub fn start_snooze_input(&mut self) {
254 if self.selected_pr().is_some() {
255 self.input_mode = InputMode::SnoozeInput;
256 self.snooze_input.clear();
257 }
258 }
259
260 pub fn confirm_snooze_input(&mut self) {
262 let (url, title) = match self.selected_pr() {
264 Some(pr) => (pr.url.clone(), pr.title.clone()),
265 None => {
266 self.input_mode = InputMode::Normal;
267 return;
268 }
269 };
270
271 let computed_until = if self.snooze_input.trim().is_empty() {
273 None
275 } else {
276 match humantime::parse_duration(&self.snooze_input) {
278 Ok(duration) => {
279 let until =
280 Utc::now() + chrono::Duration::from_std(duration).unwrap_or_default();
281 Some(until)
282 }
283 Err(_) => {
284 self.show_flash(format!("Invalid duration: '{}'", self.snooze_input));
285 self.input_mode = InputMode::Normal;
286 self.snooze_input.clear();
287 return;
288 }
289 }
290 };
291
292 let old_until = self
294 .snooze_state
295 .snoozed_entries()
296 .get(&url)
297 .and_then(|entry| entry.snooze_until);
298
299 self.snooze_state.snooze(url.clone(), computed_until);
301
302 if let Err(e) = crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state) {
304 self.show_flash(format!("Failed to save snooze state: {}", e));
305 self.input_mode = InputMode::Normal;
306 return;
307 }
308
309 match self.current_view {
311 View::Active => {
312 self.push_undo(UndoAction::Snoozed {
314 url: url.clone(),
315 title: title.clone(),
316 });
317
318 self.move_pr_between_lists(&url, true);
320
321 self.show_flash(format!("Snoozed: {} (z to undo)", title));
323 }
324 View::Snoozed => {
325 self.push_undo(UndoAction::Resnooze {
327 url: url.clone(),
328 title: title.clone(),
329 previous_until: old_until,
330 });
331
332 self.show_flash(format!("Re-snoozed: {} (z to undo)", title));
334 }
335 }
336
337 self.input_mode = InputMode::Normal;
339 self.snooze_input.clear();
340 }
341
342 pub fn cancel_snooze_input(&mut self) {
344 self.input_mode = InputMode::Normal;
345 self.snooze_input.clear();
346 }
347
348 pub fn unsnooze_selected(&mut self) {
350 if !matches!(self.current_view, View::Snoozed) {
351 return;
352 }
353
354 let (url, title, until) = match self.selected_pr() {
355 Some(pr) => {
356 let url = pr.url.clone();
357 let title = pr.title.clone();
358 let until = self
360 .snooze_state
361 .snoozed_entries()
362 .get(&url)
363 .and_then(|entry| entry.snooze_until);
364 (url, title, until)
365 }
366 None => return,
367 };
368
369 self.snooze_state.unsnooze(&url);
371
372 if let Err(e) = crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state) {
374 self.show_flash(format!("Failed to save snooze state: {}", e));
375 return;
376 }
377
378 self.push_undo(UndoAction::Unsnoozed {
380 url: url.clone(),
381 title: title.clone(),
382 until,
383 });
384
385 self.move_pr_between_lists(&url, false);
387
388 self.show_flash(format!("Unsnoozed: {} (z to undo)", title));
390 }
391
392 pub fn undo_last(&mut self) {
394 let action = match self.undo_stack.pop_front() {
395 Some(action) => action,
396 None => {
397 self.show_flash("Nothing to undo".to_string());
398 return;
399 }
400 };
401
402 match action {
403 UndoAction::Snoozed { url, title } => {
404 self.snooze_state.unsnooze(&url);
406
407 if let Err(e) =
409 crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state)
410 {
411 self.show_flash(format!("Failed to save snooze state: {}", e));
412 return;
413 }
414
415 self.move_pr_between_lists(&url, false);
417
418 self.show_flash(format!("Undid snooze: {}", title));
419 }
420 UndoAction::Unsnoozed { url, title, until } => {
421 self.snooze_state.snooze(url.clone(), until);
423
424 if let Err(e) =
426 crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state)
427 {
428 self.show_flash(format!("Failed to save snooze state: {}", e));
429 return;
430 }
431
432 self.move_pr_between_lists(&url, true);
434
435 self.show_flash(format!("Undid unsnooze: {}", title));
436 }
437 UndoAction::Resnooze {
438 url,
439 title,
440 previous_until,
441 } => {
442 self.snooze_state.snooze(url.clone(), previous_until);
444
445 if let Err(e) =
447 crate::snooze::save_snooze_state(&self.snooze_path, &self.snooze_state)
448 {
449 self.show_flash(format!("Failed to save snooze state: {}", e));
450 return;
451 }
452
453 self.show_flash(format!("Undid re-snooze: {}", title));
455 }
456 }
457 }
458
459 fn move_pr_between_lists(&mut self, url: &str, from_active_to_snoozed: bool) {
465 let (source_list, dest_list) = if from_active_to_snoozed {
466 (&mut self.active_prs, &mut self.snoozed_prs)
467 } else {
468 (&mut self.snoozed_prs, &mut self.active_prs)
469 };
470
471 if let Some(pos) = source_list.iter().position(|(pr, _)| pr.url == url) {
473 let pr_entry = source_list.remove(pos);
474
475 let insert_pos = dest_list
477 .iter()
478 .position(|(_, score)| score.score < pr_entry.1.score)
479 .unwrap_or(dest_list.len());
480 dest_list.insert(insert_pos, pr_entry);
481
482 let current_list = self.current_prs();
484 if current_list.is_empty() {
485 self.table_state.select(None);
486 } else if let Some(selected) = self.table_state.selected() {
487 if selected >= current_list.len() {
488 self.table_state.select(Some(current_list.len() - 1));
489 }
490 }
491 }
492 }
493
494 pub fn toggle_view(&mut self) {
496 self.current_view = match self.current_view {
497 View::Active => View::Snoozed,
498 View::Snoozed => View::Active,
499 };
500
501 let prs = self.current_prs();
503 if prs.is_empty() {
504 self.table_state.select(None);
505 } else {
506 self.table_state.select(Some(0));
507 }
508 }
509
510 pub fn show_help(&mut self) {
512 self.input_mode = InputMode::Help;
513 }
514
515 pub fn dismiss_help(&mut self) {
517 self.input_mode = InputMode::Normal;
518 }
519
520 pub fn show_score_breakdown(&mut self) {
522 if self.selected_pr().is_some() {
523 self.input_mode = InputMode::ScoreBreakdown;
524 }
525 }
526
527 pub fn dismiss_score_breakdown(&mut self) {
529 self.input_mode = InputMode::Normal;
530 }
531
532 pub fn selected_score_result(&self) -> Option<&crate::scoring::ScoreResult> {
534 let prs = self.current_prs();
535 self.table_state
536 .selected()
537 .and_then(|i| prs.get(i).map(|(_, sr)| sr))
538 }
539
540 pub fn update_prs(
542 &mut self,
543 active: Vec<(PullRequest, ScoreResult)>,
544 snoozed: Vec<(PullRequest, ScoreResult)>,
545 rate_limit_remaining: Option<u64>,
546 ) {
547 self.active_prs = active;
549 self.snoozed_prs = snoozed;
550
551 self.rate_limit_remaining = rate_limit_remaining;
553
554 let current_list = self.current_prs();
556 if current_list.is_empty() {
557 self.table_state.select(None);
558 } else if let Some(selected) = self.table_state.selected() {
559 if selected >= current_list.len() {
561 self.table_state.select(Some(current_list.len() - 1));
562 }
563 } else {
564 self.table_state.select(Some(0));
566 }
567
568 if let Ok(loaded_state) = crate::snooze::load_snooze_state(&self.snooze_path) {
570 self.snooze_state = loaded_state;
571 }
572
573 self.last_refresh = Instant::now();
575
576 let active_count = self.active_prs.len();
578 let snoozed_count = self.snoozed_prs.len();
579 self.show_flash(format!(
580 "Refreshed ({} active, {} snoozed)",
581 active_count, snoozed_count
582 ));
583 }
584
585 pub fn advance_spinner(&mut self) {
587 self.spinner_frame = self.spinner_frame.wrapping_add(1);
588 }
589
590 pub fn set_version_status(&mut self, status: VersionStatus) {
592 self.version_status = status;
593 }
594
595 pub fn dismiss_update_banner(&mut self) {
597 if let VersionStatus::UpdateAvailable { latest, .. } = &self.version_status {
598 crate::version_check::dismiss_version(latest);
599 self.version_status = VersionStatus::UpToDate;
600 self.show_flash("Update notice dismissed".to_string());
601 }
602 }
603
604 pub fn has_update_banner(&self) -> bool {
606 matches!(self.version_status, VersionStatus::UpdateAvailable { .. })
607 }
608}