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