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