Skip to main content

gitv_tui/ui/components/
issue_list.rs

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