1use std::{
2 cmp::min,
3 slice,
4 str::FromStr,
5 time::{Duration, Instant},
6};
7
8use async_trait::async_trait;
9use octocrab::Error as OctoError;
10use octocrab::models::Label;
11use rat_cursor::HasScreenCursor;
12use rat_widget::{
13 event::{HandleEvent, Outcome, Regular, ct_event},
14 focus::HasFocus,
15 list::{ListState, selection::RowSelection},
16 text_input::{TextInput, TextInputState},
17};
18use ratatui::{
19 buffer::Buffer,
20 layout::{Constraint, Direction, Layout as TuiLayout, Rect},
21 style::{Color, Style, Stylize},
22 widgets::{Block, Clear, ListItem, Paragraph, StatefulWidget, Widget},
23};
24use ratatui_macros::{line, span};
25use regex::RegexBuilder;
26use throbber_widgets_tui::{BRAILLE_SIX_DOUBLE, Throbber, ThrobberState, WhichUse};
27use tracing::error;
28
29use crate::{
30 app::GITHUB_CLIENT,
31 errors::AppError,
32 ui::{
33 Action, AppState, COLOR_PROFILE,
34 components::{Component, help::HelpElementKind, issue_list::MainScreen},
35 layout::Layout,
36 toast_action,
37 utils::get_border_style,
38 widgets::color_picker::{ColorPicker, ColorPickerState},
39 },
40};
41use ratatui_toaster::ToastType;
42
43const MARKER: &str = ratatui::symbols::marker::DOT;
44const STATUS_TTL: Duration = Duration::from_secs(3);
45const DEFAULT_COLOR: &str = "ededed";
46pub const HELP: &[HelpElementKind] = &[
47 crate::help_text!("Label List Help"),
48 crate::help_keybind!("Up/Down", "select label"),
49 crate::help_keybind!("a", "add label to selected issue"),
50 crate::help_keybind!("d", "remove selected label from issue"),
51 crate::help_keybind!("f", "open popup label regex search"),
52 crate::help_keybind!("Ctrl+I", "toggle case-insensitive search (popup)"),
53 crate::help_keybind!("Enter", "submit add/create input"),
54 crate::help_keybind!("Arrows", "navigate label color picker"),
55 crate::help_keybind!("Tab / Shift+Tab", "switch input and picker focus"),
56 crate::help_keybind!("Type hex", "set color manually"),
57 crate::help_keybind!("Esc", "cancel current label edit flow"),
58 crate::help_keybind!("y / n", "confirm or cancel creating missing label"),
59];
60
61#[derive(Debug)]
62pub struct LabelList {
63 state: ListState<RowSelection>,
64 labels: Vec<LabelListItem>,
65 action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
66 current_issue_number: Option<u64>,
67 mode: LabelEditMode,
68 status_message: Option<StatusMessage>,
69 pending_status: Option<String>,
70 owner: String,
71 repo: String,
72 screen: MainScreen,
73 popup_search: Option<PopupLabelSearchState>,
74 label_search_request_seq: u64,
75 index: usize,
76}
77
78#[derive(Debug, Clone)]
79struct LabelListItem(Label);
80
81#[derive(Debug)]
82enum LabelEditMode {
83 Idle,
84 Adding {
85 input: TextInputState,
86 },
87 ConfirmCreate {
88 name: String,
89 },
90 CreateColor {
91 name: String,
92 input: TextInputState,
93 picker: ColorPickerState,
94 },
95}
96
97#[derive(Debug)]
98struct PopupLabelSearchState {
99 input: TextInputState,
100 list_state: ListState<RowSelection>,
101 matches: Vec<LabelListItem>,
102 loading: bool,
103 case_insensitive: bool,
104 request_id: u64,
105 scanned_count: u32,
106 matched_count: u32,
107 error: Option<String>,
108 throbber_state: ThrobberState,
109}
110
111#[derive(Debug, Clone)]
112struct StatusMessage {
113 message: String,
114 at: Instant,
115}
116
117impl From<Label> for LabelListItem {
118 fn from(value: Label) -> Self {
119 Self(value)
120 }
121}
122
123impl std::ops::Deref for LabelListItem {
124 type Target = Label;
125
126 fn deref(&self) -> &Self::Target {
127 &self.0
128 }
129}
130
131impl From<&LabelListItem> for ListItem<'_> {
132 fn from(value: &LabelListItem) -> Self {
133 let rgb = &value.0.color;
134 let mut c = Color::from_str(&format!("#{}", rgb)).unwrap_or(Color::Gray);
135 if let Some(profile) = COLOR_PROFILE.get() {
136 let adapted = profile.adapt_color(c);
137 if let Some(adapted) = adapted {
138 c = adapted;
139 }
140 }
141 let line = line![span!("{} {}", MARKER, value.0.name).fg(c)];
142 ListItem::new(line)
143 }
144}
145
146fn popup_list_item(value: &LabelListItem) -> ListItem<'_> {
147 let rgb = &value.0.color;
148 let mut c = Color::from_str(&format!("#{}", rgb)).unwrap_or(Color::Gray);
149 if let Some(profile) = COLOR_PROFILE.get() {
150 let adapted = profile.adapt_color(c);
151 if let Some(adapted) = adapted {
152 c = adapted;
153 }
154 }
155
156 let description = value
157 .0
158 .description
159 .as_deref()
160 .filter(|desc| !desc.trim().is_empty())
161 .unwrap_or("No description");
162 let lines = vec![
163 line![span!("{} {}", MARKER, value.0.name).fg(c)],
164 line![span!(" {description}").dim()],
165 ];
166 ListItem::new(lines)
167}
168
169impl LabelList {
170 pub fn new(AppState { repo, owner, .. }: AppState) -> Self {
171 Self {
172 state: Default::default(),
173 labels: vec![],
174 action_tx: None,
175 current_issue_number: None,
176 mode: LabelEditMode::Idle,
177 status_message: None,
178 pending_status: None,
179 owner,
180 repo,
181 screen: MainScreen::default(),
182 popup_search: None,
183 label_search_request_seq: 0,
184 index: 0,
185 }
186 }
187
188 pub fn render(&mut self, area: Layout, buf: &mut Buffer) {
189 self.expire_status();
190
191 let mut list_area = area.label_list;
192 let mut footer_area = None;
193 let mut color_input_area = None;
194 if self.needs_footer() {
195 let areas = TuiLayout::default()
196 .direction(Direction::Vertical)
197 .constraints([Constraint::Min(1), Constraint::Length(3)])
198 .split(area.label_list);
199 list_area = areas[0];
200 footer_area = Some(areas[1]);
201 }
202
203 let title = if let Some(status) = &self.status_message {
204 error!("Label list status: {}", status.message);
205 format!(
206 "[{}] Labels (a:add d:remove) | {}",
207 self.index, status.message
208 )
209 } else {
210 format!("[{}] Labels (a:add d:remove)", self.index)
211 };
212 let block = Block::bordered()
213 .border_type(ratatui::widgets::BorderType::Rounded)
214 .title(title)
215 .border_style(get_border_style(&self.state));
216 let list = rat_widget::list::List::<RowSelection>::new(
217 self.labels.iter().map(Into::<ListItem>::into),
218 )
219 .select_style(Style::default().bg(Color::Black))
220 .focus_style(Style::default().bold().bg(Color::Black))
221 .block(block);
222 list.render(list_area, buf, &mut self.state);
223
224 if let Some(area) = footer_area {
225 match &mut self.mode {
226 LabelEditMode::Adding { input } => {
227 let widget = TextInput::new().block(
228 Block::bordered()
229 .border_type(ratatui::widgets::BorderType::Rounded)
230 .border_style(get_border_style(input))
231 .title("Add label"),
232 );
233 widget.render(area, buf, input);
234 }
235 LabelEditMode::ConfirmCreate { name } => {
236 let prompt = format!("Label \"{name}\" not found. Create? (y/n)");
237 Paragraph::new(prompt)
238 .block(
239 Block::bordered()
240 .border_type(ratatui::widgets::BorderType::Rounded)
241 .border_style(Style::default().yellow())
242 .title("Confirm [y/n]"),
243 )
244 .render(area, buf);
245 }
246 LabelEditMode::CreateColor { input, .. } => {
247 let widget = TextInput::new().block(
248 Block::bordered()
249 .border_type(ratatui::widgets::BorderType::Rounded)
250 .border_style(get_border_style(input))
251 .title("Label color (#RRGGBB)"),
252 );
253 widget.render(area, buf, input);
254 color_input_area = Some(area);
255 }
256 LabelEditMode::Idle => {
257 if let Some(status) = &self.status_message {
258 Paragraph::new(status.message.clone()).render(area, buf);
259 }
260 }
261 }
262 }
263
264 self.render_popup(area, buf);
265 self.render_color_picker(area, buf, color_input_area);
266 }
267
268 fn render_color_picker(&mut self, area: Layout, buf: &mut Buffer, anchor: Option<Rect>) {
269 let LabelEditMode::CreateColor { picker, .. } = &mut self.mode else {
270 return;
271 };
272 let Some(anchor) = anchor else {
273 return;
274 };
275
276 let bounds = area.main_content;
277 let popup_width = bounds.width.clamp(24, 34);
278 let popup_height = bounds.height.clamp(10, 12);
279 let max_x = bounds
280 .x
281 .saturating_add(bounds.width.saturating_sub(popup_width));
282 let max_y = bounds
283 .y
284 .saturating_add(bounds.height.saturating_sub(popup_height));
285 let x = anchor.x.saturating_sub(2).clamp(bounds.x, max_x);
286 let y = anchor
287 .y
288 .saturating_sub(popup_height.saturating_sub(1))
289 .clamp(bounds.y, max_y);
290 let popup_area = Rect {
291 x,
292 y,
293 width: popup_width,
294 height: popup_height,
295 };
296 ColorPicker.render(popup_area, buf, picker);
297 }
298
299 fn render_popup(&mut self, area: Layout, buf: &mut Buffer) {
300 let Some(popup) = self.popup_search.as_mut() else {
301 return;
302 };
303 if popup.input.gained_focus() {
304 self.state.focus.set(false);
305 }
306
307 let vert = TuiLayout::default()
308 .direction(Direction::Vertical)
309 .constraints([
310 Constraint::Percentage(12),
311 Constraint::Percentage(76),
312 Constraint::Percentage(12),
313 ])
314 .split(area.main_content);
315 let hor = TuiLayout::default()
316 .direction(Direction::Horizontal)
317 .constraints([
318 Constraint::Percentage(8),
319 Constraint::Percentage(84),
320 Constraint::Percentage(8),
321 ])
322 .split(vert[1]);
323 let popup_area = hor[1];
324
325 Clear.render(popup_area, buf);
326
327 let sections = TuiLayout::default()
328 .direction(Direction::Vertical)
329 .constraints([
330 Constraint::Length(3),
331 Constraint::Min(1),
332 Constraint::Length(1),
333 ])
334 .split(popup_area);
335 let input_area = sections[0];
336 let list_area = sections[1];
337 let status_area = sections[2];
338
339 let mut popup_title = "[Label Search] Regex".to_string();
340 if popup.loading {
341 popup_title.push_str(" | Searching");
342 } else {
343 popup_title.push_str(" | Enter: Search");
344 }
345 popup_title.push_str(if popup.case_insensitive {
346 " | CI:on"
347 } else {
348 " | CI:off"
349 });
350 popup_title.push_str(" | a:Add Esc:Close");
351
352 let mut input_block = Block::bordered()
353 .border_type(ratatui::widgets::BorderType::Rounded)
354 .border_style(get_border_style(&popup.input));
355 if !popup.loading {
356 input_block = input_block.title(popup_title);
357 }
358
359 let input = TextInput::new().block(input_block);
360 input.render(input_area, buf, &mut popup.input);
361
362 if popup.loading {
363 let title_area = ratatui::layout::Rect {
364 x: input_area.x + 1,
365 y: input_area.y,
366 width: 10,
367 height: 1,
368 };
369 let throbber = Throbber::default()
370 .label("Loading")
371 .style(Style::default().fg(Color::Cyan))
372 .throbber_set(BRAILLE_SIX_DOUBLE)
373 .use_type(WhichUse::Spin);
374 StatefulWidget::render(throbber, title_area, buf, &mut popup.throbber_state);
375 }
376
377 let list_block = Block::bordered()
378 .border_type(ratatui::widgets::BorderType::Rounded)
379 .border_style(get_border_style(&popup.list_state))
380 .title("Matches");
381 let list =
382 rat_widget::list::List::<RowSelection>::new(popup.matches.iter().map(popup_list_item))
383 .select_style(Style::default().bg(Color::Black))
384 .focus_style(Style::default().bold().bg(Color::Black))
385 .block(list_block);
386 list.render(list_area, buf, &mut popup.list_state);
387
388 if popup.matches.is_empty() && !popup.loading {
389 let message = if let Some(err) = &popup.error {
390 tracing::error!("Label search error: {err}");
391 format!("Error: {err}")
392 } else if popup.input.text().trim().is_empty() {
393 "Type a regex query and press Enter to search.".to_string()
394 } else {
395 "No matches.".to_string()
396 };
397 Paragraph::new(message).render(list_area, buf);
398 }
399
400 let status = format!(
401 "Scanned: {} Matched: {}",
402 popup.scanned_count, popup.matched_count
403 );
404 Paragraph::new(status).render(status_area, buf);
405 }
406
407 fn needs_footer(&self) -> bool {
408 matches!(
409 self.mode,
410 LabelEditMode::Adding { .. }
411 | LabelEditMode::ConfirmCreate { .. }
412 | LabelEditMode::CreateColor { .. }
413 )
414 }
415
416 fn expire_status(&mut self) {
417 if let Some(status) = &self.status_message
418 && status.at.elapsed() > STATUS_TTL
419 {
420 self.status_message = None;
421 }
422 }
423
424 fn set_status(&mut self, message: impl Into<String>) {
425 let message = message.into().replace('\n', " ");
426 self.status_message = Some(StatusMessage {
427 message,
428 at: Instant::now(),
429 });
430 }
431
432 fn set_mode(&mut self, mode: LabelEditMode) {
433 self.mode = mode;
434 }
435
436 fn reset_selection(&mut self, previous_name: Option<String>) {
437 if self.labels.is_empty() {
438 self.state.clear_selection();
439 return;
440 }
441 if let Some(name) = previous_name
442 && let Some(idx) = self.labels.iter().position(|l| l.name == name)
443 {
444 self.state.select(Some(idx));
445 return;
446 }
447 let _ = self.state.select(Some(0));
448 }
449
450 fn is_not_found(err: &OctoError) -> bool {
451 matches!(
452 err,
453 OctoError::GitHub { source, .. } if source.status_code.as_u16() == 404
454 )
455 }
456
457 fn normalize_label_name(input: &str) -> Option<String> {
458 let trimmed = input.trim();
459 if trimmed.is_empty() {
460 None
461 } else {
462 Some(trimmed.to_string())
463 }
464 }
465
466 fn normalize_color(input: &str) -> Result<String, String> {
467 let trimmed = input.trim();
468 if trimmed.is_empty() {
469 return Ok(DEFAULT_COLOR.to_string());
470 }
471 let trimmed = trimmed.trim_start_matches('#');
472 let is_hex = trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_hexdigit());
473 if is_hex {
474 Ok(trimmed.to_lowercase())
475 } else {
476 Err("Invalid color. Use 6 hex digits like eeddee.".to_string())
477 }
478 }
479
480 fn open_popup_search(&mut self) {
481 if self.popup_search.is_some() {
482 return;
483 }
484 let input = TextInputState::new_focused();
485 input.focus.set(true);
486 self.state.focus.set(false);
487 self.popup_search = Some(PopupLabelSearchState {
488 input,
489 list_state: ListState::default(),
490 matches: Vec::new(),
491 loading: false,
492 case_insensitive: false,
493 request_id: 0,
494 scanned_count: 0,
495 matched_count: 0,
496 error: None,
497 throbber_state: ThrobberState::default(),
498 });
499 }
500
501 fn close_popup_search(&mut self) {
502 self.popup_search = None;
503 }
504
505 fn build_popup_regex(query: &str, case_insensitive: bool) -> Result<regex::Regex, String> {
506 RegexBuilder::new(query)
507 .case_insensitive(case_insensitive)
508 .build()
509 .map_err(|err| err.to_string().replace('\n', " "))
510 }
511
512 fn append_popup_matches(&mut self, items: Vec<Label>) {
513 let Some(popup) = self.popup_search.as_mut() else {
514 return;
515 };
516 popup
517 .matches
518 .extend(items.into_iter().map(Into::<LabelListItem>::into));
519 if popup.list_state.selected_checked().is_none() && !popup.matches.is_empty() {
520 let _ = popup.list_state.select(Some(0));
521 }
522 }
523
524 async fn start_popup_search(&mut self) {
525 let Some(popup) = self.popup_search.as_mut() else {
526 return;
527 };
528 if popup.loading {
529 return;
530 }
531
532 let query = popup.input.text().trim().to_string();
533 if query.is_empty() {
534 popup.error = Some("Regex query required.".to_string());
535 return;
536 }
537 let regex = match Self::build_popup_regex(&query, popup.case_insensitive) {
538 Ok(regex) => regex,
539 Err(message) => {
540 popup.error = Some(message);
541 return;
542 }
543 };
544
545 self.label_search_request_seq = self.label_search_request_seq.saturating_add(1);
546 let request_id = self.label_search_request_seq;
547 popup.request_id = request_id;
548 popup.loading = true;
549 popup.error = None;
550 popup.scanned_count = 0;
551 popup.matched_count = 0;
552 popup.matches.clear();
553 popup.list_state.clear_selection();
554
555 let Some(action_tx) = self.action_tx.clone() else {
556 popup.loading = false;
557 popup.error = Some("Action channel unavailable.".to_string());
558 return;
559 };
560 let owner = self.owner.clone();
561 let repo = self.repo.clone();
562
563 tokio::spawn(async move {
564 let Some(client) = GITHUB_CLIENT.get() else {
565 let _ = action_tx
566 .send(Action::LabelSearchError {
567 request_id,
568 message: "GitHub client not initialized.".to_string(),
569 })
570 .await;
571 return;
572 };
573 let crab = client.inner();
574 let handler = crab.issues(owner, repo);
575
576 let first = handler
577 .list_labels_for_repo()
578 .per_page(100u8)
579 .page(1u32)
580 .send()
581 .await;
582
583 let mut page = match first {
584 Ok(page) => page,
585 Err(err) => {
586 let _ = action_tx
587 .send(Action::LabelSearchError {
588 request_id,
589 message: err.to_string().replace('\n', " "),
590 })
591 .await;
592 return;
593 }
594 };
595
596 let mut scanned = 0_u32;
597 let mut matched = 0_u32;
598 loop {
599 let page_items = std::mem::take(&mut page.items);
600 scanned = scanned.saturating_add(min(page_items.len(), u32::MAX as usize) as u32);
601 let mut filtered = Vec::new();
602 for label in page_items {
603 if regex.is_match(&label.name) {
604 matched = matched.saturating_add(1);
605 filtered.push(label);
606 }
607 }
608 if !filtered.is_empty() {
609 let _ = action_tx
610 .send(Action::LabelSearchPageAppend {
611 request_id,
612 items: filtered,
613 scanned,
614 matched,
615 })
616 .await;
617 }
618
619 if page.next.is_none() {
620 break;
621 }
622 let next_page = crab.get_page::<Label>(&page.next).await;
623 match next_page {
624 Ok(Some(next_page)) => page = next_page,
625 Ok(None) => break,
626 Err(err) => {
627 let _ = action_tx
628 .send(Action::LabelSearchError {
629 request_id,
630 message: err.to_string().replace('\n', " "),
631 })
632 .await;
633 return;
634 }
635 }
636 }
637
638 let _ = action_tx
639 .send(Action::LabelSearchFinished {
640 request_id,
641 scanned,
642 matched,
643 })
644 .await;
645 });
646 }
647
648 async fn apply_selected_popup_label(&mut self) {
649 let Some(popup) = self.popup_search.as_mut() else {
650 return;
651 };
652 let Some(selected) = popup.list_state.selected_checked() else {
653 popup.error = Some("No matching label selected.".to_string());
654 return;
655 };
656 let Some(label) = popup.matches.get(selected) else {
657 popup.error = Some("No matching label selected.".to_string());
658 return;
659 };
660 let name = label.name.clone();
661 self.handle_add_submit(name).await;
662 self.close_popup_search();
663 }
664
665 async fn handle_popup_event(&mut self, event: &crossterm::event::Event) -> bool {
666 let Some(popup) = self.popup_search.as_mut() else {
667 return false;
668 };
669
670 if matches!(event, ct_event!(keycode press Esc)) {
671 self.close_popup_search();
672 return true;
673 }
674 if matches!(
675 event,
676 ct_event!(key press CONTROL-'i') | ct_event!(key press ALT-'i')
677 ) {
678 popup.case_insensitive = !popup.case_insensitive;
679 return true;
680 }
681 if matches!(event, ct_event!(keycode press Enter)) {
682 self.start_popup_search().await;
683 return true;
684 }
685 if matches!(event, ct_event!(key press CONTROL-'a')) {
686 self.apply_selected_popup_label().await;
687 return true;
688 }
689 if matches!(
690 event,
691 ct_event!(keycode press Up) | ct_event!(keycode press Down)
692 ) {
693 popup.list_state.handle(event, Regular);
694 return true;
695 }
696
697 popup.input.handle(event, Regular);
698 true
699 }
700
701 async fn handle_add_submit(&mut self, name: String) {
702 let Some(issue_number) = self.current_issue_number else {
703 self.set_status("No issue selected.");
704 return;
705 };
706 if self.labels.iter().any(|l| l.name == name) {
707 self.set_status("Label already applied.");
708 return;
709 }
710
711 let Some(action_tx) = self.action_tx.clone() else {
712 return;
713 };
714 let owner = self.owner.clone();
715 let repo = self.repo.clone();
716 self.pending_status = Some(format!("Added: {name}"));
717
718 tokio::spawn(async move {
719 let Some(client) = GITHUB_CLIENT.get() else {
720 let _ = action_tx
721 .send(Action::LabelEditError {
722 message: "GitHub client not initialized.".to_string(),
723 })
724 .await;
725 return;
726 };
727 let handler = client.inner().issues(owner, repo);
728 match handler.get_label(&name).await {
729 Ok(_) => match handler
730 .add_labels(issue_number, slice::from_ref(&name))
731 .await
732 {
733 Ok(labels) => {
734 let _ = action_tx
735 .send(Action::IssueLabelsUpdated {
736 number: issue_number,
737 labels,
738 })
739 .await;
740 }
741 Err(err) => {
742 let _ = action_tx
743 .send(toast_action(
744 format!("Failed to add label: {}", err),
745 ToastType::Error,
746 ))
747 .await;
748 let _ = action_tx
749 .send(Action::LabelEditError {
750 message: err.to_string(),
751 })
752 .await;
753 }
754 },
755 Err(err) => {
756 if LabelList::is_not_found(&err) {
757 let _ = action_tx
758 .send(toast_action(
759 format!("Label not found: {}", &name),
760 ToastType::Warning,
761 ))
762 .await;
763 let _ = action_tx
764 .send(Action::LabelMissing { name: name.clone() })
765 .await;
766 } else {
767 let _ = action_tx
768 .send(toast_action(
769 format!("Failed to add label: {}", err),
770 ToastType::Error,
771 ))
772 .await;
773 let _ = action_tx
774 .send(Action::LabelEditError {
775 message: err.to_string(),
776 })
777 .await;
778 }
779 }
780 }
781 });
782 }
783
784 async fn handle_remove_selected(&mut self) {
785 let Some(issue_number) = self.current_issue_number else {
786 self.set_status("No issue selected.");
787 return;
788 };
789 let Some(selected) = self.state.selected_checked() else {
790 self.set_status("No label selected.");
791 return;
792 };
793 let Some(label) = self.labels.get(selected) else {
794 self.set_status("No label selected.");
795 return;
796 };
797 let name = label.name.clone();
798
799 let Some(action_tx) = self.action_tx.clone() else {
800 return;
801 };
802 let owner = self.owner.clone();
803 let repo = self.repo.clone();
804 self.pending_status = Some(format!("Removed: {name}"));
805
806 tokio::spawn(async move {
807 let Some(client) = GITHUB_CLIENT.get() else {
808 let _ = action_tx
809 .send(Action::LabelEditError {
810 message: "GitHub client not initialized.".to_string(),
811 })
812 .await;
813 return;
814 };
815 let handler = client.inner().issues(owner, repo);
816 match handler.remove_label(issue_number, &name).await {
817 Ok(labels) => {
818 let _ = action_tx
819 .send(Action::IssueLabelsUpdated {
820 number: issue_number,
821 labels,
822 })
823 .await;
824 }
825 Err(err) => {
826 error!("Failed to remove label: {err}");
827 let _ = action_tx
828 .send(Action::LabelEditError {
829 message: err.to_string(),
830 })
831 .await;
832 }
833 }
834 });
835 }
836
837 async fn handle_create_and_add(&mut self, name: String, color: String) {
838 let Some(issue_number) = self.current_issue_number else {
839 self.set_status("No issue selected.");
840 return;
841 };
842 let Some(action_tx) = self.action_tx.clone() else {
843 return;
844 };
845 let owner = self.owner.clone();
846 let repo = self.repo.clone();
847 self.pending_status = Some(format!("Added: {name}"));
848
849 tokio::spawn(async move {
850 let Some(client) = GITHUB_CLIENT.get() else {
851 let _ = action_tx
852 .send(Action::LabelEditError {
853 message: "GitHub client not initialized.".to_string(),
854 })
855 .await;
856 return;
857 };
858 let handler = client.inner().issues(owner, repo);
859 match handler.create_label(&name, &color, "").await {
860 Ok(_) => match handler
861 .add_labels(issue_number, slice::from_ref(&name))
862 .await
863 {
864 Ok(labels) => {
865 let _ = action_tx
866 .send(Action::IssueLabelsUpdated {
867 number: issue_number,
868 labels,
869 })
870 .await;
871 }
872 Err(err) => {
873 let _ = action_tx
874 .send(Action::LabelEditError {
875 message: err.to_string(),
876 })
877 .await;
878 }
879 },
880 Err(err) => {
881 let _ = action_tx
882 .send(Action::LabelEditError {
883 message: err.to_string(),
884 })
885 .await;
886 }
887 }
888 });
889 }
890}
891
892#[async_trait(?Send)]
893impl Component for LabelList {
894 fn render(&mut self, area: Layout, buf: &mut Buffer) {
895 self.render(area, buf);
896 }
897 fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
898 self.action_tx = Some(action_tx);
899 }
900 async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
901 match event {
902 Action::AppEvent(ref event) => {
903 if self.screen == MainScreen::DetailsFullscreen {
904 return Ok(());
905 }
906 if self.handle_popup_event(event).await {
907 return Ok(());
908 }
909
910 enum SubmitAction {
911 Add(String),
912 Create { name: String, color: String },
913 }
914
915 let mut mode = std::mem::replace(&mut self.mode, LabelEditMode::Idle);
916 let mut next_mode: Option<LabelEditMode> = None;
917 let mut submit_action: Option<SubmitAction> = None;
918
919 match &mut mode {
920 LabelEditMode::Idle => {
921 let mut handled = false;
922 if let crossterm::event::Event::Key(key) = event
923 && self.popup_search.is_none()
924 {
925 match key.code {
926 crossterm::event::KeyCode::Char('a') => {
927 if self.state.is_focused() {
928 let input = TextInputState::new_focused();
929 next_mode = Some(LabelEditMode::Adding { input });
930 handled = true;
931 }
932 }
933 crossterm::event::KeyCode::Char('d') => {
934 if self.state.is_focused() {
935 self.handle_remove_selected().await;
936 handled = true;
937 }
938 }
939 crossterm::event::KeyCode::Char('f') => {
940 if self.state.is_focused() {
941 self.open_popup_search();
942 handled = true;
943 }
944 }
945 _ => {}
946 }
947 }
948 if !handled {
949 self.state.handle(event, Regular);
950 }
951 }
952 LabelEditMode::Adding { input } => {
953 let mut skip_input = false;
954 if let crossterm::event::Event::Key(key) = event {
955 match key.code {
956 crossterm::event::KeyCode::Enter => {
957 if let Some(name) = Self::normalize_label_name(input.text()) {
958 submit_action = Some(SubmitAction::Add(name));
959 next_mode = Some(LabelEditMode::Idle);
960 } else {
961 self.set_status("Label name required.");
962 skip_input = true;
963 }
964 }
965 crossterm::event::KeyCode::Esc => {
966 next_mode = Some(LabelEditMode::Idle);
967 }
968 _ => {}
969 }
970 }
971 if next_mode.is_none() && !skip_input {
972 input.handle(event, Regular);
973 }
974 }
975 LabelEditMode::ConfirmCreate { name } => {
976 if let crossterm::event::Event::Key(key) = event {
977 match key.code {
978 crossterm::event::KeyCode::Char('y')
979 | crossterm::event::KeyCode::Char('Y') => {
980 let mut input = TextInputState::new_focused();
981 input.set_text(DEFAULT_COLOR);
982 let picker = ColorPickerState::with_initial_hex(DEFAULT_COLOR);
983 next_mode = Some(LabelEditMode::CreateColor {
984 name: name.clone(),
985 input,
986 picker,
987 });
988 }
989 crossterm::event::KeyCode::Char('n')
990 | crossterm::event::KeyCode::Char('N')
991 | crossterm::event::KeyCode::Esc => {
992 self.pending_status = None;
993 next_mode = Some(LabelEditMode::Idle);
994 }
995 _ => {}
996 }
997 }
998 }
999 LabelEditMode::CreateColor {
1000 name,
1001 input,
1002 picker,
1003 } => {
1004 let mut skip_input = false;
1005 if matches!(event, ct_event!(keycode press Tab)) {
1006 skip_input = true;
1007 }
1008 if matches!(picker.handle(event, Regular), Outcome::Changed) {
1009 input.set_text(picker.selected_hex());
1010 skip_input = true;
1011 }
1012 if let crossterm::event::Event::Key(key) = event {
1013 match key.code {
1014 crossterm::event::KeyCode::Enter => {
1015 if picker.is_focused() {
1016 submit_action = Some(SubmitAction::Create {
1017 name: name.clone(),
1018 color: picker.selected_hex().to_string(),
1019 });
1020 next_mode = Some(LabelEditMode::Idle);
1021 } else {
1022 match Self::normalize_color(input.text()) {
1023 Ok(color) => {
1024 submit_action = Some(SubmitAction::Create {
1025 name: name.clone(),
1026 color,
1027 });
1028 next_mode = Some(LabelEditMode::Idle);
1029 }
1030 Err(message) => {
1031 if let Some(action_tx) = &self.action_tx {
1032 let _ = action_tx
1033 .send(toast_action(
1034 format!(
1035 "Invalid color: {}",
1036 input.text()
1037 ),
1038 ToastType::Error,
1039 ))
1040 .await?;
1041 }
1042 self.set_status(message);
1043 skip_input = true;
1044 }
1045 }
1046 }
1047 }
1048 crossterm::event::KeyCode::Esc => {
1049 next_mode = Some(LabelEditMode::Idle);
1050 }
1051 _ => {}
1052 }
1053 }
1054
1055 if next_mode.is_none() && !skip_input && input.is_focused() {
1056 input.handle(event, Regular);
1057 if let Ok(color) = Self::normalize_color(input.text()) {
1058 *picker = ColorPickerState::with_initial_hex(&color);
1059 }
1060 }
1061 }
1062 }
1063
1064 self.mode = next_mode.unwrap_or(mode);
1065
1066 if let Some(action) = submit_action {
1067 match action {
1068 SubmitAction::Add(name) => self.handle_add_submit(name).await,
1069 SubmitAction::Create { name, color } => {
1070 self.handle_create_and_add(name, color).await
1071 }
1072 }
1073 }
1074 }
1075 Action::SelectedIssue { number, labels } => {
1076 let prev = self
1077 .state
1078 .selected_checked()
1079 .and_then(|idx| self.labels.get(idx).map(|label| label.name.clone()));
1080 self.labels = labels
1081 .into_iter()
1082 .map(Into::<LabelListItem>::into)
1083 .collect();
1084 self.current_issue_number = Some(number);
1085 self.reset_selection(prev);
1086 self.pending_status = None;
1087 self.status_message = None;
1088 self.set_mode(LabelEditMode::Idle);
1089 self.close_popup_search();
1090 }
1091 Action::IssueLabelsUpdated { number, labels } => {
1092 if Some(number) == self.current_issue_number {
1093 let prev = self
1094 .state
1095 .selected_checked()
1096 .and_then(|idx| self.labels.get(idx).map(|label| label.name.clone()));
1097 self.labels = labels
1098 .into_iter()
1099 .map(Into::<LabelListItem>::into)
1100 .collect();
1101 self.reset_selection(prev);
1102 let status = self
1103 .pending_status
1104 .take()
1105 .unwrap_or_else(|| "Labels updated.".to_string());
1106 self.set_status(status);
1107 self.set_mode(LabelEditMode::Idle);
1108 }
1109 }
1110 Action::LabelSearchPageAppend {
1111 request_id,
1112 items,
1113 scanned,
1114 matched,
1115 } => {
1116 if let Some(popup) = self.popup_search.as_mut() {
1117 if popup.request_id != request_id {
1118 return Ok(());
1119 }
1120 popup.scanned_count = scanned;
1121 popup.matched_count = matched;
1122 popup.error = None;
1123 } else {
1124 return Ok(());
1125 }
1126 self.append_popup_matches(items);
1127 }
1128 Action::LabelSearchFinished {
1129 request_id,
1130 scanned,
1131 matched,
1132 } => {
1133 if let Some(popup) = self.popup_search.as_mut() {
1134 if popup.request_id != request_id {
1135 return Ok(());
1136 }
1137 popup.loading = false;
1138 popup.scanned_count = scanned;
1139 popup.matched_count = matched;
1140 popup.error = None;
1141 }
1142 }
1143 Action::LabelSearchError {
1144 request_id,
1145 message,
1146 } => {
1147 if let Some(popup) = self.popup_search.as_mut() {
1148 if popup.request_id != request_id {
1149 return Ok(());
1150 }
1151 popup.loading = false;
1152 popup.error = Some(message);
1153 }
1154 }
1155 Action::LabelMissing { name } => {
1156 self.set_status("Label not found.");
1157 self.set_mode(LabelEditMode::ConfirmCreate { name });
1158 }
1159 Action::LabelEditError { message } => {
1160 self.pending_status = None;
1161 self.set_status(format!("Error: {message}"));
1162 self.set_mode(LabelEditMode::Idle);
1163 }
1164 Action::Tick => {
1165 if let Some(popup) = self.popup_search.as_mut()
1166 && popup.loading
1167 {
1168 popup.throbber_state.calc_next();
1169 }
1170 }
1171 Action::ChangeIssueScreen(screen) => {
1172 self.screen = screen;
1173 if screen == MainScreen::DetailsFullscreen {
1174 self.mode = LabelEditMode::Idle;
1175 self.popup_search = None;
1176 self.status_message = None;
1177 self.pending_status = None;
1178 }
1179 }
1180 _ => {}
1181 }
1182 Ok(())
1183 }
1184
1185 fn should_render(&self) -> bool {
1186 self.screen != MainScreen::DetailsFullscreen
1187 }
1188
1189 fn cursor(&self) -> Option<(u16, u16)> {
1190 if let Some(popup) = &self.popup_search {
1191 return popup.input.screen_cursor();
1192 }
1193 match &self.mode {
1194 LabelEditMode::Adding { input } => input.screen_cursor(),
1195 LabelEditMode::CreateColor { input, .. } => input.screen_cursor(),
1196 _ => None,
1197 }
1198 }
1199
1200 fn is_animating(&self) -> bool {
1201 self.status_message.is_some()
1202 || self
1203 .popup_search
1204 .as_ref()
1205 .is_some_and(|popup| popup.loading)
1206 }
1207 fn set_index(&mut self, index: usize) {
1208 self.index = index;
1209 }
1210
1211 fn set_global_help(&self) {
1212 if let Some(action_tx) = &self.action_tx {
1213 let _ = action_tx.try_send(Action::SetHelp(HELP));
1214 }
1215 }
1216
1217 fn capture_focus_event(&self, _event: &crossterm::event::Event) -> bool {
1218 self.popup_search.is_some()
1219 || matches!(
1220 self.mode,
1221 LabelEditMode::Adding { .. }
1222 | LabelEditMode::ConfirmCreate { .. }
1223 | LabelEditMode::CreateColor { .. }
1224 )
1225 }
1226}
1227impl HasFocus for LabelList {
1228 fn build(&self, builder: &mut rat_widget::focus::FocusBuilder) {
1229 let tag = builder.start(self);
1230 builder.widget(&self.state);
1231 if let Some(popup) = &self.popup_search {
1232 builder.widget(&popup.input);
1233 builder.widget(&popup.list_state);
1234 }
1235 if let LabelEditMode::CreateColor { input, picker, .. } = &self.mode {
1236 builder.widget(input);
1237 builder.widget(picker);
1238 }
1239 builder.end(tag);
1240 }
1241 fn area(&self) -> ratatui::layout::Rect {
1242 self.state.area()
1243 }
1244 fn focus(&self) -> rat_widget::focus::FocusFlag {
1245 self.state.focus()
1246 }
1247}