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