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