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