Skip to main content

gitv_tui/ui/components/
issue_list.rs

1use crate::{
2    app::GITHUB_CLIENT,
3    errors::AppError,
4    ui::{
5        Action, CloseIssueReason, MergeStrategy,
6        components::{
7            Component, help::HelpElementKind, issue_conversation::IssueConversationSeed,
8            issue_detail::IssuePreviewSeed,
9        },
10        layout::Layout,
11        utils::get_border_style,
12    },
13};
14use anyhow::anyhow;
15use async_trait::async_trait;
16use octocrab::{
17    Page,
18    issues::IssueHandler,
19    models::{IssueState, issues::Issue},
20};
21use rat_widget::{
22    event::{HandleEvent, ct_event},
23    focus::{HasFocus, Navigation},
24    list::selection::RowSelection,
25    text_input::TextInputState,
26};
27use ratatui::{
28    buffer::Buffer,
29    layout::{Constraint, Rect},
30    style::{Color, Modifier, Style, Stylize},
31    symbols,
32    widgets::{
33        Block, Clear, List as TuiList, ListItem, ListState as TuiListState, Padding,
34        StatefulWidget, Widget,
35    },
36};
37use ratatui_macros::{line, span, vertical};
38use ratatui_toaster::{ToastPosition, ToastType};
39use std::sync::{
40    Arc,
41    atomic::{AtomicU32, Ordering},
42};
43use textwrap::{Options, wrap};
44use throbber_widgets_tui::ThrobberState;
45use tokio::sync::oneshot;
46use tracing::trace;
47
48pub static LOADED_ISSUE_COUNT: AtomicU32 = AtomicU32::new(0);
49pub const HELP: &[HelpElementKind] = &[
50    crate::help_text!("Issue List Help"),
51    crate::help_keybind!("Up/Down", "navigate issues"),
52    crate::help_keybind!("Enter", "view issue details"),
53    crate::help_keybind!("C", "close selected issue"),
54    crate::help_keybind!("l", "copy issue link to clipboard"),
55    crate::help_keybind!("Enter (popup)", "confirm close reason"),
56    crate::help_keybind!("a", "add assignee(s)"),
57    crate::help_keybind!("A", "remove assignee(s)"),
58    crate::help_keybind!("n", "create new issue"),
59    crate::help_keybind!("Esc", "cancel popup / assign input"),
60];
61pub struct IssueList<'a> {
62    pub issues: Vec<IssueListItem>,
63    pub page: Option<Arc<Page<Issue>>>,
64    pub list_state: rat_widget::list::ListState<RowSelection>,
65    pub handler: IssueHandler<'a>,
66    pub action_tx: Option<tokio::sync::mpsc::Sender<crate::ui::Action>>,
67    pub throbber_state: ThrobberState,
68    pub assign_throbber_state: ThrobberState,
69    pub assign_input_state: rat_widget::text_input::TextInputState,
70    assign_loading: bool,
71    assign_done_rx: Option<oneshot::Receiver<()>>,
72    close_popup: Option<IssueClosePopupState>,
73    close_error: Option<String>,
74    pub owner: String,
75    pub repo: String,
76    index: usize,
77    state: LoadingState,
78    inner_state: IssueListState,
79    assignment_mode: AssignmentMode,
80    pub screen: MainScreen,
81}
82
83#[derive(Debug)]
84pub(crate) struct IssueClosePopupState {
85    pub(crate) issue_number: u64,
86    pub(crate) loading: bool,
87    pub(crate) throbber_state: ThrobberState,
88    pub(crate) error: Option<String>,
89    reason_state: TuiListState,
90}
91
92impl IssueClosePopupState {
93    pub(crate) fn new(issue_number: u64) -> Self {
94        let mut reason_state = TuiListState::default();
95        reason_state.select(Some(0));
96        Self {
97            issue_number,
98            loading: false,
99            throbber_state: ThrobberState::default(),
100            error: None,
101            reason_state,
102        }
103    }
104
105    pub(crate) fn select_next_reason(&mut self) {
106        self.reason_state.select_next();
107    }
108
109    pub(crate) fn select_prev_reason(&mut self) {
110        self.reason_state.select_previous();
111    }
112
113    pub(crate) fn selected_reason(&self) -> CloseIssueReason {
114        self.reason_state
115            .selected()
116            .and_then(|idx| CloseIssueReason::ALL.get(idx).copied())
117            .unwrap_or(CloseIssueReason::Completed)
118    }
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
122enum IssueListState {
123    #[default]
124    Normal,
125    AssigningInput,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
129enum AssignmentMode {
130    #[default]
131    Add,
132    Remove,
133}
134
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
136enum LoadingState {
137    #[default]
138    Loading,
139    Loaded,
140}
141
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143pub enum MainScreen {
144    #[default]
145    List,
146    Details,
147    DetailsFullscreen,
148    CreateIssue,
149}
150
151impl<'a> IssueList<'a> {
152    pub async fn new(
153        handler: IssueHandler<'a>,
154        owner: String,
155        repo: String,
156        tx: tokio::sync::mpsc::Sender<Action>,
157    ) -> Self {
158        LOADED_ISSUE_COUNT.store(0, Ordering::Relaxed);
159        let owner_clone = owner.clone();
160        let repo_clone = repo.clone();
161        tokio::spawn(async move {
162            let Some(client) = GITHUB_CLIENT.get() else {
163                return;
164            };
165            let Ok(mut p) = client
166                .inner()
167                .search()
168                .issues_and_pull_requests(&format!(
169                    "repo:{}/{} is:issue is:open",
170                    owner_clone, repo_clone
171                ))
172                .page(1u32)
173                .per_page(15u8)
174                .send()
175                .await
176            else {
177                return;
178            };
179            let items = std::mem::take(&mut p.items);
180            p.items = items;
181
182            let _ = tx
183                .send(Action::NewPage(Arc::new(p), MergeStrategy::Append))
184                .await;
185        });
186        Self {
187            page: None,
188            owner,
189            repo,
190            throbber_state: ThrobberState::default(),
191            action_tx: None,
192            issues: vec![],
193            list_state: rat_widget::list::ListState::default(),
194            assign_throbber_state: ThrobberState::default(),
195            assign_input_state: TextInputState::default(),
196            assign_loading: false,
197            assign_done_rx: None,
198            close_popup: None,
199            close_error: None,
200            handler,
201            index: 0,
202            screen: MainScreen::default(),
203            state: LoadingState::default(),
204            inner_state: IssueListState::default(),
205            assignment_mode: AssignmentMode::default(),
206        }
207    }
208
209    fn open_close_popup(&mut self) {
210        let Some(selected) = self.list_state.selected_checked() else {
211            self.close_error = Some("No issue selected.".to_string());
212            return;
213        };
214        let Some(issue) = self.issues.get(selected).map(|item| &item.0) else {
215            self.close_error = Some("No issue selected.".to_string());
216            return;
217        };
218        if issue.state == IssueState::Closed {
219            self.close_error = Some("Selected issue is already closed.".to_string());
220            return;
221        }
222        self.close_error = None;
223        self.close_popup = Some(IssueClosePopupState::new(issue.number));
224    }
225
226    fn render_close_popup(&mut self, area: Rect, buf: &mut Buffer) {
227        let Some(popup) = self.close_popup.as_mut() else {
228            return;
229        };
230        render_issue_close_popup(popup, area, buf);
231    }
232
233    async fn submit_close_popup(&mut self) {
234        let Some(popup) = self.close_popup.as_mut() else {
235            return;
236        };
237        if popup.loading {
238            return;
239        }
240        let reason = popup.selected_reason();
241        let number = popup.issue_number;
242        popup.loading = true;
243        popup.error = None;
244
245        let Some(action_tx) = self.action_tx.clone() else {
246            popup.loading = false;
247            popup.error = Some("Action channel unavailable.".to_string());
248            return;
249        };
250        let owner = self.owner.clone();
251        let repo = self.repo.clone();
252        tokio::spawn(async move {
253            let Some(client) = GITHUB_CLIENT.get() else {
254                let _ = action_tx
255                    .send(Action::IssueCloseError {
256                        number,
257                        message: "GitHub client not initialized.".to_string(),
258                    })
259                    .await;
260                return;
261            };
262            let issues = client.inner().issues(owner, repo);
263            match issues
264                .update(number)
265                .state(IssueState::Closed)
266                .state_reason(reason.to_octocrab())
267                .send()
268                .await
269            {
270                Ok(issue) => {
271                    let _ = action_tx
272                        .send(Action::IssueCloseSuccess {
273                            issue: Box::new(issue),
274                        })
275                        .await;
276                }
277                Err(err) => {
278                    let _ = action_tx
279                        .send(Action::IssueCloseError {
280                            number,
281                            message: err.to_string().replace('\n', " "),
282                        })
283                        .await;
284                }
285            }
286        });
287    }
288
289    async fn handle_close_popup_event(&mut self, event: &crossterm::event::Event) -> bool {
290        let Some(popup) = self.close_popup.as_mut() else {
291            return false;
292        };
293        if popup.loading {
294            if matches!(event, ct_event!(keycode press Esc)) {
295                popup.loading = false;
296            }
297            return true;
298        }
299        if matches!(event, ct_event!(keycode press Esc)) {
300            self.close_popup = None;
301            return true;
302        }
303        if matches!(event, ct_event!(keycode press Up)) {
304            popup.select_prev_reason();
305            return true;
306        }
307        if matches!(event, ct_event!(keycode press Down)) {
308            popup.select_next_reason();
309            return true;
310        }
311        if matches!(event, ct_event!(keycode press Enter)) {
312            self.submit_close_popup().await;
313            return true;
314        }
315        true
316    }
317
318    pub fn render(&mut self, mut area: Layout, buf: &mut Buffer) {
319        if self.assign_input_state.lost_focus() {
320            self.inner_state = IssueListState::Normal;
321        }
322
323        let mut assign_input_area = Rect::default();
324        if self.inner_state == IssueListState::AssigningInput {
325            let split = vertical![*=1, ==3].split(area.main_content);
326            area.main_content = split[0];
327            assign_input_area = split[1];
328        }
329        let mut block = Block::bordered()
330            .border_type(ratatui::widgets::BorderType::Rounded)
331            .border_style(get_border_style(&self.list_state))
332            .padding(Padding::horizontal(3));
333        if self.state != LoadingState::Loading {
334            let mut title = format!("[{}] Issues", self.index);
335            if let Some(err) = &self.close_error {
336                title.push_str(" | ");
337                title.push_str(err);
338            }
339            block = block.title(title);
340        }
341        let list = rat_widget::list::List::<RowSelection>::new(
342            self.issues.iter().map(Into::<ListItem>::into),
343        )
344        .block(block)
345        .style(Style::default())
346        .focus_style(Style::default().reversed().add_modifier(Modifier::BOLD));
347        list.render(area.main_content, buf, &mut self.list_state);
348        if self.state == LoadingState::Loading {
349            let title_area = Rect {
350                x: area.main_content.x + 1,
351                y: area.main_content.y,
352                width: 10,
353                height: 1,
354            };
355            let full = throbber_widgets_tui::Throbber::default()
356                .label("Loading")
357                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
358                .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
359                .use_type(throbber_widgets_tui::WhichUse::Spin);
360            StatefulWidget::render(full, title_area, buf, &mut self.throbber_state);
361        }
362        if self.inner_state == IssueListState::AssigningInput {
363            let mut input_block = Block::bordered()
364                .border_type(ratatui::widgets::BorderType::Rounded)
365                .border_style(get_border_style(&self.assign_input_state));
366            if !self.assign_loading {
367                input_block = input_block.title(match self.assignment_mode {
368                    AssignmentMode::Add => "Assign to",
369                    AssignmentMode::Remove => "Remove assignee(s)",
370                });
371            }
372            let input = rat_widget::text_input::TextInput::new().block(input_block);
373            input.render(assign_input_area, buf, &mut self.assign_input_state);
374            if self.assign_loading {
375                let title_area = Rect {
376                    x: assign_input_area.x + 1,
377                    y: assign_input_area.y,
378                    width: 10,
379                    height: 1,
380                };
381                let full = throbber_widgets_tui::Throbber::default()
382                    .label("Loading")
383                    .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
384                    .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
385                    .use_type(throbber_widgets_tui::WhichUse::Spin);
386                StatefulWidget::render(full, title_area, buf, &mut self.assign_throbber_state);
387            }
388        }
389        self.render_close_popup(area.main_content, buf);
390    }
391}
392
393pub(crate) fn render_issue_close_popup(
394    popup: &mut IssueClosePopupState,
395    area: Rect,
396    buf: &mut Buffer,
397) {
398    let popup_area = area.centered(Constraint::Percentage(20), Constraint::Length(5));
399    Clear.render(popup_area, buf);
400
401    let mut block = Block::bordered()
402        .border_type(ratatui::widgets::BorderType::Rounded)
403        .title_bottom("Enter: close  Esc: cancel")
404        .title(format!("Close issue #{}", popup.issue_number));
405    if let Some(err) = &popup.error {
406        block = block.title(format!("Close issue #{} | {}", popup.issue_number, err));
407    }
408    let inner = block.inner(popup_area);
409    block.render(popup_area, buf);
410
411    if popup.reason_state.selected().is_none() {
412        popup.reason_state.select(Some(0));
413    }
414    let items = CloseIssueReason::ALL
415        .iter()
416        .map(|reason| ListItem::new(reason.label()))
417        .collect::<Vec<_>>();
418    let list = TuiList::new(items)
419        .highlight_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD))
420        .highlight_symbol("> ");
421    StatefulWidget::render(list, inner, buf, &mut popup.reason_state);
422
423    if popup.loading {
424        let title_area = Rect {
425            x: popup_area.x + 1,
426            y: popup_area.y,
427            width: 10,
428            height: 1,
429        };
430        let throbber = throbber_widgets_tui::Throbber::default()
431            .label("Closing")
432            .style(Style::new().fg(Color::Cyan))
433            .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
434            .use_type(throbber_widgets_tui::WhichUse::Spin);
435        StatefulWidget::render(throbber, title_area, buf, &mut popup.throbber_state);
436    }
437}
438
439pub struct IssueListItem(pub Issue);
440
441impl std::ops::Deref for IssueListItem {
442    type Target = Issue;
443
444    fn deref(&self) -> &Self::Target {
445        &self.0
446    }
447}
448
449impl From<Issue> for IssueListItem {
450    fn from(issue: Issue) -> Self {
451        Self(issue)
452    }
453}
454
455impl From<&IssueListItem> for ListItem<'_> {
456    fn from(value: &IssueListItem) -> Self {
457        let options = Options::with_termwidth();
458        let binding = value.body.clone().unwrap_or("No desc provided".to_string());
459        let mut body = wrap(binding.trim(), options);
460        body.truncate(2);
461
462        let lines = vec![
463            line![
464                "   ",
465                span!(value.0.title.as_str()),
466                " ",
467                span!("#{}", value.0.number).dim(),
468            ],
469            line![
470                span!(symbols::shade::FULL).style({
471                    if matches!(value.0.state, IssueState::Open) {
472                        Style::new().green()
473                    } else {
474                        Style::new().magenta()
475                    }
476                }),
477                "  ",
478                span!(
479                    "Opened by {} at {}",
480                    value.0.user.login,
481                    value.0.created_at.format("%Y-%m-%d %H:%M:%S")
482                )
483                .dim(),
484            ],
485            line!["   ", span!(body.join(" ")).style(Style::new().dim())],
486        ];
487        ListItem::new(lines)
488    }
489}
490
491#[async_trait(?Send)]
492impl Component for IssueList<'_> {
493    fn render(&mut self, area: Layout, buf: &mut Buffer) {
494        self.render(area, buf);
495    }
496
497    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<crate::ui::Action>) {
498        self.action_tx = Some(action_tx);
499    }
500
501    async fn handle_event(&mut self, event: crate::ui::Action) -> Result<(), AppError> {
502        match event {
503            crate::ui::Action::Tick => {
504                if self.state == LoadingState::Loading {
505                    self.throbber_state.calc_next();
506                }
507                if self.assign_loading {
508                    self.assign_throbber_state.calc_next();
509                }
510                if let Some(popup) = self.close_popup.as_mut()
511                    && popup.loading
512                {
513                    popup.throbber_state.calc_next();
514                }
515                if let Some(rx) = self.assign_done_rx.as_mut()
516                    && rx.try_recv().is_ok()
517                {
518                    self.assign_done_rx = None;
519                    self.assign_loading = false;
520                    self.assign_input_state.set_text("");
521                    self.inner_state = IssueListState::Normal;
522                    self.list_state.focus.set(true);
523                    if let Some(action_tx) = self.action_tx.as_ref() {
524                        let _ = action_tx.send(Action::ForceRender).await;
525                    }
526                }
527            }
528            crate::ui::Action::AppEvent(ref event) => {
529                if self.screen != MainScreen::List {
530                    return Ok(());
531                }
532                if self.handle_close_popup_event(event).await {
533                    return Ok(());
534                }
535
536                match event {
537                    ct_event!(key press 'a') if self.list_state.is_focused() => {
538                        self.inner_state = IssueListState::AssigningInput;
539                        self.assignment_mode = AssignmentMode::Add;
540                        self.assign_input_state.set_text("");
541                        self.assign_input_state.focus.set(true);
542                        self.list_state.focus.set(false);
543                        return Ok(());
544                    }
545                    ct_event!(key press SHIFT-'A') if self.list_state.is_focused() => {
546                        self.inner_state = IssueListState::AssigningInput;
547                        self.assignment_mode = AssignmentMode::Remove;
548                        self.assign_input_state.set_text("");
549                        self.assign_input_state.focus.set(true);
550                        self.list_state.focus.set(false);
551                        return Ok(());
552                    }
553                    ct_event!(key press 'n') if self.list_state.is_focused() => {
554                        self.action_tx
555                            .as_ref()
556                            .ok_or_else(|| {
557                                AppError::Other(anyhow!("issue list action channel unavailable"))
558                            })?
559                            .send(crate::ui::Action::EnterIssueCreate)
560                            .await?;
561                        self.action_tx
562                            .as_ref()
563                            .ok_or_else(|| {
564                                AppError::Other(anyhow!("issue list action channel unavailable"))
565                            })?
566                            .send(crate::ui::Action::ChangeIssueScreen(
567                                MainScreen::CreateIssue,
568                            ))
569                            .await?;
570                        return Ok(());
571                    }
572                    ct_event!(key press SHIFT-'C')
573                        if self.list_state.is_focused()
574                            && self.inner_state == IssueListState::Normal =>
575                    {
576                        self.open_close_popup();
577                        return Ok(());
578                    }
579                    ct_event!(keycode press Esc)
580                        if self.inner_state == IssueListState::AssigningInput =>
581                    {
582                        self.assign_input_state.set_text("");
583                        self.inner_state = IssueListState::Normal;
584                        self.list_state.focus.set(true);
585                        if let Some(action_tx) = self.action_tx.as_ref() {
586                            action_tx.send(Action::ForceRender).await?;
587                        }
588                        return Ok(());
589                    }
590
591                    ct_event!(key press 'l') if self.list_state.is_focused() => {
592                        let Some(selected) = self.list_state.selected_checked() else {
593                            return Ok(());
594                        };
595                        let issue = &self.issues[selected].0;
596                        let link = format!(
597                            "https://github.com/{}/{}/issues/{}",
598                            self.owner, self.repo, issue.number
599                        );
600
601                        cli_clipboard::set_contents(link)
602                            .map_err(|_| anyhow!("Error copying to clipboard"))?;
603                        if let Some(tx) = self.action_tx.as_ref() {
604                            tx.send(Action::ToastAction(ratatui_toaster::ToastMessage::Show {
605                                message: "Copied Link to Clipboard".to_string(),
606                                toast_type: ToastType::Success,
607                                position: ToastPosition::TopRight,
608                            }))
609                            .await?;
610                            tx.send(Action::ForceRender).await?;
611                        }
612                    }
613
614                    _ => {}
615                }
616                if matches!(event, ct_event!(keycode press Enter))
617                    && self.inner_state == IssueListState::AssigningInput
618                    && !self.assign_loading
619                    && let Some(selected) = self.list_state.selected_checked()
620                {
621                    let issue = &self.issues[selected].0;
622                    let value: String = self.assign_input_state.value();
623                    let mut assignees = value
624                        .split(',')
625                        .map(|s| s.trim().to_string())
626                        .collect::<Vec<_>>();
627                    if !assignees.is_empty() {
628                        let tx = self
629                            .action_tx
630                            .as_ref()
631                            .ok_or_else(|| {
632                                AppError::Other(anyhow!("issue list action channel unavailable"))
633                            })?
634                            .clone();
635                        let (done_tx, done_rx) = oneshot::channel();
636                        self.assign_done_rx = Some(done_rx);
637                        self.assign_loading = true;
638                        let assignment_mode = self.assignment_mode;
639                        let number = issue.number;
640                        let owner = self.owner.clone();
641                        let repo = self.repo.clone();
642                        tokio::spawn(async move {
643                            let assignees = std::mem::take(&mut assignees);
644                            let assignees = assignees
645                                .iter()
646                                .filter_map(|s| if s.is_empty() { None } else { Some(&**s) })
647                                .collect::<Vec<_>>();
648
649                            let issue_handler = if let Some(client) = GITHUB_CLIENT.get() {
650                                client.inner().issues(owner, repo)
651                            } else {
652                                let _ = done_tx.send(());
653                                return;
654                            };
655                            let res = match assignment_mode {
656                                AssignmentMode::Add => {
657                                    issue_handler
658                                        .add_assignees(number, assignees.as_slice())
659                                        .await
660                                }
661                                AssignmentMode::Remove => {
662                                    issue_handler
663                                        .remove_assignees(number, assignees.as_slice())
664                                        .await
665                                }
666                            };
667                            if let Ok(issue) = res {
668                                let _ = tx
669                                    .send(crate::ui::Action::SelectedIssuePreview {
670                                        seed: IssuePreviewSeed::from_issue(&issue),
671                                    })
672                                    .await;
673                            }
674                            let _ = done_tx.send(());
675                        });
676                    }
677                }
678                if matches!(event, ct_event!(keycode press Enter)) && self.list_state.is_focused() {
679                    if let Some(selected) = self.list_state.selected_checked() {
680                        let issue = &self.issues[selected].0;
681                        self.action_tx
682                            .as_ref()
683                            .ok_or_else(|| {
684                                AppError::Other(anyhow!("issue list action channel unavailable"))
685                            })?
686                            .send(crate::ui::Action::EnterIssueDetails {
687                                seed: IssueConversationSeed::from_issue(issue),
688                            })
689                            .await?;
690                        self.action_tx
691                            .as_ref()
692                            .ok_or_else(|| {
693                                AppError::Other(anyhow!("issue list action channel unavailable"))
694                            })?
695                            .send(crate::ui::Action::ChangeIssueScreen(MainScreen::Details))
696                            .await?;
697                    }
698                    return Ok(());
699                }
700
701                self.assign_input_state
702                    .handle(event, rat_widget::event::Regular);
703                if let rat_widget::event::Outcome::Changed =
704                    self.list_state.handle(event, rat_widget::event::Regular)
705                {
706                    let selected = self.list_state.selected_checked();
707                    if let Some(selected) = selected {
708                        if selected == self.issues.len() - 1
709                            && let Some(page) = &self.page
710                        {
711                            let tx = self
712                                .action_tx
713                                .as_ref()
714                                .ok_or_else(|| {
715                                    AppError::Other(anyhow!(
716                                        "issue list action channel unavailable"
717                                    ))
718                                })?
719                                .clone();
720                            let page_next = page.next.clone();
721                            self.state = LoadingState::Loading;
722                            tokio::spawn(async move {
723                                let Some(client) = GITHUB_CLIENT.get() else {
724                                    let _ = tx.send(crate::ui::Action::FinishedLoading).await;
725                                    return;
726                                };
727                                let p = client.inner().get_page::<Issue>(&page_next).await;
728                                if let Ok(pres) = p
729                                    && let Some(mut p) = pres
730                                {
731                                    let items = std::mem::take(&mut p.items);
732                                    let items = items
733                                        .into_iter()
734                                        .filter(|i| i.pull_request.is_none())
735                                        .collect();
736                                    p.items = items;
737                                    let _ = tx
738                                        .send(crate::ui::Action::NewPage(
739                                            Arc::new(p),
740                                            MergeStrategy::Append,
741                                        ))
742                                        .await;
743                                }
744                                let _ = tx.send(crate::ui::Action::FinishedLoading).await;
745                            });
746                        }
747                        let issue = &self.issues[selected].0;
748                        let labels = &issue.labels;
749                        self.action_tx
750                            .as_ref()
751                            .ok_or_else(|| {
752                                AppError::Other(anyhow!("issue list action channel unavailable"))
753                            })?
754                            .send(crate::ui::Action::SelectedIssue {
755                                number: issue.number,
756                                labels: labels.clone(),
757                            })
758                            .await?;
759                        self.action_tx
760                            .as_ref()
761                            .ok_or_else(|| {
762                                AppError::Other(anyhow!("issue list action channel unavailable"))
763                            })?
764                            .send(crate::ui::Action::SelectedIssuePreview {
765                                seed: IssuePreviewSeed::from_issue(issue),
766                            })
767                            .await?;
768                    }
769                }
770            }
771            crate::ui::Action::NewPage(p, merge_strat) => {
772                trace!("New Page with {} issues", p.items.len());
773                match merge_strat {
774                    MergeStrategy::Replace => {
775                        self.issues = p.items.iter().cloned().map(IssueListItem).collect()
776                    }
777                    MergeStrategy::Append => self
778                        .issues
779                        .extend(p.items.iter().cloned().map(IssueListItem)),
780                }
781                let count = self.issues.len().min(u32::MAX as usize) as u32;
782                LOADED_ISSUE_COUNT.store(count, Ordering::Relaxed);
783                self.page = Some(p);
784                self.state = LoadingState::Loaded;
785            }
786            crate::ui::Action::FinishedLoading => {
787                self.state = LoadingState::Loaded;
788            }
789            crate::ui::Action::IssueCloseSuccess { issue } => {
790                let issue = *issue;
791                if let Some(existing) = self.issues.iter_mut().find(|i| i.0.number == issue.number)
792                {
793                    existing.0 = issue.clone();
794                }
795                let initiated_here = self
796                    .close_popup
797                    .as_ref()
798                    .is_some_and(|popup| popup.issue_number == issue.number);
799                if initiated_here {
800                    self.close_popup = None;
801                    self.close_error = None;
802                    if let Some(action_tx) = self.action_tx.as_ref() {
803                        let _ = action_tx
804                            .send(Action::SelectedIssuePreview {
805                                seed: IssuePreviewSeed::from_issue(&issue),
806                            })
807                            .await;
808                        let _ = action_tx.send(Action::RefreshIssueList).await;
809                    }
810                }
811            }
812            crate::ui::Action::IssueCloseError { number, message } => {
813                if let Some(popup) = self.close_popup.as_mut()
814                    && popup.issue_number == number
815                {
816                    popup.loading = false;
817                    popup.error = Some(message.clone());
818                    self.close_error = Some(message);
819                }
820            }
821            crate::ui::Action::IssueLabelsUpdated { number, labels } => {
822                if let Some(issue) = self.issues.iter_mut().find(|i| i.0.number == number) {
823                    issue.0.labels = labels;
824                }
825            }
826            crate::ui::Action::ChangeIssueScreen(screen) => {
827                self.screen = screen;
828                if screen == MainScreen::List {
829                    self.list_state.focus.set(true);
830                } else {
831                    self.list_state.focus.set(false);
832                    self.close_popup = None;
833                }
834            }
835            _ => {}
836        }
837        Ok(())
838    }
839
840    fn should_render(&self) -> bool {
841        self.screen == MainScreen::List
842    }
843
844    fn is_animating(&self) -> bool {
845        self.screen == MainScreen::List
846            && (self.state == LoadingState::Loading
847                || self.assign_loading
848                || self.close_popup.as_ref().is_some_and(|popup| popup.loading))
849    }
850    fn set_index(&mut self, index: usize) {
851        self.index = index;
852    }
853
854    fn set_global_help(&self) {
855        trace!("Setting global help for IssueList");
856        if let Some(action_tx) = self.action_tx.as_ref() {
857            let _ = action_tx.try_send(crate::ui::Action::SetHelp(HELP));
858        }
859    }
860
861    fn capture_focus_event(&self, _event: &crossterm::event::Event) -> bool {
862        self.close_popup.is_some()
863    }
864}
865
866impl HasFocus for IssueList<'_> {
867    fn build(&self, builder: &mut rat_widget::focus::FocusBuilder) {
868        let tag = builder.start(self);
869        builder.widget(&self.list_state);
870        if self.inner_state == IssueListState::AssigningInput {
871            builder.widget(&self.assign_input_state);
872        }
873        builder.end(tag);
874    }
875    fn area(&self) -> ratatui::layout::Rect {
876        self.list_state.area()
877    }
878    fn focus(&self) -> rat_widget::focus::FocusFlag {
879        self.list_state.focus()
880    }
881
882    fn navigable(&self) -> Navigation {
883        if self.screen == MainScreen::List {
884            Navigation::Regular
885        } else {
886            Navigation::None
887        }
888    }
889}