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 mut body = wrap(body_text.trim(), options);
774 body.truncate(2);
775
776 let bookmarked = bookmarks.is_bookmarked(&self.owner, &self.repo, issue.number);
777 let bookmark_symbol = if bookmarked { " b " } else { " " };
778 let title = pool.resolve_str(issue.title);
779 let author = pool.author_login(issue.author);
780 let created_at = pool.resolve_str(issue.created_at_full);
781
782 let lines = vec![
783 line![
784 span!(bookmark_symbol).style(if bookmarked {
785 Style::new().reversed()
786 } else {
787 Style::new()
788 }),
789 span!(title.to_string()),
790 " ",
791 span!("#{}", issue.number).dim(),
792 ],
793 line![
794 span!(symbols::shade::FULL).style({
795 if matches!(issue.state, IssueState::Open) {
796 Style::new().green()
797 } else {
798 Style::new().magenta()
799 }
800 }),
801 " ",
802 span!(format!("Opened by {author} at {created_at}")).dim(),
803 ],
804 line![
805 " ",
806 span!(body.join(" ").to_string()).style(Style::new().dim())
807 ],
808 ];
809 ListItem::new(lines)
810 }
811}
812
813pub(crate) fn render_issue_close_popup(
814 popup: &mut IssueClosePopupState,
815 area: Rect,
816 buf: &mut Buffer,
817) {
818 let popup_area = area.centered(Constraint::Percentage(20), Constraint::Length(5));
819 Clear.render(popup_area, buf);
820
821 let mut block = Block::bordered()
822 .border_type(ratatui::widgets::BorderType::Rounded)
823 .title_bottom("Enter: close Esc: cancel")
824 .title(format!("Close issue #{}", popup.issue_number));
825 if let Some(err) = &popup.error {
826 block = block.title(format!("Close issue #{} | {}", popup.issue_number, err));
827 }
828 let inner = block.inner(popup_area);
829 block.render(popup_area, buf);
830
831 if popup.reason_state.selected().is_none() {
832 popup.reason_state.select(Some(0));
833 }
834 let items = CloseIssueReason::ALL
835 .iter()
836 .map(|reason| ListItem::new(reason.label()))
837 .collect::<Vec<_>>();
838 let list = TuiList::new(items)
839 .highlight_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD))
840 .highlight_symbol("> ");
841 StatefulWidget::render(list, inner, buf, &mut popup.reason_state);
842
843 if popup.loading {
844 let title_area = Rect {
845 x: popup_area.x + 1,
846 y: popup_area.y,
847 width: 10,
848 height: 1,
849 };
850 let throbber = Throbber::default()
851 .label("Closing")
852 .style(Style::new().fg(Color::Cyan))
853 .throbber_set(BRAILLE_SIX_DOUBLE)
854 .use_type(WhichUse::Spin);
855 StatefulWidget::render(throbber, title_area, buf, &mut popup.throbber_state);
856 }
857}
858
859pub struct IssueListItem(pub IssueId);
860
861#[async_trait(?Send)]
862impl Component for IssueList<'_> {
863 fn render(&mut self, area: Layout, buf: &mut Buffer) {
864 self.render(area, buf);
865 }
866
867 fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<crate::ui::Action>) {
868 self.action_tx = Some(action_tx);
869 }
870
871 async fn handle_event(&mut self, event: crate::ui::Action) -> Result<(), AppError> {
872 match event {
873 crate::ui::Action::Tick => {
874 if self.state == LoadingState::Loading {
875 self.throbber_state.calc_next();
876 }
877 if self.assign_loading {
878 self.assign_throbber_state.calc_next();
879 }
880 if let Some(popup) = self.close_popup.as_mut()
881 && popup.loading
882 {
883 popup.throbber_state.calc_next();
884 }
885 if let Some(popup) = self.bookmark_popup.as_mut()
886 && !popup.loading_numbers.is_empty()
887 {
888 popup.throbber_state.calc_next();
889 }
890 if let Some(rx) = self.assign_done_rx.as_mut()
891 && rx.try_recv().is_ok()
892 {
893 self.assign_done_rx = None;
894 self.assign_loading = false;
895 self.assign_input_state.set_text("");
896 self.inner_state = IssueListState::Normal;
897 self.list_state.focus.set(true);
898 if let Some(action_tx) = self.action_tx.as_ref() {
899 let _ = action_tx.send(Action::ForceRender).await;
900 }
901 }
902 }
903 crate::ui::Action::AppEvent(ref event) => {
904 if self.screen != MainScreen::List {
905 return Ok(());
906 }
907 if self.handle_bookmark_popup_event(event).await? {
908 return Ok(());
909 }
910 if self.handle_close_popup_event(event).await {
911 return Ok(());
912 }
913
914 match event {
915 ct_event!(key press 'a') if self.list_state.is_focused() => {
916 self.inner_state = IssueListState::AssigningInput;
917 self.assignment_mode = AssignmentMode::Add;
918 self.assign_input_state.set_text("");
919 self.assign_input_state.focus.set(true);
920 self.list_state.focus.set(false);
921 return Ok(());
922 }
923 ct_event!(key press SHIFT-'A') if self.list_state.is_focused() => {
924 self.inner_state = IssueListState::AssigningInput;
925 self.assignment_mode = AssignmentMode::Remove;
926 self.assign_input_state.set_text("");
927 self.assign_input_state.focus.set(true);
928 self.list_state.focus.set(false);
929 return Ok(());
930 }
931 ct_event!(key press SHIFT-'B') if self.list_state.is_focused() => {
932 if self.bookmark_popup.is_some() {
933 self.close_bookmark_popup();
934 } else {
935 self.open_bookmark_popup();
936 }
937 return Ok(());
938 }
939 ct_event!(key press 'b') => {
940 if let Some(selected) = self.list_state.selected_checked() {
941 let issue = {
942 let pool =
943 self.issue_pool.read().expect("issue pool lock poisoned");
944 pool.get_issue(self.issues[selected].0).clone()
945 };
946 {
947 let mut bookmarks =
948 self.bookmarks.write().expect("bookmarks lock poisoned");
949 if bookmarks.is_bookmarked(&self.owner, &self.repo, issue.number) {
950 bookmarks.remove(&self.owner, &self.repo, issue.number);
951 } else {
952 bookmarks.add(&self.owner, &self.repo, issue.number);
953 }
954 }
955 if let Some(action_tx) = self.action_tx.as_ref() {
956 let _ = action_tx.send(Action::ForceRender).await;
957 }
958 }
959 }
960 ct_event!(key press 'n') if self.list_state.is_focused() => {
961 self.action_tx
962 .as_ref()
963 .ok_or_else(|| {
964 AppError::Other(anyhow!("issue list action channel unavailable"))
965 })?
966 .send(crate::ui::Action::EnterIssueCreate)
967 .await?;
968 self.action_tx
969 .as_ref()
970 .ok_or_else(|| {
971 AppError::Other(anyhow!("issue list action channel unavailable"))
972 })?
973 .send(crate::ui::Action::ChangeIssueScreen(
974 MainScreen::CreateIssue,
975 ))
976 .await?;
977 return Ok(());
978 }
979 ct_event!(key press SHIFT-'C')
980 if self.list_state.is_focused()
981 && self.inner_state == IssueListState::Normal =>
982 {
983 self.open_close_popup();
984 return Ok(());
985 }
986 ct_event!(keycode press Esc)
987 if self.inner_state == IssueListState::AssigningInput =>
988 {
989 self.assign_input_state.set_text("");
990 self.inner_state = IssueListState::Normal;
991 self.list_state.focus.set(true);
992 if let Some(action_tx) = self.action_tx.as_ref() {
993 action_tx.send(Action::ForceRender).await?;
994 }
995 return Ok(());
996 }
997
998 ct_event!(key press 'l') if self.list_state.is_focused() => {
999 let Some(selected) = self.list_state.selected_checked() else {
1000 return Ok(());
1001 };
1002 let issue = {
1003 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1004 pool.get_issue(self.issues[selected].0).clone()
1005 };
1006 let link = format!(
1007 "https://github.com/{}/{}/issues/{}",
1008 self.owner, self.repo, issue.number
1009 );
1010
1011 cli_clipboard::set_contents(link)
1012 .map_err(|_| anyhow!("Error copying to clipboard"))?;
1013 if let Some(tx) = self.action_tx.as_ref() {
1014 tx.send(Action::ToastAction(ratatui_toaster::ToastMessage::Show {
1015 message: "Copied Link to Clipboard".to_string(),
1016 toast_type: ToastType::Success,
1017 position: ToastPosition::TopRight,
1018 }))
1019 .await?;
1020 tx.send(Action::ForceRender).await?;
1021 }
1022 }
1023
1024 _ => {}
1025 }
1026 if matches!(event, ct_event!(keycode press Enter))
1027 && self.inner_state == IssueListState::AssigningInput
1028 && !self.assign_loading
1029 && let Some(selected) = self.list_state.selected_checked()
1030 {
1031 let issue = {
1032 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1033 pool.get_issue(self.issues[selected].0).clone()
1034 };
1035 let value: String = self.assign_input_state.value();
1036 let mut assignees = value
1037 .split(',')
1038 .map(|s| s.trim().to_string())
1039 .collect::<Vec<_>>();
1040 if !assignees.is_empty() {
1041 let tx = self
1042 .action_tx
1043 .as_ref()
1044 .ok_or_else(|| {
1045 AppError::Other(anyhow!("issue list action channel unavailable"))
1046 })?
1047 .clone();
1048 let (done_tx, done_rx) = oneshot::channel();
1049 self.assign_done_rx = Some(done_rx);
1050 self.assign_loading = true;
1051 let assignment_mode = self.assignment_mode;
1052 let number = issue.number;
1053 let owner = self.owner.clone();
1054 let repo = self.repo.clone();
1055 tokio::spawn(async move {
1056 let assignees = std::mem::take(&mut assignees);
1057 let assignees = assignees
1058 .iter()
1059 .filter_map(|s| if s.is_empty() { None } else { Some(&**s) })
1060 .collect::<Vec<_>>();
1061
1062 let issue_handler = if let Some(client) = GITHUB_CLIENT.get() {
1063 client.inner().issues(owner, repo)
1064 } else {
1065 let _ = done_tx.send(());
1066 return;
1067 };
1068 let res = match assignment_mode {
1069 AssignmentMode::Add => {
1070 issue_handler
1071 .add_assignees(number, assignees.as_slice())
1072 .await
1073 }
1074 AssignmentMode::Remove => {
1075 issue_handler
1076 .remove_assignees(number, assignees.as_slice())
1077 .await
1078 }
1079 };
1080 if let Ok(issue) = res {
1081 let _ = tx
1082 .send(crate::ui::Action::SelectedIssuePreview {
1083 seed: IssuePreviewSeed::from_issue(&issue),
1084 })
1085 .await;
1086 }
1087 let _ = done_tx.send(());
1088 });
1089 }
1090 }
1091 if matches!(event, ct_event!(keycode press Enter)) && self.list_state.is_focused() {
1092 if let Some(selected) = self.list_state.selected_checked() {
1093 let conversation_seed = {
1094 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1095 let issue = pool.get_issue(self.issues[selected].0);
1096 IssueConversationSeed::from_ui_issue(issue, &pool)
1097 };
1098 self.action_tx
1099 .as_ref()
1100 .ok_or_else(|| {
1101 AppError::Other(anyhow!("issue list action channel unavailable"))
1102 })?
1103 .send(crate::ui::Action::EnterIssueDetails {
1104 seed: conversation_seed,
1105 })
1106 .await?;
1107 self.action_tx
1108 .as_ref()
1109 .ok_or_else(|| {
1110 AppError::Other(anyhow!("issue list action channel unavailable"))
1111 })?
1112 .send(crate::ui::Action::ChangeIssueScreen(MainScreen::Details))
1113 .await?;
1114 }
1115 return Ok(());
1116 }
1117
1118 self.assign_input_state
1119 .handle(event, rat_widget::event::Regular);
1120 if let rat_widget::event::Outcome::Changed =
1121 self.list_state.handle(event, rat_widget::event::Regular)
1122 {
1123 let selected = self.list_state.selected_checked();
1124 if let Some(selected) = selected {
1125 if selected == self.issues.len() - 1
1126 && let Some(page) = &self.page
1127 {
1128 let tx = self
1129 .action_tx
1130 .as_ref()
1131 .ok_or_else(|| {
1132 AppError::Other(anyhow!(
1133 "issue list action channel unavailable"
1134 ))
1135 })?
1136 .clone();
1137 let page_next = page.next.clone();
1138 self.state = LoadingState::Loading;
1139 tokio::spawn(async move {
1140 let Some(client) = GITHUB_CLIENT.get() else {
1141 let _ = tx.send(crate::ui::Action::FinishedLoading).await;
1142 return;
1143 };
1144 let p = client.inner().get_page::<Issue>(&page_next).await;
1145 if let Ok(pres) = p
1146 && let Some(mut p) = pres
1147 {
1148 let items = std::mem::take(&mut p.items);
1149 let items = items
1150 .into_iter()
1151 .filter(|i| i.pull_request.is_none())
1152 .collect();
1153 p.items = items;
1154 let _ = tx
1155 .send(crate::ui::Action::NewPage(
1156 Arc::new(p),
1157 MergeStrategy::Append,
1158 ))
1159 .await;
1160 }
1161 let _ = tx.send(crate::ui::Action::FinishedLoading).await;
1162 });
1163 }
1164 let (issue_number, labels, preview_seed) = {
1165 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1166 let issue = pool.get_issue(self.issues[selected].0);
1167 (
1168 issue.number,
1169 issue.labels.clone(),
1170 IssuePreviewSeed::from_ui_issue(issue, &pool),
1171 )
1172 };
1173 self.action_tx
1174 .as_ref()
1175 .ok_or_else(|| {
1176 AppError::Other(anyhow!("issue list action channel unavailable"))
1177 })?
1178 .send(crate::ui::Action::SelectedIssue {
1179 number: issue_number,
1180 labels,
1181 })
1182 .await?;
1183 self.action_tx
1184 .as_ref()
1185 .ok_or_else(|| {
1186 AppError::Other(anyhow!("issue list action channel unavailable"))
1187 })?
1188 .send(crate::ui::Action::SelectedIssuePreview { seed: preview_seed })
1189 .await?;
1190 }
1191 }
1192 }
1193 crate::ui::Action::NewPage(p, merge_strat) => {
1194 trace!("New Page with {} issues", p.items.len());
1195 let converted = {
1196 let mut pool = self.issue_pool.write().expect("issue pool lock poisoned");
1197 p.items
1198 .iter()
1199 .map(|issue| {
1200 let compact = UiIssue::from_octocrab(issue, &mut pool);
1201 IssueListItem(pool.upsert_issue(compact))
1202 })
1203 .collect::<Vec<_>>()
1204 };
1205 match merge_strat {
1206 MergeStrategy::Replace => self.issues = converted,
1207 MergeStrategy::Append => self.issues.extend(converted),
1208 }
1209 let count = self.issues.len().min(u32::MAX as usize) as u32;
1210 LOADED_ISSUE_COUNT.store(count, Ordering::Relaxed);
1211 let mut page_meta = (*p).clone();
1212 page_meta.items.clear();
1213 self.page = Some(Arc::new(page_meta));
1214 self.state = LoadingState::Loaded;
1215 }
1216 crate::ui::Action::FinishedLoading => {
1217 self.state = LoadingState::Loaded;
1218 }
1219 crate::ui::Action::IssueCloseSuccess { issue_id } => {
1220 let (issue_number, preview_seed) = {
1221 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1222 let compact = pool.get_issue(issue_id);
1223 (
1224 compact.number,
1225 IssuePreviewSeed::from_ui_issue(compact, &pool),
1226 )
1227 };
1228 let existing_idx = {
1229 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1230 self.issues
1231 .iter()
1232 .position(|item| pool.get_issue(item.0).number == issue_number)
1233 };
1234 if let Some(existing_idx) = existing_idx {
1235 self.issues[existing_idx].0 = issue_id;
1236 }
1237 let initiated_here = self
1238 .close_popup
1239 .as_ref()
1240 .is_some_and(|popup| popup.issue_number == issue_number);
1241 if initiated_here {
1242 self.close_popup = None;
1243 self.close_error = None;
1244 if let Some(action_tx) = self.action_tx.as_ref() {
1245 let _ = action_tx
1246 .send(Action::SelectedIssuePreview { seed: preview_seed })
1247 .await;
1248 let _ = action_tx.send(Action::RefreshIssueList).await;
1249 }
1250 }
1251 }
1252 crate::ui::Action::IssueCloseError { number, message } => {
1253 if let Some(popup) = self.close_popup.as_mut()
1254 && popup.issue_number == number
1255 {
1256 popup.loading = false;
1257 popup.error = Some(message.clone());
1258 self.close_error = Some(message);
1259 }
1260 }
1261 crate::ui::Action::IssueLabelsUpdated { number, labels } => {
1262 let issue_id = {
1263 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1264 self.issues.iter().find_map(|item| {
1265 (pool.get_issue(item.0).number == number).then_some(item.0)
1266 })
1267 };
1268 if let Some(issue_id) = issue_id {
1269 let mut pool = self.issue_pool.write().expect("issue pool lock poisoned");
1270 pool.get_issue_mut(issue_id).labels = labels;
1271 }
1272 }
1273 crate::ui::Action::BookmarkTitleLoaded { number, title } => {
1274 self.bookmark_titles.insert(number, title);
1275 self.bookmark_title_errors.remove(&number);
1276 if let Some(popup) = self.bookmark_popup.as_mut() {
1277 popup.loading_numbers.remove(&number);
1278 }
1279 }
1280 crate::ui::Action::BookmarkTitleLoadError { number, message } => {
1281 self.bookmark_title_errors.insert(number, message);
1282 if let Some(popup) = self.bookmark_popup.as_mut() {
1283 popup.loading_numbers.remove(&number);
1284 }
1285 }
1286 crate::ui::Action::BookmarkedIssueLoaded { issue_id } => {
1287 let (issue_number, labels, preview_seed, conversation_seed) = {
1288 let pool = self.issue_pool.read().expect("issue pool lock poisoned");
1289 let compact = pool.get_issue(issue_id);
1290 (
1291 compact.number,
1292 compact.labels.clone(),
1293 IssuePreviewSeed::from_ui_issue(compact, &pool),
1294 IssueConversationSeed::from_ui_issue(compact, &pool),
1295 )
1296 };
1297 let should_open = self
1298 .bookmark_popup
1299 .as_ref()
1300 .is_some_and(|popup| popup.opening_issue == Some(issue_number));
1301 if !should_open {
1302 return Ok(());
1303 }
1304
1305 let number = issue_number;
1306 self.close_bookmark_popup();
1307
1308 if let Some(action_tx) = self.action_tx.as_ref() {
1309 action_tx
1310 .send(Action::SelectedIssue { number, labels })
1311 .await?;
1312 action_tx
1313 .send(Action::SelectedIssuePreview { seed: preview_seed })
1314 .await?;
1315 action_tx
1316 .send(Action::EnterIssueDetails {
1317 seed: conversation_seed,
1318 })
1319 .await?;
1320 action_tx
1321 .send(Action::ChangeIssueScreen(MainScreen::Details))
1322 .await?;
1323 }
1324 }
1325 crate::ui::Action::BookmarkedIssueLoadError { number, message } => {
1326 if let Some(popup) = self.bookmark_popup.as_mut()
1327 && popup.opening_issue == Some(number)
1328 {
1329 popup.opening_issue = None;
1330 self.bookmark_error = Some(message.to_string());
1331 }
1332 }
1333 crate::ui::Action::ChangeIssueScreen(screen) => {
1334 self.screen = screen;
1335 if screen == MainScreen::List {
1336 self.list_state.focus.set(true);
1337 } else {
1338 self.close_popup = None;
1339 self.close_bookmark_popup();
1340 self.list_state.focus.set(false);
1341 }
1342 }
1343 _ => {}
1344 }
1345 Ok(())
1346 }
1347
1348 fn should_render(&self) -> bool {
1349 self.screen == MainScreen::List
1350 }
1351
1352 fn is_animating(&self) -> bool {
1353 self.screen == MainScreen::List
1354 && (self.state == LoadingState::Loading
1355 || self.assign_loading
1356 || self.close_popup.as_ref().is_some_and(|popup| popup.loading)
1357 || self
1358 .bookmark_popup
1359 .as_ref()
1360 .is_some_and(|popup| !popup.loading_numbers.is_empty()))
1361 }
1362 fn set_index(&mut self, index: usize) {
1363 self.index = index;
1364 }
1365
1366 fn set_global_help(&self) {
1367 trace!("Setting global help for IssueList");
1368 if let Some(action_tx) = self.action_tx.as_ref() {
1369 let _ = action_tx.try_send(crate::ui::Action::SetHelp(HELP));
1370 }
1371 }
1372
1373 fn capture_focus_event(&self, _event: &crossterm::event::Event) -> bool {
1374 self.close_popup.is_some() || self.bookmark_popup.is_some()
1375 }
1376}
1377
1378impl HasFocus for IssueList<'_> {
1379 fn build(&self, builder: &mut rat_widget::focus::FocusBuilder) {
1380 let tag = builder.start(self);
1381 builder.widget(&self.list_state);
1382 if self.inner_state == IssueListState::AssigningInput {
1383 builder.widget(&self.assign_input_state);
1384 }
1385 builder.end(tag);
1386 }
1387 fn area(&self) -> ratatui::layout::Rect {
1388 self.list_state.area()
1389 }
1390 fn focus(&self) -> rat_widget::focus::FocusFlag {
1391 self.list_state.focus()
1392 }
1393
1394 fn navigable(&self) -> Navigation {
1395 if self.screen == MainScreen::List {
1396 Navigation::Regular
1397 } else {
1398 Navigation::None
1399 }
1400 }
1401}