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