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