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