Skip to main content

gitu/
picker.rs

1use fuzzy_matcher::FuzzyMatcher;
2use fuzzy_matcher::skim::SkimMatcherV2;
3use std::borrow::Cow;
4
5use crate::item_data::Ref;
6use crate::item_data::Rev;
7use crate::text_input::TextInput;
8
9/// Data that can be selected in a picker
10#[derive(Debug, Clone, PartialEq)]
11pub enum PickerData {
12    /// A selected item value
13    Item(String),
14    /// Custom user input (literal text from input field)
15    CustomInput(String),
16}
17
18impl PickerData {
19    pub fn display(&self) -> &str {
20        match self {
21            PickerData::Item(s) | PickerData::CustomInput(s) => s,
22        }
23    }
24}
25
26/// An item in the picker list
27#[derive(Debug, Clone, PartialEq)]
28pub struct PickerItem {
29    /// The text to display and match against
30    pub display: Cow<'static, str>,
31    /// Associated data
32    pub data: PickerData,
33}
34
35impl PickerItem {
36    pub fn new(display: impl Into<Cow<'static, str>>, data: PickerData) -> Self {
37        Self {
38            display: display.into(),
39            data,
40        }
41    }
42}
43
44/// Result of a fuzzy match with score
45#[derive(Debug, Clone)]
46struct MatchResult {
47    index: usize,
48    score: i64,
49}
50
51/// Picker status
52#[derive(Debug, Clone, PartialEq)]
53pub enum PickerStatus {
54    /// Picker is active
55    Active,
56    /// User selected an item
57    Done,
58    /// User cancelled
59    Cancelled,
60}
61
62/// State of the picker component
63pub struct PickerState {
64    /// All available items (excluding custom input)
65    items: Vec<PickerItem>,
66    /// Filtered and sorted indices based on current pattern
67    filtered_indices: Vec<usize>,
68    /// Current cursor position in filtered results
69    cursor: usize,
70    /// Current input pattern
71    pub(crate) input_state: TextInput,
72    /// Fuzzy matcher
73    matcher: SkimMatcherV2,
74    /// Prompt text to display
75    pub prompt_text: Cow<'static, str>,
76    /// Current status
77    status: PickerStatus,
78    /// Allow user to input custom value not in the list
79    allow_custom_input: bool,
80    /// Custom input item (separate from items list)
81    custom_input_item: Option<PickerItem>,
82}
83
84pub(crate) struct PickerParams<'a> {
85    pub(crate) prompt: Cow<'static, str>,
86    pub(crate) refs: &'a [Ref],
87    pub(crate) exclude_ref: Option<Ref>,
88    pub(crate) default: Option<Rev>,
89    pub(crate) allow_custom_input: bool,
90}
91
92impl PickerState {
93    /// Create a new picker with items
94    pub fn new(
95        prompt: impl Into<Cow<'static, str>>,
96        items: Vec<PickerItem>,
97        allow_custom_input: bool,
98    ) -> Self {
99        let mut state = Self {
100            items: items.clone(),
101            filtered_indices: Vec::new(),
102            cursor: 0,
103            input_state: TextInput::default(),
104            matcher: SkimMatcherV2::default(),
105            prompt_text: prompt.into(),
106            status: PickerStatus::Active,
107            allow_custom_input,
108            custom_input_item: None,
109        };
110        state.update_filter();
111        state
112    }
113
114    /// Create a picker showing only local branches by shorthand name.
115    /// The default branch (if any) is listed first. The exclude_ref is omitted.
116    pub(crate) fn with_branches(params: PickerParams) -> Self {
117        let default_name = params.default.as_ref().map(|r| r.shorthand().to_string());
118        let exclude_name = params
119            .exclude_ref
120            .as_ref()
121            .map(|r| r.shorthand().to_string());
122
123        let items = default_name
124            .iter()
125            .map(|name| PickerItem::new(name.clone(), PickerData::Item(name.clone())))
126            .chain(
127                params
128                    .refs
129                    .iter()
130                    .filter(|r| matches!(r, Ref::Head(_)))
131                    .filter(|r| exclude_name.as_deref().is_none_or(|e| e != r.shorthand()))
132                    .filter(|r| default_name.as_deref().is_none_or(|d| d != r.shorthand()))
133                    .map(|r| {
134                        let name = r.shorthand().to_string();
135                        PickerItem::new(name.clone(), PickerData::Item(name))
136                    }),
137            )
138            .collect();
139
140        Self::new(params.prompt, items, params.allow_custom_input)
141    }
142
143    /// Create a picker from refs. Tags are prefixed with "tag: " in display.
144    /// The default ref (if any) is listed first. The exclude_ref is omitted.
145    pub(crate) fn with_refs(params: PickerParams) -> Self {
146        let ref_to_item = |r: &Ref| {
147            let (display, refname) = match r {
148                Ref::Tag(name) => (format!("tag: {}", name), r.to_full_refname()),
149                _ => (r.shorthand().to_string(), r.shorthand().to_string()),
150            };
151            PickerItem::new(display, PickerData::Item(refname))
152        };
153
154        let default_item = params.default.as_ref().map(|default| match default {
155            Rev::Ref(r) => ref_to_item(r),
156            Rev::Commit(c) => PickerItem::new(c.clone(), PickerData::Item(c.clone())),
157        });
158
159        let items = default_item
160            .into_iter()
161            .chain(
162                params
163                    .refs
164                    .iter()
165                    .filter(|r| {
166                        params.exclude_ref.as_ref().is_none_or(|e| e != *r)
167                            && params.default.as_ref().is_none_or(|d| match d {
168                                Rev::Ref(d) => d != *r,
169                                _ => true,
170                            })
171                    })
172                    .map(ref_to_item),
173            )
174            .collect();
175
176        Self::new(params.prompt, items, params.allow_custom_input)
177    }
178
179    /// Get current input pattern
180    pub fn pattern(&self) -> &str {
181        &self.input_state.value
182    }
183
184    /// Update the filter based on current input pattern
185    pub fn update_filter(&mut self) {
186        let pattern = self.pattern().to_string();
187
188        if pattern.is_empty() {
189            // Show all items when no pattern
190            self.filtered_indices = (0..self.items.len()).collect();
191            self.custom_input_item = None;
192        } else {
193            // Fuzzy match and sort by score
194            let mut matches: Vec<MatchResult> = self
195                .items
196                .iter()
197                .enumerate()
198                .filter_map(|(i, item)| {
199                    self.matcher
200                        .fuzzy_match(&item.display, &pattern)
201                        .map(|score| MatchResult { index: i, score })
202                })
203                .collect();
204
205            // Sort by score (higher is better)
206            matches.sort_by_key(|b| std::cmp::Reverse(b.score));
207
208            self.filtered_indices = matches.into_iter().map(|m| m.index).collect();
209
210            // Create custom input item if enabled
211            if self.allow_custom_input {
212                self.custom_input_item = Some(PickerItem::new(
213                    pattern.clone(),
214                    PickerData::CustomInput(pattern),
215                ));
216            } else {
217                self.custom_input_item = None;
218            }
219        }
220
221        // Reset cursor if out of bounds
222        let total_count = self.filtered_indices.len()
223            + if self.custom_input_item.is_some() {
224                1
225            } else {
226                0
227            };
228        if self.cursor >= total_count {
229            self.cursor = 0;
230        }
231    }
232
233    /// Get the currently selected item, if any
234    pub fn selected(&self) -> Option<&PickerItem> {
235        // Check if cursor is on custom input item (always at the end)
236        if self.cursor == self.filtered_indices.len() {
237            return self.custom_input_item.as_ref();
238        }
239
240        self.filtered_indices
241            .get(self.cursor)
242            .and_then(|&i| self.items.get(i))
243    }
244
245    /// Get all filtered items with their original indices
246    /// Custom input item (if present) is always last with index usize::MAX
247    pub fn filtered_items(&self) -> impl Iterator<Item = (usize, &PickerItem)> {
248        self.filtered_indices
249            .iter()
250            .filter_map(|&i| self.items.get(i).map(|item| (i, item)))
251            .chain(
252                self.custom_input_item
253                    .as_ref()
254                    .map(|item| (usize::MAX, item)),
255            )
256    }
257
258    /// Move cursor to next item
259    pub fn next(&mut self) {
260        let total_count = self.filtered_indices.len()
261            + if self.custom_input_item.is_some() {
262                1
263            } else {
264                0
265            };
266        if total_count > 0 {
267            self.cursor = (self.cursor + 1) % total_count;
268        }
269    }
270
271    /// Move cursor to previous item
272    pub fn previous(&mut self) {
273        let total_count = self.filtered_indices.len()
274            + if self.custom_input_item.is_some() {
275                1
276            } else {
277                0
278            };
279        if total_count > 0 {
280            self.cursor = if self.cursor == 0 {
281                total_count - 1
282            } else {
283                self.cursor - 1
284            };
285        }
286    }
287
288    /// Get current cursor position
289    pub fn cursor(&self) -> usize {
290        self.cursor
291    }
292
293    /// Get total number of items
294    pub fn total_items(&self) -> usize {
295        self.items.len()
296    }
297
298    /// Get number of filtered items
299    pub fn filtered_count(&self) -> usize {
300        self.filtered_indices.len()
301    }
302
303    /// Get the fuzzy match positions for a given item (for highlighting)
304    pub fn match_indices(&self, item_index: usize) -> Option<Vec<usize>> {
305        let pattern = self.pattern();
306        if pattern.is_empty() {
307            return None;
308        }
309
310        // Don't highlight custom input items (marked with usize::MAX)
311        if item_index == usize::MAX {
312            return None;
313        }
314
315        self.items
316            .get(item_index)
317            .and_then(|item| self.matcher.fuzzy_indices(&item.display, pattern))
318            .map(|(_, indices)| indices)
319    }
320
321    /// Get current status
322    pub fn status(&self) -> &PickerStatus {
323        &self.status
324    }
325
326    /// Mark picker as done (user selected an item)
327    pub fn done(&mut self) {
328        self.status = PickerStatus::Done;
329    }
330
331    /// Mark picker as cancelled (user cancelled)
332    pub fn cancel(&mut self) {
333        self.status = PickerStatus::Cancelled;
334    }
335
336    /// Check if picker is done
337    pub fn is_done(&self) -> bool {
338        self.status == PickerStatus::Done
339    }
340
341    /// Check if picker is cancelled
342    pub fn is_cancelled(&self) -> bool {
343        self.status == PickerStatus::Cancelled
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
351
352    fn create_test_items() -> Vec<PickerItem> {
353        vec![
354            PickerItem::new("main", PickerData::Item("main".to_string())),
355            PickerItem::new("develop", PickerData::Item("develop".to_string())),
356            PickerItem::new("feature/test", PickerData::Item("feature/test".to_string())),
357            PickerItem::new("feature/new", PickerData::Item("feature/new".to_string())),
358            PickerItem::new("bugfix/123", PickerData::Item("bugfix/123".to_string())),
359        ]
360    }
361
362    #[test]
363    fn test_picker_data_display() {
364        let revision = PickerData::Item("main".to_string());
365        assert_eq!(revision.display(), "main");
366
367        let custom = PickerData::CustomInput("custom".to_string());
368        assert_eq!(custom.display(), "custom");
369    }
370
371    #[test]
372    fn test_picker_item_new() {
373        let item = PickerItem::new("main", PickerData::Item("main".to_string()));
374        assert_eq!(item.display.as_ref(), "main");
375        assert_eq!(item.data.display(), "main");
376    }
377
378    #[test]
379    fn test_picker_state_new_without_custom_input() {
380        let items = create_test_items();
381        let state = PickerState::new("Select branch", items.clone(), false);
382
383        assert_eq!(state.prompt_text.as_ref(), "Select branch");
384        assert_eq!(state.total_items(), 5);
385        assert_eq!(state.filtered_count(), 5);
386        assert_eq!(state.cursor(), 0);
387        assert_eq!(state.pattern(), "");
388        assert_eq!(state.status(), &PickerStatus::Active);
389        assert!(!state.allow_custom_input);
390    }
391
392    #[test]
393    fn test_picker_state_new_with_custom_input() {
394        let items = create_test_items();
395        let state = PickerState::new("Select branch", items, true);
396
397        assert!(state.allow_custom_input);
398        assert_eq!(state.custom_input_item, None); // No custom item when pattern is empty
399    }
400
401    #[test]
402    fn test_empty_pattern_shows_all_items() {
403        let items = create_test_items();
404        let state = PickerState::new("Select", items, false);
405
406        assert_eq!(state.filtered_count(), 5);
407        assert_eq!(state.filtered_indices, vec![0, 1, 2, 3, 4]);
408    }
409
410    #[test]
411    fn test_fuzzy_filtering() {
412        let items = create_test_items();
413        let mut state = PickerState::new("Select", items, false);
414
415        state
416            .input_state
417            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
418        state.update_filter();
419
420        // Should match "feature/test", "feature/new", and "bugfix/123" (fuzzy match)
421        assert_eq!(state.filtered_count(), 3);
422
423        let filtered: Vec<_> = state.filtered_items().collect();
424        assert!(
425            filtered
426                .iter()
427                .any(|(_, item)| item.display == "feature/test")
428        );
429        assert!(
430            filtered
431                .iter()
432                .any(|(_, item)| item.display == "feature/new")
433        );
434        assert!(
435            filtered
436                .iter()
437                .any(|(_, item)| item.display == "bugfix/123")
438        );
439    }
440
441    #[test]
442    fn test_fuzzy_filtering_sorts_by_score() {
443        // Create items with varying match quality for pattern "feat"
444        let items = vec![
445            PickerItem::new("feat", PickerData::Item("feat".to_string())), // Exact match
446            PickerItem::new("feature", PickerData::Item("feature".to_string())), // Prefix match
447            PickerItem::new("feature/test", PickerData::Item("feature/test".to_string())), // Prefix with more chars
448            PickerItem::new("fix-eat-bug", PickerData::Item("fix-eat-bug".to_string())), // Scattered match
449        ];
450        let mut state = PickerState::new("Select", items, false);
451
452        // Type "feat" pattern
453        state
454            .input_state
455            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
456        state
457            .input_state
458            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
459        state
460            .input_state
461            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
462        state
463            .input_state
464            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
465        state.update_filter();
466
467        // All should match
468        assert_eq!(state.filtered_count(), 4);
469
470        // Verify sorted by score: exact match > prefix match > scattered match
471        let filtered: Vec<_> = state
472            .filtered_items()
473            .map(|(_, item)| item.display.as_ref())
474            .collect();
475
476        // "feat" (exact) should be first, "fix-eat-bug" (scattered) should be last
477        assert_eq!(filtered[0], "feat");
478        assert_eq!(filtered[filtered.len() - 1], "fix-eat-bug");
479    }
480
481    #[test]
482    fn test_case_insensitive_matching() {
483        let items = vec![
484            PickerItem::new("Feature", PickerData::Item("Feature".to_string())),
485            PickerItem::new("feature", PickerData::Item("feature".to_string())),
486            PickerItem::new("FEATURE", PickerData::Item("FEATURE".to_string())),
487        ];
488
489        let mut state = PickerState::new("Select item", items, false);
490
491        state
492            .input_state
493            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
494        state
495            .input_state
496            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
497        state
498            .input_state
499            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
500        state.update_filter();
501
502        // fuzzy-matcher is case-insensitive by default
503        assert_eq!(state.filtered_count(), 3);
504    }
505
506    #[test]
507    fn test_no_matches() {
508        let items = create_test_items();
509        let mut state = PickerState::new("Select", items, false);
510
511        state
512            .input_state
513            .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
514        state
515            .input_state
516            .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()));
517        state
518            .input_state
519            .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty()));
520        state.update_filter();
521
522        assert_eq!(state.filtered_count(), 0);
523        assert!(state.selected().is_none());
524    }
525
526    #[test]
527    fn test_custom_input_creation() {
528        let items = create_test_items();
529        let mut state = PickerState::new("Select", items, true);
530
531        // Use pattern "fea" which matches feature/test and feature/new
532        state
533            .input_state
534            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
535        state
536            .input_state
537            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
538        state
539            .input_state
540            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
541        state.update_filter();
542
543        // Custom input item should be created
544        assert!(state.custom_input_item.is_some());
545
546        // Should have multiple regular matches + custom input
547        let filtered: Vec<_> = state.filtered_items().collect();
548        assert!(filtered.len() >= 3); // At least 2 feature items + bugfix + custom input
549
550        // Custom input should be last in filtered items
551        let last = filtered.last().unwrap();
552        assert_eq!(last.0, usize::MAX);
553        assert_eq!(last.1.display.as_ref(), "fea");
554    }
555
556    #[test]
557    fn test_custom_input_not_created_when_disabled() {
558        let items = create_test_items();
559        let mut state = PickerState::new("Select", items, false);
560
561        state
562            .input_state
563            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
564        state
565            .input_state
566            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
567        state.update_filter();
568
569        assert!(state.custom_input_item.is_none());
570    }
571
572    #[test]
573    fn test_custom_input_not_created_on_empty_pattern() {
574        let items = create_test_items();
575        let mut state = PickerState::new("Select", items, true);
576
577        // Start with no input
578        assert!(state.custom_input_item.is_none());
579
580        // Add then remove input
581        state
582            .input_state
583            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
584        state.update_filter();
585        assert!(state.custom_input_item.is_some());
586
587        state
588            .input_state
589            .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
590        state.update_filter();
591        assert!(state.custom_input_item.is_none());
592    }
593
594    #[test]
595    fn test_cursor_next() {
596        let items = create_test_items();
597        let mut state = PickerState::new("Select", items, false);
598
599        assert_eq!(state.cursor(), 0);
600
601        state.next();
602        assert_eq!(state.cursor(), 1);
603
604        state.next();
605        assert_eq!(state.cursor(), 2);
606    }
607
608    #[test]
609    fn test_cursor_next_wraps_around() {
610        let items = create_test_items();
611        let mut state = PickerState::new("Select", items, false);
612
613        // Move to end
614        for _ in 0..5 {
615            state.next();
616        }
617
618        // Should wrap to 0
619        assert_eq!(state.cursor(), 0);
620    }
621
622    #[test]
623    fn test_cursor_previous() {
624        let items = create_test_items();
625        let mut state = PickerState::new("Select", items, false);
626
627        state.next();
628        state.next();
629        assert_eq!(state.cursor(), 2);
630
631        state.previous();
632        assert_eq!(state.cursor(), 1);
633
634        state.previous();
635        assert_eq!(state.cursor(), 0);
636    }
637
638    #[test]
639    fn test_cursor_previous_wraps_around() {
640        let items = create_test_items();
641        let mut state = PickerState::new("Select", items, false);
642
643        assert_eq!(state.cursor(), 0);
644
645        state.previous();
646        // Should wrap to last item
647        assert_eq!(state.cursor(), 4);
648    }
649
650    #[test]
651    fn test_cursor_with_custom_input() {
652        let items = create_test_items();
653        let mut state = PickerState::new("Select", items, true);
654
655        state
656            .input_state
657            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
658        state.update_filter();
659
660        // 3 matched items (feature/test, feature/new, bugfix/123) + 1 custom input = 4 total
661        assert_eq!(state.cursor(), 0);
662
663        state.next();
664        assert_eq!(state.cursor(), 1);
665
666        state.next();
667        assert_eq!(state.cursor(), 2);
668
669        state.next();
670        assert_eq!(state.cursor(), 3); // Custom input position
671
672        state.next();
673        assert_eq!(state.cursor(), 0); // Wrapped around forward
674
675        state.previous();
676        assert_eq!(state.cursor(), 3); // Wrapped around backward to custom input
677    }
678
679    #[test]
680    fn test_navigation_with_custom_input_at_end() {
681        let items = vec![
682            PickerItem::new("feature/a", PickerData::Item("feature/a".to_string())),
683            PickerItem::new("feature/b", PickerData::Item("feature/b".to_string())),
684        ];
685
686        let mut state = PickerState::new("Select", items, true);
687
688        // Type "feat" to get matches + custom input
689        state
690            .input_state
691            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
692        state
693            .input_state
694            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
695        state
696            .input_state
697            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
698        state
699            .input_state
700            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
701        state.update_filter();
702
703        // Should have 2 matches + custom input = 3 total
704        assert_eq!(state.filtered_count(), 2);
705        assert_eq!(state.cursor(), 0);
706
707        // Navigate to first match
708        let selected = state.selected().unwrap();
709        match &selected.data {
710            PickerData::Item(_) => {}
711            _ => panic!("Expected first item to be a revision"),
712        }
713
714        // Navigate to second match
715        state.next();
716        assert_eq!(state.cursor(), 1);
717
718        // Navigate to custom input (last item)
719        state.next();
720        assert_eq!(state.cursor(), 2);
721        let selected = state.selected().unwrap();
722        match &selected.data {
723            PickerData::CustomInput(s) => assert_eq!(s, "feat"),
724            _ => panic!("Expected custom input at end"),
725        }
726
727        // Wrap around to first
728        state.next();
729        assert_eq!(state.cursor(), 0);
730    }
731
732    #[test]
733    fn test_cursor_resets_when_filter_reduces_items() {
734        let items = create_test_items();
735        let mut state = PickerState::new("Select", items, false);
736
737        // Move cursor to position 4
738        for _ in 0..4 {
739            state.next();
740        }
741        assert_eq!(state.cursor(), 4);
742
743        // Filter to only 2 items
744        state
745            .input_state
746            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
747        state.update_filter();
748
749        // Cursor should reset to 0
750        assert_eq!(state.cursor(), 0);
751    }
752
753    #[test]
754    fn test_scroll_through_many_items() {
755        // Create 20 items to test scrolling behavior
756        let items: Vec<_> = (0..20)
757            .map(|i| {
758                PickerItem::new(
759                    format!("branch-{:02}", i),
760                    PickerData::Item(format!("branch-{:02}", i)),
761                )
762            })
763            .collect();
764
765        let mut state = PickerState::new("Select", items, false);
766
767        // Start at first item
768        assert_eq!(state.cursor(), 0);
769
770        // Navigate to middle item
771        for _ in 0..10 {
772            state.next();
773        }
774        assert_eq!(state.cursor(), 10);
775
776        // Navigate to last item
777        for _ in 0..9 {
778            state.next();
779        }
780        assert_eq!(state.cursor(), 19);
781
782        // Wrap around to first
783        state.next();
784        assert_eq!(state.cursor(), 0);
785
786        // Navigate backwards
787        state.previous();
788        assert_eq!(state.cursor(), 19);
789    }
790
791    #[test]
792    fn test_navigation_after_filtering() {
793        let items = vec![
794            PickerItem::new("feature/a", PickerData::Item("feature/a".to_string())),
795            PickerItem::new("feature/b", PickerData::Item("feature/b".to_string())),
796            PickerItem::new("main", PickerData::Item("main".to_string())),
797            PickerItem::new("develop", PickerData::Item("develop".to_string())),
798        ];
799
800        let mut state = PickerState::new("Select", items, false);
801
802        // Filter to get only feature/* branches
803        state
804            .input_state
805            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
806        state
807            .input_state
808            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
809        state
810            .input_state
811            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
812        state.update_filter();
813
814        // Should have 2 filtered items
815        assert_eq!(state.filtered_count(), 2);
816        assert_eq!(state.cursor(), 0);
817
818        // Navigate through filtered items
819        state.next();
820        assert_eq!(state.cursor(), 1);
821
822        // Wrap around
823        state.next();
824        assert_eq!(state.cursor(), 0);
825
826        // Go backwards
827        state.previous();
828        assert_eq!(state.cursor(), 1);
829    }
830
831    #[test]
832    fn test_selected_returns_correct_item() {
833        let items = create_test_items();
834        let mut state = PickerState::new("Select", items, false);
835
836        let selected = state.selected().unwrap();
837        assert_eq!(selected.display.as_ref(), "main");
838
839        state.next();
840        state.next();
841        let selected = state.selected().unwrap();
842        assert_eq!(selected.display.as_ref(), "feature/test");
843    }
844
845    #[test]
846    fn test_selected_with_filter() {
847        let items = create_test_items();
848        let mut state = PickerState::new("Select", items, false);
849
850        state
851            .input_state
852            .handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()));
853        state.update_filter();
854
855        // Should match "bugfix/123"
856        let selected = state.selected().unwrap();
857        assert_eq!(selected.display.as_ref(), "bugfix/123");
858    }
859
860    #[test]
861    fn test_selected_returns_custom_input() {
862        let items = create_test_items();
863        let mut state = PickerState::new("Select", items, true);
864
865        // Use a pattern that doesn't match any items
866        state
867            .input_state
868            .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()));
869        state
870            .input_state
871            .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()));
872        state.update_filter();
873
874        // No matches, only custom input at cursor 0
875        assert_eq!(state.cursor(), 0);
876        assert_eq!(state.selected().unwrap().display.as_ref(), "qq");
877    }
878
879    #[test]
880    fn test_select_custom_input() {
881        let items = create_test_items();
882        let mut state = PickerState::new("Select", items, true);
883
884        // Type a pattern that doesn't match anything
885        state
886            .input_state
887            .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
888        state
889            .input_state
890            .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()));
891        state
892            .input_state
893            .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty()));
894        state.update_filter();
895
896        // Should have no matches but custom input
897        assert_eq!(state.filtered_count(), 0);
898        assert!(state.custom_input_item.is_some());
899
900        // Cursor should be at custom input (position 0 since no other items)
901        assert_eq!(state.cursor(), 0);
902
903        // Selected item should be the custom input
904        let selected = state.selected().unwrap();
905        assert_eq!(selected.display, "xyz");
906        match &selected.data {
907            PickerData::CustomInput(s) => assert_eq!(s, "xyz"),
908            _ => panic!("Expected CustomInput"),
909        }
910
911        // Mark as done
912        state.done();
913        assert!(state.is_done());
914        assert_eq!(state.status(), &PickerStatus::Done);
915    }
916
917    #[test]
918    fn test_filtered_items_order() {
919        let items = create_test_items();
920        let mut state = PickerState::new("Select", items, true);
921
922        state
923            .input_state
924            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
925        state.update_filter();
926
927        let indices: Vec<_> = state.filtered_items().map(|(idx, _)| idx).collect();
928
929        // Verify normal item indices followed by custom input
930        assert_eq!(indices[0], 2); // feature/test
931        assert_eq!(indices[1], 3); // feature/new
932        assert_eq!(indices[2], 4); // bugfix/123
933        assert_eq!(indices[3], usize::MAX); // custom input
934    }
935
936    #[test]
937    fn test_match_indices_with_pattern() {
938        let items = create_test_items();
939        let mut state = PickerState::new("Select", items, false);
940
941        state
942            .input_state
943            .handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty()));
944        state
945            .input_state
946            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
947        state
948            .input_state
949            .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::empty()));
950        state.update_filter();
951
952        // Get the index of "main" in original items
953        let indices = state.match_indices(0);
954        assert!(indices.is_some());
955
956        let indices = indices.unwrap();
957        assert_eq!(indices.len(), 3); // 'm', 'a', 'i' should match
958    }
959
960    #[test]
961    fn test_match_indices_empty_pattern() {
962        let items = create_test_items();
963        let state = PickerState::new("Select", items, false);
964
965        let indices = state.match_indices(0);
966        assert!(indices.is_none());
967    }
968
969    #[test]
970    fn test_match_indices_custom_input_returns_none() {
971        let items = create_test_items();
972        let mut state = PickerState::new("Select", items, true);
973
974        state
975            .input_state
976            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
977        state.update_filter();
978
979        // usize::MAX is used for custom input items
980        let indices = state.match_indices(usize::MAX);
981        assert!(indices.is_none());
982    }
983
984    #[test]
985    fn test_status_transitions() {
986        let items = create_test_items();
987        let mut state = PickerState::new("Select", items, false);
988
989        assert_eq!(state.status(), &PickerStatus::Active);
990        assert!(!state.is_done());
991        assert!(!state.is_cancelled());
992
993        state.done();
994        assert_eq!(state.status(), &PickerStatus::Done);
995        assert!(state.is_done());
996        assert!(!state.is_cancelled());
997    }
998
999    #[test]
1000    fn test_status_cancelled() {
1001        let items = create_test_items();
1002        let mut state = PickerState::new("Select", items, false);
1003
1004        state.cancel();
1005        assert_eq!(state.status(), &PickerStatus::Cancelled);
1006        assert!(!state.is_done());
1007        assert!(state.is_cancelled());
1008    }
1009
1010    #[test]
1011    fn test_empty_items_list() {
1012        let state = PickerState::new("Select", vec![], false);
1013
1014        assert_eq!(state.total_items(), 0);
1015        assert_eq!(state.filtered_count(), 0);
1016        assert_eq!(state.cursor(), 0);
1017        assert!(state.selected().is_none());
1018    }
1019
1020    #[test]
1021    fn test_empty_items_with_custom_input() {
1022        let mut state = PickerState::new("Select", vec![], true);
1023
1024        state
1025            .input_state
1026            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
1027        state.update_filter();
1028
1029        assert_eq!(state.total_items(), 0);
1030        assert_eq!(state.filtered_count(), 0);
1031        assert!(state.custom_input_item.is_some());
1032
1033        assert_eq!(state.selected().unwrap().display.as_ref(), "a");
1034    }
1035
1036    #[test]
1037    fn test_cursor_navigation_empty_list() {
1038        let mut state = PickerState::new("Select", vec![], false);
1039
1040        state.next();
1041        assert_eq!(state.cursor(), 0);
1042
1043        state.previous();
1044        assert_eq!(state.cursor(), 0);
1045    }
1046
1047    #[test]
1048    fn test_single_item_navigation() {
1049        let items = vec![PickerItem::new(
1050            "only",
1051            PickerData::Item("only".to_string()),
1052        )];
1053        let mut state = PickerState::new("Select", items, false);
1054
1055        assert_eq!(state.cursor(), 0);
1056
1057        state.next();
1058        assert_eq!(state.cursor(), 0); // Wraps to same item
1059
1060        state.previous();
1061        assert_eq!(state.cursor(), 0); // Wraps to same item
1062    }
1063
1064    #[test]
1065    fn test_with_refs_basic_sorting() {
1066        // Test that items are sorted as: default -> branches -> tags -> remotes
1067        let refs = &[
1068            Ref::Tag("v1.0.0".to_string()),
1069            Ref::Remote("origin/main".to_string()),
1070            Ref::Head("feature".to_string()),
1071            Ref::Head("main".to_string()),
1072            Ref::Tag("v2.0.0".to_string()),
1073        ];
1074
1075        let state = PickerState::with_refs(PickerParams {
1076            prompt: "Select".into(),
1077            refs,
1078            exclude_ref: None,
1079            default: None,
1080            allow_custom_input: false,
1081        });
1082
1083        let items: Vec<_> = state
1084            .filtered_items()
1085            .map(|(_, item)| item.display.as_ref())
1086            .collect();
1087
1088        // Order is preserved from input
1089        assert_eq!(
1090            items,
1091            &[
1092                "tag: v1.0.0",
1093                "origin/main",
1094                "feature",
1095                "main",
1096                "tag: v2.0.0"
1097            ]
1098        )
1099    }
1100
1101    #[test]
1102    fn test_with_refs_empty_list() {
1103        let state = PickerState::with_refs(PickerParams {
1104            prompt: "Select".into(),
1105            refs: &[],
1106            exclude_ref: None,
1107            default: None,
1108            allow_custom_input: false,
1109        });
1110
1111        assert_eq!(state.total_items(), 0);
1112        assert_eq!(state.filtered_count(), 0);
1113        assert!(state.selected().is_none());
1114    }
1115
1116    #[test]
1117    fn test_with_refs_with_default() {
1118        // Test that default ref is shown first
1119        let refs = &[
1120            Ref::Head("feature".to_string()),
1121            Ref::Head("main".to_string()),
1122            Ref::Tag("v1.0.0".to_string()),
1123        ];
1124
1125        let default = Some(Rev::Ref(Ref::Head("main".to_string())));
1126        let state = PickerState::with_refs(PickerParams {
1127            prompt: "Select".into(),
1128            refs,
1129            exclude_ref: None,
1130            default,
1131            allow_custom_input: false,
1132        });
1133
1134        let items: Vec<_> = state
1135            .filtered_items()
1136            .map(|(_, item)| item.display.as_ref())
1137            .collect();
1138
1139        // Default should be first, then remaining in input order
1140        assert_eq!(&items, &["main", "feature", "tag: v1.0.0"]);
1141    }
1142
1143    #[test]
1144    fn test_with_refs_exclude_ref() {
1145        // Test that excluded ref is not shown
1146        let refs = &[
1147            Ref::Head("feature".to_string()),
1148            Ref::Head("main".to_string()),
1149            Ref::Tag("v1.0.0".to_string()),
1150        ];
1151
1152        let state = PickerState::with_refs(PickerParams {
1153            prompt: "Select".into(),
1154            refs,
1155            exclude_ref: Some(Ref::Head("main".to_string())),
1156            default: None,
1157            allow_custom_input: false,
1158        });
1159
1160        let items: Vec<_> = state
1161            .filtered_items()
1162            .map(|(_, item)| item.display.as_ref())
1163            .collect();
1164
1165        // main should be excluded
1166        assert_eq!(&items, &["feature", "tag: v1.0.0"])
1167    }
1168
1169    #[test]
1170    fn test_with_refs_duplicate_names() {
1171        // Tag and branch with same name are disambiguated by "tag: " prefix
1172        let refs = &[
1173            Ref::Head("v1.0.0".to_string()),
1174            Ref::Tag("v1.0.0".to_string()),
1175            Ref::Head("main".to_string()),
1176        ];
1177
1178        let state = PickerState::with_refs(PickerParams {
1179            prompt: "Select".into(),
1180            refs,
1181            exclude_ref: None,
1182            default: None,
1183            allow_custom_input: false,
1184        });
1185
1186        let items: Vec<_> = state
1187            .filtered_items()
1188            .map(|(_, item)| item.display.as_ref())
1189            .collect();
1190
1191        assert_eq!(&items, &["v1.0.0", "tag: v1.0.0", "main"])
1192    }
1193
1194    #[test]
1195    fn test_with_refs_duplicate_with_default() {
1196        // Tag default shown with "tag: " prefix
1197        let refs = &[
1198            Ref::Head("v1.0.0".to_string()),
1199            Ref::Tag("v1.0.0".to_string()),
1200            Ref::Head("main".to_string()),
1201        ];
1202
1203        let state = PickerState::with_refs(PickerParams {
1204            prompt: "Select".into(),
1205            refs,
1206            exclude_ref: None,
1207            default: Some(Rev::Ref(Ref::Tag("v1.0.0".to_string()))),
1208            allow_custom_input: false,
1209        });
1210
1211        let items: Vec<_> = state
1212            .filtered_items()
1213            .map(|(_, item)| item.display.as_ref())
1214            .collect();
1215
1216        assert_eq!(items, &["tag: v1.0.0", "v1.0.0", "main"])
1217    }
1218
1219    #[test]
1220    fn test_with_refs_exclude_and_default_same() {
1221        // Test when exclude and default are the same
1222        // The implementation adds default first, then filters exclude from remaining items
1223        // So if default == exclude, default is still added, then exclude filters it from the rest
1224        let refs = &[
1225            Ref::Head("feature".to_string()),
1226            Ref::Head("main".to_string()),
1227        ];
1228
1229        let state = PickerState::with_refs(PickerParams {
1230            prompt: "Select".into(),
1231            refs,
1232            exclude_ref: Some(Ref::Head("main".to_string())),
1233            default: Some(Rev::Ref(Ref::Head("main".to_string()))),
1234            allow_custom_input: false,
1235        });
1236
1237        let items: Vec<_> = state
1238            .filtered_items()
1239            .map(|(_, item)| item.display.as_ref())
1240            .collect();
1241
1242        // Default is added first, then exclude filters remaining items
1243        // So we get: main (default) + feature (not excluded)
1244        assert_eq!(&items, &["main", "feature"])
1245    }
1246
1247    #[test]
1248    fn test_with_refs_with_custom_input() {
1249        let refs = &[
1250            Ref::Head("main".to_string()),
1251            Ref::Tag("v1.0.0".to_string()),
1252        ];
1253
1254        let state = PickerState::with_refs(PickerParams {
1255            prompt: "Select".into(),
1256            refs,
1257            exclude_ref: None,
1258            default: None,
1259            allow_custom_input: true,
1260        });
1261
1262        // Custom input should be allowed
1263        assert!(state.allow_custom_input);
1264
1265        // With empty pattern, no custom input item
1266        assert!(state.custom_input_item.is_none());
1267    }
1268
1269    #[test]
1270    fn test_with_refs_exclude_tag_sibling() {
1271        // If branch is excluded, remaining tag still shows "tag: " prefix
1272        let refs = &[
1273            Ref::Head("v1.0.0".to_string()),
1274            Ref::Tag("v1.0.0".to_string()),
1275        ];
1276
1277        let state = PickerState::with_refs(PickerParams {
1278            prompt: "Select".into(),
1279            refs,
1280            exclude_ref: Some(Ref::Head("v1.0.0".to_string())),
1281            default: None,
1282            allow_custom_input: false,
1283        });
1284
1285        let items: Vec<_> = state
1286            .filtered_items()
1287            .map(|(_, item)| item.display.as_ref())
1288            .collect();
1289
1290        assert_eq!(&items, &["tag: v1.0.0"]);
1291    }
1292
1293    #[test]
1294    fn test_with_refs_remote_tracking_default() {
1295        let refs = &[
1296            Ref::Head("main".to_string()),
1297            Ref::Remote("origin/main".to_string()),
1298            Ref::Remote("origin/feature".to_string()),
1299        ];
1300
1301        let state = PickerState::with_refs(PickerParams {
1302            prompt: "Select".into(),
1303            refs,
1304            exclude_ref: None,
1305            default: Some(Rev::Ref(Ref::Remote("origin/main".to_string()))),
1306            allow_custom_input: false,
1307        });
1308
1309        let items: Vec<_> = state
1310            .filtered_items()
1311            .map(|(_, item)| item.display.as_ref())
1312            .collect();
1313
1314        // Default remote-tracking branch shown first as shorthand, then remaining
1315        assert_eq!(&items, &["origin/main", "main", "origin/feature"]);
1316    }
1317}