1use crate::{
2 app::GITHUB_CLIENT,
3 errors::AppError,
4 ui::{
5 Action, CloseIssueReason, MergeStrategy,
6 components::{
7 Component, help::HelpElementKind, issue_conversation::IssueConversationSeed,
8 issue_detail::IssuePreviewSeed,
9 },
10 layout::Layout,
11 utils::get_border_style,
12 },
13};
14use anyhow::anyhow;
15use async_trait::async_trait;
16use octocrab::{
17 Page,
18 issues::IssueHandler,
19 models::{IssueState, issues::Issue},
20};
21use rat_widget::{
22 event::{HandleEvent, ct_event},
23 focus::{HasFocus, Navigation},
24 list::selection::RowSelection,
25 text_input::TextInputState,
26};
27use ratatui::{
28 buffer::Buffer,
29 layout::{Constraint, Rect},
30 style::{Color, Modifier, Style, Stylize},
31 symbols,
32 widgets::{
33 Block, Clear, List as TuiList, ListItem, ListState as TuiListState, Padding,
34 StatefulWidget, Widget,
35 },
36};
37use ratatui_macros::{line, span, vertical};
38use ratatui_toaster::{ToastPosition, ToastType};
39use std::sync::{
40 Arc,
41 atomic::{AtomicU32, Ordering},
42};
43use textwrap::{Options, wrap};
44use throbber_widgets_tui::ThrobberState;
45use tokio::sync::oneshot;
46use tracing::trace;
47
48pub static LOADED_ISSUE_COUNT: AtomicU32 = AtomicU32::new(0);
49pub const HELP: &[HelpElementKind] = &[
50 crate::help_text!("Issue List Help"),
51 crate::help_keybind!("Up/Down", "navigate issues"),
52 crate::help_keybind!("Enter", "view issue details"),
53 crate::help_keybind!("C", "close selected issue"),
54 crate::help_keybind!("l", "copy issue link to clipboard"),
55 crate::help_keybind!("Enter (popup)", "confirm close reason"),
56 crate::help_keybind!("a", "add assignee(s)"),
57 crate::help_keybind!("A", "remove assignee(s)"),
58 crate::help_keybind!("n", "create new issue"),
59 crate::help_keybind!("Esc", "cancel popup / assign input"),
60];
61pub struct IssueList<'a> {
62 pub issues: Vec<IssueListItem>,
63 pub page: Option<Arc<Page<Issue>>>,
64 pub list_state: rat_widget::list::ListState<RowSelection>,
65 pub handler: IssueHandler<'a>,
66 pub action_tx: Option<tokio::sync::mpsc::Sender<crate::ui::Action>>,
67 pub throbber_state: ThrobberState,
68 pub assign_throbber_state: ThrobberState,
69 pub assign_input_state: rat_widget::text_input::TextInputState,
70 assign_loading: bool,
71 assign_done_rx: Option<oneshot::Receiver<()>>,
72 close_popup: Option<IssueClosePopupState>,
73 close_error: Option<String>,
74 pub owner: String,
75 pub repo: String,
76 index: usize,
77 state: LoadingState,
78 inner_state: IssueListState,
79 assignment_mode: AssignmentMode,
80 pub screen: MainScreen,
81}
82
83#[derive(Debug)]
84pub(crate) struct IssueClosePopupState {
85 pub(crate) issue_number: u64,
86 pub(crate) loading: bool,
87 pub(crate) throbber_state: ThrobberState,
88 pub(crate) error: Option<String>,
89 reason_state: TuiListState,
90}
91
92impl IssueClosePopupState {
93 pub(crate) fn new(issue_number: u64) -> Self {
94 let mut reason_state = TuiListState::default();
95 reason_state.select(Some(0));
96 Self {
97 issue_number,
98 loading: false,
99 throbber_state: ThrobberState::default(),
100 error: None,
101 reason_state,
102 }
103 }
104
105 pub(crate) fn select_next_reason(&mut self) {
106 self.reason_state.select_next();
107 }
108
109 pub(crate) fn select_prev_reason(&mut self) {
110 self.reason_state.select_previous();
111 }
112
113 pub(crate) fn selected_reason(&self) -> CloseIssueReason {
114 self.reason_state
115 .selected()
116 .and_then(|idx| CloseIssueReason::ALL.get(idx).copied())
117 .unwrap_or(CloseIssueReason::Completed)
118 }
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
122enum IssueListState {
123 #[default]
124 Normal,
125 AssigningInput,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
129enum AssignmentMode {
130 #[default]
131 Add,
132 Remove,
133}
134
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
136enum LoadingState {
137 #[default]
138 Loading,
139 Loaded,
140}
141
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143pub enum MainScreen {
144 #[default]
145 List,
146 Details,
147 DetailsFullscreen,
148 CreateIssue,
149}
150
151impl<'a> IssueList<'a> {
152 pub async fn new(
153 handler: IssueHandler<'a>,
154 owner: String,
155 repo: String,
156 tx: tokio::sync::mpsc::Sender<Action>,
157 ) -> Self {
158 LOADED_ISSUE_COUNT.store(0, Ordering::Relaxed);
159 let owner_clone = owner.clone();
160 let repo_clone = repo.clone();
161 tokio::spawn(async move {
162 let Some(client) = GITHUB_CLIENT.get() else {
163 return;
164 };
165 let Ok(mut p) = client
166 .inner()
167 .search()
168 .issues_and_pull_requests(&format!(
169 "repo:{}/{} is:issue is:open",
170 owner_clone, repo_clone
171 ))
172 .page(1u32)
173 .per_page(15u8)
174 .send()
175 .await
176 else {
177 return;
178 };
179 let items = std::mem::take(&mut p.items);
180 p.items = items;
181
182 let _ = tx
183 .send(Action::NewPage(Arc::new(p), MergeStrategy::Append))
184 .await;
185 });
186 Self {
187 page: None,
188 owner,
189 repo,
190 throbber_state: ThrobberState::default(),
191 action_tx: None,
192 issues: vec![],
193 list_state: rat_widget::list::ListState::default(),
194 assign_throbber_state: ThrobberState::default(),
195 assign_input_state: TextInputState::default(),
196 assign_loading: false,
197 assign_done_rx: None,
198 close_popup: None,
199 close_error: None,
200 handler,
201 index: 0,
202 screen: MainScreen::default(),
203 state: LoadingState::default(),
204 inner_state: IssueListState::default(),
205 assignment_mode: AssignmentMode::default(),
206 }
207 }
208
209 fn open_close_popup(&mut self) {
210 let Some(selected) = self.list_state.selected_checked() else {
211 self.close_error = Some("No issue selected.".to_string());
212 return;
213 };
214 let Some(issue) = self.issues.get(selected).map(|item| &item.0) else {
215 self.close_error = Some("No issue selected.".to_string());
216 return;
217 };
218 if issue.state == IssueState::Closed {
219 self.close_error = Some("Selected issue is already closed.".to_string());
220 return;
221 }
222 self.close_error = None;
223 self.close_popup = Some(IssueClosePopupState::new(issue.number));
224 }
225
226 fn render_close_popup(&mut self, area: Rect, buf: &mut Buffer) {
227 let Some(popup) = self.close_popup.as_mut() else {
228 return;
229 };
230 render_issue_close_popup(popup, area, buf);
231 }
232
233 async fn submit_close_popup(&mut self) {
234 let Some(popup) = self.close_popup.as_mut() else {
235 return;
236 };
237 if popup.loading {
238 return;
239 }
240 let reason = popup.selected_reason();
241 let number = popup.issue_number;
242 popup.loading = true;
243 popup.error = None;
244
245 let Some(action_tx) = self.action_tx.clone() else {
246 popup.loading = false;
247 popup.error = Some("Action channel unavailable.".to_string());
248 return;
249 };
250 let owner = self.owner.clone();
251 let repo = self.repo.clone();
252 tokio::spawn(async move {
253 let Some(client) = GITHUB_CLIENT.get() else {
254 let _ = action_tx
255 .send(Action::IssueCloseError {
256 number,
257 message: "GitHub client not initialized.".to_string(),
258 })
259 .await;
260 return;
261 };
262 let issues = client.inner().issues(owner, repo);
263 match issues
264 .update(number)
265 .state(IssueState::Closed)
266 .state_reason(reason.to_octocrab())
267 .send()
268 .await
269 {
270 Ok(issue) => {
271 let _ = action_tx
272 .send(Action::IssueCloseSuccess {
273 issue: Box::new(issue),
274 })
275 .await;
276 }
277 Err(err) => {
278 let _ = action_tx
279 .send(Action::IssueCloseError {
280 number,
281 message: err.to_string().replace('\n', " "),
282 })
283 .await;
284 }
285 }
286 });
287 }
288
289 async fn handle_close_popup_event(&mut self, event: &crossterm::event::Event) -> bool {
290 let Some(popup) = self.close_popup.as_mut() else {
291 return false;
292 };
293 if popup.loading {
294 if matches!(event, ct_event!(keycode press Esc)) {
295 popup.loading = false;
296 }
297 return true;
298 }
299 if matches!(event, ct_event!(keycode press Esc)) {
300 self.close_popup = None;
301 return true;
302 }
303 if matches!(event, ct_event!(keycode press Up)) {
304 popup.select_prev_reason();
305 return true;
306 }
307 if matches!(event, ct_event!(keycode press Down)) {
308 popup.select_next_reason();
309 return true;
310 }
311 if matches!(event, ct_event!(keycode press Enter)) {
312 self.submit_close_popup().await;
313 return true;
314 }
315 true
316 }
317
318 pub fn render(&mut self, mut area: Layout, buf: &mut Buffer) {
319 if self.assign_input_state.lost_focus() {
320 self.inner_state = IssueListState::Normal;
321 }
322
323 let mut assign_input_area = Rect::default();
324 if self.inner_state == IssueListState::AssigningInput {
325 let split = vertical![*=1, ==3].split(area.main_content);
326 area.main_content = split[0];
327 assign_input_area = split[1];
328 }
329 let mut block = Block::bordered()
330 .border_type(ratatui::widgets::BorderType::Rounded)
331 .border_style(get_border_style(&self.list_state))
332 .padding(Padding::horizontal(3));
333 if self.state != LoadingState::Loading {
334 let mut title = format!("[{}] Issues", self.index);
335 if let Some(err) = &self.close_error {
336 title.push_str(" | ");
337 title.push_str(err);
338 }
339 block = block.title(title);
340 }
341 let list = rat_widget::list::List::<RowSelection>::new(
342 self.issues.iter().map(Into::<ListItem>::into),
343 )
344 .block(block)
345 .style(Style::default())
346 .focus_style(Style::default().reversed().add_modifier(Modifier::BOLD));
347 list.render(area.main_content, buf, &mut self.list_state);
348 if self.state == LoadingState::Loading {
349 let title_area = Rect {
350 x: area.main_content.x + 1,
351 y: area.main_content.y,
352 width: 10,
353 height: 1,
354 };
355 let full = throbber_widgets_tui::Throbber::default()
356 .label("Loading")
357 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
358 .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
359 .use_type(throbber_widgets_tui::WhichUse::Spin);
360 StatefulWidget::render(full, title_area, buf, &mut self.throbber_state);
361 }
362 if self.inner_state == IssueListState::AssigningInput {
363 let mut input_block = Block::bordered()
364 .border_type(ratatui::widgets::BorderType::Rounded)
365 .border_style(get_border_style(&self.assign_input_state));
366 if !self.assign_loading {
367 input_block = input_block.title(match self.assignment_mode {
368 AssignmentMode::Add => "Assign to",
369 AssignmentMode::Remove => "Remove assignee(s)",
370 });
371 }
372 let input = rat_widget::text_input::TextInput::new().block(input_block);
373 input.render(assign_input_area, buf, &mut self.assign_input_state);
374 if self.assign_loading {
375 let title_area = Rect {
376 x: assign_input_area.x + 1,
377 y: assign_input_area.y,
378 width: 10,
379 height: 1,
380 };
381 let full = throbber_widgets_tui::Throbber::default()
382 .label("Loading")
383 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
384 .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
385 .use_type(throbber_widgets_tui::WhichUse::Spin);
386 StatefulWidget::render(full, title_area, buf, &mut self.assign_throbber_state);
387 }
388 }
389 self.render_close_popup(area.main_content, buf);
390 }
391}
392
393pub(crate) fn render_issue_close_popup(
394 popup: &mut IssueClosePopupState,
395 area: Rect,
396 buf: &mut Buffer,
397) {
398 let popup_area = area.centered(Constraint::Percentage(20), Constraint::Length(5));
399 Clear.render(popup_area, buf);
400
401 let mut block = Block::bordered()
402 .border_type(ratatui::widgets::BorderType::Rounded)
403 .title_bottom("Enter: close Esc: cancel")
404 .title(format!("Close issue #{}", popup.issue_number));
405 if let Some(err) = &popup.error {
406 block = block.title(format!("Close issue #{} | {}", popup.issue_number, err));
407 }
408 let inner = block.inner(popup_area);
409 block.render(popup_area, buf);
410
411 if popup.reason_state.selected().is_none() {
412 popup.reason_state.select(Some(0));
413 }
414 let items = CloseIssueReason::ALL
415 .iter()
416 .map(|reason| ListItem::new(reason.label()))
417 .collect::<Vec<_>>();
418 let list = TuiList::new(items)
419 .highlight_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD))
420 .highlight_symbol("> ");
421 StatefulWidget::render(list, inner, buf, &mut popup.reason_state);
422
423 if popup.loading {
424 let title_area = Rect {
425 x: popup_area.x + 1,
426 y: popup_area.y,
427 width: 10,
428 height: 1,
429 };
430 let throbber = throbber_widgets_tui::Throbber::default()
431 .label("Closing")
432 .style(Style::new().fg(Color::Cyan))
433 .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
434 .use_type(throbber_widgets_tui::WhichUse::Spin);
435 StatefulWidget::render(throbber, title_area, buf, &mut popup.throbber_state);
436 }
437}
438
439pub struct IssueListItem(pub Issue);
440
441impl std::ops::Deref for IssueListItem {
442 type Target = Issue;
443
444 fn deref(&self) -> &Self::Target {
445 &self.0
446 }
447}
448
449impl From<Issue> for IssueListItem {
450 fn from(issue: Issue) -> Self {
451 Self(issue)
452 }
453}
454
455impl From<&IssueListItem> for ListItem<'_> {
456 fn from(value: &IssueListItem) -> Self {
457 let options = Options::with_termwidth();
458 let binding = value.body.clone().unwrap_or("No desc provided".to_string());
459 let mut body = wrap(binding.trim(), options);
460 body.truncate(2);
461
462 let lines = vec![
463 line![
464 " ",
465 span!(value.0.title.as_str()),
466 " ",
467 span!("#{}", value.0.number).dim(),
468 ],
469 line![
470 span!(symbols::shade::FULL).style({
471 if matches!(value.0.state, IssueState::Open) {
472 Style::new().green()
473 } else {
474 Style::new().magenta()
475 }
476 }),
477 " ",
478 span!(
479 "Opened by {} at {}",
480 value.0.user.login,
481 value.0.created_at.format("%Y-%m-%d %H:%M:%S")
482 )
483 .dim(),
484 ],
485 line![" ", span!(body.join(" ")).style(Style::new().dim())],
486 ];
487 ListItem::new(lines)
488 }
489}
490
491#[async_trait(?Send)]
492impl Component for IssueList<'_> {
493 fn render(&mut self, area: Layout, buf: &mut Buffer) {
494 self.render(area, buf);
495 }
496
497 fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<crate::ui::Action>) {
498 self.action_tx = Some(action_tx);
499 }
500
501 async fn handle_event(&mut self, event: crate::ui::Action) -> Result<(), AppError> {
502 match event {
503 crate::ui::Action::Tick => {
504 if self.state == LoadingState::Loading {
505 self.throbber_state.calc_next();
506 }
507 if self.assign_loading {
508 self.assign_throbber_state.calc_next();
509 }
510 if let Some(popup) = self.close_popup.as_mut()
511 && popup.loading
512 {
513 popup.throbber_state.calc_next();
514 }
515 if let Some(rx) = self.assign_done_rx.as_mut()
516 && rx.try_recv().is_ok()
517 {
518 self.assign_done_rx = None;
519 self.assign_loading = false;
520 self.assign_input_state.set_text("");
521 self.inner_state = IssueListState::Normal;
522 self.list_state.focus.set(true);
523 if let Some(action_tx) = self.action_tx.as_ref() {
524 let _ = action_tx.send(Action::ForceRender).await;
525 }
526 }
527 }
528 crate::ui::Action::AppEvent(ref event) => {
529 if self.screen != MainScreen::List {
530 return Ok(());
531 }
532 if self.handle_close_popup_event(event).await {
533 return Ok(());
534 }
535
536 match event {
537 ct_event!(key press 'a') if self.list_state.is_focused() => {
538 self.inner_state = IssueListState::AssigningInput;
539 self.assignment_mode = AssignmentMode::Add;
540 self.assign_input_state.set_text("");
541 self.assign_input_state.focus.set(true);
542 self.list_state.focus.set(false);
543 return Ok(());
544 }
545 ct_event!(key press SHIFT-'A') if self.list_state.is_focused() => {
546 self.inner_state = IssueListState::AssigningInput;
547 self.assignment_mode = AssignmentMode::Remove;
548 self.assign_input_state.set_text("");
549 self.assign_input_state.focus.set(true);
550 self.list_state.focus.set(false);
551 return Ok(());
552 }
553 ct_event!(key press 'n') if self.list_state.is_focused() => {
554 self.action_tx
555 .as_ref()
556 .ok_or_else(|| {
557 AppError::Other(anyhow!("issue list action channel unavailable"))
558 })?
559 .send(crate::ui::Action::EnterIssueCreate)
560 .await?;
561 self.action_tx
562 .as_ref()
563 .ok_or_else(|| {
564 AppError::Other(anyhow!("issue list action channel unavailable"))
565 })?
566 .send(crate::ui::Action::ChangeIssueScreen(
567 MainScreen::CreateIssue,
568 ))
569 .await?;
570 return Ok(());
571 }
572 ct_event!(key press SHIFT-'C')
573 if self.list_state.is_focused()
574 && self.inner_state == IssueListState::Normal =>
575 {
576 self.open_close_popup();
577 return Ok(());
578 }
579 ct_event!(keycode press Esc)
580 if self.inner_state == IssueListState::AssigningInput =>
581 {
582 self.assign_input_state.set_text("");
583 self.inner_state = IssueListState::Normal;
584 self.list_state.focus.set(true);
585 if let Some(action_tx) = self.action_tx.as_ref() {
586 action_tx.send(Action::ForceRender).await?;
587 }
588 return Ok(());
589 }
590
591 ct_event!(key press 'l') if self.list_state.is_focused() => {
592 let Some(selected) = self.list_state.selected_checked() else {
593 return Ok(());
594 };
595 let issue = &self.issues[selected].0;
596 let link = format!(
597 "https://github.com/{}/{}/issues/{}",
598 self.owner, self.repo, issue.number
599 );
600
601 cli_clipboard::set_contents(link)
602 .map_err(|_| anyhow!("Error copying to clipboard"))?;
603 if let Some(tx) = self.action_tx.as_ref() {
604 tx.send(Action::ToastAction(ratatui_toaster::ToastMessage::Show {
605 message: "Copied Link to Clipboard".to_string(),
606 toast_type: ToastType::Success,
607 position: ToastPosition::TopRight,
608 }))
609 .await?;
610 tx.send(Action::ForceRender).await?;
611 }
612 }
613
614 _ => {}
615 }
616 if matches!(event, ct_event!(keycode press Enter))
617 && self.inner_state == IssueListState::AssigningInput
618 && !self.assign_loading
619 && let Some(selected) = self.list_state.selected_checked()
620 {
621 let issue = &self.issues[selected].0;
622 let value: String = self.assign_input_state.value();
623 let mut assignees = value
624 .split(',')
625 .map(|s| s.trim().to_string())
626 .collect::<Vec<_>>();
627 if !assignees.is_empty() {
628 let tx = self
629 .action_tx
630 .as_ref()
631 .ok_or_else(|| {
632 AppError::Other(anyhow!("issue list action channel unavailable"))
633 })?
634 .clone();
635 let (done_tx, done_rx) = oneshot::channel();
636 self.assign_done_rx = Some(done_rx);
637 self.assign_loading = true;
638 let assignment_mode = self.assignment_mode;
639 let number = issue.number;
640 let owner = self.owner.clone();
641 let repo = self.repo.clone();
642 tokio::spawn(async move {
643 let assignees = std::mem::take(&mut assignees);
644 let assignees = assignees
645 .iter()
646 .filter_map(|s| if s.is_empty() { None } else { Some(&**s) })
647 .collect::<Vec<_>>();
648
649 let issue_handler = if let Some(client) = GITHUB_CLIENT.get() {
650 client.inner().issues(owner, repo)
651 } else {
652 let _ = done_tx.send(());
653 return;
654 };
655 let res = match assignment_mode {
656 AssignmentMode::Add => {
657 issue_handler
658 .add_assignees(number, assignees.as_slice())
659 .await
660 }
661 AssignmentMode::Remove => {
662 issue_handler
663 .remove_assignees(number, assignees.as_slice())
664 .await
665 }
666 };
667 if let Ok(issue) = res {
668 let _ = tx
669 .send(crate::ui::Action::SelectedIssuePreview {
670 seed: IssuePreviewSeed::from_issue(&issue),
671 })
672 .await;
673 }
674 let _ = done_tx.send(());
675 });
676 }
677 }
678 if matches!(event, ct_event!(keycode press Enter)) && self.list_state.is_focused() {
679 if let Some(selected) = self.list_state.selected_checked() {
680 let issue = &self.issues[selected].0;
681 self.action_tx
682 .as_ref()
683 .ok_or_else(|| {
684 AppError::Other(anyhow!("issue list action channel unavailable"))
685 })?
686 .send(crate::ui::Action::EnterIssueDetails {
687 seed: IssueConversationSeed::from_issue(issue),
688 })
689 .await?;
690 self.action_tx
691 .as_ref()
692 .ok_or_else(|| {
693 AppError::Other(anyhow!("issue list action channel unavailable"))
694 })?
695 .send(crate::ui::Action::ChangeIssueScreen(MainScreen::Details))
696 .await?;
697 }
698 return Ok(());
699 }
700
701 self.assign_input_state
702 .handle(event, rat_widget::event::Regular);
703 if let rat_widget::event::Outcome::Changed =
704 self.list_state.handle(event, rat_widget::event::Regular)
705 {
706 let selected = self.list_state.selected_checked();
707 if let Some(selected) = selected {
708 if selected == self.issues.len() - 1
709 && let Some(page) = &self.page
710 {
711 let tx = self
712 .action_tx
713 .as_ref()
714 .ok_or_else(|| {
715 AppError::Other(anyhow!(
716 "issue list action channel unavailable"
717 ))
718 })?
719 .clone();
720 let page_next = page.next.clone();
721 self.state = LoadingState::Loading;
722 tokio::spawn(async move {
723 let Some(client) = GITHUB_CLIENT.get() else {
724 let _ = tx.send(crate::ui::Action::FinishedLoading).await;
725 return;
726 };
727 let p = client.inner().get_page::<Issue>(&page_next).await;
728 if let Ok(pres) = p
729 && let Some(mut p) = pres
730 {
731 let items = std::mem::take(&mut p.items);
732 let items = items
733 .into_iter()
734 .filter(|i| i.pull_request.is_none())
735 .collect();
736 p.items = items;
737 let _ = tx
738 .send(crate::ui::Action::NewPage(
739 Arc::new(p),
740 MergeStrategy::Append,
741 ))
742 .await;
743 }
744 let _ = tx.send(crate::ui::Action::FinishedLoading).await;
745 });
746 }
747 let issue = &self.issues[selected].0;
748 let labels = &issue.labels;
749 self.action_tx
750 .as_ref()
751 .ok_or_else(|| {
752 AppError::Other(anyhow!("issue list action channel unavailable"))
753 })?
754 .send(crate::ui::Action::SelectedIssue {
755 number: issue.number,
756 labels: labels.clone(),
757 })
758 .await?;
759 self.action_tx
760 .as_ref()
761 .ok_or_else(|| {
762 AppError::Other(anyhow!("issue list action channel unavailable"))
763 })?
764 .send(crate::ui::Action::SelectedIssuePreview {
765 seed: IssuePreviewSeed::from_issue(issue),
766 })
767 .await?;
768 }
769 }
770 }
771 crate::ui::Action::NewPage(p, merge_strat) => {
772 trace!("New Page with {} issues", p.items.len());
773 match merge_strat {
774 MergeStrategy::Replace => {
775 self.issues = p.items.iter().cloned().map(IssueListItem).collect()
776 }
777 MergeStrategy::Append => self
778 .issues
779 .extend(p.items.iter().cloned().map(IssueListItem)),
780 }
781 let count = self.issues.len().min(u32::MAX as usize) as u32;
782 LOADED_ISSUE_COUNT.store(count, Ordering::Relaxed);
783 self.page = Some(p);
784 self.state = LoadingState::Loaded;
785 }
786 crate::ui::Action::FinishedLoading => {
787 self.state = LoadingState::Loaded;
788 }
789 crate::ui::Action::IssueCloseSuccess { issue } => {
790 let issue = *issue;
791 if let Some(existing) = self.issues.iter_mut().find(|i| i.0.number == issue.number)
792 {
793 existing.0 = issue.clone();
794 }
795 let initiated_here = self
796 .close_popup
797 .as_ref()
798 .is_some_and(|popup| popup.issue_number == issue.number);
799 if initiated_here {
800 self.close_popup = None;
801 self.close_error = None;
802 if let Some(action_tx) = self.action_tx.as_ref() {
803 let _ = action_tx
804 .send(Action::SelectedIssuePreview {
805 seed: IssuePreviewSeed::from_issue(&issue),
806 })
807 .await;
808 let _ = action_tx.send(Action::RefreshIssueList).await;
809 }
810 }
811 }
812 crate::ui::Action::IssueCloseError { number, message } => {
813 if let Some(popup) = self.close_popup.as_mut()
814 && popup.issue_number == number
815 {
816 popup.loading = false;
817 popup.error = Some(message.clone());
818 self.close_error = Some(message);
819 }
820 }
821 crate::ui::Action::IssueLabelsUpdated { number, labels } => {
822 if let Some(issue) = self.issues.iter_mut().find(|i| i.0.number == number) {
823 issue.0.labels = labels;
824 }
825 }
826 crate::ui::Action::ChangeIssueScreen(screen) => {
827 self.screen = screen;
828 if screen == MainScreen::List {
829 self.list_state.focus.set(true);
830 } else {
831 self.list_state.focus.set(false);
832 self.close_popup = None;
833 }
834 }
835 _ => {}
836 }
837 Ok(())
838 }
839
840 fn should_render(&self) -> bool {
841 self.screen == MainScreen::List
842 }
843
844 fn is_animating(&self) -> bool {
845 self.screen == MainScreen::List
846 && (self.state == LoadingState::Loading
847 || self.assign_loading
848 || self.close_popup.as_ref().is_some_and(|popup| popup.loading))
849 }
850 fn set_index(&mut self, index: usize) {
851 self.index = index;
852 }
853
854 fn set_global_help(&self) {
855 trace!("Setting global help for IssueList");
856 if let Some(action_tx) = self.action_tx.as_ref() {
857 let _ = action_tx.try_send(crate::ui::Action::SetHelp(HELP));
858 }
859 }
860
861 fn capture_focus_event(&self, _event: &crossterm::event::Event) -> bool {
862 self.close_popup.is_some()
863 }
864}
865
866impl HasFocus for IssueList<'_> {
867 fn build(&self, builder: &mut rat_widget::focus::FocusBuilder) {
868 let tag = builder.start(self);
869 builder.widget(&self.list_state);
870 if self.inner_state == IssueListState::AssigningInput {
871 builder.widget(&self.assign_input_state);
872 }
873 builder.end(tag);
874 }
875 fn area(&self) -> ratatui::layout::Rect {
876 self.list_state.area()
877 }
878 fn focus(&self) -> rat_widget::focus::FocusFlag {
879 self.list_state.focus()
880 }
881
882 fn navigable(&self) -> Navigation {
883 if self.screen == MainScreen::List {
884 Navigation::Regular
885 } else {
886 Navigation::None
887 }
888 }
889}