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