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}
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    /// Create a new App with empty PR lists in loading state
128    /// Used for launching TUI before data arrives
129    #[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    /// Open the selected PR in the browser
245    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    /// Start snooze input mode (works on both Active and Snoozed views)
253    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    /// Confirm and apply the snooze input
261    pub fn confirm_snooze_input(&mut self) {
262        // Get selected PR info before mutating
263        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        // Parse duration from input
272        let computed_until = if self.snooze_input.trim().is_empty() {
273            // Empty string = indefinite snooze
274            None
275        } else {
276            // Parse duration string
277            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        // Capture old snooze_until before overwriting (needed for undo on re-snooze)
293        let old_until = self
294            .snooze_state
295            .snoozed_entries()
296            .get(&url)
297            .and_then(|entry| entry.snooze_until);
298
299        // Apply snooze
300        self.snooze_state.snooze(url.clone(), computed_until);
301
302        // Save to disk
303        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        // Branch behavior based on current view
310        match self.current_view {
311            View::Active => {
312                // Push to undo stack
313                self.push_undo(UndoAction::Snoozed {
314                    url: url.clone(),
315                    title: title.clone(),
316                });
317
318                // Move PR from active to snoozed
319                self.move_pr_between_lists(&url, true);
320
321                // Show flash message
322                self.show_flash(format!("Snoozed: {} (z to undo)", title));
323            }
324            View::Snoozed => {
325                // Push re-snooze to undo stack with previous duration
326                self.push_undo(UndoAction::Resnooze {
327                    url: url.clone(),
328                    title: title.clone(),
329                    previous_until: old_until,
330                });
331
332                // PR stays in snoozed list -- no move needed
333                self.show_flash(format!("Re-snoozed: {} (z to undo)", title));
334            }
335        }
336
337        // Return to normal mode
338        self.input_mode = InputMode::Normal;
339        self.snooze_input.clear();
340    }
341
342    /// Cancel snooze input
343    pub fn cancel_snooze_input(&mut self) {
344        self.input_mode = InputMode::Normal;
345        self.snooze_input.clear();
346    }
347
348    /// Unsnooze the selected PR (only works in Snoozed view)
349    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                // Look up snooze entry to get the until time for undo
359                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        // Unsnooze
370        self.snooze_state.unsnooze(&url);
371
372        // Save to disk
373        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        // Push to undo stack
379        self.push_undo(UndoAction::Unsnoozed {
380            url: url.clone(),
381            title: title.clone(),
382            until,
383        });
384
385        // Move PR from snoozed to active
386        self.move_pr_between_lists(&url, false);
387
388        // Show flash message
389        self.show_flash(format!("Unsnoozed: {} (z to undo)", title));
390    }
391
392    /// Undo the last snooze or unsnooze action
393    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                // Undo a snooze: unsnooze the PR
405                self.snooze_state.unsnooze(&url);
406
407                // Save to disk
408                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                // Move PR back from snoozed to active
416                self.move_pr_between_lists(&url, false);
417
418                self.show_flash(format!("Undid snooze: {}", title));
419            }
420            UndoAction::Unsnoozed { url, title, until } => {
421                // Undo an unsnooze: re-snooze the PR
422                self.snooze_state.snooze(url.clone(), until);
423
424                // Save to disk
425                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                // Move PR back from active to snoozed
433                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                // Undo a re-snooze: restore the previous snooze duration
443                self.snooze_state.snooze(url.clone(), previous_until);
444
445                // Save to disk
446                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                // PR stays in snoozed list -- no move needed
454                self.show_flash(format!("Undid re-snooze: {}", title));
455            }
456        }
457    }
458
459    /// Move a PR between active and snoozed lists
460    ///
461    /// # Arguments
462    /// * `url` - The URL of the PR to move
463    /// * `from_active_to_snoozed` - true to move from active to snoozed, false for the reverse
464    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        // Find and remove PR from source list
472        if let Some(pos) = source_list.iter().position(|(pr, _)| pr.url == url) {
473            let pr_entry = source_list.remove(pos);
474
475            // Insert into destination list, maintaining score-descending sort
476            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            // Fix table selection to stay valid
483            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    /// Toggle between Active and Snoozed views
495    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        // Reset selection to first item in the new view, or None if empty
502        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    /// Show help overlay
511    pub fn show_help(&mut self) {
512        self.input_mode = InputMode::Help;
513    }
514
515    /// Dismiss help overlay
516    pub fn dismiss_help(&mut self) {
517        self.input_mode = InputMode::Normal;
518    }
519
520    /// Show score breakdown overlay
521    pub fn show_score_breakdown(&mut self) {
522        if self.selected_pr().is_some() {
523            self.input_mode = InputMode::ScoreBreakdown;
524        }
525    }
526
527    /// Dismiss score breakdown overlay
528    pub fn dismiss_score_breakdown(&mut self) {
529        self.input_mode = InputMode::Normal;
530    }
531
532    /// Get the selected PR's ScoreResult
533    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    /// Update PRs with fresh data from fetch
541    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        // Replace PR lists
548        self.active_prs = active;
549        self.snoozed_prs = snoozed;
550
551        // Update rate limit info
552        self.rate_limit_remaining = rate_limit_remaining;
553
554        // Preserve selection if possible
555        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            // Clamp to new list length
560            if selected >= current_list.len() {
561                self.table_state.select(Some(current_list.len() - 1));
562            }
563        } else {
564            // No selection before, select first if list is non-empty
565            self.table_state.select(Some(0));
566        }
567
568        // Reload snooze state from disk (in case it was modified externally)
569        if let Ok(loaded_state) = crate::snooze::load_snooze_state(&self.snooze_path) {
570            self.snooze_state = loaded_state;
571        }
572
573        // Update refresh timestamp
574        self.last_refresh = Instant::now();
575
576        // Show flash message
577        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    /// Advance the loading spinner animation frame
586    pub fn advance_spinner(&mut self) {
587        self.spinner_frame = self.spinner_frame.wrapping_add(1);
588    }
589
590    /// Set the version check status
591    pub fn set_version_status(&mut self, status: VersionStatus) {
592        self.version_status = status;
593    }
594
595    /// Dismiss the update banner and persist the dismissal
596    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    /// Check if the update banner should be shown
605    pub fn has_update_banner(&self) -> bool {
606        matches!(self.version_status, VersionStatus::UpdateAvailable { .. })
607    }
608}