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