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