Skip to content

Commit 32ab50f

Browse files
CopilotByron
andcommitted
Implement case-sensitive glob search with '^f' shortcut
Co-authored-by: Byron <[email protected]>
1 parent 7abf86a commit 32ab50f

File tree

4 files changed

+86
-11
lines changed

4 files changed

+86
-11
lines changed

src/interactive/app/eventloop.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ impl AppState {
294294
Glob => {
295295
let glob_pane = window.glob_pane.as_mut().expect("glob pane");
296296
match key.code {
297-
Enter => self.search_glob_pattern(&mut tree_view, &glob_pane.input),
297+
Enter => self.search_glob_pattern(&mut tree_view, &glob_pane.input, glob_pane.case_sensitive),
298298
_ => glob_pane.process_events(key),
299299
}
300300
}
@@ -476,9 +476,9 @@ impl AppState {
476476
}
477477
}
478478

479-
fn search_glob_pattern(&mut self, tree_view: &mut TreeView<'_>, glob_pattern: &str) {
479+
fn search_glob_pattern(&mut self, tree_view: &mut TreeView<'_>, glob_pattern: &str, case_sensitive: bool) {
480480
use FocussedPane::*;
481-
match glob_search(tree_view.tree(), self.navigation.view_root, glob_pattern) {
481+
match glob_search(tree_view.tree(), self.navigation.view_root, glob_pattern, case_sensitive) {
482482
Ok(matches) if matches.is_empty() => {
483483
self.message = Some("No match found".into());
484484
}

src/interactive/app/tests/unit.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,19 @@ fn it_can_handle_ending_traversal_without_reaching_the_top() -> Result<()> {
3535
#[test]
3636
fn it_can_do_a_glob_search() {
3737
let (tree, root_index) = sample_02_tree(false);
38-
let result = glob_search(&tree, root_index, "tests/fixtures/sample-02").unwrap();
38+
let result = glob_search(&tree, root_index, "tests/fixtures/sample-02", false).unwrap();
3939
let expected = vec![TreeIndex::from(1)];
4040
assert_eq!(result, expected);
4141
}
42+
43+
#[test]
44+
fn it_can_do_a_case_sensitive_glob_search() {
45+
let (tree, root_index) = sample_02_tree(false);
46+
// This should match case-insensitively
47+
let result_insensitive = glob_search(&tree, root_index, "TESTS/FIXTURES/SAMPLE-02", false).unwrap();
48+
assert_eq!(result_insensitive, vec![TreeIndex::from(1)]);
49+
50+
// This should not match case-sensitively
51+
let result_sensitive = glob_search(&tree, root_index, "TESTS/FIXTURES/SAMPLE-02", true).unwrap();
52+
assert!(result_sensitive.is_empty());
53+
}

src/interactive/widgets/glob.rs

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,21 @@ pub struct GlobPane {
3434
/// and is treated as 'one character'. If not, it will be off, which isn't the end of the world.
3535
// TODO: use `tui-textarea` for proper cursor handling, needs native crossterm events.
3636
cursor_grapheme_idx: usize,
37+
/// Whether the glob search should be case-sensitive
38+
pub case_sensitive: bool,
3739
}
3840

3941
impl GlobPane {
4042
pub fn process_events(&mut self, key: Key) {
4143
use crosstermion::crossterm::event::KeyCode::*;
44+
use crosstermion::crossterm::event::KeyModifiers;
4245
if key.kind == KeyEventKind::Release {
4346
return;
4447
}
4548
match key.code {
49+
Char('i') if key.modifiers.contains(KeyModifiers::CONTROL) => {
50+
self.case_sensitive = !self.case_sensitive;
51+
}
4652
Char(to_insert) => {
4753
self.enter_char(to_insert);
4854
}
@@ -113,7 +119,11 @@ impl GlobPane {
113119
has_focus,
114120
} = props.borrow();
115121

116-
let title = "Git-Glob";
122+
let title = if self.case_sensitive {
123+
"Git-Glob (case-sensitive)"
124+
} else {
125+
"Git-Glob (case-insensitive)"
126+
};
117127
let block = Block::default()
118128
.title(title)
119129
.border_style(*border_style)
@@ -146,7 +156,7 @@ impl GlobPane {
146156
}
147157

148158
fn draw_top_right_help(area: Rect, title: &str, buf: &mut Buffer) -> Rect {
149-
let help_text = " search = enter | cancel = esc ";
159+
let help_text = " search = enter | case = ^I | cancel = esc ";
150160
let help_text_block_width = block_width(help_text);
151161
let bound = Rect {
152162
width: area.width.saturating_sub(1),
@@ -178,6 +188,7 @@ fn glob_search_neighbours(
178188
root_index: TreeIndex,
179189
glob: &gix_glob::Pattern,
180190
path: &mut BString,
191+
case_sensitive: bool,
181192
) {
182193
for node_index in tree.neighbors_directed(root_index, Direction::Outgoing) {
183194
if let Some(node) = tree.node_weight(node_index) {
@@ -189,27 +200,79 @@ fn glob_search_neighbours(
189200
Some(previous_len + 1)
190201
};
191202
path.extend_from_slice(gix_path::into_bstr(&node.name).as_ref());
203+
let case_mode = if case_sensitive {
204+
gix_glob::pattern::Case::Sensitive
205+
} else {
206+
gix_glob::pattern::Case::Fold
207+
};
192208
if glob.matches_repo_relative_path(
193209
path.as_ref(),
194210
basename_start,
195211
Some(node.is_dir),
196-
gix_glob::pattern::Case::Fold,
212+
case_mode,
197213
gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
198214
) {
199215
results.push(node_index);
200216
} else {
201-
glob_search_neighbours(results, tree, node_index, glob, path);
217+
glob_search_neighbours(results, tree, node_index, glob, path, case_sensitive);
202218
}
203219
path.truncate(previous_len);
204220
}
205221
}
206222
}
207223

208-
pub fn glob_search(tree: &Tree, root_index: TreeIndex, glob: &str) -> Result<Vec<TreeIndex>> {
224+
pub fn glob_search(tree: &Tree, root_index: TreeIndex, glob: &str, case_sensitive: bool) -> Result<Vec<TreeIndex>> {
209225
let glob = gix_glob::Pattern::from_bytes_without_negation(glob.as_bytes())
210226
.with_context(|| anyhow!("Glob was empty or only whitespace"))?;
211227
let mut results = Vec::new();
212228
let mut path = Default::default();
213-
glob_search_neighbours(&mut results, tree, root_index, &glob, &mut path);
229+
glob_search_neighbours(&mut results, tree, root_index, &glob, &mut path, case_sensitive);
214230
Ok(results)
215231
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
use crosstermion::crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
237+
238+
#[test]
239+
fn test_i_key_types_into_input() {
240+
let mut glob_pane = GlobPane::default();
241+
assert_eq!(glob_pane.input, "");
242+
assert!(!glob_pane.case_sensitive); // default is case-insensitive
243+
244+
// Test that typing 'I' adds it to the input
245+
let key_i = Key {
246+
code: KeyCode::Char('I'),
247+
modifiers: KeyModifiers::empty(),
248+
kind: KeyEventKind::Press,
249+
state: crosstermion::crossterm::event::KeyEventState::empty(),
250+
};
251+
glob_pane.process_events(key_i);
252+
253+
assert_eq!(glob_pane.input, "I");
254+
assert!(!glob_pane.case_sensitive); // should remain unchanged
255+
}
256+
257+
#[test]
258+
fn test_ctrl_i_toggles_case_sensitivity() {
259+
let mut glob_pane = GlobPane::default();
260+
assert!(!glob_pane.case_sensitive); // default is case-insensitive
261+
262+
// Test that Ctrl+I toggles case sensitivity
263+
let key_ctrl_i = Key {
264+
code: KeyCode::Char('i'),
265+
modifiers: KeyModifiers::CONTROL,
266+
kind: KeyEventKind::Press,
267+
state: crosstermion::crossterm::event::KeyEventState::empty(),
268+
};
269+
glob_pane.process_events(key_ctrl_i);
270+
271+
assert_eq!(glob_pane.input, ""); // input should remain empty
272+
assert!(glob_pane.case_sensitive); // should toggle to case-sensitive
273+
274+
// Test toggling back
275+
glob_pane.process_events(key_ctrl_i);
276+
assert!(! glob_pane.case_sensitive); // should toggle back to case-insensitive
277+
}
278+
}

src/interactive/widgets/help.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ impl HelpPane {
181181
hotkey("a", "Toggle all entries.", None);
182182
hotkey(
183183
"/",
184-
"Git-style glob search, case-insensitive.",
184+
"Git-style glob search. Toggle case with 'I'.",
185185
Some("Search starts from the current directory."),
186186
);
187187
hotkey("r", "Refresh only the selected entry.", None);

0 commit comments

Comments
 (0)