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