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::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    /// Create a new App with empty PR lists in loading state
122    /// Used for launching TUI before data arrives
123    #[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    /// Open the selected PR in the browser
236    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    /// Start snooze input mode (works on both Active and Snoozed views)
244    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    /// Confirm and apply the snooze input
252    pub fn confirm_snooze_input(&mut self) {
253        // Get selected PR info before mutating
254        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        // Parse duration from input
263        let computed_until = if self.snooze_input.trim().is_empty() {
264            // Empty string = indefinite snooze
265            None
266        } else {
267            // Parse duration string
268            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        // Capture old snooze_until before overwriting (needed for undo on re-snooze)
284        let old_until = self
285            .snooze_state
286            .snoozed_entries()
287            .get(&url)
288            .and_then(|entry| entry.snooze_until);
289
290        // Apply snooze
291        self.snooze_state.snooze(url.clone(), computed_until);
292
293        // Save to disk
294        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        // Branch behavior based on current view
301        match self.current_view {
302            View::Active => {
303                // Push to undo stack
304                self.push_undo(UndoAction::Snoozed {
305                    url: url.clone(),
306                    title: title.clone(),
307                });
308
309                // Move PR from active to snoozed
310                self.move_pr_between_lists(&url, true);
311
312                // Show flash message
313                self.show_flash(format!("Snoozed: {} (z to undo)", title));
314            }
315            View::Snoozed => {
316                // Push re-snooze to undo stack with previous duration
317                self.push_undo(UndoAction::Resnooze {
318                    url: url.clone(),
319                    title: title.clone(),
320                    previous_until: old_until,
321                });
322
323                // PR stays in snoozed list -- no move needed
324                self.show_flash(format!("Re-snoozed: {} (z to undo)", title));
325            }
326        }
327
328        // Return to normal mode
329        self.input_mode = InputMode::Normal;
330        self.snooze_input.clear();
331    }
332
333    /// Cancel snooze input
334    pub fn cancel_snooze_input(&mut self) {
335        self.input_mode = InputMode::Normal;
336        self.snooze_input.clear();
337    }
338
339    /// Unsnooze the selected PR (only works in Snoozed view)
340    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                // Look up snooze entry to get the until time for undo
350                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        // Unsnooze
361        self.snooze_state.unsnooze(&url);
362
363        // Save to disk
364        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        // Push to undo stack
370        self.push_undo(UndoAction::Unsnoozed {
371            url: url.clone(),
372            title: title.clone(),
373            until,
374        });
375
376        // Move PR from snoozed to active
377        self.move_pr_between_lists(&url, false);
378
379        // Show flash message
380        self.show_flash(format!("Unsnoozed: {} (z to undo)", title));
381    }
382
383    /// Undo the last snooze or unsnooze action
384    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                // Undo a snooze: unsnooze the PR
396                self.snooze_state.unsnooze(&url);
397
398                // Save to disk
399                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                // Move PR back from snoozed to active
407                self.move_pr_between_lists(&url, false);
408
409                self.show_flash(format!("Undid snooze: {}", title));
410            }
411            UndoAction::Unsnoozed { url, title, until } => {
412                // Undo an unsnooze: re-snooze the PR
413                self.snooze_state.snooze(url.clone(), until);
414
415                // Save to disk
416                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                // Move PR back from active to snoozed
424                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                // Undo a re-snooze: restore the previous snooze duration
434                self.snooze_state.snooze(url.clone(), previous_until);
435
436                // Save to disk
437                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                // PR stays in snoozed list -- no move needed
445                self.show_flash(format!("Undid re-snooze: {}", title));
446            }
447        }
448    }
449
450    /// Move a PR between active and snoozed lists
451    ///
452    /// # Arguments
453    /// * `url` - The URL of the PR to move
454    /// * `from_active_to_snoozed` - true to move from active to snoozed, false for the reverse
455    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        // Find and remove PR from source list
463        if let Some(pos) = source_list.iter().position(|(pr, _)| pr.url == url) {
464            let pr_entry = source_list.remove(pos);
465
466            // Insert into destination list, maintaining score-descending sort
467            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            // Fix table selection to stay valid
474            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    /// Toggle between Active and Snoozed views
486    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        // Reset selection to first item in the new view, or None if empty
493        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    /// Show help overlay
502    pub fn show_help(&mut self) {
503        self.input_mode = InputMode::Help;
504    }
505
506    /// Dismiss help overlay
507    pub fn dismiss_help(&mut self) {
508        self.input_mode = InputMode::Normal;
509    }
510
511    /// Show score breakdown overlay
512    pub fn show_score_breakdown(&mut self) {
513        if self.selected_pr().is_some() {
514            self.input_mode = InputMode::ScoreBreakdown;
515        }
516    }
517
518    /// Dismiss score breakdown overlay
519    pub fn dismiss_score_breakdown(&mut self) {
520        self.input_mode = InputMode::Normal;
521    }
522
523    /// Get the selected PR's ScoreResult
524    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    /// Update PRs with fresh data from fetch
532    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        // Replace PR lists
539        self.active_prs = active;
540        self.snoozed_prs = snoozed;
541
542        // Update rate limit info
543        self.rate_limit_remaining = rate_limit_remaining;
544
545        // Preserve selection if possible
546        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            // Clamp to new list length
551            if selected >= current_list.len() {
552                self.table_state.select(Some(current_list.len() - 1));
553            }
554        } else {
555            // No selection before, select first if list is non-empty
556            self.table_state.select(Some(0));
557        }
558
559        // Reload snooze state from disk (in case it was modified externally)
560        if let Ok(loaded_state) = crate::snooze::load_snooze_state(&self.snooze_path) {
561            self.snooze_state = loaded_state;
562        }
563
564        // Update refresh timestamp
565        self.last_refresh = Instant::now();
566
567        // Show flash message
568        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    /// Advance the loading spinner animation frame
577    pub fn advance_spinner(&mut self) {
578        self.spinner_frame = self.spinner_frame.wrapping_add(1);
579    }
580
581    /// Set the version check status
582    pub fn set_version_status(&mut self, status: VersionStatus) {
583        self.version_status = status;
584    }
585
586    /// Dismiss the update banner and persist the dismissal
587    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    /// Check if the update banner should be shown
596    pub fn has_update_banner(&self) -> bool {
597        matches!(self.version_status, VersionStatus::UpdateAvailable { .. })
598    }
599}