From 948ad2d618e709af167817448865cf6990cbdbce Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Thu, 11 Dec 2025 02:54:11 +0900 Subject: [PATCH 1/6] feat: add interactive picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement an interactive fuzzy finder picker component: - Fuzzy matching with real-time filtering - Keyboard navigation (↓/↑, Ctrl-n/p) - Select with Enter, cancel with Esc/Ctrl-c - Visual highlighting of matched characters Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 19 + Cargo.toml | 1 + README.md | 27 ++ src/app.rs | 103 ++++- src/config.rs | 15 + src/default_config.toml | 5 + src/lib.rs | 1 + src/picker.rs | 811 ++++++++++++++++++++++++++++++++++++++++ src/screen/mod.rs | 9 +- src/tests/mod.rs | 1 + src/tests/picker.rs | 223 +++++++++++ src/ui.rs | 11 +- src/ui/menu.rs | 4 + src/ui/picker.rs | 136 +++++++ 14 files changed, 1362 insertions(+), 4 deletions(-) create mode 100644 src/picker.rs create mode 100644 src/tests/picker.rs create mode 100644 src/ui/picker.rs diff --git a/Cargo.lock b/Cargo.lock index 01134672f3..b81804f2ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,15 @@ dependencies = [ "libc", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -668,6 +677,7 @@ dependencies = [ "crossterm", "etcetera", "figment", + "fuzzy-matcher", "git-version", "git2", "imara-diff", @@ -1805,6 +1815,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 70a2afae9f..93ea1932db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,3 +75,4 @@ strum = { version = "0.26.3", features = ["strum_macros"] } tinyvec = "1.10.0" smashquote = "0.1.2" imara-diff = { version = "0.2.0", default-features = false } +fuzzy-matcher = "0.3.7" diff --git a/README.md b/README.md index 3664b302c5..5822a22898 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,20 @@ A help-menu can be shown by pressing the `h` key, or by configuring `general.alw +#### Picker Keybinds +When using the interactive picker (for branch/commit selection): + +| Key | Action | +|-----|--------| +| `↓` or `Ctrl-n` | Next item | +| `↑` or `Ctrl-p` | Previous item | +| `Enter` | Select current item | +| `Esc` or `Ctrl-c` | Cancel | +| Any text | Filter items (fuzzy matching) | + +> [!NOTE] +> Picker keybinds will be configurable in a future release. + ### Configuration The environment variables `VISUAL`, `EDITOR` or `GIT_EDITOR` (checked in this order) dictate which editor Gitu will open. This means that e. g. commit messages will be opened in the `GIT_EDITOR` by Git, but if the user wishes to do edits to the actual files in a different editor, `VISUAL` or `EDITOR` can be set accordingly. @@ -38,6 +52,19 @@ Configuration is also loaded from: - Windows: `%USERPROFILE%\AppData\Roaming\gitu\config.toml` , refer to the [default configuration](src/default_config.toml). + +#### Picker Style Customization + +You can customize the appearance of the interactive picker by adding the following to your config: + +```toml +[style.picker] +prompt = { fg = "cyan" } # Prompt text color +info = { mods = "DIM" } # Status line style (e.g., "3/10 matches") +selection_line = { mods = "BOLD" } # Selected item style +matched = { fg = "yellow", mods = "BOLD" } # Fuzzy-matched characters highlight +``` + ### Installing Gitu Follow the install instructions: [Installing Gitu](docs/installing.md)\ Or install from your package manager: diff --git a/src/app.rs b/src/app.rs index b34a02d731..45154bfa0d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,8 @@ use crate::item_data::RefKind; use crate::menu::Menu; use crate::menu::PendingMenu; use crate::ops::Op; +use crate::picker::PickerData; +use crate::picker::PickerState; use crate::prompt; use crate::screen; use crate::screen::Screen; @@ -52,6 +54,7 @@ pub(crate) struct State { enable_async_cmds: bool, pub current_cmd_log: CmdLog, pub prompt: prompt::Prompt, + pub picker: Option, pub clipboard: Option, needs_redraw: bool, file_watcher: Option, @@ -103,6 +106,7 @@ impl App { pending_menu, current_cmd_log: CmdLog::new(), prompt: prompt::Prompt::new(), + picker: None, clipboard, file_watcher: None, needs_redraw: true, @@ -213,7 +217,9 @@ impl App { self.state.current_cmd_log.clear(); } - if self.state.prompt.state.is_focused() { + if self.state.picker.is_some() { + self.handle_picker_input(key); + } else if self.state.prompt.state.is_focused() { self.state.prompt.state.handle_key_event(key); } else { self.handle_key_input(term, key)?; @@ -618,6 +624,101 @@ impl App { self.redraw_now(term)?; } } + + /// Show a picker and wait for user to select an item or cancel. + /// + /// Returns: + /// - `Ok(Some(data))` - User selected an item + /// - `Ok(None)` - User cancelled (Esc or Ctrl-C) + /// - `Err(e)` - An error occurred + /// + /// # Example + /// ```ignore + /// let items = vec![ + /// PickerItem::new("main", PickerData::Revision("main".to_string())), + /// PickerItem::new("develop", PickerData::Revision("develop".to_string())), + /// ]; + /// let picker = PickerState::new("Select branch", items, false); + /// + /// match app.picker(term, picker)? { + /// Some(PickerData::Revision(name)) => { + /// // User selected a branch + /// println!("Selected: {}", name); + /// } + /// Some(PickerData::CustomInput(_)) => { + /// // Should not happen when allow_custom_input is false + /// } + /// None => { + /// // User cancelled + /// println!("Cancelled"); + /// } + /// } + /// ``` + #[allow(dead_code)] + pub fn picker( + &mut self, + term: &mut Term, + picker_state: PickerState, + ) -> Res> { + self.state.picker = Some(picker_state); + let result = self.handle_picker(term); + + self.state.picker = None; + + result + } + + fn handle_picker(&mut self, term: &mut Term) -> Res> { + self.redraw_now(term)?; + + loop { + let event = term.backend_mut().read_event()?; + self.handle_event(term, event)?; + + if let Some(ref picker) = self.state.picker { + if picker.is_done() { + // User selected an item + return Ok(picker.selected().map(|item| item.data.clone())); + } else if picker.is_cancelled() { + // User cancelled - this is not an error + return Ok(None); + } + } + + self.redraw_now(term)?; + } + } + + fn handle_picker_input(&mut self, key: event::KeyEvent) { + if let Some(ref mut picker) = self.state.picker { + use crossterm::event::KeyCode; + use crossterm::event::KeyModifiers; + + match (key.code, key.modifiers) { + // Navigation + (KeyCode::Down, KeyModifiers::NONE) + | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + picker.next(); + } + (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + picker.previous(); + } + // Select + (KeyCode::Enter, _) => { + picker.done(); + } + // Cancel + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + picker.cancel(); + } + // Text input - delegate to text state + _ => { + picker.input_state.handle_key_event(key); + picker.update_filter(); + } + } + } + } } fn get_prompt_result(params: &PromptParams, app: &mut App) -> Res { diff --git a/src/config.rs b/src/config.rs index 124fe9dddb..ca972bb20d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,6 +67,9 @@ pub struct StyleConfig { #[serde(default)] pub syntax_highlight: SyntaxHighlightConfig, + #[serde(default)] + pub picker: PickerStyleConfig, + pub cursor: SymbolStyleConfigEntry, pub selection_line: StyleConfigEntry, pub selection_bar: SymbolStyleConfigEntry, @@ -149,6 +152,18 @@ pub struct SyntaxHighlightConfig { pub variable_parameter: StyleConfigEntry, } +#[derive(Default, Debug, Deserialize)] +pub struct PickerStyleConfig { + #[serde(default)] + pub prompt: StyleConfigEntry, + #[serde(default)] + pub info: StyleConfigEntry, + #[serde(default)] + pub selection_line: StyleConfigEntry, + #[serde(default)] + pub matched: StyleConfigEntry, +} + #[derive(Default, Debug, Deserialize)] pub struct StyleConfigEntry { #[serde(default)] diff --git a/src/default_config.toml b/src/default_config.toml index 6483dd50ed..1a7e33ecb1 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -70,6 +70,11 @@ syntax_highlight.type_builtin = { fg = "yellow" } syntax_highlight.variable_builtin = {} syntax_highlight.variable_parameter = {} +picker.prompt = { fg = "cyan" } +picker.info = { mods = "DIM" } +picker.selection_line = { mods = "BOLD" } +picker.matched = { fg = "yellow", mods = "BOLD" } + cursor = { symbol = "▌", fg = "blue" } selection_bar = { symbol = "▌", fg = "blue", mods = "DIM" } selection_line = { mods = "BOLD" } diff --git a/src/lib.rs b/src/lib.rs index 2f7e720171..0f8ca83ff4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ mod items; mod key_parser; mod menu; mod ops; +pub mod picker; mod prompt; mod screen; mod syntax_parser; diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000000..b23e268834 --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,811 @@ +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use std::borrow::Cow; +use tui_prompts::State as _; +use tui_prompts::TextState; + +/// Data that can be selected in a picker +#[derive(Debug, Clone, PartialEq)] +pub enum PickerData { + /// Revision (branch, commit, reference, etc.) + Revision(String), + /// Remote name + Remote(String), + /// Custom user input (literal text from input field) + CustomInput(String), +} + +impl PickerData { + /// Get the display string for this data + pub fn display(&self) -> &str { + match self { + PickerData::Revision(s) => s, + PickerData::Remote(s) => s, + PickerData::CustomInput(s) => s, + } + } +} + +/// An item in the picker list +#[derive(Debug, Clone, PartialEq)] +pub struct PickerItem { + /// The text to display and match against + pub display: Cow<'static, str>, + /// Associated data + pub data: PickerData, +} + +impl PickerItem { + pub fn new(display: impl Into>, data: PickerData) -> Self { + Self { + display: display.into(), + data, + } + } +} + +/// Result of a fuzzy match with score +#[derive(Debug, Clone)] +struct MatchResult { + index: usize, + score: i64, +} + +/// Picker status +#[derive(Debug, Clone, PartialEq)] +pub enum PickerStatus { + /// Picker is active + Active, + /// User selected an item + Done, + /// User cancelled + Cancelled, +} + +/// State of the picker component +pub struct PickerState { + /// All available items (excluding custom input) + items: Vec, + /// Filtered and sorted indices based on current pattern + filtered_indices: Vec, + /// Current cursor position in filtered results + cursor: usize, + /// Current input pattern + pub input_state: TextState<'static>, + /// Fuzzy matcher + matcher: SkimMatcherV2, + /// Prompt text to display + pub prompt_text: Cow<'static, str>, + /// Current status + status: PickerStatus, + /// Allow user to input custom value not in the list + allow_custom_input: bool, + /// Custom input item (separate from items list) + custom_input_item: Option, +} + +impl PickerState { + /// Create a new picker with items + pub fn new( + prompt: impl Into>, + items: Vec, + allow_custom_input: bool, + ) -> Self { + let mut state = Self { + items: items.clone(), + filtered_indices: Vec::new(), + cursor: 0, + input_state: TextState::default(), + matcher: SkimMatcherV2::default(), + prompt_text: prompt.into(), + status: PickerStatus::Active, + allow_custom_input, + custom_input_item: None, + }; + state.update_filter(); + state + } + + /// Get current input pattern + pub fn pattern(&self) -> &str { + self.input_state.value() + } + + /// Update the filter based on current input pattern + pub fn update_filter(&mut self) { + let pattern = self.pattern().to_string(); + + if pattern.is_empty() { + // Show all items when no pattern + self.filtered_indices = (0..self.items.len()).collect(); + self.custom_input_item = None; + } else { + // Fuzzy match and sort by score + let mut matches: Vec = self + .items + .iter() + .enumerate() + .filter_map(|(i, item)| { + self.matcher + .fuzzy_match(&item.display, &pattern) + .map(|score| MatchResult { index: i, score }) + }) + .collect(); + + // Sort by score (higher is better) + matches.sort_by(|a, b| b.score.cmp(&a.score)); + + self.filtered_indices = matches.into_iter().map(|m| m.index).collect(); + + // Create custom input item if enabled + if self.allow_custom_input { + self.custom_input_item = Some(PickerItem::new( + pattern.clone(), + PickerData::CustomInput(pattern), + )); + } else { + self.custom_input_item = None; + } + } + + // Reset cursor if out of bounds + let total_count = self.filtered_indices.len() + + if self.custom_input_item.is_some() { + 1 + } else { + 0 + }; + if self.cursor >= total_count { + self.cursor = 0; + } + } + + /// Get the currently selected item, if any + pub fn selected(&self) -> Option<&PickerItem> { + // Check if cursor is on custom input item (always at the end) + if self.cursor == self.filtered_indices.len() { + return self.custom_input_item.as_ref(); + } + + self.filtered_indices + .get(self.cursor) + .and_then(|&i| self.items.get(i)) + } + + /// Get all filtered items with their original indices + /// Custom input item (if present) is always last with index usize::MAX + pub fn filtered_items(&self) -> impl Iterator { + self.filtered_indices + .iter() + .filter_map(|&i| self.items.get(i).map(|item| (i, item))) + .chain( + self.custom_input_item + .as_ref() + .map(|item| (usize::MAX, item)), + ) + } + + /// Move cursor to next item + pub fn next(&mut self) { + let total_count = self.filtered_indices.len() + + if self.custom_input_item.is_some() { + 1 + } else { + 0 + }; + if total_count > 0 { + self.cursor = (self.cursor + 1) % total_count; + } + } + + /// Move cursor to previous item + pub fn previous(&mut self) { + let total_count = self.filtered_indices.len() + + if self.custom_input_item.is_some() { + 1 + } else { + 0 + }; + if total_count > 0 { + self.cursor = if self.cursor == 0 { + total_count - 1 + } else { + self.cursor - 1 + }; + } + } + + /// Get current cursor position + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Get total number of items + pub fn total_items(&self) -> usize { + self.items.len() + } + + /// Get number of filtered items + pub fn filtered_count(&self) -> usize { + self.filtered_indices.len() + } + + /// Get the fuzzy match positions for a given item (for highlighting) + pub fn match_indices(&self, item_index: usize) -> Option> { + let pattern = self.pattern(); + if pattern.is_empty() { + return None; + } + + // Don't highlight custom input items (marked with usize::MAX) + if item_index == usize::MAX { + return None; + } + + self.items + .get(item_index) + .and_then(|item| self.matcher.fuzzy_indices(&item.display, pattern)) + .map(|(_, indices)| indices) + } + + /// Get current status + pub fn status(&self) -> &PickerStatus { + &self.status + } + + /// Mark picker as done (user selected an item) + pub fn done(&mut self) { + self.status = PickerStatus::Done; + } + + /// Mark picker as cancelled (user cancelled) + pub fn cancel(&mut self) { + self.status = PickerStatus::Cancelled; + } + + /// Check if picker is done + pub fn is_done(&self) -> bool { + self.status == PickerStatus::Done + } + + /// Check if picker is cancelled + pub fn is_cancelled(&self) -> bool { + self.status == PickerStatus::Cancelled + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn create_test_items() -> Vec { + vec![ + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + PickerItem::new( + "feature/test", + PickerData::Revision("feature/test".to_string()), + ), + PickerItem::new( + "feature/new", + PickerData::Revision("feature/new".to_string()), + ), + PickerItem::new("bugfix/123", PickerData::Revision("bugfix/123".to_string())), + ] + } + + #[test] + fn test_picker_data_display() { + let revision = PickerData::Revision("main".to_string()); + assert_eq!(revision.display(), "main"); + + let custom = PickerData::CustomInput("custom".to_string()); + assert_eq!(custom.display(), "custom"); + } + + #[test] + fn test_picker_item_new() { + let item = PickerItem::new("main", PickerData::Revision("main".to_string())); + assert_eq!(item.display.as_ref(), "main"); + assert_eq!(item.data.display(), "main"); + } + + #[test] + fn test_picker_state_new_without_custom_input() { + let items = create_test_items(); + let state = PickerState::new("Select branch", items.clone(), false); + + assert_eq!(state.prompt_text.as_ref(), "Select branch"); + assert_eq!(state.total_items(), 5); + assert_eq!(state.filtered_count(), 5); + assert_eq!(state.cursor(), 0); + assert_eq!(state.pattern(), ""); + assert_eq!(state.status(), &PickerStatus::Active); + assert!(!state.allow_custom_input); + } + + #[test] + fn test_picker_state_new_with_custom_input() { + let items = create_test_items(); + let state = PickerState::new("Select branch", items, true); + + assert!(state.allow_custom_input); + assert_eq!(state.custom_input_item, None); // No custom item when pattern is empty + } + + #[test] + fn test_empty_pattern_shows_all_items() { + let items = create_test_items(); + let state = PickerState::new("Select", items, false); + + assert_eq!(state.filtered_count(), 5); + assert_eq!(state.filtered_indices, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn test_fuzzy_filtering() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + // Should match "feature/test", "feature/new", and "bugfix/123" (fuzzy match) + assert_eq!(state.filtered_count(), 3); + + let filtered: Vec<_> = state.filtered_items().collect(); + assert!( + filtered + .iter() + .any(|(_, item)| item.display == "feature/test") + ); + assert!( + filtered + .iter() + .any(|(_, item)| item.display == "feature/new") + ); + assert!( + filtered + .iter() + .any(|(_, item)| item.display == "bugfix/123") + ); + } + + #[test] + fn test_fuzzy_filtering_sorts_by_score() { + // Create items with varying match quality for pattern "feat" + let items = vec![ + PickerItem::new("feat", PickerData::Revision("feat".to_string())), // Exact match + PickerItem::new("feature", PickerData::Revision("feature".to_string())), // Prefix match + PickerItem::new( + "feature/test", + PickerData::Revision("feature/test".to_string()), + ), // Prefix with more chars + PickerItem::new( + "fix-eat-bug", + PickerData::Revision("fix-eat-bug".to_string()), + ), // Scattered match + ]; + let mut state = PickerState::new("Select", items, false); + + // Type "feat" pattern + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state.update_filter(); + + // All should match + assert_eq!(state.filtered_count(), 4); + + // Verify sorted by score: exact match > prefix match > scattered match + let filtered: Vec<_> = state + .filtered_items() + .map(|(_, item)| item.display.as_ref()) + .collect(); + + // "feat" (exact) should be first, "fix-eat-bug" (scattered) should be last + assert_eq!(filtered[0], "feat"); + assert_eq!(filtered[filtered.len() - 1], "fix-eat-bug"); + } + + #[test] + fn test_no_matches() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state.update_filter(); + + assert_eq!(state.filtered_count(), 0); + assert!(state.selected().is_none()); + } + + #[test] + fn test_custom_input_creation() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Use pattern "fea" which matches feature/test and feature/new + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // Custom input item should be created + assert!(state.custom_input_item.is_some()); + + // Should have multiple regular matches + custom input + let filtered: Vec<_> = state.filtered_items().collect(); + assert!(filtered.len() >= 3); // At least 2 feature items + bugfix + custom input + + // Custom input should be last in filtered items + let last = filtered.last().unwrap(); + assert_eq!(last.0, usize::MAX); + assert_eq!(last.1.display.as_ref(), "fea"); + } + + #[test] + fn test_custom_input_not_created_when_disabled() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state.update_filter(); + + assert!(state.custom_input_item.is_none()); + } + + #[test] + fn test_custom_input_not_created_on_empty_pattern() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Start with no input + assert!(state.custom_input_item.is_none()); + + // Add then remove input + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + assert!(state.custom_input_item.is_some()); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())); + state.update_filter(); + assert!(state.custom_input_item.is_none()); + } + + #[test] + fn test_cursor_next() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.cursor(), 0); + + state.next(); + assert_eq!(state.cursor(), 1); + + state.next(); + assert_eq!(state.cursor(), 2); + } + + #[test] + fn test_cursor_next_wraps_around() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + // Move to end + for _ in 0..5 { + state.next(); + } + + // Should wrap to 0 + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_cursor_previous() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state.next(); + state.next(); + assert_eq!(state.cursor(), 2); + + state.previous(); + assert_eq!(state.cursor(), 1); + + state.previous(); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_cursor_previous_wraps_around() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.cursor(), 0); + + state.previous(); + // Should wrap to last item + assert_eq!(state.cursor(), 4); + } + + #[test] + fn test_cursor_with_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + // 3 matched items (feature/test, feature/new, bugfix/123) + 1 custom input = 4 total + assert_eq!(state.cursor(), 0); + + state.next(); + assert_eq!(state.cursor(), 1); + + state.next(); + assert_eq!(state.cursor(), 2); + + state.next(); + assert_eq!(state.cursor(), 3); // Custom input position + + state.next(); + assert_eq!(state.cursor(), 0); // Wrapped around forward + + state.previous(); + assert_eq!(state.cursor(), 3); // Wrapped around backward to custom input + } + + #[test] + fn test_cursor_resets_when_filter_reduces_items() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + // Move cursor to position 4 + for _ in 0..4 { + state.next(); + } + assert_eq!(state.cursor(), 4); + + // Filter to only 2 items + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + // Cursor should reset to 0 + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_selected_returns_correct_item() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + let selected = state.selected().unwrap(); + assert_eq!(selected.display.as_ref(), "main"); + + state.next(); + state.next(); + let selected = state.selected().unwrap(); + assert_eq!(selected.display.as_ref(), "feature/test"); + } + + #[test] + fn test_selected_with_filter() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty())); + state.update_filter(); + + // Should match "bugfix/123" + let selected = state.selected().unwrap(); + assert_eq!(selected.display.as_ref(), "bugfix/123"); + } + + #[test] + fn test_selected_returns_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Use a pattern that doesn't match any items + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())); + state.update_filter(); + + // No matches, only custom input at cursor 0 + assert_eq!(state.cursor(), 0); + assert_eq!(state.selected().unwrap().display.as_ref(), "qq"); + } + + #[test] + fn test_filtered_items_order() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + let indices: Vec<_> = state.filtered_items().map(|(idx, _)| idx).collect(); + + // Verify normal item indices followed by custom input + assert_eq!(indices[0], 2); // feature/test + assert_eq!(indices[1], 3); // feature/new + assert_eq!(indices[2], 4); // bugfix/123 + assert_eq!(indices[3], usize::MAX); // custom input + } + + #[test] + fn test_match_indices_with_pattern() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::empty())); + state.update_filter(); + + // Get the index of "main" in original items + let indices = state.match_indices(0); + assert!(indices.is_some()); + + let indices = indices.unwrap(); + assert_eq!(indices.len(), 3); // 'm', 'a', 'i' should match + } + + #[test] + fn test_match_indices_empty_pattern() { + let items = create_test_items(); + let state = PickerState::new("Select", items, false); + + let indices = state.match_indices(0); + assert!(indices.is_none()); + } + + #[test] + fn test_match_indices_custom_input_returns_none() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state.update_filter(); + + // usize::MAX is used for custom input items + let indices = state.match_indices(usize::MAX); + assert!(indices.is_none()); + } + + #[test] + fn test_status_transitions() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.status(), &PickerStatus::Active); + assert!(!state.is_done()); + assert!(!state.is_cancelled()); + + state.done(); + assert_eq!(state.status(), &PickerStatus::Done); + assert!(state.is_done()); + assert!(!state.is_cancelled()); + } + + #[test] + fn test_status_cancelled() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state.cancel(); + assert_eq!(state.status(), &PickerStatus::Cancelled); + assert!(!state.is_done()); + assert!(state.is_cancelled()); + } + + #[test] + fn test_empty_items_list() { + let state = PickerState::new("Select", vec![], false); + + assert_eq!(state.total_items(), 0); + assert_eq!(state.filtered_count(), 0); + assert_eq!(state.cursor(), 0); + assert!(state.selected().is_none()); + } + + #[test] + fn test_empty_items_with_custom_input() { + let mut state = PickerState::new("Select", vec![], true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + assert_eq!(state.total_items(), 0); + assert_eq!(state.filtered_count(), 0); + assert!(state.custom_input_item.is_some()); + + assert_eq!(state.selected().unwrap().display.as_ref(), "a"); + } + + #[test] + fn test_cursor_navigation_empty_list() { + let mut state = PickerState::new("Select", vec![], false); + + state.next(); + assert_eq!(state.cursor(), 0); + + state.previous(); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_single_item_navigation() { + let items = vec![PickerItem::new( + "only", + PickerData::Revision("only".to_string()), + )]; + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.cursor(), 0); + + state.next(); + assert_eq!(state.cursor(), 0); // Wraps to same item + + state.previous(); + assert_eq!(state.cursor(), 0); // Wraps to same item + } +} diff --git a/src/screen/mod.rs b/src/screen/mod.rs index cdc3ed9579..f797e83453 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -374,7 +374,12 @@ struct LineView<'a> { const SPACES: &str = " "; -pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, size: Size, screen: &'a Screen) { +pub(crate) fn layout_screen<'a>( + layout: &mut UiTree<'a>, + size: Size, + screen: &'a Screen, + hide_cursor: bool, +) { let style = &screen.config.style; layout.vertical(None, OPTS, |layout| { @@ -386,7 +391,7 @@ pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, size: Size, screen: &'a let bg = area_sel.patch(line_sel); let mut line_end = 1; - let gutter_char = if line.highlighted { + let gutter_char = if !hide_cursor && line.highlighted { gutter_char(style, is_line_sel, bg) } else { (" ".into(), Style::new()) diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 6856917bab..80463f0e60 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -21,6 +21,7 @@ mod editor; mod fetch; mod log; mod merge; +mod picker; mod pull; mod push; mod quit; diff --git a/src/tests/picker.rs b/src/tests/picker.rs new file mode 100644 index 0000000000..67611f245d --- /dev/null +++ b/src/tests/picker.rs @@ -0,0 +1,223 @@ +use crate::picker::{PickerData, PickerItem, PickerState}; +use tui_prompts::State as _; + +#[test] +fn picker_basic() { + let items = vec![ + PickerItem::new( + "feature/auth", + PickerData::Revision("feature/auth".to_string()), + ), + PickerItem::new("feature/ui", PickerData::Revision("feature/ui".to_string())), + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + ]; + + let picker = PickerState::new("Select branch", items, false); + + assert_eq!(picker.total_items(), 4); + assert_eq!(picker.filtered_count(), 4); + assert_eq!(picker.cursor(), 0); +} + +#[test] +fn picker_fuzzy_match() { + let items = vec![ + PickerItem::new( + "feature/auth", + PickerData::Revision("feature/auth".to_string()), + ), + PickerItem::new("feature/ui", PickerData::Revision("feature/ui".to_string())), + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + ]; + + let mut picker = PickerState::new("Select branch", items, false); + + // Simulate typing "fea" + picker.input_state.value_mut().push_str("fea"); + picker.update_filter(); + + // Should match "feature/auth" and "feature/ui" + assert_eq!(picker.filtered_count(), 2); +} + +#[test] +fn picker_navigation() { + let items = vec![ + PickerItem::new("item1", PickerData::Revision("item1".to_string())), + PickerItem::new("item2", PickerData::Revision("item2".to_string())), + PickerItem::new("item3", PickerData::Revision("item3".to_string())), + ]; + + let mut picker = PickerState::new("Select item", items, false); + + assert_eq!(picker.cursor(), 0); + + picker.next(); + assert_eq!(picker.cursor(), 1); + + picker.next(); + assert_eq!(picker.cursor(), 2); + + // Wrap around + picker.next(); + assert_eq!(picker.cursor(), 0); + + // Go back + picker.previous(); + assert_eq!(picker.cursor(), 2); +} + +#[test] +fn picker_selection() { + let items = vec![ + PickerItem::new("item1", PickerData::Revision("item1".to_string())), + PickerItem::new("item2", PickerData::Revision("item2".to_string())), + ]; + + let mut picker = PickerState::new("Select item", items, false); + + let selected = picker.selected().unwrap(); + assert_eq!(selected.display, "item1"); + + picker.next(); + let selected = picker.selected().unwrap(); + assert_eq!(selected.display, "item2"); +} + +#[test] +fn picker_empty_pattern() { + let items = vec![ + PickerItem::new("item1", PickerData::Revision("item1".to_string())), + PickerItem::new("item2", PickerData::Revision("item2".to_string())), + ]; + + let picker = PickerState::new("Select item", items, false); + + // Empty pattern should show all items + assert_eq!(picker.pattern(), ""); + assert_eq!(picker.filtered_count(), 2); +} + +#[test] +fn picker_no_matches() { + let items = vec![ + PickerItem::new("item1", PickerData::Revision("item1".to_string())), + PickerItem::new("item2", PickerData::Revision("item2".to_string())), + ]; + + let mut picker = PickerState::new("Select item", items, false); + + picker.input_state.value_mut().push_str("xyz"); + picker.update_filter(); + + assert_eq!(picker.filtered_count(), 0); + assert!(picker.selected().is_none()); +} + +#[test] +fn picker_case_insensitive() { + let items = vec![ + PickerItem::new("Feature", PickerData::Revision("Feature".to_string())), + PickerItem::new("feature", PickerData::Revision("feature".to_string())), + PickerItem::new("FEATURE", PickerData::Revision("FEATURE".to_string())), + ]; + + let mut picker = PickerState::new("Select item", items, false); + + picker.input_state.value_mut().push_str("fea"); + picker.update_filter(); + + // fuzzy-matcher is case-insensitive by default + assert_eq!(picker.filtered_count(), 3); +} + +#[test] +fn picker_custom_input_disabled() { + let items = vec![ + PickerItem::new("item1", PickerData::Revision("item1".to_string())), + PickerItem::new("item2", PickerData::Revision("item2".to_string())), + ]; + + let mut picker = PickerState::new("Select item", items, false); + + picker.input_state.value_mut().push_str("custom"); + picker.update_filter(); + + // Should have no matches, and no custom input item + assert_eq!(picker.filtered_count(), 0); +} + +#[test] +fn picker_custom_input_enabled() { + let items = vec![ + PickerItem::new("item1", PickerData::Revision("item1".to_string())), + PickerItem::new("item2", PickerData::Revision("item2".to_string())), + ]; + + let mut picker = PickerState::new("Select item", items, true); + + picker.input_state.value_mut().push_str("custom"); + picker.update_filter(); + + // Should have 0 filtered items (no matches) + assert_eq!(picker.filtered_count(), 0); + + let selected = picker.selected().unwrap(); + assert_eq!(selected.display, "custom"); + match &selected.data { + PickerData::CustomInput(s) => assert_eq!(s, "custom"), + _ => panic!("Expected CustomInput"), + } +} + +#[test] +fn picker_custom_input_with_matches() { + let items = vec![ + PickerItem::new( + "feature/auth", + PickerData::Revision("feature/auth".to_string()), + ), + PickerItem::new("feature/ui", PickerData::Revision("feature/ui".to_string())), + ]; + + let mut picker = PickerState::new("Select branch", items, true); + + picker.input_state.value_mut().push_str("feat"); + picker.update_filter(); + + // Should have 2 filtered items (custom input not counted) + assert_eq!(picker.filtered_count(), 2); + + // Navigate to last item (custom input) + picker.next(); + picker.next(); + + let selected = picker.selected().unwrap(); + assert_eq!(selected.display, "feat"); + match &selected.data { + PickerData::CustomInput(s) => assert_eq!(s, "feat"), + _ => panic!("Expected CustomInput"), + } +} + +#[test] +fn picker_custom_input_empty_pattern() { + let items = vec![PickerItem::new( + "item1", + PickerData::Revision("item1".to_string()), + )]; + + let picker = PickerState::new("Select item", items, true); + + // Empty pattern should not add custom input + assert_eq!(picker.pattern(), ""); + assert_eq!(picker.filtered_count(), 1); + + let selected = picker.selected().unwrap(); + match &selected.data { + PickerData::Revision(_) => {} + _ => panic!("Expected Revision, not CustomInput"), + } +} diff --git a/src/ui.rs b/src/ui.rs index 5ea5be773c..0d1d2a2a5a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -14,6 +14,7 @@ use unicode_segmentation::UnicodeSegmentation; pub(crate) mod layout; mod menu; +pub mod picker; const CARET: &str = "\u{2588}"; const DASHES: &str = "────────────────────────────────────────────────────────────────"; @@ -33,13 +34,15 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { layout.vertical(None, OPTS, |layout| { layout.vertical(None, OPTS.grow(), |layout| { - screen::layout_screen(layout, size, state.screens.last().unwrap()); + let hide_cursor = state.picker.is_some(); + screen::layout_screen(layout, size, state.screens.last().unwrap(), hide_cursor); }); layout.vertical(None, OPTS, |layout| { menu::layout_menu(layout, state, size.width as usize); layout_command_log(layout, state, size.width as usize); layout_prompt(layout, state, size.width as usize); + layout_picker(layout, state, size.width as usize); }); }); @@ -94,6 +97,12 @@ fn layout_prompt<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { }); } +fn layout_picker<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { + if let Some(ref picker_state) = state.picker { + picker::layout_picker(layout, picker_state, &state.config, width); + } +} + pub(crate) fn layout_text<'a>(layout: &mut UiTree<'a>, text: Text<'a>) { layout.vertical(None, OPTS, |layout| { for line in text { diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 780e1b4bac..c66cca4098 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -18,6 +18,10 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: return; } + if state.picker.is_some() { + return; + } + let config = Arc::clone(&state.config); let item = state.screens.last().unwrap().get_selected_item(); let style = &config.style; diff --git a/src/ui/picker.rs b/src/ui/picker.rs new file mode 100644 index 0000000000..2459c006bc --- /dev/null +++ b/src/ui/picker.rs @@ -0,0 +1,136 @@ +use std::borrow::Cow; + +use ratatui::prelude::*; +use tui_prompts::State as _; +use unicode_segmentation::UnicodeSegmentation; + +use crate::config::Config; +use crate::picker::PickerState; +use crate::ui::layout::OPTS; +use crate::ui::{CARET, DASHES, STYLE, UiTree, layout_span, repeat_chars}; + +const MAX_ITEMS_DISPLAY: usize = 10; + +/// Layout the picker UI +pub(crate) fn layout_picker<'a>( + layout: &mut UiTree<'a>, + state: &'a PickerState, + config: &Config, + width: usize, +) { + // Separator line + repeat_chars(layout, width, DASHES, STYLE); + + let prompt_style: Style = (&config.style.picker.prompt).into(); + let info_style: Style = (&config.style.picker.info).into(); + layout.horizontal(None, OPTS, |layout| { + let status_text = format!(" {}/{} ", state.filtered_count(), state.total_items()); + layout_span(layout, (status_text.into(), info_style)); + + // Prompt with separator (like regular prompt) + layout_span(layout, (state.prompt_text.as_ref().into(), prompt_style)); + layout_span(layout, (" › ".into(), prompt_style)); + layout_span(layout, (state.input_state.value().into(), Style::new())); + layout_span(layout, (CARET.into(), Style::new())); + }); + + // Calculate visible items range (scroll window) + let cursor = state.cursor(); + let total_items = state.filtered_items().count(); + let visible_count = MAX_ITEMS_DISPLAY.min(total_items); + let start = calculate_visible_range(cursor, total_items, MAX_ITEMS_DISPLAY); + + // Render items - always show MAX_ITEMS_DISPLAY rows for fixed height + let mut rendered_count = 0; + for (display_idx, (original_idx, item)) in state + .filtered_items() + .enumerate() + .skip(start) + .take(visible_count) + { + let is_selected = display_idx == cursor; + let style = if is_selected { + (&config.style.picker.selection_line).into() + } else { + Style::new() + }; + + layout.horizontal(None, OPTS, |layout| { + // Selection indicator (cursor bar like status screen) + if is_selected { + let cursor_style: Style = (&config.style.cursor).into(); + let indicator = format!("{}", config.style.cursor.symbol); + layout_span(layout, (indicator.into(), cursor_style)); + } else { + layout_span(layout, (" ".into(), Style::new())); + } + + // Render item text with fuzzy match highlighting + if let Some(match_indices) = state.match_indices(original_idx) { + render_highlighted_text(layout, &item.display, &match_indices, style, config); + } else { + layout_span(layout, (item.display.as_ref().into(), style)); + } + }); + rendered_count += 1; + } + + // Fill remaining rows with empty lines to maintain fixed height + for _ in rendered_count..MAX_ITEMS_DISPLAY { + layout.horizontal(None, OPTS, |layout| { + layout_span(layout, (" ".into(), Style::new())); + }); + } +} + +/// Calculate the visible range start for scrolling based on cursor position +fn calculate_visible_range(cursor: usize, total: usize, max_items: usize) -> usize { + if total <= max_items { + return 0; + } + + // Center the cursor in the visible window + let half = max_items / 2; + if cursor < half { + 0 + } else if cursor >= total - half { + total - max_items + } else { + cursor - half + } +} + +/// Render text with specific characters highlighted (for fuzzy match visualization) +fn render_highlighted_text<'a>( + layout: &mut UiTree<'a>, + text: &'a str, + highlight_indices: &[usize], + base_style: Style, + config: &Config, +) { + let graphemes: Vec<&str> = text.graphemes(true).collect(); + let highlight_style: Style = (&config.style.picker.matched).into(); + + let mut buffer = String::new(); + + for (idx, &grapheme) in graphemes.iter().enumerate() { + let should_highlight = highlight_indices.contains(&idx); + + if should_highlight { + // Flush non-highlighted buffer + if !buffer.is_empty() { + layout_span(layout, (Cow::Owned(buffer.clone()), base_style)); + buffer.clear(); + } + // Render highlighted character + layout_span(layout, (Cow::Owned(grapheme.to_string()), highlight_style)); + } else { + buffer.push_str(grapheme); + } + } + + // Flush remaining buffer + if !buffer.is_empty() { + layout_span(layout, (Cow::Owned(buffer), base_style)); + } +} From b84c9d95abc1077159397cc7e93f1e8c9073f801 Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Wed, 24 Dec 2025 02:05:51 +0900 Subject: [PATCH 2/6] test: add picker UI tests and remove duplicates - Add picker UI layout tests with snapshots - Remove duplicate picker tests from src/tests/picker.rs - Consolidate case-insensitive test into src/picker.rs - Clean up 39 unreferenced snapshot files --- .../gitu__git__diff__tests__changed_line.snap | 8 - ..._diff__tests__changed_line_no_newline.snap | 10 - ...__diff__tests__multiple_changed_lines.snap | 11 - src/picker.rs | 25 ++ .../gitu__tests__commit_from_empty.snap | 32 --- src/snapshots/gitu__tests__moved_file.snap | 56 ----- .../gitu__tests__unstaged_changes.snap | 54 ----- src/tests/mod.rs | 1 - src/tests/picker.rs | 223 ------------------ .../gitu__tests__checkout__checkout_menu.snap | 25 -- ..._tests__checkout__checkout_new_branch.snap | 25 -- ..._tests__checkout__switch_branch_input.snap | 25 -- ...sts__checkout__switch_branch_selected.snap | 25 -- ...__tests__discard__discard_staged_file.snap | 25 -- ...ard_staged_file_when_unstaged_changes.snap | 25 -- .../gitu__tests__log__log_grep_filter.snap | 25 -- ...__tests__log__log_grep_prompt_invalid.snap | 25 -- ...itu__tests__log__log_grep_prompt_show.snap | 25 -- ...tu__tests__log__log_grep_prompt_valid.snap | 25 -- ...gitu__tests__log__log_n_limits_commit.snap | 25 -- ...itu__tests__log__log_n_prompt_invalid.snap | 25 -- .../gitu__tests__log__log_n_prompt_show.snap | 25 -- .../gitu__tests__log__log_n_prompt_valid.snap | 25 -- ...gitu__ui__layout__tests__align_bottom.snap | 7 - .../gitu__ui__layout__tests__align_right.snap | 5 - ...s__out_of_bounds_horizontal_align_end.snap | 6 - ...sts__out_of_bounds_vertical_align_end.snap | 6 - .../gitu__ui__layout__tests__stacked.snap | 5 - .../layzer__tests__align_bottom.snap | 7 - .../snapshots/layzer__tests__align_right.snap | 5 - .../snapshots/layzer__tests__gitu_mockup.snap | 34 --- .../layzer__tests__horizontal_gap.snap | 5 - .../layzer__tests__horizontal_layout.snap | 5 - .../layzer__tests__nested_layouts.snap | 6 - ...yzer__tests__out_of_bounds_horizontal.snap | 6 - ...s__out_of_bounds_horizontal_align_end.snap | 6 - ...layzer__tests__out_of_bounds_vertical.snap | 6 - ...sts__out_of_bounds_vertical_align_end.snap | 6 - .../snapshots/layzer__tests__single_text.snap | 6 - .../snapshots/layzer__tests__stacked.snap | 5 - .../layzer__tests__vertical_gap.snap | 7 - .../layzer__tests__vertical_layout.snap | 7 - src/ui/picker.rs | 148 ++++++++++++ ...picker__tests__picker_cursor_movement.snap | Bin 0 -> 284 bytes ...ui__picker__tests__picker_empty_input.snap | Bin 0 -> 284 bytes ...i__picker__tests__picker_narrow_width.snap | Bin 0 -> 217 bytes ...cker__tests__picker_with_custom_input.snap | Bin 0 -> 233 bytes ...ui__picker__tests__picker_with_filter.snap | Bin 0 -> 260 bytes 48 files changed, 173 insertions(+), 885 deletions(-) delete mode 100644 src/git/snapshots/gitu__git__diff__tests__changed_line.snap delete mode 100644 src/git/snapshots/gitu__git__diff__tests__changed_line_no_newline.snap delete mode 100644 src/git/snapshots/gitu__git__diff__tests__multiple_changed_lines.snap delete mode 100644 src/snapshots/gitu__tests__commit_from_empty.snap delete mode 100644 src/snapshots/gitu__tests__moved_file.snap delete mode 100644 src/snapshots/gitu__tests__unstaged_changes.snap delete mode 100644 src/tests/picker.rs delete mode 100644 src/tests/snapshots/gitu__tests__checkout__checkout_menu.snap delete mode 100644 src/tests/snapshots/gitu__tests__checkout__checkout_new_branch.snap delete mode 100644 src/tests/snapshots/gitu__tests__checkout__switch_branch_input.snap delete mode 100644 src/tests/snapshots/gitu__tests__checkout__switch_branch_selected.snap delete mode 100644 src/tests/snapshots/gitu__tests__discard__discard_staged_file.snap delete mode 100644 src/tests/snapshots/gitu__tests__discard__discard_staged_file_when_unstaged_changes.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_grep_filter.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_grep_prompt_invalid.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_grep_prompt_show.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_grep_prompt_valid.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_n_limits_commit.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_n_prompt_invalid.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_n_prompt_show.snap delete mode 100644 src/tests/snapshots/gitu__tests__log__log_n_prompt_valid.snap delete mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap delete mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap delete mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap delete mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap delete mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__align_bottom.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__align_right.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__nested_layouts.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__single_text.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__stacked.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__vertical_gap.snap delete mode 100644 src/ui/layout/snapshots/layzer__tests__vertical_layout.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_cursor_movement.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_empty_input.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_narrow_width.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_with_custom_input.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_with_filter.snap diff --git a/src/git/snapshots/gitu__git__diff__tests__changed_line.snap b/src/git/snapshots/gitu__git__diff__tests__changed_line.snap deleted file mode 100644 index 4cd385749c..0000000000 --- a/src/git/snapshots/gitu__git__diff__tests__changed_line.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: src/git/diff.rs -expression: "hunks[0].format_patch()" ---- -header -@@ -1 +1 @@ --old line -+new line diff --git a/src/git/snapshots/gitu__git__diff__tests__changed_line_no_newline.snap b/src/git/snapshots/gitu__git__diff__tests__changed_line_no_newline.snap deleted file mode 100644 index 39c2978a92..0000000000 --- a/src/git/snapshots/gitu__git__diff__tests__changed_line_no_newline.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: src/git/diff.rs -expression: "hunks[0].format_patch()" ---- -header -@@ -1 +1 @@ --old line -\ No newline at end of file -+new line -\ No newline at end of file diff --git a/src/git/snapshots/gitu__git__diff__tests__multiple_changed_lines.snap b/src/git/snapshots/gitu__git__diff__tests__multiple_changed_lines.snap deleted file mode 100644 index 6935d57a69..0000000000 --- a/src/git/snapshots/gitu__git__diff__tests__multiple_changed_lines.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: src/git/diff.rs -expression: "hunks[0].format_patch()" ---- -header -@@ -1,3 +1,3 @@ -+three -+two - one --two --three diff --git a/src/picker.rs b/src/picker.rs index b23e268834..e2ad638bae 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -420,6 +420,31 @@ mod tests { assert_eq!(filtered[filtered.len() - 1], "fix-eat-bug"); } + #[test] + fn test_case_insensitive_matching() { + let items = vec![ + PickerItem::new("Feature", PickerData::Revision("Feature".to_string())), + PickerItem::new("feature", PickerData::Revision("feature".to_string())), + PickerItem::new("FEATURE", PickerData::Revision("FEATURE".to_string())), + ]; + + let mut state = PickerState::new("Select item", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // fuzzy-matcher is case-insensitive by default + assert_eq!(state.filtered_count(), 3); + } + #[test] fn test_no_matches() { let items = create_test_items(); diff --git a/src/snapshots/gitu__tests__commit_from_empty.snap b/src/snapshots/gitu__tests__commit_from_empty.snap deleted file mode 100644 index 879fd4791e..0000000000 --- a/src/snapshots/gitu__tests__commit_from_empty.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: src/lib.rs -expression: "redact_hashes(terminal, &state.repo)" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 60, height: 10 }, - content: [ - "🢒On branch main ", - " Your branch is up to date with 'origin/main'. ", - " ", - " Recent commits ", - " _______ main add new-file ", - " ", - " ", - " ", - " ", - " ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Rgb(80, 73, 69), underline: Reset, modifier: NONE, - x: 1, y: 0, fg: Rgb(216, 166, 87), bg: Rgb(80, 73, 69), underline: Reset, modifier: BOLD, - x: 15, y: 0, fg: Reset, bg: Rgb(80, 73, 69), underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Rgb(42, 40, 39), underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: BOLD, - x: 15, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 4, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: NONE, - x: 8, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 4, fg: Rgb(169, 182, 101), bg: Reset, underline: Reset, modifier: NONE, - x: 13, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/src/snapshots/gitu__tests__moved_file.snap b/src/snapshots/gitu__tests__moved_file.snap deleted file mode 100644 index f120c5451c..0000000000 --- a/src/snapshots/gitu__tests__moved_file.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: src/lib.rs -expression: "redact_hashes(terminal, &state.repo)" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 60, height: 20 }, - content: [ - "🢒On branch main ", - " Your branch is up to date with 'origin/main'. ", - " ", - " Staged changes (2) ", - " moved-file ", - " @@ -0,0 +1 @@ ", - " +hello ", - " new-file ", - " @@ -1 +0,0 @@ ", - " -hello ", - " ", - " Recent commits ", - " _______ main add new-file ", - " ", - " ", - " ", - " ", - " ", - " ", - " ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Rgb(80, 73, 69), underline: Reset, modifier: NONE, - x: 1, y: 0, fg: Rgb(216, 166, 87), bg: Rgb(80, 73, 69), underline: Reset, modifier: BOLD, - x: 15, y: 0, fg: Reset, bg: Rgb(80, 73, 69), underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Rgb(42, 40, 39), underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: BOLD, - x: 19, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 4, fg: Rgb(211, 134, 155), bg: Reset, underline: Reset, modifier: BOLD, - x: 11, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 5, fg: Rgb(125, 174, 163), bg: Reset, underline: Reset, modifier: NONE, - x: 14, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 6, fg: Rgb(169, 182, 101), bg: Reset, underline: Reset, modifier: NONE, - x: 7, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 7, fg: Rgb(211, 134, 155), bg: Reset, underline: Reset, modifier: BOLD, - x: 9, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 8, fg: Rgb(125, 174, 163), bg: Reset, underline: Reset, modifier: NONE, - x: 14, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 9, fg: Rgb(234, 105, 98), bg: Reset, underline: Reset, modifier: NONE, - x: 7, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 11, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: BOLD, - x: 15, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 12, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: NONE, - x: 8, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 12, fg: Rgb(169, 182, 101), bg: Reset, underline: Reset, modifier: NONE, - x: 13, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/src/snapshots/gitu__tests__unstaged_changes.snap b/src/snapshots/gitu__tests__unstaged_changes.snap deleted file mode 100644 index 88a4478482..0000000000 --- a/src/snapshots/gitu__tests__unstaged_changes.snap +++ /dev/null @@ -1,54 +0,0 @@ ---- -source: src/lib.rs -expression: "redact_hashes(terminal, &state.repo)" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 60, height: 20 }, - content: [ - "🢒On branch main ", - " Your branch is up to date with 'origin/main'. ", - " ", - " Unstaged changes (1) ", - " testfile ", - " @@ -1,2 +1,2 @@ ", - " -testing ", - " +test ", - " testtest ", - " ", - " Recent commits ", - " _______ main add testfile ", - " ", - " ", - " ", - " ", - " ", - " ", - " ", - " ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Rgb(80, 73, 69), underline: Reset, modifier: NONE, - x: 1, y: 0, fg: Rgb(216, 166, 87), bg: Rgb(80, 73, 69), underline: Reset, modifier: BOLD, - x: 15, y: 0, fg: Reset, bg: Rgb(80, 73, 69), underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Rgb(42, 40, 39), underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 4, fg: Rgb(211, 134, 155), bg: Reset, underline: Reset, modifier: BOLD, - x: 9, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 5, fg: Rgb(125, 174, 163), bg: Reset, underline: Reset, modifier: NONE, - x: 16, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 6, fg: Rgb(234, 105, 98), bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 6, fg: Rgb(234, 105, 98), bg: Reset, underline: Reset, modifier: REVERSED, - x: 9, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 7, fg: Rgb(169, 182, 101), bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 7, fg: Rgb(169, 182, 101), bg: Reset, underline: Reset, modifier: REVERSED, - x: 6, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 10, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: BOLD, - x: 15, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 11, fg: Rgb(216, 166, 87), bg: Reset, underline: Reset, modifier: NONE, - x: 8, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 11, fg: Rgb(169, 182, 101), bg: Reset, underline: Reset, modifier: NONE, - x: 13, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 80463f0e60..6856917bab 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -21,7 +21,6 @@ mod editor; mod fetch; mod log; mod merge; -mod picker; mod pull; mod push; mod quit; diff --git a/src/tests/picker.rs b/src/tests/picker.rs deleted file mode 100644 index 67611f245d..0000000000 --- a/src/tests/picker.rs +++ /dev/null @@ -1,223 +0,0 @@ -use crate::picker::{PickerData, PickerItem, PickerState}; -use tui_prompts::State as _; - -#[test] -fn picker_basic() { - let items = vec![ - PickerItem::new( - "feature/auth", - PickerData::Revision("feature/auth".to_string()), - ), - PickerItem::new("feature/ui", PickerData::Revision("feature/ui".to_string())), - PickerItem::new("main", PickerData::Revision("main".to_string())), - PickerItem::new("develop", PickerData::Revision("develop".to_string())), - ]; - - let picker = PickerState::new("Select branch", items, false); - - assert_eq!(picker.total_items(), 4); - assert_eq!(picker.filtered_count(), 4); - assert_eq!(picker.cursor(), 0); -} - -#[test] -fn picker_fuzzy_match() { - let items = vec![ - PickerItem::new( - "feature/auth", - PickerData::Revision("feature/auth".to_string()), - ), - PickerItem::new("feature/ui", PickerData::Revision("feature/ui".to_string())), - PickerItem::new("main", PickerData::Revision("main".to_string())), - PickerItem::new("develop", PickerData::Revision("develop".to_string())), - ]; - - let mut picker = PickerState::new("Select branch", items, false); - - // Simulate typing "fea" - picker.input_state.value_mut().push_str("fea"); - picker.update_filter(); - - // Should match "feature/auth" and "feature/ui" - assert_eq!(picker.filtered_count(), 2); -} - -#[test] -fn picker_navigation() { - let items = vec![ - PickerItem::new("item1", PickerData::Revision("item1".to_string())), - PickerItem::new("item2", PickerData::Revision("item2".to_string())), - PickerItem::new("item3", PickerData::Revision("item3".to_string())), - ]; - - let mut picker = PickerState::new("Select item", items, false); - - assert_eq!(picker.cursor(), 0); - - picker.next(); - assert_eq!(picker.cursor(), 1); - - picker.next(); - assert_eq!(picker.cursor(), 2); - - // Wrap around - picker.next(); - assert_eq!(picker.cursor(), 0); - - // Go back - picker.previous(); - assert_eq!(picker.cursor(), 2); -} - -#[test] -fn picker_selection() { - let items = vec![ - PickerItem::new("item1", PickerData::Revision("item1".to_string())), - PickerItem::new("item2", PickerData::Revision("item2".to_string())), - ]; - - let mut picker = PickerState::new("Select item", items, false); - - let selected = picker.selected().unwrap(); - assert_eq!(selected.display, "item1"); - - picker.next(); - let selected = picker.selected().unwrap(); - assert_eq!(selected.display, "item2"); -} - -#[test] -fn picker_empty_pattern() { - let items = vec![ - PickerItem::new("item1", PickerData::Revision("item1".to_string())), - PickerItem::new("item2", PickerData::Revision("item2".to_string())), - ]; - - let picker = PickerState::new("Select item", items, false); - - // Empty pattern should show all items - assert_eq!(picker.pattern(), ""); - assert_eq!(picker.filtered_count(), 2); -} - -#[test] -fn picker_no_matches() { - let items = vec![ - PickerItem::new("item1", PickerData::Revision("item1".to_string())), - PickerItem::new("item2", PickerData::Revision("item2".to_string())), - ]; - - let mut picker = PickerState::new("Select item", items, false); - - picker.input_state.value_mut().push_str("xyz"); - picker.update_filter(); - - assert_eq!(picker.filtered_count(), 0); - assert!(picker.selected().is_none()); -} - -#[test] -fn picker_case_insensitive() { - let items = vec![ - PickerItem::new("Feature", PickerData::Revision("Feature".to_string())), - PickerItem::new("feature", PickerData::Revision("feature".to_string())), - PickerItem::new("FEATURE", PickerData::Revision("FEATURE".to_string())), - ]; - - let mut picker = PickerState::new("Select item", items, false); - - picker.input_state.value_mut().push_str("fea"); - picker.update_filter(); - - // fuzzy-matcher is case-insensitive by default - assert_eq!(picker.filtered_count(), 3); -} - -#[test] -fn picker_custom_input_disabled() { - let items = vec![ - PickerItem::new("item1", PickerData::Revision("item1".to_string())), - PickerItem::new("item2", PickerData::Revision("item2".to_string())), - ]; - - let mut picker = PickerState::new("Select item", items, false); - - picker.input_state.value_mut().push_str("custom"); - picker.update_filter(); - - // Should have no matches, and no custom input item - assert_eq!(picker.filtered_count(), 0); -} - -#[test] -fn picker_custom_input_enabled() { - let items = vec![ - PickerItem::new("item1", PickerData::Revision("item1".to_string())), - PickerItem::new("item2", PickerData::Revision("item2".to_string())), - ]; - - let mut picker = PickerState::new("Select item", items, true); - - picker.input_state.value_mut().push_str("custom"); - picker.update_filter(); - - // Should have 0 filtered items (no matches) - assert_eq!(picker.filtered_count(), 0); - - let selected = picker.selected().unwrap(); - assert_eq!(selected.display, "custom"); - match &selected.data { - PickerData::CustomInput(s) => assert_eq!(s, "custom"), - _ => panic!("Expected CustomInput"), - } -} - -#[test] -fn picker_custom_input_with_matches() { - let items = vec![ - PickerItem::new( - "feature/auth", - PickerData::Revision("feature/auth".to_string()), - ), - PickerItem::new("feature/ui", PickerData::Revision("feature/ui".to_string())), - ]; - - let mut picker = PickerState::new("Select branch", items, true); - - picker.input_state.value_mut().push_str("feat"); - picker.update_filter(); - - // Should have 2 filtered items (custom input not counted) - assert_eq!(picker.filtered_count(), 2); - - // Navigate to last item (custom input) - picker.next(); - picker.next(); - - let selected = picker.selected().unwrap(); - assert_eq!(selected.display, "feat"); - match &selected.data { - PickerData::CustomInput(s) => assert_eq!(s, "feat"), - _ => panic!("Expected CustomInput"), - } -} - -#[test] -fn picker_custom_input_empty_pattern() { - let items = vec![PickerItem::new( - "item1", - PickerData::Revision("item1".to_string()), - )]; - - let picker = PickerState::new("Select item", items, true); - - // Empty pattern should not add custom input - assert_eq!(picker.pattern(), ""); - assert_eq!(picker.filtered_count(), 1); - - let selected = picker.selected().unwrap(); - match &selected.data { - PickerData::Revision(_) => {} - _ => panic!("Expected Revision, not CustomInput"), - } -} diff --git a/src/tests/snapshots/gitu__tests__checkout__checkout_menu.snap b/src/tests/snapshots/gitu__tests__checkout__checkout_menu.snap deleted file mode 100644 index ef7de7a373..0000000000 --- a/src/tests/snapshots/gitu__tests__checkout__checkout_menu.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/mod.rs -expression: ctx.redact_buffer() ---- - Branches | -▌* main | - other-branch | - | - Remote origin | - origin/HEAD | - origin/main | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -Branch | -b Checkout branch/revision | -c Checkout new branch | -d Delete branch | -q/ Quit/Close | -styles_hash: d05cb29e19813b7b diff --git a/src/tests/snapshots/gitu__tests__checkout__checkout_new_branch.snap b/src/tests/snapshots/gitu__tests__checkout__checkout_new_branch.snap deleted file mode 100644 index 52ab1eca75..0000000000 --- a/src/tests/snapshots/gitu__tests__checkout__checkout_new_branch.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/mod.rs -expression: ctx.redact_buffer() ---- -▌On branch x | - | - Recent commits | - b66a0bf main x origin/main add initial-file | - | - | - | - | - | - | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -$ git checkout -b x | -Switched to a new branch 'x' | -styles_hash: daabd53dfc068e06 diff --git a/src/tests/snapshots/gitu__tests__checkout__switch_branch_input.snap b/src/tests/snapshots/gitu__tests__checkout__switch_branch_input.snap deleted file mode 100644 index a1db76dbf4..0000000000 --- a/src/tests/snapshots/gitu__tests__checkout__switch_branch_input.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/mod.rs -expression: ctx.redact_buffer() ---- - Branches | - * hi | -▌ main | - | - Remote origin | - origin/HEAD | - origin/main | - | - | - | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -$ git checkout hi | -Switched to branch 'hi' | -styles_hash: 480e59d5b4c28364 diff --git a/src/tests/snapshots/gitu__tests__checkout__switch_branch_selected.snap b/src/tests/snapshots/gitu__tests__checkout__switch_branch_selected.snap deleted file mode 100644 index 885572548d..0000000000 --- a/src/tests/snapshots/gitu__tests__checkout__switch_branch_selected.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/mod.rs -expression: ctx.redact_buffer() ---- - Branches | - main | -▌* other-branch | - | - Remote origin | - origin/HEAD | - origin/main | - | - | - | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -$ git checkout other-branch | -Switched to branch 'other-branch' | -styles_hash: 5006bae7555ab9c9 diff --git a/src/tests/snapshots/gitu__tests__discard__discard_staged_file.snap b/src/tests/snapshots/gitu__tests__discard__discard_staged_file.snap deleted file mode 100644 index 0df8c71ac1..0000000000 --- a/src/tests/snapshots/gitu__tests__discard__discard_staged_file.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/discard.rs -expression: ctx.redact_buffer() ---- - On branch main | - Your branch is ahead of 'origin/main' by 1 commit(s). | - | - Recent commits | -▌4f3ed19 main add file-one | - b66a0bf origin/main add initial-file | - | - | - | - | - | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -$ git checkout HEAD -- file-one | -styles_hash: eb6a652a52e5107d diff --git a/src/tests/snapshots/gitu__tests__discard__discard_staged_file_when_unstaged_changes.snap b/src/tests/snapshots/gitu__tests__discard__discard_staged_file_when_unstaged_changes.snap deleted file mode 100644 index f991e2c42f..0000000000 --- a/src/tests/snapshots/gitu__tests__discard__discard_staged_file_when_unstaged_changes.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/discard.rs -expression: ctx.redact_buffer() ---- - On branch main | - Your branch is up to date with 'origin/main'. | - | - Unstaged changes (1) | - modified initial-file | - @@ -1 +1 @@ | - -hello | - +modified | - | - Recent commits | -▌85f3b96 main origin/main add initial-file | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -$ git restore --staged initial-file | -styles_hash: 22fa4ad45b1c9037 diff --git a/src/tests/snapshots/gitu__tests__log__log_grep_filter.snap b/src/tests/snapshots/gitu__tests__log__log_grep_filter.snap deleted file mode 100644 index e948db0274..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_grep_filter.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌_______ add this-should-be-at-the-top | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -styles_hash: 48d19316a51368e3 diff --git a/src/tests/snapshots/gitu__tests__log__log_grep_prompt_invalid.snap b/src/tests/snapshots/gitu__tests__log__log_grep_prompt_invalid.snap deleted file mode 100644 index 3d28927b77..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_grep_prompt_invalid.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌On branch main | -▌Your branch is ahead of 'origin/main' by 2 commit. | - | - Recent commits | - _______ main add this-should-not-be-visible | - _______ add this-should-be-at-the-top | - _______ origin/main add initial-file | - | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -Log Arguments | -l Log current -F Search messages (--grep=ui) | -o Log other -n Limit number of commits (-n=256) | -q/ Quit/Close | -styles_hash: ac5b45388c922f9b diff --git a/src/tests/snapshots/gitu__tests__log__log_grep_prompt_show.snap b/src/tests/snapshots/gitu__tests__log__log_grep_prompt_show.snap deleted file mode 100644 index 4aced77f00..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_grep_prompt_show.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌On branch main | -▌Your branch is ahead of 'origin/main' by 2 commit. | - | - Recent commits | - _______ main add this-should-not-be-visible | - _______ add this-should-be-at-the-top | - _______ origin/main add initial-file | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -? Search messages: › | -────────────────────────────────────────────────────────────────────────────────| -Log Arguments | -l Log current -F Search messages (--grep) | -o Log other -n Limit number of commits (-n=256) | -q/ Quit/Close | -styles_hash: 16a36258644f9faa diff --git a/src/tests/snapshots/gitu__tests__log__log_grep_prompt_valid.snap b/src/tests/snapshots/gitu__tests__log__log_grep_prompt_valid.snap deleted file mode 100644 index 4aced77f00..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_grep_prompt_valid.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌On branch main | -▌Your branch is ahead of 'origin/main' by 2 commit. | - | - Recent commits | - _______ main add this-should-not-be-visible | - _______ add this-should-be-at-the-top | - _______ origin/main add initial-file | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -? Search messages: › | -────────────────────────────────────────────────────────────────────────────────| -Log Arguments | -l Log current -F Search messages (--grep) | -o Log other -n Limit number of commits (-n=256) | -q/ Quit/Close | -styles_hash: 16a36258644f9faa diff --git a/src/tests/snapshots/gitu__tests__log__log_n_limits_commit.snap b/src/tests/snapshots/gitu__tests__log__log_n_limits_commit.snap deleted file mode 100644 index da0d8f38d5..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_n_limits_commit.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌_______ main add this-should-not-be-visible | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -styles_hash: 8e09fff3b0c10526 diff --git a/src/tests/snapshots/gitu__tests__log__log_n_prompt_invalid.snap b/src/tests/snapshots/gitu__tests__log__log_n_prompt_invalid.snap deleted file mode 100644 index 16acd14f32..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_n_prompt_invalid.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌On branch main | -▌Your branch is ahead of 'origin/main' by 2 commit. | - | - Recent commits | - _______ main add this-should-not-be-visible | - _______ add this-should-be-at-the-top | - _______ origin/main add initial-file | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -Log Arguments | -l Log current -F Search messages (--grep) | -o Log other -n Limit number of commits (-n) | -q/ Quit/Close | -────────────────────────────────────────────────────────────────────────────────| -! Value must be a number greater than 0 | -styles_hash: 4973077ec637e391 diff --git a/src/tests/snapshots/gitu__tests__log__log_n_prompt_show.snap b/src/tests/snapshots/gitu__tests__log__log_n_prompt_show.snap deleted file mode 100644 index 0b3507e961..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_n_prompt_show.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌On branch main | -▌Your branch is ahead of 'origin/main' by 2 commit. | - | - Recent commits | - _______ main add this-should-not-be-visible | - _______ add this-should-be-at-the-top | - _______ origin/main add initial-file | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -? Limit number of commits (default 256): › | -────────────────────────────────────────────────────────────────────────────────| -Log Arguments | -l Log current -F Search messages (--grep) | -o Log other -n Limit number of commits (-n) | -q/ Quit/Close | -styles_hash: 53b6b1f36c36ef42 diff --git a/src/tests/snapshots/gitu__tests__log__log_n_prompt_valid.snap b/src/tests/snapshots/gitu__tests__log__log_n_prompt_valid.snap deleted file mode 100644 index 81a6d28afa..0000000000 --- a/src/tests/snapshots/gitu__tests__log__log_n_prompt_valid.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: src/tests/log.rs -expression: ctx.redact_buffer() ---- -▌On branch main | -▌Your branch is ahead of 'origin/main' by 2 commit. | - | - Recent commits | - _______ main add this-should-not-be-visible | - _______ add this-should-be-at-the-top | - _______ origin/main add initial-file | - | - | - | - | - | - | - | - | -────────────────────────────────────────────────────────────────────────────────| -Log Arguments | -l Log current -F Search messages (--grep) | -o Log other -n Limit number of commits (-n=10) | -q/ Quit/Close | -styles_hash: 95f9cd1612fca0f3 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap deleted file mode 100644 index ad05c79146..0000000000 --- a/src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: src/ui/layout/mod.rs -expression: render_to_string(layout) ---- -Stack 1 - -Stack 2, bottom aligned diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap deleted file mode 100644 index be6d930fa4..0000000000 --- a/src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: src/ui/layout/mod.rs -expression: render_to_string(layout) ---- - Aligned to the right diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap deleted file mode 100644 index 2b5b1ebf1f..0000000000 --- a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/ui/layout/mod.rs -expression: render_to_string(layout) ---- -12345T -123456 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap deleted file mode 100644 index 57ef6dbc11..0000000000 --- a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/ui/layout/mod.rs -expression: render_to_string(layout) ---- -1 -2 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap deleted file mode 100644 index 1d60b94344..0000000000 --- a/src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: src/ui/layout/mod.rs -expression: render_to_string(layout) ---- -This is on top (leftovers here) diff --git a/src/ui/layout/snapshots/layzer__tests__align_bottom.snap b/src/ui/layout/snapshots/layzer__tests__align_bottom.snap deleted file mode 100644 index edb54814c5..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__align_bottom.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -Stack 1 - -Stack 2, bottom aligned diff --git a/src/ui/layout/snapshots/layzer__tests__align_right.snap b/src/ui/layout/snapshots/layzer__tests__align_right.snap deleted file mode 100644 index 2c6933403e..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__align_right.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- - Aligned to the right diff --git a/src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap b/src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap deleted file mode 100644 index 261d0c16e2..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -On branch master -Your branch is up to date with 'origin/master' - -Recent commits -b3492a8 master origin/master chore: update dependencies -013844c refactor: appease linter -5536ea3 feat: Show the diff on the stash detail screen - - - - - - - - -─────────────────────────────────────────────────────────────── -Help Submenu @@ -271,7 +271,7 -Y Show Refs b Branch s Stage - Toggle section c Commit u Unstage -k/ Up f Fetch Show -j/ Down h/? Help K Discard -/ Up line l Log -/ Down line M Remote -/ Prev section F Pull -/ Next section P Push -/ Parent section r Rebase - Half page up X Reset - Half page down V Revert -g Refresh z Stash -q/ Quit/Close diff --git a/src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap b/src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap deleted file mode 100644 index b59f3563aa..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- -one two diff --git a/src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap b/src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap deleted file mode 100644 index 044ce64164..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- -ABBCCC diff --git a/src/ui/layout/snapshots/layzer__tests__nested_layouts.snap b/src/ui/layout/snapshots/layzer__tests__nested_layouts.snap deleted file mode 100644 index 3be4426c4d..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__nested_layouts.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- -AC -BD diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap deleted file mode 100644 index 3041a245e8..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -12345T -123456 diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap deleted file mode 100644 index 3041a245e8..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -12345T -123456 diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap deleted file mode 100644 index 0965fe7deb..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -11 -22 diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap deleted file mode 100644 index c087e04691..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -1 -2 diff --git a/src/ui/layout/snapshots/layzer__tests__single_text.snap b/src/ui/layout/snapshots/layzer__tests__single_text.snap deleted file mode 100644 index 078f18babb..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__single_text.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: src/lib.rs -expression: render_to_string(layout) ---- -Hello -lol diff --git a/src/ui/layout/snapshots/layzer__tests__stacked.snap b/src/ui/layout/snapshots/layzer__tests__stacked.snap deleted file mode 100644 index d824f46d8a..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__stacked.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- -This is on top (leftovers here) diff --git a/src/ui/layout/snapshots/layzer__tests__vertical_gap.snap b/src/ui/layout/snapshots/layzer__tests__vertical_gap.snap deleted file mode 100644 index 8d59ebc8aa..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__vertical_gap.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- -one - -two diff --git a/src/ui/layout/snapshots/layzer__tests__vertical_layout.snap b/src/ui/layout/snapshots/layzer__tests__vertical_layout.snap deleted file mode 100644 index 916de116de..0000000000 --- a/src/ui/layout/snapshots/layzer__tests__vertical_layout.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: src/lib.rs -expression: layout.render_to_string() ---- -First -Second -Third diff --git a/src/ui/picker.rs b/src/ui/picker.rs index 2459c006bc..aab29d023d 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -134,3 +134,151 @@ fn render_highlighted_text<'a>( layout_span(layout, (Cow::Owned(buffer), base_style)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::picker::{PickerData, PickerItem, PickerState}; + use crate::ui::layout::LayoutTree; + use itertools::Itertools; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::collections::BTreeMap; + + /// Create a default test config for picker tests + fn test_config() -> Config { + use crate::config::{GeneralConfig, StyleConfig}; + + Config { + general: GeneralConfig::default(), + style: StyleConfig::default(), + bindings: BTreeMap::new().try_into().unwrap(), + } + } + + fn create_test_items() -> Vec { + vec![ + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + PickerItem::new("feature/test", PickerData::Revision("feature/test".to_string())), + PickerItem::new("feature/new", PickerData::Revision("feature/new".to_string())), + PickerItem::new("bugfix/123", PickerData::Revision("bugfix/123".to_string())), + ] + } + + /// Render the picker layout to a string for testing purposes. + /// Note: ASCII only — does not support Unicode beyond single-byte chars. + fn render_to_string(layout: UiTree, width: usize, height: usize) -> String { + let mut grid = vec![' '; height * width]; + + for item in layout.iter() { + let x0 = item.pos[0] as usize; + let y0 = item.pos[1] as usize; + let item_width = item.size[0] as usize; + let text = &item.data.0; + + for (i, c) in text.chars().take(item_width).enumerate() { + if y0 < height && x0 + i < width { + grid[y0 * width + (x0 + i)] = c; + } + } + } + + grid.chunks(width) + .map(|row| row.iter().collect::().trim_end().to_string()) + .join("\n") + } + + #[test] + fn test_picker_empty_input() { + let items = create_test_items(); + let state = PickerState::new("Select branch", items, false); + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_with_filter() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Type "fea" to filter + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_cursor_movement() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Move cursor to third item + state.next(); + state.next(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_with_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("New branch", items, true); + + // Type a custom branch name + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state.update_filter(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_narrow_width() { + let items = create_test_items(); + let state = PickerState::new("Select", items, false); + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 20); + }); + layout.compute([20, 15]); + + insta::assert_snapshot!(render_to_string(layout, 20, 15)); + } +} diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_cursor_movement.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_cursor_movement.snap new file mode 100644 index 0000000000000000000000000000000000000000..a247f5ad07754ee06e662b3d46d6f7b039e03e4c GIT binary patch literal 284 zcmc)Cu?@m75I|Ato8puTq!<$*P1az5$T62-5$x#BhR~&C0K_nilS!aq2BhD-<}0N@ zLSjo53R|6=9-KWBuUUY`fl0&#Utz}NkHmEhwM2HlTlVH2Qp|C`$+0L`8L01TeolW0 zu+$|Y9;hc9v0>xw3FqmJ^Xmb4G0p=X=}LVVV9}C^iAhJ22>6qy8(@>V){VN@?%)GR CFNEa) literal 0 HcmV?d00001 diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_empty_input.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_empty_input.snap new file mode 100644 index 0000000000000000000000000000000000000000..5957986572e9cf98a371fa7bccec01c556754870 GIT binary patch literal 284 zcmc)Cu?@m75I|Ato8puTq!<$*P1az5$T62-5$x#BhR~&C0K_nilS!as1f<`*<}0N@ zLSjo53R|6=9-KWBuUUY`fl0&#Utz}NkHmEhwM2HlTlVH2Qp|C`$+0L`8L01TeolW0 zu+$|Y9;hc9v0>xw3FqmJ^XmbNi*X+CNLT8^0N9d=iAhJ2_>-p_V3WGmjk?(G-~&ca BgyjGL literal 0 HcmV?d00001 diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_narrow_width.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_narrow_width.snap new file mode 100644 index 0000000000000000000000000000000000000000..c9479ad1f83060d2f5c78a3dfd4887b8bb736740 GIT binary patch literal 217 zcmbWvF%E)25I|AuImLuZ6V??B4cG7h1ed{V#9cBotJqO`0OMgi4kyvh-sCs0`NkNK znAlN`!pcWD@Z))R-}aj=0g-h_Vl-_0Zm9(j3cOjzzV}zD2ql7Udp3&^TKi literal 0 HcmV?d00001 diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_with_custom_input.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_with_custom_input.snap new file mode 100644 index 0000000000000000000000000000000000000000..53fdd305f5faafa98f4fac9fe1286cc4fdd9519a GIT binary patch literal 233 zcmdPZ)#WPAFD*(=wNfZ9O4cvU)Gx?P&Q2}TD=Ownttco;EiTT?&$CicDoV{uNiB*m z$&W8CDay=C*T_k%%r7m`Q7|#kQ7|;sRN~SFn)PT(!+;E!iUFui*Jkp zNtr#hDC}(xb_o7Lykr5o4NM{-#TF~3cp&aHb)p>NY2Leg%39-gRpYu@RA8pB@*Mv% mz>RGXaYsFQ#XY;|&sfGc9?6y017;I7Gg&23_|~6Ay21x6v3;@t literal 0 HcmV?d00001 From 714d2276628b9feb0e797d7bf7e32dc2de4d9cf1 Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Fri, 26 Dec 2025 04:17:01 +0900 Subject: [PATCH 3/6] test(picker): add comprehensive tests for common use cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests covering generic picker functionality needed for branch selection and similar use cases: Logic tests (src/picker.rs): - Scrolling through many items (20+) - Navigation after filtering - Custom input selection with state transitions - Navigation with custom input at end of list UI tests (src/ui/picker.rs): - Scroll display at middle position - Scroll display near end - No matches display - Filtered results with navigation All tests are organized by category (basic → edge cases) and placed next to related tests for better maintainability. --- src/picker.rs | 169 ++++++++++++++++++ src/ui/picker.rs | 105 +++++++++++ ...ests__picker_filtered_with_navigation.snap | Bin 0 -> 260 bytes ..._ui__picker__tests__picker_no_matches.snap | 6 + ...cker__tests__picker_scroll_many_items.snap | Bin 0 -> 342 bytes ...picker__tests__picker_scroll_near_end.snap | Bin 0 -> 342 bytes 6 files changed, 280 insertions(+) create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_filtered_with_navigation.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_many_items.snap create mode 100644 src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_near_end.snap diff --git a/src/picker.rs b/src/picker.rs index e2ad638bae..ec70302ffa 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -618,6 +618,59 @@ mod tests { assert_eq!(state.cursor(), 3); // Wrapped around backward to custom input } + #[test] + fn test_navigation_with_custom_input_at_end() { + let items = vec![ + PickerItem::new("feature/a", PickerData::Revision("feature/a".to_string())), + PickerItem::new("feature/b", PickerData::Revision("feature/b".to_string())), + ]; + + let mut state = PickerState::new("Select", items, true); + + // Type "feat" to get matches + custom input + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state.update_filter(); + + // Should have 2 matches + custom input = 3 total + assert_eq!(state.filtered_count(), 2); + assert_eq!(state.cursor(), 0); + + // Navigate to first match + let selected = state.selected().unwrap(); + match &selected.data { + PickerData::Revision(_) => {} + _ => panic!("Expected first item to be a revision"), + } + + // Navigate to second match + state.next(); + assert_eq!(state.cursor(), 1); + + // Navigate to custom input (last item) + state.next(); + assert_eq!(state.cursor(), 2); + let selected = state.selected().unwrap(); + match &selected.data { + PickerData::CustomInput(s) => assert_eq!(s, "feat"), + _ => panic!("Expected custom input at end"), + } + + // Wrap around to first + state.next(); + assert_eq!(state.cursor(), 0); + } + #[test] fn test_cursor_resets_when_filter_reduces_items() { let items = create_test_items(); @@ -639,6 +692,84 @@ mod tests { assert_eq!(state.cursor(), 0); } + #[test] + fn test_scroll_through_many_items() { + // Create 20 items to test scrolling behavior + let items: Vec<_> = (0..20) + .map(|i| { + PickerItem::new( + format!("branch-{:02}", i), + PickerData::Revision(format!("branch-{:02}", i)), + ) + }) + .collect(); + + let mut state = PickerState::new("Select", items, false); + + // Start at first item + assert_eq!(state.cursor(), 0); + + // Navigate to middle item + for _ in 0..10 { + state.next(); + } + assert_eq!(state.cursor(), 10); + + // Navigate to last item + for _ in 0..9 { + state.next(); + } + assert_eq!(state.cursor(), 19); + + // Wrap around to first + state.next(); + assert_eq!(state.cursor(), 0); + + // Navigate backwards + state.previous(); + assert_eq!(state.cursor(), 19); + } + + #[test] + fn test_navigation_after_filtering() { + let items = vec![ + PickerItem::new("feature/a", PickerData::Revision("feature/a".to_string())), + PickerItem::new("feature/b", PickerData::Revision("feature/b".to_string())), + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + ]; + + let mut state = PickerState::new("Select", items, false); + + // Filter to get only feature/* branches + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // Should have 2 filtered items + assert_eq!(state.filtered_count(), 2); + assert_eq!(state.cursor(), 0); + + // Navigate through filtered items + state.next(); + assert_eq!(state.cursor(), 1); + + // Wrap around + state.next(); + assert_eq!(state.cursor(), 0); + + // Go backwards + state.previous(); + assert_eq!(state.cursor(), 1); + } + #[test] fn test_selected_returns_correct_item() { let items = create_test_items(); @@ -687,6 +818,44 @@ mod tests { assert_eq!(state.selected().unwrap().display.as_ref(), "qq"); } + #[test] + fn test_select_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Type a pattern that doesn't match anything + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state.update_filter(); + + // Should have no matches but custom input + assert_eq!(state.filtered_count(), 0); + assert!(state.custom_input_item.is_some()); + + // Cursor should be at custom input (position 0 since no other items) + assert_eq!(state.cursor(), 0); + + // Selected item should be the custom input + let selected = state.selected().unwrap(); + assert_eq!(selected.display, "xyz"); + match &selected.data { + PickerData::CustomInput(s) => assert_eq!(s, "xyz"), + _ => panic!("Expected CustomInput"), + } + + // Mark as done + state.done(); + assert!(state.is_done()); + assert_eq!(state.status(), &PickerStatus::Done); + } + #[test] fn test_filtered_items_order() { let items = create_test_items(); diff --git a/src/ui/picker.rs b/src/ui/picker.rs index aab29d023d..fbcbbf44cd 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -246,6 +246,89 @@ mod tests { insta::assert_snapshot!(render_to_string(layout, 40, 15)); } + #[test] + fn test_picker_scroll_many_items() { + // Create 20 items to test scrolling + let items: Vec<_> = (0..20) + .map(|i| { + PickerItem::new( + format!("branch-{:02}", i), + PickerData::Revision(format!("branch-{:02}", i)), + ) + }) + .collect(); + + let mut state = PickerState::new("Select branch", items, false); + let config = test_config(); + + // Move cursor to middle (position 10) + for _ in 0..10 { + state.next(); + } + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_scroll_near_end() { + // Create 20 items to test scrolling near the end + let items: Vec<_> = (0..20) + .map(|i| { + PickerItem::new( + format!("branch-{:02}", i), + PickerData::Revision(format!("branch-{:02}", i)), + ) + }) + .collect(); + + let mut state = PickerState::new("Select branch", items, false); + let config = test_config(); + + // Move cursor near end (position 18) + for _ in 0..18 { + state.next(); + } + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_filtered_with_navigation() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Filter to get feature branches + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // Navigate to second item + state.next(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + #[test] fn test_picker_with_custom_input() { let items = create_test_items(); @@ -267,6 +350,28 @@ mod tests { insta::assert_snapshot!(render_to_string(layout, 40, 15)); } + #[test] + fn test_picker_no_matches() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Type something that doesn't match + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state.update_filter(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + #[test] fn test_picker_narrow_width() { let items = create_test_items(); diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_filtered_with_navigation.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_filtered_with_navigation.snap new file mode 100644 index 0000000000000000000000000000000000000000..91a9f8291ff3ab952677439e7a84d7545c0ac640 GIT binary patch literal 260 zcmc(Xu?@m75CExvidQO-Vho5hStA2P#ynsV+0p-7K$nsM5W_HzCjkm(KsxDk%^71r zQf5yr3VWM_9fLm;FIj-DBa=u-vBioh?umO%y(ouxoDc4nvevj=)wpgJ6`0SfJf^<@ kxUmf)b~KPz+_8)Pgk^f+fn0gs;cLxIR!J0QzlwB$H!g8~vH$=8 literal 0 HcmV?d00001 diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap new file mode 100644 index 0000000000..c2a0057bf7 --- /dev/null +++ b/src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/picker.rs +expression: "render_to_string(layout, 40, 15)" +--- +──────────────────────────────────────── + 0/5 Select branch › xyz█ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_many_items.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_many_items.snap new file mode 100644 index 0000000000000000000000000000000000000000..125651511de817aa49bdd730fa619bbb36096c3c GIT binary patch literal 342 zcmc)CO$x#=5Cve@ImOVGLSvKK`qOK8fKub2p(QDk6m-|62M`bAae5L3#W{lT7TW7#@p;FN0A`$Za zHQmR*L_n^xTmiryJ2Vjv)Hrj7X?();xQTCB3U&!)jNAZT2d5v?FnOa!Pk%x`Xf@4LK71?f0}pgEet7#Qs-c*MJD8D zYkG`-IRS-Mg$95 Date: Wed, 14 Jan 2026 00:48:57 +0900 Subject: [PATCH 4/6] feat(picker): make keybindings configurable Add support for customizing picker keybindings through the config file. Users can now configure next, previous, done, and cancel actions under [bindings.picker] section in their config.toml. --- README.md | 14 ------- src/app.rs | 42 +++++++++----------- src/config.rs | 86 ++++++++++++++++++++++++++++++++++++++--- src/default_config.toml | 5 +++ src/ui/picker.rs | 3 +- 5 files changed, 105 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 5822a22898..8328b0f7ae 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,6 @@ A help-menu can be shown by pressing the `h` key, or by configuring `general.alw -#### Picker Keybinds -When using the interactive picker (for branch/commit selection): - -| Key | Action | -|-----|--------| -| `↓` or `Ctrl-n` | Next item | -| `↑` or `Ctrl-p` | Previous item | -| `Enter` | Select current item | -| `Esc` or `Ctrl-c` | Cancel | -| Any text | Filter items (fuzzy matching) | - -> [!NOTE] -> Picker keybinds will be configurable in a future release. - ### Configuration The environment variables `VISUAL`, `EDITOR` or `GIT_EDITOR` (checked in this order) dictate which editor Gitu will open. This means that e. g. commit messages will be opened in the `GIT_EDITOR` by Git, but if the user wishes to do edits to the actual files in a different editor, `VISUAL` or `EDITOR` can be set accordingly. diff --git a/src/app.rs b/src/app.rs index 45154bfa0d..8a6cdc7704 100644 --- a/src/app.rs +++ b/src/app.rs @@ -691,31 +691,25 @@ impl App { fn handle_picker_input(&mut self, key: event::KeyEvent) { if let Some(ref mut picker) = self.state.picker { - use crossterm::event::KeyCode; - use crossterm::event::KeyModifiers; - - match (key.code, key.modifiers) { - // Navigation - (KeyCode::Down, KeyModifiers::NONE) - | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - picker.next(); - } - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - picker.previous(); - } - // Select - (KeyCode::Enter, _) => { - picker.done(); - } - // Cancel - (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { - picker.cancel(); - } + // The character received in the KeyEvent changes as shift is pressed, + // e.g. '/' becomes '?' on a US keyboard. So just ignore SHIFT. + let mods_without_shift = key.modifiers.difference(KeyModifiers::SHIFT); + let key_combo = vec![(mods_without_shift, key.code)]; + + let bindings = &self.state.config.picker_bindings; + + if bindings.next.iter().any(|b| b == &key_combo) { + picker.next(); + } else if bindings.previous.iter().any(|b| b == &key_combo) { + picker.previous(); + } else if bindings.done.iter().any(|b| b == &key_combo) { + picker.done(); + } else if bindings.cancel.iter().any(|b| b == &key_combo) { + picker.cancel(); + } else { // Text input - delegate to text state - _ => { - picker.input_state.handle_key_event(key); - picker.update_filter(); - } + picker.input_state.handle_key_event(key); + picker.update_filter(); } } } diff --git a/src/config.rs b/src/config.rs index ca972bb20d..2b2f459de0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::{collections::BTreeMap, path::PathBuf}; -use crate::{Bindings, Res, error::Error, menu::Menu, ops::Op}; +use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op}; +use crossterm::event::{KeyCode, KeyModifiers}; use etcetera::{BaseStrategy, choose_base_strategy}; use figment::{ Figment, @@ -15,6 +16,27 @@ pub struct Config { pub general: GeneralConfig, pub style: StyleConfig, pub bindings: Bindings, + pub picker_bindings: PickerBindings, +} + +#[derive(Default, Deserialize)] +pub(crate) struct PickerBindingsConfig { + #[serde(default)] + pub next: Vec, + #[serde(default)] + pub previous: Vec, + #[serde(default)] + pub done: Vec, + #[serde(default)] + pub cancel: Vec, +} + +#[derive(Default, Deserialize)] +pub(crate) struct BindingsConfig { + #[serde(flatten)] + pub menus: BTreeMap>>, + #[serde(default)] + pub picker: PickerBindingsConfig, } #[derive(Default, Deserialize)] @@ -23,7 +45,7 @@ pub struct Config { pub(crate) struct FigmentConfig { pub general: GeneralConfig, pub style: StyleConfig, - pub bindings: BTreeMap>>, + pub bindings: BindingsConfig, } #[derive(Default, Debug, Deserialize)] @@ -210,6 +232,55 @@ impl From<&SymbolStyleConfigEntry> for Style { } } +pub struct PickerBindings { + pub next: Vec>, + pub previous: Vec>, + pub done: Vec>, + pub cancel: Vec>, +} + +impl TryFrom for PickerBindings { + type Error = crate::error::Error; + + fn try_from(config: PickerBindingsConfig) -> Result { + let mut bad_bindings = Vec::new(); + + let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings); + let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings); + let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings); + let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings); + + if !bad_bindings.is_empty() { + return Err(Error::Bindings { bad_key_bindings: bad_bindings }); + } + + Ok(Self { + next, + previous, + done, + cancel, + }) + } +} + +fn parse_picker_keys( + raw_keys: &[String], + action_name: &str, + bad_bindings: &mut Vec, +) -> Vec> { + raw_keys + .iter() + .filter_map(|keys| { + if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) { + Some(parsed) + } else { + bad_bindings.push(format!("- {} = {}", action_name, keys)); + None + } + }) + .collect() +} + pub fn init_config(path: Option) -> Res { let config_path = path.unwrap_or_else(config_path); @@ -222,19 +293,21 @@ pub fn init_config(path: Option) -> Res { let FigmentConfig { general, style, - bindings: raw_bindings, + bindings: bindings_config, } = Figment::new() .merge(Toml::string(DEFAULT_CONFIG)) .merge(Toml::file(config_path)) .extract() .map_err(Box::new) .map_err(Error::Config)?; - let bindings = Bindings::try_from(raw_bindings)?; + let bindings = Bindings::try_from(bindings_config.menus)?; + let picker_bindings = PickerBindings::try_from(bindings_config.picker)?; Ok(Config { general, style, bindings, + picker_bindings, }) } @@ -250,7 +323,7 @@ pub(crate) fn init_test_config() -> Res { let FigmentConfig { mut general, style, - bindings: raw_bindings, + bindings: bindings_config, } = Figment::new() .merge(Toml::string(DEFAULT_CONFIG)) .extract() @@ -263,7 +336,8 @@ pub(crate) fn init_test_config() -> Res { Ok(Config { general, style, - bindings: Bindings::try_from(raw_bindings).unwrap(), + bindings: Bindings::try_from(bindings_config.menus).unwrap(), + picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(), }) } diff --git a/src/default_config.toml b/src/default_config.toml index 1a7e33ecb1..b800e064b6 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -223,3 +223,8 @@ stash_menu.stash_pop = ["p"] stash_menu.stash_apply = ["a"] stash_menu.stash_drop = ["k"] stash_menu.quit = ["q", "esc"] + +picker.next = ["down", "ctrl+n"] +picker.previous = ["up", "ctrl+p"] +picker.done = ["enter"] +picker.cancel = ["esc", "ctrl+c"] diff --git a/src/ui/picker.rs b/src/ui/picker.rs index fbcbbf44cd..405acb19b8 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -147,12 +147,13 @@ mod tests { /// Create a default test config for picker tests fn test_config() -> Config { - use crate::config::{GeneralConfig, StyleConfig}; + use crate::config::{GeneralConfig, PickerBindingsConfig, StyleConfig}; Config { general: GeneralConfig::default(), style: StyleConfig::default(), bindings: BTreeMap::new().try_into().unwrap(), + picker_bindings: PickerBindingsConfig::default().try_into().unwrap(), } } From e223740858b1e9a9eb5f493d029a73c0dc6f4c22 Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Wed, 14 Jan 2026 01:25:07 +0900 Subject: [PATCH 5/6] fix: update picker UI to use separator style from config --- src/ui/picker.rs | 61 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/src/ui/picker.rs b/src/ui/picker.rs index 405acb19b8..8e937d203b 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -7,7 +7,7 @@ use unicode_segmentation::UnicodeSegmentation; use crate::config::Config; use crate::picker::PickerState; use crate::ui::layout::OPTS; -use crate::ui::{CARET, DASHES, STYLE, UiTree, layout_span, repeat_chars}; +use crate::ui::{CARET, DASHES, UiTree, layout_span, repeat_chars}; const MAX_ITEMS_DISPLAY: usize = 10; @@ -19,7 +19,8 @@ pub(crate) fn layout_picker<'a>( width: usize, ) { // Separator line - repeat_chars(layout, width, DASHES, STYLE); + let separator_style = Style::from(&config.style.separator); + repeat_chars(layout, width, DASHES, separator_style); let prompt_style: Style = (&config.style.picker.prompt).into(); let info_style: Style = (&config.style.picker.info).into(); @@ -141,8 +142,8 @@ mod tests { use crate::config::Config; use crate::picker::{PickerData, PickerItem, PickerState}; use crate::ui::layout::LayoutTree; - use itertools::Itertools; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use itertools::Itertools; use std::collections::BTreeMap; /// Create a default test config for picker tests @@ -161,8 +162,14 @@ mod tests { vec![ PickerItem::new("main", PickerData::Revision("main".to_string())), PickerItem::new("develop", PickerData::Revision("develop".to_string())), - PickerItem::new("feature/test", PickerData::Revision("feature/test".to_string())), - PickerItem::new("feature/new", PickerData::Revision("feature/new".to_string())), + PickerItem::new( + "feature/test", + PickerData::Revision("feature/test".to_string()), + ), + PickerItem::new( + "feature/new", + PickerData::Revision("feature/new".to_string()), + ), PickerItem::new("bugfix/123", PickerData::Revision("bugfix/123".to_string())), ] } @@ -211,9 +218,15 @@ mod tests { let mut state = PickerState::new("Select branch", items, false); // Type "fea" to filter - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); state.update_filter(); let config = test_config(); @@ -311,9 +324,15 @@ mod tests { let mut state = PickerState::new("Select branch", items, false); // Filter to get feature branches - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); state.update_filter(); // Navigate to second item @@ -336,8 +355,12 @@ mod tests { let mut state = PickerState::new("New branch", items, true); // Type a custom branch name - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); state.update_filter(); let config = test_config(); @@ -357,9 +380,15 @@ mod tests { let mut state = PickerState::new("Select branch", items, false); // Type something that doesn't match - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); - state.input_state.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); state.update_filter(); let config = test_config(); From 0cc2f302247981bf785f408febe73a8562fd20de Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Wed, 14 Jan 2026 01:27:31 +0900 Subject: [PATCH 6/6] test: update non_utf8_diff snapshot after merge --- .pre-commit-config.yaml | 1 + .../snapshots/gitu__tests__non_utf8_diff.snap | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 120000 .pre-commit-config.yaml create mode 100644 src/tests/snapshots/gitu__tests__non_utf8_diff.snap diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 120000 index 0000000000..c475374bb3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1 @@ +/nix/store/5jr06kaid73ilblgxp0njd5k35nxynnx-pre-commit-config.json \ No newline at end of file diff --git a/src/tests/snapshots/gitu__tests__non_utf8_diff.snap b/src/tests/snapshots/gitu__tests__non_utf8_diff.snap new file mode 100644 index 0000000000..9d52e22b93 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__non_utf8_diff.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/mod.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 1 commit(s). | + | + Unstaged changes (1) | + modified non_utf8.txt | + @@ -1 +1 @@ | + -File with valid UTF-8 | + \ No newline at end of file | + +FileFile with invalid UTF-8: �� | + | + Recent commits | + 7c3d61a main add non_utf8.txt | + b66a0bf origin/main add initial-file | + | + | + | + | + | + | + | +styles_hash: 232c1529ecd86f60