Skip to main content

gitv_tui/ui/components/
issue_list.rs

1use crate::{
2    app::GITHUB_CLIENT,
3    bookmarks::Bookmarks,
4    errors::AppError,
5    ui::{
6        Action, CloseIssueReason, MergeStrategy,
7        components::{
8            Component, help::HelpElementKind, issue_conversation::IssueConversationSeed,
9            issue_detail::IssuePreviewSeed,
10        },
11        issue_data::{IssueId, UiIssue, UiIssuePool},
12        layout::Layout,
13        utils::get_border_style,
14    },
15};
16use anyhow::anyhow;
17use async_trait::async_trait;
18use octocrab::{
19    Page,
20    issues::IssueHandler,
21    models::{IssueState, issues::Issue},
22};
23use rat_widget::{
24    event::{HandleEvent, ct_event},
25    focus::{HasFocus, Navigation},
26    list::selection::RowSelection,
27    text_input::TextInputState,
28};
29use ratatui::{
30    buffer::Buffer,
31    layout::{Constraint, Rect},
32    style::{Color, Modifier, Style, Stylize},
33    symbols,
34    text::Line,
35    widgets::{
36        Block, Clear, List as TuiList, ListItem, ListState as TuiListState, Padding,
37        StatefulWidget, Widget,
38    },
39};
40use ratatui_macros::{line, span, vertical};
41use ratatui_toaster::{ToastPosition, ToastType};
42use std::{
43    collections::{HashMap, HashSet},
44    sync::{
45        Arc, RwLock,
46        atomic::{AtomicU32, Ordering},
47    },
48};
49use textwrap::{Options, wrap};
50use throbber_widgets_tui::{BRAILLE_SIX_DOUBLE, Throbber, ThrobberState, WhichUse};
51use tokio::sync::oneshot;
52use tokio_util::sync::CancellationToken;
53use tracing::trace;
54
55pub static LOADED_ISSUE_COUNT: AtomicU32 = AtomicU32::new(0);
56const DETAILS_PREVIEW_WINDOW_SIZE: usize = 5;
57pub const HELP: &[HelpElementKind] = &[
58    crate::help_text!("Issue List Help"),
59    crate::help_keybind!("Up/Down", "navigate issues"),
60    crate::help_keybind!("Enter", "view issue details"),
61    crate::help_keybind!("b", "toggle bookmark"),
62    crate::help_keybind!("B", "open bookmark finder"),
63    crate::help_keybind!("C", "close selected issue"),
64    crate::help_keybind!("l", "copy issue link to clipboard"),
65    crate::help_keybind!("Enter (bookmark popup)", "open selected bookmark"),
66    crate::help_keybind!("Esc (bookmark popup)", "close bookmark popup"),
67    crate::help_keybind!("Enter (popup)", "confirm close reason"),
68    crate::help_keybind!("a", "add assignee(s)"),
69    crate::help_keybind!("A", "remove assignee(s)"),
70    crate::help_keybind!("n", "create new issue"),
71    crate::help_keybind!("Esc", "cancel popup / assign input"),
72];
73pub struct IssueList<'a> {
74    pub issues: Vec<IssueListItem>,
75    pub page: Option<Arc<Page<Issue>>>,
76    issue_pool: Arc<RwLock<UiIssuePool>>,
77    pub list_state: rat_widget::list::ListState<RowSelection>,
78    pub handler: IssueHandler<'a>,
79    pub action_tx: Option<tokio::sync::mpsc::Sender<crate::ui::Action>>,
80    pub throbber_state: ThrobberState,
81    pub assign_throbber_state: ThrobberState,
82    pub assign_input_state: rat_widget::text_input::TextInputState,
83    bookmarks: Arc<RwLock<Bookmarks>>,
84    assign_loading: bool,
85    assign_done_rx: Option<oneshot::Receiver<()>>,
86    close_popup: Option<IssueClosePopupState>,
87    close_error: Option<String>,
88    bookmark_popup: Option<BookmarkPopupState>,
89    bookmark_titles: HashMap<u64, Arc<str>>,
90    bookmark_title_errors: HashMap<u64, Arc<str>>,
91    bookmark_error: Option<String>,
92    pub owner: String,
93    pub repo: String,
94    index: usize,
95    state: LoadingState,
96    inner_state: IssueListState,
97    assignment_mode: AssignmentMode,
98    pub screen: MainScreen,
99}
100
101#[derive(Debug)]
102pub(crate) struct IssueClosePopupState {
103    pub(crate) issue_number: u64,
104    pub(crate) loading: bool,
105    pub(crate) throbber_state: ThrobberState,
106    pub(crate) error: Option<String>,
107    reason_state: TuiListState,
108}
109
110#[derive(Debug)]
111struct BookmarkPopupState {
112    issue_numbers: Vec<u64>,
113    state: TuiListState,
114    loading_numbers: HashSet<u64>,
115    fetch_cancel: CancellationToken,
116    throbber_state: ThrobberState,
117    opening_issue: Option<u64>,
118}
119
120impl IssueClosePopupState {
121    pub(crate) fn new(issue_number: u64) -> Self {
122        let mut reason_state = TuiListState::default();
123        reason_state.select(Some(0));
124        Self {
125            issue_number,
126            loading: false,
127            throbber_state: ThrobberState::default(),
128            error: None,
129            reason_state,
130        }
131    }
132
133    pub(crate) fn select_next_reason(&mut self) {
134        self.reason_state.select_next();
135    }
136
137    pub(crate) fn select_prev_reason(&mut self) {
138        self.reason_state.select_previous();
139    }
140
141    pub(crate) fn selected_reason(&self) -> CloseIssueReason {
142        self.reason_state
143            .selected()
144            .and_then(|idx| CloseIssueReason::ALL.get(idx).copied())
145            .unwrap_or(CloseIssueReason::Completed)
146    }
147}
148
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
150enum IssueListState {
151    #[default]
152    Normal,
153    AssigningInput,
154}
155
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
157enum AssignmentMode {
158    #[default]
159    Add,
160    Remove,
161}
162
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
164enum LoadingState {
165    #[default]
166    Loading,
167    Loaded,
168}
169
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
171pub enum MainScreen {
172    #[default]
173    List,
174    Details,
175    DetailsFullscreen,
176    CreateIssue,
177}
178
179impl<'a> IssueList<'a> {
180    async fn send_issue_list_preview_for_number(&self, number: u64) -> Result<(), AppError> {
181        let issue_ids = {
182            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
183            build_details_preview_issue_ids(&self.issues, &pool, number).unwrap_or_default()
184        };
185
186        if let Some(action_tx) = self.action_tx.as_ref() {
187            action_tx
188                .send(Action::IssueListPreviewUpdated {
189                    issue_ids,
190                    selected_number: number,
191                })
192                .await?;
193        }
194        Ok(())
195    }
196
197    pub async fn new(
198        handler: IssueHandler<'a>,
199        owner: String,
200        repo: String,
201        tx: tokio::sync::mpsc::Sender<Action>,
202        bookmarks: Arc<RwLock<Bookmarks>>,
203        issue_pool: Arc<RwLock<UiIssuePool>>,
204    ) -> Self {
205        LOADED_ISSUE_COUNT.store(0, Ordering::Relaxed);
206        let owner_clone = owner.clone();
207        let repo_clone = repo.clone();
208        tokio::spawn(async move {
209            let Some(client) = GITHUB_CLIENT.get() else {
210                return;
211            };
212            let Ok(p) = client
213                .inner()
214                .search()
215                .issues_and_pull_requests(&format!(
216                    "repo:{}/{} is:issue is:open",
217                    owner_clone, repo_clone
218                ))
219                .page(1u32)
220                .per_page(15u8)
221                .send()
222                .await
223            else {
224                return;
225            };
226
227            let _ = tx
228                .send(Action::NewPage(Arc::new(p), MergeStrategy::Append))
229                .await;
230        });
231        Self {
232            page: None,
233            issue_pool,
234            owner,
235            bookmarks,
236            repo,
237            throbber_state: ThrobberState::default(),
238            action_tx: None,
239            issues: vec![],
240            list_state: rat_widget::list::ListState::default(),
241            assign_throbber_state: ThrobberState::default(),
242            assign_input_state: TextInputState::default(),
243            assign_loading: false,
244            assign_done_rx: None,
245            close_popup: None,
246            close_error: None,
247            bookmark_popup: None,
248            bookmark_titles: HashMap::new(),
249            bookmark_title_errors: HashMap::new(),
250            bookmark_error: None,
251            handler,
252            index: 0,
253            screen: MainScreen::default(),
254            state: LoadingState::default(),
255            inner_state: IssueListState::default(),
256            assignment_mode: AssignmentMode::default(),
257        }
258    }
259
260    fn open_close_popup(&mut self) {
261        let Some(selected) = self.list_state.selected_checked() else {
262            self.close_error = Some("No issue selected.".to_string());
263            return;
264        };
265        let Some(issue_id) = self.issues.get(selected).map(|item| item.0) else {
266            self.close_error = Some("No issue selected.".to_string());
267            return;
268        };
269        let issue = {
270            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
271            pool.get_issue(issue_id).clone()
272        };
273        if issue.state == IssueState::Closed {
274            self.close_error = Some("Selected issue is already closed.".to_string());
275            return;
276        }
277        self.close_error = None;
278        self.close_popup = Some(IssueClosePopupState::new(issue.number));
279    }
280
281    fn render_close_popup(&mut self, area: Rect, buf: &mut Buffer) {
282        let Some(popup) = self.close_popup.as_mut() else {
283            return;
284        };
285        render_issue_close_popup(popup, area, buf);
286    }
287
288    async fn submit_close_popup(&mut self) {
289        let Some(popup) = self.close_popup.as_mut() else {
290            return;
291        };
292        if popup.loading {
293            return;
294        }
295        let reason = popup.selected_reason();
296        let number = popup.issue_number;
297        popup.loading = true;
298        popup.error = None;
299
300        let Some(action_tx) = self.action_tx.clone() else {
301            popup.loading = false;
302            popup.error = Some("Action channel unavailable.".to_string());
303            return;
304        };
305        let owner = self.owner.clone();
306        let repo = self.repo.clone();
307        let issue_pool = self.issue_pool.clone();
308        tokio::spawn(async move {
309            let Some(client) = GITHUB_CLIENT.get() else {
310                let _ = action_tx
311                    .send(Action::IssueCloseError {
312                        number,
313                        message: "GitHub client not initialized.".to_string(),
314                    })
315                    .await;
316                return;
317            };
318            let issues = client.inner().issues(owner, repo);
319            match issues
320                .update(number)
321                .state(IssueState::Closed)
322                .state_reason(reason.to_octocrab())
323                .send()
324                .await
325            {
326                Ok(issue) => {
327                    let issue_id = {
328                        let mut pool = issue_pool.write().expect("issue pool lock poisoned");
329                        let compact = UiIssue::from_octocrab(&issue, &mut pool);
330                        pool.upsert_issue(compact)
331                    };
332                    let _ = action_tx.send(Action::IssueCloseSuccess { issue_id }).await;
333                }
334                Err(err) => {
335                    let _ = action_tx
336                        .send(Action::IssueCloseError {
337                            number,
338                            message: err.to_string().replace('\n', " "),
339                        })
340                        .await;
341                }
342            }
343        });
344    }
345
346    async fn handle_close_popup_event(&mut self, event: &crossterm::event::Event) -> bool {
347        let Some(popup) = self.close_popup.as_mut() else {
348            return false;
349        };
350        if popup.loading {
351            if matches!(event, ct_event!(keycode press Esc)) {
352                popup.loading = false;
353            }
354            return true;
355        }
356        if matches!(event, ct_event!(keycode press Esc)) {
357            self.close_popup = None;
358            return true;
359        }
360        if matches!(event, ct_event!(keycode press Up)) {
361            popup.select_prev_reason();
362            return true;
363        }
364        if matches!(event, ct_event!(keycode press Down)) {
365            popup.select_next_reason();
366            return true;
367        }
368        if matches!(event, ct_event!(keycode press Enter)) {
369            self.submit_close_popup().await;
370            return true;
371        }
372        true
373    }
374
375    fn open_bookmark_popup(&mut self) {
376        let mut issue_numbers = {
377            let bookmarks = self.bookmarks.read().expect("bookmarks lock poisoned");
378            bookmarks.get_bookmarked_issues(&self.owner, &self.repo)
379        };
380        if issue_numbers.is_empty() {
381            self.bookmark_error = Some("No bookmarks found for this repository.".to_string());
382            return;
383        }
384
385        issue_numbers.sort_unstable();
386        let mut state = TuiListState::default();
387        state.select(Some(0));
388        self.list_state.focus.set(false);
389        self.bookmark_error = None;
390        self.bookmark_popup = Some(BookmarkPopupState {
391            issue_numbers,
392            state,
393            loading_numbers: HashSet::new(),
394            fetch_cancel: CancellationToken::new(),
395            throbber_state: ThrobberState::default(),
396            opening_issue: None,
397        });
398        self.ensure_bookmark_titles_for_window();
399    }
400
401    fn close_bookmark_popup(&mut self) {
402        if let Some(popup) = self.bookmark_popup.take() {
403            popup.fetch_cancel.cancel();
404        }
405        if self.screen == MainScreen::List {
406            self.list_state.focus.set(true);
407        }
408    }
409
410    fn selected_bookmark_number(&self) -> Option<u64> {
411        let popup = self.bookmark_popup.as_ref()?;
412        let selected = popup.state.selected()?;
413        popup.issue_numbers.get(selected).copied()
414    }
415
416    fn ensure_bookmark_titles_for_window(&mut self) {
417        let Some(popup) = self.bookmark_popup.as_ref() else {
418            return;
419        };
420        if popup.issue_numbers.is_empty() {
421            return;
422        }
423        let selected = popup.state.selected().unwrap_or(0);
424        let start = selected.saturating_sub(4);
425        let end = selected
426            .saturating_add(5)
427            .min(popup.issue_numbers.len().saturating_sub(1));
428        let to_request = popup.issue_numbers[start..=end]
429            .iter()
430            .copied()
431            .filter(|number| {
432                !self.bookmark_titles.contains_key(number)
433                    && !self.bookmark_title_errors.contains_key(number)
434                    && !popup.loading_numbers.contains(number)
435            })
436            .collect::<Vec<_>>();
437        for number in to_request {
438            self.fetch_bookmark_title(number);
439        }
440    }
441
442    fn fetch_bookmark_title(&mut self, number: u64) {
443        let Some(popup) = self.bookmark_popup.as_mut() else {
444            return;
445        };
446        if !popup.loading_numbers.insert(number) {
447            return;
448        }
449        let Some(action_tx) = self.action_tx.clone() else {
450            popup.loading_numbers.remove(&number);
451            return;
452        };
453        let owner = self.owner.clone();
454        let repo = self.repo.clone();
455        let cancel = popup.fetch_cancel.clone();
456        tokio::spawn(async move {
457            let Some(client) = GITHUB_CLIENT.get() else {
458                let _ = action_tx
459                    .send(Action::BookmarkTitleLoadError {
460                        number,
461                        message: Arc::<str>::from("GitHub client not initialized."),
462                    })
463                    .await;
464                return;
465            };
466            let issues = client.inner().issues(owner, repo);
467            let title_result = tokio::select! {
468                _ = cancel.cancelled() => {
469                    return;
470                }
471                result = issues.get(number) => {
472                    result
473                }
474            };
475
476            match title_result {
477                Ok(issue) => {
478                    let _ = action_tx
479                        .send(Action::BookmarkTitleLoaded {
480                            number,
481                            title: Arc::<str>::from(issue.title),
482                        })
483                        .await;
484                }
485                Err(err) => {
486                    let _ = action_tx
487                        .send(Action::BookmarkTitleLoadError {
488                            number,
489                            message: Arc::<str>::from(err.to_string().replace('\n', " ")),
490                        })
491                        .await;
492                }
493            }
494        });
495    }
496
497    async fn open_selected_bookmark(&mut self) -> Result<(), AppError> {
498        let Some(number) = self.selected_bookmark_number() else {
499            return Ok(());
500        };
501
502        if let Some((labels, preview_seed, conversation_seed)) = {
503            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
504            self.issues.iter().find_map(|item| {
505                let issue = pool.get_issue(item.0);
506                (issue.number == number).then_some((
507                    issue.labels.clone(),
508                    IssuePreviewSeed::from_ui_issue(issue, &pool),
509                    IssueConversationSeed::from_ui_issue(issue, &pool),
510                ))
511            })
512        } {
513            self.close_bookmark_popup();
514            if let Some(action_tx) = self.action_tx.clone() {
515                action_tx
516                    .send(Action::SelectedIssue { number, labels })
517                    .await?;
518                action_tx
519                    .send(Action::SelectedIssuePreview { seed: preview_seed })
520                    .await?;
521                self.send_issue_list_preview_for_number(number).await?;
522                action_tx
523                    .send(Action::EnterIssueDetails {
524                        seed: conversation_seed,
525                    })
526                    .await?;
527                action_tx
528                    .send(Action::ChangeIssueScreen(MainScreen::Details))
529                    .await?;
530            }
531            return Ok(());
532        }
533
534        let Some(popup) = self.bookmark_popup.as_mut() else {
535            return Ok(());
536        };
537        if popup.opening_issue == Some(number) {
538            return Ok(());
539        }
540        popup.opening_issue = Some(number);
541        let Some(action_tx) = self.action_tx.clone() else {
542            popup.opening_issue = None;
543            return Ok(());
544        };
545        let owner = self.owner.clone();
546        let repo = self.repo.clone();
547        let cancel = popup.fetch_cancel.clone();
548        let issue_pool = self.issue_pool.clone();
549        tokio::spawn(async move {
550            let Some(client) = GITHUB_CLIENT.get() else {
551                let _ = action_tx
552                    .send(Action::BookmarkedIssueLoadError {
553                        number,
554                        message: Arc::<str>::from("GitHub client not initialized."),
555                    })
556                    .await;
557                return;
558            };
559            let issues = client.inner().issues(owner, repo);
560            let issue_result = tokio::select! {
561                _ = cancel.cancelled() => {
562                    return;
563                }
564                result = issues.get(number) => {
565                    result
566                }
567            };
568            match issue_result {
569                Ok(issue) => {
570                    let issue_id = {
571                        let mut pool = issue_pool.write().expect("issue pool lock poisoned");
572                        let compact = UiIssue::from_octocrab(&issue, &mut pool);
573                        pool.upsert_issue(compact)
574                    };
575                    let _ = action_tx
576                        .send(Action::BookmarkedIssueLoaded { issue_id })
577                        .await;
578                }
579                Err(err) => {
580                    let _ = action_tx
581                        .send(Action::BookmarkedIssueLoadError {
582                            number,
583                            message: Arc::<str>::from(err.to_string().replace('\n', " ")),
584                        })
585                        .await;
586                }
587            }
588        });
589        Ok(())
590    }
591
592    async fn handle_bookmark_popup_event(
593        &mut self,
594        event: &crossterm::event::Event,
595    ) -> Result<bool, AppError> {
596        let Some(_) = self.bookmark_popup.as_ref() else {
597            return Ok(false);
598        };
599
600        if matches!(event, ct_event!(keycode press Esc)) {
601            self.close_bookmark_popup();
602            return Ok(true);
603        }
604        if matches!(event, ct_event!(keycode press Enter)) {
605            self.open_selected_bookmark().await?;
606            return Ok(true);
607        }
608
609        if let Some(popup) = self.bookmark_popup.as_mut() {
610            if matches!(event, ct_event!(keycode press Up)) {
611                popup.state.select_previous();
612                self.ensure_bookmark_titles_for_window();
613                return Ok(true);
614            }
615            if matches!(event, ct_event!(keycode press Down)) {
616                popup.state.select_next();
617                self.ensure_bookmark_titles_for_window();
618                return Ok(true);
619            }
620            return Ok(true);
621        }
622
623        Ok(true)
624    }
625
626    fn render_bookmark_popup_item(
627        number: u64,
628        width: usize,
629        bookmark_titles: &HashMap<u64, Arc<str>>,
630        bookmark_title_errors: &HashMap<u64, Arc<str>>,
631    ) -> ListItem<'static> {
632        let width = width.max(10);
633        let (content, style) = if let Some(title) = bookmark_titles.get(&number) {
634            (format!("#{number} {title}"), Style::default())
635        } else if let Some(err) = bookmark_title_errors.get(&number) {
636            (
637                format!("#{number} Failed to load title: {err}"),
638                Style::default().fg(Color::LightRed),
639            )
640        } else {
641            (format!("#{number} Title pending"), Style::default().dim())
642        };
643
644        let lines = wrap(content.as_str(), Options::new(width))
645            .into_iter()
646            .map(|line| Line::from(line.into_owned()))
647            .collect::<Vec<_>>();
648        ListItem::new(lines).style(style)
649    }
650
651    fn render_bookmark_popup(&mut self, area: Rect, buf: &mut Buffer) {
652        let Some(popup) = self.bookmark_popup.as_mut() else {
653            return;
654        };
655
656        let popup_area = area.centered(Constraint::Percentage(50), Constraint::Percentage(30));
657        Clear.render(popup_area, buf);
658        let mut title = "Bookmarks | Enter: open Esc: close".to_string();
659        if !popup.loading_numbers.is_empty() {
660            title.push_str(&format!(" | Loading {}", popup.loading_numbers.len()));
661        }
662        if let Some(number) = popup.opening_issue {
663            title.push_str(&format!(" | Opening #{number}..."));
664        }
665        let block = Block::bordered()
666            .border_type(ratatui::widgets::BorderType::Rounded)
667            .title(title);
668        let inner = block.inner(popup_area);
669
670        let wrap_width = inner.width.saturating_sub(3).max(10) as usize;
671        let title_cache = &self.bookmark_titles;
672        let title_errors = &self.bookmark_title_errors;
673        let list = TuiList::new(popup.issue_numbers.iter().copied().map(|number| {
674            Self::render_bookmark_popup_item(number, wrap_width, title_cache, title_errors)
675        }))
676        .highlight_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD))
677        .block(block)
678        .highlight_symbol("> ");
679        StatefulWidget::render(list, popup_area, buf, &mut popup.state);
680
681        if !popup.loading_numbers.is_empty() {
682            let title_area = Rect {
683                x: popup_area.x + 1,
684                y: popup_area.y,
685                width: 10,
686                height: 1,
687            };
688            let throbber = Throbber::default()
689                .label("Loading")
690                .style(Style::new().fg(Color::Cyan))
691                .throbber_set(BRAILLE_SIX_DOUBLE)
692                .use_type(WhichUse::Spin);
693            StatefulWidget::render(throbber, title_area, buf, &mut popup.throbber_state);
694        }
695    }
696
697    pub fn render(&mut self, mut area: Layout, buf: &mut Buffer) {
698        if self.assign_input_state.lost_focus() {
699            self.inner_state = IssueListState::Normal;
700        }
701
702        let mut assign_input_area = Rect::default();
703        if self.inner_state == IssueListState::AssigningInput {
704            let split = vertical![*=1, ==3].split(area.main_content);
705            area.main_content = split[0];
706            assign_input_area = split[1];
707        }
708        let mut block = Block::default().padding(Padding::horizontal(3));
709        if self.state != LoadingState::Loading {
710            let mut title = format!("[{}] Issues", self.index);
711            if let Some(err) = &self.close_error {
712                title.push_str(" | ");
713                title.push_str(err);
714            } else if let Some(err) = &self.bookmark_error {
715                title.push_str(" | ");
716                title.push_str(err);
717            }
718            block = block.title(title);
719        }
720        {
721            let bookmarks = self.bookmarks.read().unwrap();
722            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
723            let list = rat_widget::list::List::<RowSelection>::new(
724                self.issues
725                    .iter()
726                    .map(|issue| self.build_list_item(issue, &bookmarks, &pool)),
727            )
728            .block(block)
729            .style(Style::default())
730            .focus_style(Style::default().reversed().add_modifier(Modifier::BOLD));
731            list.render(area.main_content, buf, &mut self.list_state);
732        }
733        if self.state == LoadingState::Loading {
734            let title_area = Rect {
735                x: area.main_content.x + 1,
736                y: area.main_content.y,
737                width: 10,
738                height: 1,
739            };
740            let full = Throbber::default()
741                .label("Loading")
742                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
743                .throbber_set(BRAILLE_SIX_DOUBLE)
744                .use_type(WhichUse::Spin);
745            StatefulWidget::render(full, title_area, buf, &mut self.throbber_state);
746        }
747        if self.inner_state == IssueListState::AssigningInput {
748            let mut input_block = Block::bordered()
749                .border_type(ratatui::widgets::BorderType::Rounded)
750                .border_style(get_border_style(&self.assign_input_state));
751            if !self.assign_loading {
752                input_block = input_block.title(match self.assignment_mode {
753                    AssignmentMode::Add => "Assign to",
754                    AssignmentMode::Remove => "Remove assignee(s)",
755                });
756            }
757            let input = rat_widget::text_input::TextInput::new().block(input_block);
758            input.render(assign_input_area, buf, &mut self.assign_input_state);
759            if self.assign_loading {
760                let title_area = Rect {
761                    x: assign_input_area.x + 1,
762                    y: assign_input_area.y,
763                    width: 10,
764                    height: 1,
765                };
766                let full = Throbber::default()
767                    .label("Loading")
768                    .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
769                    .throbber_set(BRAILLE_SIX_DOUBLE)
770                    .use_type(WhichUse::Spin);
771                StatefulWidget::render(full, title_area, buf, &mut self.assign_throbber_state);
772            }
773        }
774        self.render_close_popup(area.main_content, buf);
775        self.render_bookmark_popup(area.main_content, buf);
776    }
777
778    fn build_list_item(
779        &self,
780        issue: &IssueListItem,
781        bookmarks: &Bookmarks,
782        pool: &UiIssuePool,
783    ) -> ListItem<'static> {
784        let issue = pool.get_issue(issue.0);
785        let bookmarked = bookmarks.is_bookmarked(&self.owner, &self.repo, issue.number);
786        build_issue_list_item(issue, pool, bookmarked, true)
787    }
788}
789
790pub(crate) fn build_details_preview_issue_ids(
791    issues: &[IssueListItem],
792    pool: &UiIssuePool,
793    selected_number: u64,
794) -> Option<Vec<IssueId>> {
795    let selected_idx = issues
796        .iter()
797        .position(|item| pool.get_issue(item.0).number == selected_number)?;
798    let half_window = DETAILS_PREVIEW_WINDOW_SIZE / 2;
799    let start = selected_idx.saturating_sub(half_window);
800    let end = (start + DETAILS_PREVIEW_WINDOW_SIZE).min(issues.len());
801    let start = end.saturating_sub(DETAILS_PREVIEW_WINDOW_SIZE);
802    Some(issues[start..end].iter().map(|item| item.0).collect())
803}
804
805pub(crate) fn build_issue_list_item(
806    issue: &UiIssue,
807    pool: &UiIssuePool,
808    bookmarked: bool,
809    show_bookmark: bool,
810) -> ListItem<'static> {
811    ListItem::new(build_issue_list_lines(
812        issue,
813        pool,
814        bookmarked,
815        show_bookmark,
816    ))
817}
818
819pub(crate) fn build_issue_list_lines(
820    issue: &UiIssue,
821    pool: &UiIssuePool,
822    bookmarked: bool,
823    show_bookmark: bool,
824) -> Vec<Line<'static>> {
825    let body_text = pool
826        .resolve_opt_str(issue.body)
827        .unwrap_or("No desc provided");
828    let body_preview = build_issue_body_preview(body_text, Options::with_termwidth());
829    let title = pool.resolve_str(issue.title);
830    let author = pool.author_login(issue.author);
831    let created_at = pool.resolve_str(issue.created_at_full);
832
833    let title_line = if show_bookmark {
834        let bookmark_symbol = if bookmarked { " b " } else { "   " };
835        line![
836            span!(bookmark_symbol).style(if bookmarked {
837                Style::new().reversed()
838            } else {
839                Style::new()
840            }),
841            span!(title.to_string()),
842            " ",
843            span!("#{}", issue.number).dim(),
844        ]
845    } else {
846        line![
847            span!(title.to_string()),
848            " ",
849            span!("#{}", issue.number).dim(),
850        ]
851    };
852
853    let metadata_spacing = if show_bookmark { "  " } else { " " };
854    let body_indent = if show_bookmark { "   " } else { "" };
855
856    vec![
857        title_line,
858        line![
859            span!(symbols::shade::FULL).style({
860                if matches!(issue.state, IssueState::Open) {
861                    Style::new().green()
862                } else {
863                    Style::new().magenta()
864                }
865            }),
866            metadata_spacing,
867            span!(format!("Opened by {author} at {created_at}")).dim(),
868        ],
869        line![body_indent, span!(body_preview).style(Style::new().dim())],
870    ]
871}
872
873pub(crate) fn build_issue_body_preview(body_text: &str, options: Options<'_>) -> String {
874    let mut body = wrap(body_text.trim(), options);
875    body.truncate(2);
876    body.join(" ")
877}
878
879pub(crate) fn render_issue_close_popup(
880    popup: &mut IssueClosePopupState,
881    area: Rect,
882    buf: &mut Buffer,
883) {
884    let popup_area = area.centered(Constraint::Percentage(20), Constraint::Length(5));
885    Clear.render(popup_area, buf);
886
887    let mut block = Block::bordered()
888        .border_type(ratatui::widgets::BorderType::Rounded)
889        .title_bottom("Enter: close  Esc: cancel")
890        .title(format!("Close issue #{}", popup.issue_number));
891    if let Some(err) = &popup.error {
892        block = block.title(format!("Close issue #{} | {}", popup.issue_number, err));
893    }
894    let inner = block.inner(popup_area);
895    block.render(popup_area, buf);
896
897    if popup.reason_state.selected().is_none() {
898        popup.reason_state.select(Some(0));
899    }
900    let items = CloseIssueReason::ALL
901        .iter()
902        .map(|reason| ListItem::new(reason.label()))
903        .collect::<Vec<_>>();
904    let list = TuiList::new(items)
905        .highlight_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD))
906        .highlight_symbol("> ");
907    StatefulWidget::render(list, inner, buf, &mut popup.reason_state);
908
909    if popup.loading {
910        let title_area = Rect {
911            x: popup_area.x + 1,
912            y: popup_area.y,
913            width: 10,
914            height: 1,
915        };
916        let throbber = Throbber::default()
917            .label("Closing")
918            .style(Style::new().fg(Color::Cyan))
919            .throbber_set(BRAILLE_SIX_DOUBLE)
920            .use_type(WhichUse::Spin);
921        StatefulWidget::render(throbber, title_area, buf, &mut popup.throbber_state);
922    }
923}
924
925pub struct IssueListItem(pub IssueId);
926
927#[async_trait(?Send)]
928impl Component for IssueList<'_> {
929    fn render(&mut self, area: Layout, buf: &mut Buffer) {
930        self.render(area, buf);
931    }
932
933    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<crate::ui::Action>) {
934        self.action_tx = Some(action_tx);
935    }
936
937    async fn handle_event(&mut self, event: crate::ui::Action) -> Result<(), AppError> {
938        match event {
939            crate::ui::Action::Tick => {
940                if self.state == LoadingState::Loading {
941                    self.throbber_state.calc_next();
942                }
943                if self.assign_loading {
944                    self.assign_throbber_state.calc_next();
945                }
946                if let Some(popup) = self.close_popup.as_mut()
947                    && popup.loading
948                {
949                    popup.throbber_state.calc_next();
950                }
951                if let Some(popup) = self.bookmark_popup.as_mut()
952                    && !popup.loading_numbers.is_empty()
953                {
954                    popup.throbber_state.calc_next();
955                }
956                if let Some(rx) = self.assign_done_rx.as_mut()
957                    && rx.try_recv().is_ok()
958                {
959                    self.assign_done_rx = None;
960                    self.assign_loading = false;
961                    self.assign_input_state.set_text("");
962                    self.inner_state = IssueListState::Normal;
963                    self.list_state.focus.set(true);
964                    if let Some(action_tx) = self.action_tx.as_ref() {
965                        let _ = action_tx.send(Action::ForceRender).await;
966                    }
967                }
968            }
969            crate::ui::Action::AppEvent(ref event) => {
970                if self.screen != MainScreen::List {
971                    return Ok(());
972                }
973                if self.handle_bookmark_popup_event(event).await? {
974                    return Ok(());
975                }
976                if self.handle_close_popup_event(event).await {
977                    return Ok(());
978                }
979
980                match event {
981                    ct_event!(key press 'a') if self.list_state.is_focused() => {
982                        self.inner_state = IssueListState::AssigningInput;
983                        self.assignment_mode = AssignmentMode::Add;
984                        self.assign_input_state.set_text("");
985                        self.assign_input_state.focus.set(true);
986                        self.list_state.focus.set(false);
987                        return Ok(());
988                    }
989                    ct_event!(key press SHIFT-'A') if self.list_state.is_focused() => {
990                        self.inner_state = IssueListState::AssigningInput;
991                        self.assignment_mode = AssignmentMode::Remove;
992                        self.assign_input_state.set_text("");
993                        self.assign_input_state.focus.set(true);
994                        self.list_state.focus.set(false);
995                        return Ok(());
996                    }
997                    ct_event!(key press SHIFT-'B') if self.list_state.is_focused() => {
998                        if self.bookmark_popup.is_some() {
999                            self.close_bookmark_popup();
1000                        } else {
1001                            self.open_bookmark_popup();
1002                        }
1003                        return Ok(());
1004                    }
1005                    ct_event!(key press 'b') => {
1006                        if let Some(selected) = self.list_state.selected_checked() {
1007                            let issue = {
1008                                let pool =
1009                                    self.issue_pool.read().expect("issue pool lock poisoned");
1010                                pool.get_issue(self.issues[selected].0).clone()
1011                            };
1012                            {
1013                                let mut bookmarks =
1014                                    self.bookmarks.write().expect("bookmarks lock poisoned");
1015                                if bookmarks.is_bookmarked(&self.owner, &self.repo, issue.number) {
1016                                    bookmarks.remove(&self.owner, &self.repo, issue.number);
1017                                } else {
1018                                    bookmarks.add(&self.owner, &self.repo, issue.number);
1019                                }
1020                            }
1021                            if let Some(action_tx) = self.action_tx.as_ref() {
1022                                let _ = action_tx.send(Action::ForceRender).await;
1023                            }
1024                        }
1025                    }
1026                    ct_event!(key press 'n') if self.list_state.is_focused() => {
1027                        self.action_tx
1028                            .as_ref()
1029                            .ok_or_else(|| {
1030                                AppError::Other(anyhow!("issue list action channel unavailable"))
1031                            })?
1032                            .send(crate::ui::Action::EnterIssueCreate)
1033                            .await?;
1034                        self.action_tx
1035                            .as_ref()
1036                            .ok_or_else(|| {
1037                                AppError::Other(anyhow!("issue list action channel unavailable"))
1038                            })?
1039                            .send(crate::ui::Action::ChangeIssueScreen(
1040                                MainScreen::CreateIssue,
1041                            ))
1042                            .await?;
1043                        return Ok(());
1044                    }
1045                    ct_event!(key press SHIFT-'C')
1046                        if self.list_state.is_focused()
1047                            && self.inner_state == IssueListState::Normal =>
1048                    {
1049                        self.open_close_popup();
1050                        return Ok(());
1051                    }
1052                    ct_event!(keycode press Esc)
1053                        if self.inner_state == IssueListState::AssigningInput =>
1054                    {
1055                        self.assign_input_state.set_text("");
1056                        self.inner_state = IssueListState::Normal;
1057                        self.list_state.focus.set(true);
1058                        if let Some(action_tx) = self.action_tx.as_ref() {
1059                            action_tx.send(Action::ForceRender).await?;
1060                        }
1061                        return Ok(());
1062                    }
1063
1064                    ct_event!(key press 'l') if self.list_state.is_focused() => {
1065                        let Some(selected) = self.list_state.selected_checked() else {
1066                            return Ok(());
1067                        };
1068                        let issue = {
1069                            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1070                            pool.get_issue(self.issues[selected].0).clone()
1071                        };
1072                        let link = format!(
1073                            "https://github.com/{}/{}/issues/{}",
1074                            self.owner, self.repo, issue.number
1075                        );
1076
1077                        cli_clipboard::set_contents(link)
1078                            .map_err(|_| anyhow!("Error copying to clipboard"))?;
1079                        if let Some(tx) = self.action_tx.as_ref() {
1080                            tx.send(Action::ToastAction(ratatui_toaster::ToastMessage::Show {
1081                                message: "Copied Link to Clipboard".to_string(),
1082                                toast_type: ToastType::Success,
1083                                position: ToastPosition::TopRight,
1084                            }))
1085                            .await?;
1086                            tx.send(Action::ForceRender).await?;
1087                        }
1088                    }
1089
1090                    _ => {}
1091                }
1092                if matches!(event, ct_event!(keycode press Enter))
1093                    && self.inner_state == IssueListState::AssigningInput
1094                    && !self.assign_loading
1095                    && let Some(selected) = self.list_state.selected_checked()
1096                {
1097                    let issue = {
1098                        let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1099                        pool.get_issue(self.issues[selected].0).clone()
1100                    };
1101                    let value: String = self.assign_input_state.value();
1102                    let mut assignees = value
1103                        .split(',')
1104                        .map(|s| s.trim().to_string())
1105                        .collect::<Vec<_>>();
1106                    if !assignees.is_empty() {
1107                        let tx = self
1108                            .action_tx
1109                            .as_ref()
1110                            .ok_or_else(|| {
1111                                AppError::Other(anyhow!("issue list action channel unavailable"))
1112                            })?
1113                            .clone();
1114                        let (done_tx, done_rx) = oneshot::channel();
1115                        self.assign_done_rx = Some(done_rx);
1116                        self.assign_loading = true;
1117                        let assignment_mode = self.assignment_mode;
1118                        let number = issue.number;
1119                        let owner = self.owner.clone();
1120                        let repo = self.repo.clone();
1121                        tokio::spawn(async move {
1122                            let assignees = std::mem::take(&mut assignees);
1123                            let assignees = assignees
1124                                .iter()
1125                                .filter_map(|s| if s.is_empty() { None } else { Some(&**s) })
1126                                .collect::<Vec<_>>();
1127
1128                            let issue_handler = if let Some(client) = GITHUB_CLIENT.get() {
1129                                client.inner().issues(owner, repo)
1130                            } else {
1131                                let _ = done_tx.send(());
1132                                return;
1133                            };
1134                            let res = match assignment_mode {
1135                                AssignmentMode::Add => {
1136                                    issue_handler
1137                                        .add_assignees(number, assignees.as_slice())
1138                                        .await
1139                                }
1140                                AssignmentMode::Remove => {
1141                                    issue_handler
1142                                        .remove_assignees(number, assignees.as_slice())
1143                                        .await
1144                                }
1145                            };
1146                            if let Ok(issue) = res {
1147                                let _ = tx
1148                                    .send(crate::ui::Action::SelectedIssuePreview {
1149                                        seed: IssuePreviewSeed::from_issue(&issue),
1150                                    })
1151                                    .await;
1152                            }
1153                            let _ = done_tx.send(());
1154                        });
1155                    }
1156                }
1157                if matches!(event, ct_event!(keycode press Enter)) && self.list_state.is_focused() {
1158                    if let Some(selected) = self.list_state.selected_checked() {
1159                        let conversation_seed = {
1160                            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1161                            let issue = pool.get_issue(self.issues[selected].0);
1162                            IssueConversationSeed::from_ui_issue(issue, &pool)
1163                        };
1164                        self.send_issue_list_preview_for_number(conversation_seed.number)
1165                            .await?;
1166                        self.action_tx
1167                            .as_ref()
1168                            .ok_or_else(|| {
1169                                AppError::Other(anyhow!("issue list action channel unavailable"))
1170                            })?
1171                            .send(crate::ui::Action::EnterIssueDetails {
1172                                seed: conversation_seed,
1173                            })
1174                            .await?;
1175                        self.action_tx
1176                            .as_ref()
1177                            .ok_or_else(|| {
1178                                AppError::Other(anyhow!("issue list action channel unavailable"))
1179                            })?
1180                            .send(crate::ui::Action::ChangeIssueScreen(MainScreen::Details))
1181                            .await?;
1182                    }
1183                    return Ok(());
1184                }
1185
1186                self.assign_input_state
1187                    .handle(event, rat_widget::event::Regular);
1188                if let rat_widget::event::Outcome::Changed =
1189                    self.list_state.handle(event, rat_widget::event::Regular)
1190                {
1191                    let selected = self.list_state.selected_checked();
1192                    if let Some(selected) = selected {
1193                        if selected == self.issues.len() - 1
1194                            && let Some(page) = &self.page
1195                        {
1196                            let tx = self
1197                                .action_tx
1198                                .as_ref()
1199                                .ok_or_else(|| {
1200                                    AppError::Other(anyhow!(
1201                                        "issue list action channel unavailable"
1202                                    ))
1203                                })?
1204                                .clone();
1205                            let page_next = page.next.clone();
1206                            self.state = LoadingState::Loading;
1207                            tokio::spawn(async move {
1208                                let Some(client) = GITHUB_CLIENT.get() else {
1209                                    let _ = tx.send(crate::ui::Action::FinishedLoading).await;
1210                                    return;
1211                                };
1212                                let p = client.inner().get_page::<Issue>(&page_next).await;
1213                                if let Ok(pres) = p
1214                                    && let Some(mut p) = pres
1215                                {
1216                                    let items = std::mem::take(&mut p.items);
1217                                    let items = items
1218                                        .into_iter()
1219                                        .filter(|i| i.pull_request.is_none())
1220                                        .collect();
1221                                    p.items = items;
1222                                    let _ = tx
1223                                        .send(crate::ui::Action::NewPage(
1224                                            Arc::new(p),
1225                                            MergeStrategy::Append,
1226                                        ))
1227                                        .await;
1228                                }
1229                                let _ = tx.send(crate::ui::Action::FinishedLoading).await;
1230                            });
1231                        }
1232                        let body_owned: Option<Arc<str>> = {
1233                            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1234                            let issue = pool.get_issue(self.issues[selected].0);
1235                            issue
1236                                .body
1237                                .map(|body_id| Arc::<str>::from(pool.resolve_str(body_id)))
1238                        };
1239                        if let Some(body) = body_owned {
1240                            self.action_tx
1241                                .as_ref()
1242                                .ok_or_else(|| {
1243                                    AppError::Other(anyhow!(
1244                                        "issue list action channel unavailable"
1245                                    ))
1246                                })?
1247                                .send(crate::ui::Action::ChangeIssueBodyPreview(body))
1248                                .await?;
1249                        }
1250                        let (issue_number, labels, preview_seed) = {
1251                            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1252                            let issue = { pool.get_issue(self.issues[selected].0) };
1253                            (
1254                                issue.number,
1255                                issue.labels.clone(),
1256                                IssuePreviewSeed::from_ui_issue(issue, &pool),
1257                            )
1258                        };
1259                        self.action_tx
1260                            .as_ref()
1261                            .ok_or_else(|| {
1262                                AppError::Other(anyhow!("issue list action channel unavailable"))
1263                            })?
1264                            .send(crate::ui::Action::SelectedIssue {
1265                                number: issue_number,
1266                                labels,
1267                            })
1268                            .await?;
1269                        self.action_tx
1270                            .as_ref()
1271                            .ok_or_else(|| {
1272                                AppError::Other(anyhow!("issue list action channel unavailable"))
1273                            })?
1274                            .send(crate::ui::Action::SelectedIssuePreview { seed: preview_seed })
1275                            .await?;
1276                    }
1277                }
1278            }
1279            crate::ui::Action::NewPage(p, merge_strat) => {
1280                trace!("New Page with {} issues", p.items.len());
1281                let converted = {
1282                    let mut pool = self.issue_pool.write().expect("issue pool lock poisoned");
1283                    p.items
1284                        .iter()
1285                        .map(|issue| {
1286                            let compact = UiIssue::from_octocrab(issue, &mut pool);
1287                            IssueListItem(pool.upsert_issue(compact))
1288                        })
1289                        .collect::<Vec<_>>()
1290                };
1291                match merge_strat {
1292                    MergeStrategy::Replace => self.issues = converted,
1293                    MergeStrategy::Append => self.issues.extend(converted),
1294                }
1295                let count = self.issues.len().min(u32::MAX as usize) as u32;
1296                LOADED_ISSUE_COUNT.store(count, Ordering::Relaxed);
1297                let mut page_meta = (*p).clone();
1298                page_meta.items.clear();
1299                self.page = Some(Arc::new(page_meta));
1300                self.state = LoadingState::Loaded;
1301            }
1302            crate::ui::Action::FinishedLoading => {
1303                self.state = LoadingState::Loaded;
1304            }
1305            crate::ui::Action::IssueCloseSuccess { issue_id } => {
1306                let (issue_number, preview_seed) = {
1307                    let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1308                    let compact = pool.get_issue(issue_id);
1309                    (
1310                        compact.number,
1311                        IssuePreviewSeed::from_ui_issue(compact, &pool),
1312                    )
1313                };
1314                let existing_idx = {
1315                    let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1316                    self.issues
1317                        .iter()
1318                        .position(|item| pool.get_issue(item.0).number == issue_number)
1319                };
1320                if let Some(existing_idx) = existing_idx {
1321                    self.issues[existing_idx].0 = issue_id;
1322                }
1323                let initiated_here = self
1324                    .close_popup
1325                    .as_ref()
1326                    .is_some_and(|popup| popup.issue_number == issue_number);
1327                if initiated_here {
1328                    self.close_popup = None;
1329                    self.close_error = None;
1330                    if let Some(action_tx) = self.action_tx.as_ref() {
1331                        let _ = action_tx
1332                            .send(Action::SelectedIssuePreview { seed: preview_seed })
1333                            .await;
1334                        let _ = action_tx.send(Action::RefreshIssueList).await;
1335                    }
1336                }
1337            }
1338            crate::ui::Action::IssueCloseError { number, message } => {
1339                if let Some(popup) = self.close_popup.as_mut()
1340                    && popup.issue_number == number
1341                {
1342                    popup.loading = false;
1343                    popup.error = Some(message.clone());
1344                    self.close_error = Some(message);
1345                }
1346            }
1347            crate::ui::Action::IssueLabelsUpdated { number, labels } => {
1348                let issue_id = {
1349                    let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1350                    self.issues.iter().find_map(|item| {
1351                        (pool.get_issue(item.0).number == number).then_some(item.0)
1352                    })
1353                };
1354                if let Some(issue_id) = issue_id {
1355                    let mut pool = self.issue_pool.write().expect("issue pool lock poisoned");
1356                    pool.get_issue_mut(issue_id).labels = labels;
1357                }
1358            }
1359            crate::ui::Action::BookmarkTitleLoaded { number, title } => {
1360                self.bookmark_titles.insert(number, title);
1361                self.bookmark_title_errors.remove(&number);
1362                if let Some(popup) = self.bookmark_popup.as_mut() {
1363                    popup.loading_numbers.remove(&number);
1364                }
1365            }
1366            crate::ui::Action::BookmarkTitleLoadError { number, message } => {
1367                self.bookmark_title_errors.insert(number, message);
1368                if let Some(popup) = self.bookmark_popup.as_mut() {
1369                    popup.loading_numbers.remove(&number);
1370                }
1371            }
1372            crate::ui::Action::BookmarkedIssueLoaded { issue_id } => {
1373                let (issue_number, labels, preview_seed, conversation_seed) = {
1374                    let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1375                    let compact = pool.get_issue(issue_id);
1376                    (
1377                        compact.number,
1378                        compact.labels.clone(),
1379                        IssuePreviewSeed::from_ui_issue(compact, &pool),
1380                        IssueConversationSeed::from_ui_issue(compact, &pool),
1381                    )
1382                };
1383                let should_open = self
1384                    .bookmark_popup
1385                    .as_ref()
1386                    .is_some_and(|popup| popup.opening_issue == Some(issue_number));
1387                if !should_open {
1388                    return Ok(());
1389                }
1390
1391                let number = issue_number;
1392                self.close_bookmark_popup();
1393
1394                if let Some(action_tx) = self.action_tx.clone() {
1395                    action_tx
1396                        .send(Action::SelectedIssue { number, labels })
1397                        .await?;
1398                    action_tx
1399                        .send(Action::SelectedIssuePreview { seed: preview_seed })
1400                        .await?;
1401                    let issue_ids = {
1402                        let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1403                        build_details_preview_issue_ids(&self.issues, &pool, number)
1404                            .unwrap_or_else(|| vec![issue_id])
1405                    };
1406                    action_tx
1407                        .send(Action::IssueListPreviewUpdated {
1408                            issue_ids,
1409                            selected_number: number,
1410                        })
1411                        .await?;
1412                    action_tx
1413                        .send(Action::EnterIssueDetails {
1414                            seed: conversation_seed,
1415                        })
1416                        .await?;
1417                    action_tx
1418                        .send(Action::ChangeIssueScreen(MainScreen::Details))
1419                        .await?;
1420                }
1421            }
1422            crate::ui::Action::BookmarkedIssueLoadError { number, message } => {
1423                if let Some(popup) = self.bookmark_popup.as_mut()
1424                    && popup.opening_issue == Some(number)
1425                {
1426                    popup.opening_issue = None;
1427                    self.bookmark_error = Some(message.to_string());
1428                }
1429            }
1430            crate::ui::Action::ChangeIssueScreen(screen) => {
1431                self.screen = screen;
1432                if screen == MainScreen::List {
1433                    self.list_state.focus.set(true);
1434                } else {
1435                    self.close_popup = None;
1436                    self.close_bookmark_popup();
1437                    self.list_state.focus.set(false);
1438                }
1439            }
1440            _ => {}
1441        }
1442        Ok(())
1443    }
1444
1445    fn should_render(&self) -> bool {
1446        self.screen == MainScreen::List
1447    }
1448
1449    fn is_animating(&self) -> bool {
1450        self.screen == MainScreen::List
1451            && (self.state == LoadingState::Loading
1452                || self.assign_loading
1453                || self.close_popup.as_ref().is_some_and(|popup| popup.loading)
1454                || self
1455                    .bookmark_popup
1456                    .as_ref()
1457                    .is_some_and(|popup| !popup.loading_numbers.is_empty()))
1458    }
1459    fn set_index(&mut self, index: usize) {
1460        self.index = index;
1461    }
1462
1463    fn set_global_help(&self) {
1464        trace!("Setting global help for IssueList");
1465        if let Some(action_tx) = self.action_tx.as_ref() {
1466            let _ = action_tx.try_send(crate::ui::Action::SetHelp(HELP));
1467        }
1468    }
1469
1470    fn capture_focus_event(&self, _event: &crossterm::event::Event) -> bool {
1471        self.close_popup.is_some() || self.bookmark_popup.is_some()
1472    }
1473}
1474
1475impl HasFocus for IssueList<'_> {
1476    fn build(&self, builder: &mut rat_widget::focus::FocusBuilder) {
1477        let tag = builder.start(self);
1478        builder.widget(&self.list_state);
1479        if self.inner_state == IssueListState::AssigningInput {
1480            builder.widget(&self.assign_input_state);
1481        }
1482        builder.end(tag);
1483    }
1484    fn area(&self) -> ratatui::layout::Rect {
1485        self.list_state.area()
1486    }
1487    fn focus(&self) -> rat_widget::focus::FocusFlag {
1488        self.list_state.focus()
1489    }
1490
1491    fn navigable(&self) -> Navigation {
1492        if self.screen == MainScreen::List {
1493            Navigation::Regular
1494        } else {
1495            Navigation::None
1496        }
1497    }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502    use super::*;
1503    use crate::ui::testing::{DummyDataConfig, dummy_ui_data_with};
1504
1505    #[test]
1506    fn preview_window_centers_on_selected_issue_when_available() {
1507        let data = dummy_ui_data_with(DummyDataConfig {
1508            issue_count: 7,
1509            ..DummyDataConfig::default()
1510        });
1511        let issues = data
1512            .issue_ids
1513            .iter()
1514            .copied()
1515            .map(IssueListItem)
1516            .collect::<Vec<_>>();
1517
1518        let selected_number = data.issue_numbers[3];
1519        let issue_ids = build_details_preview_issue_ids(&issues, &data.pool, selected_number)
1520            .expect("preview window should exist");
1521        let numbers = issue_ids
1522            .iter()
1523            .map(|issue_id| data.pool.get_issue(*issue_id).number)
1524            .collect::<Vec<_>>();
1525
1526        assert_eq!(numbers, data.issue_numbers[1..6].to_vec());
1527    }
1528
1529    #[test]
1530    fn preview_window_returns_none_for_missing_issue() {
1531        let data = dummy_ui_data_with(DummyDataConfig {
1532            issue_count: 3,
1533            ..DummyDataConfig::default()
1534        });
1535        let issues = data
1536            .issue_ids
1537            .iter()
1538            .copied()
1539            .map(IssueListItem)
1540            .collect::<Vec<_>>();
1541
1542        assert!(build_details_preview_issue_ids(&issues, &data.pool, 999_999).is_none());
1543    }
1544}