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