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