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