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}