Skip to main content

gitv_tui/ui/components/
label_list.rs

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}