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