Skip to main content

pr_bro/tui/
app.rs

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    /// Create a new App with empty PR lists in loading state
130    /// Used for launching TUI before data arrives
131    #[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    /// Open the selected PR in the browser
248    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    /// Start snooze input mode (works on both Active and Snoozed views)
256    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    /// Confirm and apply the snooze input
264    pub fn confirm_snooze_input(&mut self) {
265        // Get selected PR info before mutating
266        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        // Parse duration from input
275        let computed_until = if self.snooze_input.trim().is_empty() {
276            // Empty string = indefinite snooze
277            None
278        } else {
279            // Parse duration string
280            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        // Capture old snooze_until before overwriting (needed for undo on re-snooze)
296        let old_until = self
297            .snooze_state
298            .snoozed_entries()
299            .get(&url)
300            .and_then(|entry| entry.snooze_until);
301
302        // Apply snooze
303        self.snooze_state.snooze(url.clone(), computed_until);
304
305        // Save to disk
306        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        // Branch behavior based on current view
313        match self.current_view {
314            View::Active => {
315                // Push to undo stack
316                self.push_undo(UndoAction::Snoozed {
317                    url: url.clone(),
318                    title: title.clone(),
319                });
320
321                // Move PR from active to snoozed
322                self.move_pr_between_lists(&url, true);
323
324                // Show flash message
325                self.show_flash(format!("Snoozed: {} (z to undo)", title));
326            }
327            View::Snoozed => {
328                // Push re-snooze to undo stack with previous duration
329                self.push_undo(UndoAction::Resnooze {
330                    url: url.clone(),
331                    title: title.clone(),
332                    previous_until: old_until,
333                });
334
335                // PR stays in snoozed list -- no move needed
336                self.show_flash(format!("Re-snoozed: {} (z to undo)", title));
337            }
338        }
339
340        // Return to normal mode
341        self.input_mode = InputMode::Normal;
342        self.snooze_input.clear();
343    }
344
345    /// Cancel snooze input
346    pub fn cancel_snooze_input(&mut self) {
347        self.input_mode = InputMode::Normal;
348        self.snooze_input.clear();
349    }
350
351    /// Unsnooze the selected PR (only works in Snoozed view)
352    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                // Look up snooze entry to get the until time for undo
362                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        // Unsnooze
373        self.snooze_state.unsnooze(&url);
374
375        // Save to disk
376        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        // Push to undo stack
382        self.push_undo(UndoAction::Unsnoozed {
383            url: url.clone(),
384            title: title.clone(),
385            until,
386        });
387
388        // Move PR from snoozed to active
389        self.move_pr_between_lists(&url, false);
390
391        // Show flash message
392        self.show_flash(format!("Unsnoozed: {} (z to undo)", title));
393    }
394
395    /// Undo the last snooze or unsnooze action
396    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                // Undo a snooze: unsnooze the PR
408                self.snooze_state.unsnooze(&url);
409
410                // Save to disk
411                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                // Move PR back from snoozed to active
419                self.move_pr_between_lists(&url, false);
420
421                self.show_flash(format!("Undid snooze: {}", title));
422            }
423            UndoAction::Unsnoozed { url, title, until } => {
424                // Undo an unsnooze: re-snooze the PR
425                self.snooze_state.snooze(url.clone(), until);
426
427                // Save to disk
428                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                // Move PR back from active to snoozed
436                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                // Undo a re-snooze: restore the previous snooze duration
446                self.snooze_state.snooze(url.clone(), previous_until);
447
448                // Save to disk
449                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                // PR stays in snoozed list -- no move needed
457                self.show_flash(format!("Undid re-snooze: {}", title));
458            }
459        }
460    }
461
462    /// Move a PR between active and snoozed lists
463    ///
464    /// # Arguments
465    /// * `url` - The URL of the PR to move
466    /// * `from_active_to_snoozed` - true to move from active to snoozed, false for the reverse
467    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        // Find and remove PR from source list
475        if let Some(pos) = source_list.iter().position(|(pr, _)| pr.url == url) {
476            let pr_entry = source_list.remove(pos);
477
478            // Insert into destination list, maintaining score-descending sort
479            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            // Fix table selection to stay valid
486            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    /// Toggle between Active and Snoozed views
498    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        // Reset selection to first item in the new view, or None if empty
505        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    /// Show help overlay
514    pub fn show_help(&mut self) {
515        self.input_mode = InputMode::Help;
516    }
517
518    /// Dismiss help overlay
519    pub fn dismiss_help(&mut self) {
520        self.input_mode = InputMode::Normal;
521    }
522
523    /// Show score breakdown overlay
524    pub fn show_score_breakdown(&mut self) {
525        if self.selected_pr().is_some() {
526            self.input_mode = InputMode::ScoreBreakdown;
527        }
528    }
529
530    /// Dismiss score breakdown overlay
531    pub fn dismiss_score_breakdown(&mut self) {
532        self.input_mode = InputMode::Normal;
533    }
534
535    /// Get the selected PR's ScoreResult
536    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    /// Update PRs with fresh data from fetch
544    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        // Replace PR lists
551        self.active_prs = active;
552        self.snoozed_prs = snoozed;
553
554        // Update rate limit info
555        self.rate_limit_remaining = rate_limit_remaining;
556
557        // Preserve selection if possible
558        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            // Clamp to new list length
563            if selected >= current_list.len() {
564                self.table_state.select(Some(current_list.len() - 1));
565            }
566        } else {
567            // No selection before, select first if list is non-empty
568            self.table_state.select(Some(0));
569        }
570
571        // Reload snooze state from disk (in case it was modified externally)
572        if let Ok(loaded_state) = crate::snooze::load_snooze_state(&self.snooze_path) {
573            self.snooze_state = loaded_state;
574        }
575
576        // Update refresh timestamp
577        self.last_refresh = Instant::now();
578
579        // Show flash message
580        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    /// Advance the loading spinner animation frame
589    pub fn advance_spinner(&mut self) {
590        self.spinner_frame = self.spinner_frame.wrapping_add(1);
591    }
592
593    /// Set the version check status
594    pub fn set_version_status(&mut self, status: VersionStatus) {
595        self.version_status = status;
596    }
597
598    /// Dismiss the update banner and persist the dismissal
599    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    /// Check if the update banner should be shown
608    pub fn has_update_banner(&self) -> bool {
609        matches!(self.version_status, VersionStatus::UpdateAvailable { .. })
610    }
611}