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