Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #20 from RivoLink/feat/fuzzy-markdown-picker

feat: fuzzy markdown picker

authored by

Rivo Link and committed by
GitHub
2e390357 13a337e2

+1063 -71
+6 -2
README.md
··· 85 85 claude "explain Rust lifetimes" | leaf 86 86 cat TESTING.md | leaf 87 87 88 - # Open the file picker in the current directory 88 + # Open the fuzzy Markdown picker in the current directory and subdirectories 89 89 leaf 90 + 91 + # Open the classic directory browser picker 92 + leaf --picker 90 93 91 94 ``` 92 95 ··· 121 124 - ✅ YAML frontmatter is ignored in both preview and TOC 122 125 - ✅ Native stdin input with bounded size 123 126 - ✅ `leaf --update` to fetch, verify via published SHA256, and install the latest release on supported platforms 124 - - ✅ File picker when launched without a file 127 + - ✅ Fuzzy Markdown picker when launched without a file 128 + - ✅ Classic directory browser picker with `leaf --picker` 125 129 - ✅ Theme picker with runtime preview 126 130 - ✅ Help modal with in-app shortcuts 127 131
+341 -29
src/app.rs
··· 10 10 }; 11 11 use ratatui::text::Line; 12 12 use std::{ 13 + collections::VecDeque, 13 14 fs, 14 15 path::PathBuf, 15 16 time::{Duration, Instant, SystemTime}, ··· 67 68 pub(crate) struct FilePickerEntry { 68 69 label: String, 69 70 path: PathBuf, 70 - is_dir: bool, 71 + label_lower: String, 72 + file_name: String, 73 + file_name_lower: String, 74 + file_name_offset: usize, 75 + path_depth: usize, 71 76 } 72 77 73 78 impl FilePickerEntry { 79 + fn new(label: String, path: PathBuf) -> Self { 80 + let file_name = Self::file_name_component(&label).to_string(); 81 + let file_name_offset = label 82 + .rfind(std::path::MAIN_SEPARATOR) 83 + .map(|idx| label[..idx + 1].chars().count()) 84 + .unwrap_or(0); 85 + let path_depth = label.matches(std::path::MAIN_SEPARATOR).count(); 86 + 87 + Self { 88 + label_lower: label.to_lowercase(), 89 + file_name_lower: file_name.to_lowercase(), 90 + label, 91 + path, 92 + file_name, 93 + file_name_offset, 94 + path_depth, 95 + } 96 + } 97 + 74 98 pub(crate) fn label(&self) -> &str { 75 99 &self.label 76 100 } 77 101 78 - pub(crate) fn is_dir(&self) -> bool { 79 - self.is_dir 102 + fn label_lower(&self) -> &str { 103 + &self.label_lower 104 + } 105 + 106 + fn file_name_lower(&self) -> &str { 107 + &self.file_name_lower 108 + } 109 + 110 + fn file_name_offset(&self) -> usize { 111 + self.file_name_offset 112 + } 113 + 114 + fn path_depth(&self) -> usize { 115 + self.path_depth 116 + } 117 + 118 + fn file_name_component(path: &str) -> &str { 119 + path.rsplit(std::path::MAIN_SEPARATOR).next().unwrap_or(path) 120 + } 121 + 122 + fn is_dir_like(&self) -> bool { 123 + self.label == ".." || self.label.ends_with('/') 80 124 } 81 125 } 82 126 83 127 pub(crate) struct FilePickerState { 84 128 open: bool, 129 + mode: FilePickerMode, 85 130 dir: PathBuf, 86 131 entries: Vec<FilePickerEntry>, 132 + filtered: Vec<usize>, 133 + match_positions: Vec<Vec<usize>>, 87 134 index: usize, 135 + query: String, 136 + } 137 + 138 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 139 + pub(crate) enum FilePickerMode { 140 + Browser, 141 + Fuzzy, 88 142 } 89 143 90 144 pub(crate) struct ThemePickerState { ··· 144 198 let mut entries = Vec::new(); 145 199 146 200 if let Some(parent) = dir.parent() { 147 - entries.push(FilePickerEntry { 148 - label: "..".to_string(), 149 - path: parent.to_path_buf(), 150 - is_dir: true, 151 - }); 201 + entries.push(FilePickerEntry::new("..".to_string(), parent.to_path_buf())); 152 202 } 153 203 154 204 let mut dirs = Vec::new(); ··· 163 213 let name = entry.file_name().to_string_lossy().to_string(); 164 214 165 215 if file_type.is_dir() { 166 - dirs.push(FilePickerEntry { 167 - label: format!("{name}/"), 168 - path, 169 - is_dir: true, 170 - }); 216 + dirs.push(FilePickerEntry::new(format!("{name}/"), path)); 171 217 } else if file_type.is_file() && Self::is_markdown_path(&path) { 172 - files.push(FilePickerEntry { 173 - label: name, 174 - path, 175 - is_dir: false, 176 - }); 218 + files.push(FilePickerEntry::new(name, path)); 177 219 } 178 220 } 179 221 180 - dirs.sort_by_key(|entry| entry.label.to_lowercase()); 181 - files.sort_by_key(|entry| entry.label.to_lowercase()); 222 + dirs.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 223 + files.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 182 224 entries.extend(dirs); 183 225 entries.extend(files); 184 226 Ok(entries) 185 227 } 186 228 187 - fn file_picker_entry_path(entry: &FilePickerEntry) -> PathBuf { 188 - entry.path.clone() 229 + fn build_fuzzy_file_picker_entries( 230 + dir: &std::path::Path, 231 + ) -> std::io::Result<Vec<FilePickerEntry>> { 232 + let mut entries = Vec::new(); 233 + let mut queue = VecDeque::from([dir.to_path_buf()]); 234 + 235 + while let Some(current_dir) = queue.pop_front() { 236 + let mut dirs = Vec::new(); 237 + let mut files = Vec::new(); 238 + 239 + for entry in fs::read_dir(&current_dir)? { 240 + let entry = entry?; 241 + let path = entry.path(); 242 + let file_type = match entry.file_type() { 243 + Ok(file_type) => file_type, 244 + Err(_) => continue, 245 + }; 246 + 247 + if file_type.is_dir() { 248 + dirs.push(path); 249 + continue; 250 + } 251 + 252 + if file_type.is_file() && Self::is_markdown_path(&path) { 253 + let label = path 254 + .strip_prefix(dir) 255 + .unwrap_or(&path) 256 + .display() 257 + .to_string(); 258 + files.push(FilePickerEntry::new(label, path)); 259 + } 260 + } 261 + 262 + files.sort_by(|left, right| Self::fuzzy_entry_sort_key(left).cmp(&Self::fuzzy_entry_sort_key(right))); 263 + dirs.sort_by_key(|path| { 264 + let label = path 265 + .strip_prefix(dir) 266 + .unwrap_or(path) 267 + .display() 268 + .to_string(); 269 + ( 270 + !label 271 + .split(std::path::MAIN_SEPARATOR) 272 + .next() 273 + .unwrap_or(&label) 274 + .starts_with('.'), 275 + label.to_lowercase(), 276 + ) 277 + }); 278 + 279 + entries.extend(files); 280 + queue.extend(dirs); 281 + } 282 + 283 + Ok(entries) 284 + } 285 + 286 + fn fuzzy_entry_sort_key(entry: &FilePickerEntry) -> (bool, &str) { 287 + let first_component = entry 288 + .label 289 + .split(std::path::MAIN_SEPARATOR) 290 + .next() 291 + .unwrap_or(entry.label()); 292 + (!first_component.starts_with('.'), entry.label_lower()) 293 + } 294 + 295 + fn fuzzy_component_match(candidate: &str, query: &str) -> Option<(usize, Vec<usize>)> { 296 + if let Some(start) = candidate.find(query) { 297 + let start_chars = candidate[..start].chars().count(); 298 + let query_len = query.chars().count(); 299 + let len_diff = candidate.chars().count().saturating_sub(query_len); 300 + let prefix_bonus = usize::from(start_chars == 0).saturating_mul(80); 301 + let boundary_bonus = 302 + usize::from(Self::is_match_boundary(candidate, start_chars)).saturating_mul(40); 303 + let score = start_chars 304 + .saturating_mul(10) 305 + .saturating_add(len_diff) 306 + .saturating_sub(prefix_bonus) 307 + .saturating_sub(boundary_bonus); 308 + let positions = (start_chars..start_chars + query_len).collect::<Vec<_>>(); 309 + return Some((score, positions)); 310 + } 311 + 312 + let mut search_from = 0usize; 313 + let mut positions = Vec::with_capacity(query.len()); 314 + 315 + for needle in query.chars() { 316 + let found = candidate[search_from..] 317 + .char_indices() 318 + .find(|(_, ch)| *ch == needle) 319 + .map(|(idx, _)| search_from + idx)?; 320 + let char_pos = candidate[..found].chars().count(); 321 + positions.push(char_pos); 322 + search_from = found + needle.len_utf8(); 323 + } 324 + 325 + let first = *positions.first()?; 326 + let last = *positions.last()?; 327 + let span = last.saturating_sub(first); 328 + let gaps = positions 329 + .windows(2) 330 + .map(|window| window[1].saturating_sub(window[0]).saturating_sub(1)) 331 + .sum::<usize>(); 332 + let len_diff = candidate.chars().count().saturating_sub(query.chars().count()); 333 + let prefix_bonus = usize::from(first == 0).saturating_mul(80); 334 + let boundary_bonus = 335 + usize::from(Self::is_match_boundary(candidate, first)).saturating_mul(40); 336 + let score = 1_000usize 337 + .saturating_add(gaps.saturating_mul(120)) 338 + .saturating_add(first.saturating_mul(10)) 339 + .saturating_add(span) 340 + .saturating_add(len_diff) 341 + .saturating_sub(prefix_bonus) 342 + .saturating_sub(boundary_bonus); 343 + Some((score, positions)) 344 + } 345 + 346 + fn is_match_boundary(candidate: &str, char_pos: usize) -> bool { 347 + if char_pos == 0 { 348 + return true; 349 + } 350 + 351 + candidate 352 + .chars() 353 + .nth(char_pos.saturating_sub(1)) 354 + .is_some_and(|ch| matches!(ch, '-' | '_' | '.' | ' ')) 355 + } 356 + 357 + fn fuzzy_match(entry: &FilePickerEntry, query: &str) -> Option<(usize, Vec<usize>)> { 358 + if query.is_empty() { 359 + return Some((0, Vec::new())); 360 + } 361 + 362 + let (score, positions) = Self::fuzzy_component_match(entry.file_name_lower(), query)?; 363 + Some(( 364 + score, 365 + positions 366 + .into_iter() 367 + .map(|position| entry.file_name_offset() + position) 368 + .collect(), 369 + )) 370 + } 371 + 372 + fn refresh_file_picker_matches(&mut self) { 373 + if self.is_browser_file_picker() { 374 + self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 375 + self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 376 + self.file_picker.index = self 377 + .file_picker 378 + .index 379 + .min(self.file_picker.filtered.len().saturating_sub(1)); 380 + return; 381 + } 382 + 383 + let query = self.file_picker.query.trim().to_lowercase(); 384 + let mut filtered = self 385 + .file_picker 386 + .entries 387 + .iter() 388 + .enumerate() 389 + .filter_map(|(idx, entry)| { 390 + Self::fuzzy_match(entry, &query).map(|(score, positions)| { 391 + ( 392 + idx, 393 + score, 394 + entry.path_depth(), 395 + entry.file_name_lower(), 396 + entry.label_lower(), 397 + positions, 398 + ) 399 + }) 400 + }) 401 + .collect::<Vec<_>>(); 402 + 403 + filtered.sort_by( 404 + |(left_idx, left_score, left_depth, left_name, left_label, _), 405 + (right_idx, right_score, right_depth, right_name, right_label, _)| { 406 + left_score 407 + .cmp(right_score) 408 + .then_with(|| left_depth.cmp(right_depth)) 409 + .then_with(|| left_name.cmp(right_name)) 410 + .then_with(|| left_label.cmp(right_label)) 411 + .then_with(|| left_idx.cmp(right_idx)) 412 + }, 413 + ); 414 + 415 + self.file_picker.filtered = filtered.iter().map(|(idx, ..)| *idx).collect(); 416 + self.file_picker.match_positions = filtered 417 + .into_iter() 418 + .map(|(_, _, _, _, _, positions)| positions) 419 + .collect(); 420 + if self.file_picker.filtered.is_empty() 421 + || self.file_picker.index >= self.file_picker.filtered.len() 422 + { 423 + self.file_picker.index = 0; 424 + } 425 + } 426 + 427 + fn selected_file_picker_entry(&self) -> Option<&FilePickerEntry> { 428 + let idx = *self.file_picker.filtered.get(self.file_picker.index)?; 429 + self.file_picker.entries.get(idx) 189 430 } 190 431 191 432 #[cfg(test)] ··· 264 505 help_open: false, 265 506 file_picker: FilePickerState { 266 507 open: false, 508 + mode: FilePickerMode::Browser, 267 509 dir: PathBuf::from("."), 268 510 entries: Vec::new(), 511 + filtered: Vec::new(), 512 + match_positions: Vec::new(), 269 513 index: 0, 514 + query: String::new(), 270 515 }, 271 516 theme_picker: ThemePickerState { 272 517 open: false, ··· 570 815 } 571 816 572 817 pub(crate) fn open_file_picker(&mut self, dir: PathBuf) -> bool { 573 - match Self::build_file_picker_entries(&dir) { 818 + self.open_file_picker_with_mode(dir, FilePickerMode::Browser) 819 + } 820 + 821 + pub(crate) fn open_fuzzy_file_picker(&mut self, dir: PathBuf) -> bool { 822 + self.open_file_picker_with_mode(dir, FilePickerMode::Fuzzy) 823 + } 824 + 825 + fn open_file_picker_with_mode(&mut self, dir: PathBuf, mode: FilePickerMode) -> bool { 826 + let entries = match mode { 827 + FilePickerMode::Browser => Self::build_file_picker_entries(&dir), 828 + FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&dir), 829 + }; 830 + 831 + match entries { 574 832 Ok(entries) => { 575 833 self.file_picker.open = true; 834 + self.file_picker.mode = mode; 576 835 self.file_picker.dir = dir; 577 836 self.file_picker.entries = entries; 837 + self.file_picker.query.clear(); 578 838 self.file_picker.index = 0; 839 + self.refresh_file_picker_matches(); 579 840 true 580 841 } 581 842 Err(_) => false, 582 843 } 583 844 } 584 845 846 + pub(crate) fn is_fuzzy_file_picker(&self) -> bool { 847 + self.file_picker.mode == FilePickerMode::Fuzzy 848 + } 849 + 850 + pub(crate) fn is_browser_file_picker(&self) -> bool { 851 + self.file_picker.mode == FilePickerMode::Browser 852 + } 853 + 585 854 pub(crate) fn is_file_picker_open(&self) -> bool { 586 855 self.file_picker.open 587 856 } ··· 594 863 &self.file_picker.entries 595 864 } 596 865 866 + pub(crate) fn file_picker_filtered_indices(&self) -> &[usize] { 867 + &self.file_picker.filtered 868 + } 869 + 870 + pub(crate) fn file_picker_match_positions(&self, filtered_idx: usize) -> &[usize] { 871 + self.file_picker 872 + .match_positions 873 + .get(filtered_idx) 874 + .map(Vec::as_slice) 875 + .unwrap_or(&[]) 876 + } 877 + 597 878 pub(crate) fn file_picker_index(&self) -> usize { 598 879 self.file_picker.index 599 880 } 600 881 882 + pub(crate) fn file_picker_query(&self) -> &str { 883 + &self.file_picker.query 884 + } 885 + 601 886 pub(crate) fn move_file_picker_up(&mut self) { 602 - let total = self.file_picker.entries.len(); 887 + let total = self.file_picker.filtered.len(); 603 888 if total == 0 { 604 889 return; 605 890 } ··· 611 896 } 612 897 613 898 pub(crate) fn move_file_picker_down(&mut self) { 614 - let total = self.file_picker.entries.len(); 899 + let total = self.file_picker.filtered.len(); 615 900 if total == 0 { 616 901 return; 617 902 } 618 903 self.file_picker.index = (self.file_picker.index + 1) % total; 619 904 } 620 905 906 + pub(crate) fn push_file_picker_query(&mut self, ch: char) { 907 + if self.is_browser_file_picker() { 908 + return; 909 + } 910 + self.file_picker.query.push(ch); 911 + self.refresh_file_picker_matches(); 912 + } 913 + 914 + pub(crate) fn pop_file_picker_query(&mut self) { 915 + if self.is_browser_file_picker() { 916 + return; 917 + } 918 + self.file_picker.query.pop(); 919 + self.refresh_file_picker_matches(); 920 + } 921 + 922 + pub(crate) fn clear_file_picker_query(&mut self) { 923 + if self.is_browser_file_picker() { 924 + return; 925 + } 926 + self.file_picker.query.clear(); 927 + self.refresh_file_picker_matches(); 928 + } 929 + 621 930 pub(crate) fn open_file_picker_parent(&mut self) -> bool { 931 + if self.is_fuzzy_file_picker() { 932 + return false; 933 + } 622 934 let Some(parent) = self.file_picker.dir.parent() else { 623 935 return false; 624 936 }; ··· 970 1282 ss: &SyntaxSet, 971 1283 themes: &ThemeSet, 972 1284 ) -> bool { 973 - let Some(entry) = self.file_picker.entries.get(self.file_picker.index).cloned() else { 1285 + let Some(entry) = self.selected_file_picker_entry().cloned() else { 974 1286 return false; 975 1287 }; 976 - if entry.is_dir() { 977 - self.open_file_picker(Self::file_picker_entry_path(&entry)) 1288 + if self.is_browser_file_picker() && entry.is_dir_like() { 1289 + self.open_file_picker(entry.path) 978 1290 } else { 979 - self.load_path(Self::file_picker_entry_path(&entry), ss, themes) 1291 + self.load_path(entry.path, ss, themes) 980 1292 } 981 1293 } 982 1294
+11 -1
src/cli.rs
··· 4 4 5 5 #[derive(Debug, Default, PartialEq, Eq)] 6 6 pub(crate) struct CliOptions { 7 + pub(crate) picker: bool, 7 8 pub(crate) watch: bool, 8 9 pub(crate) update: bool, 9 10 pub(crate) debug_input: bool, ··· 14 15 } 15 16 16 17 pub(crate) fn usage_text() -> &'static str { 17 - "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] [file.md]\n leaf --update\n echo '# Hello' | leaf" 18 + "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] [file.md]\n leaf --picker\n leaf --update\n echo '# Hello' | leaf" 18 19 } 19 20 20 21 pub(crate) fn version_text() -> &'static str { ··· 45 46 } 46 47 47 48 match arg.as_str() { 49 + "--picker" => options.picker = true, 48 50 "--watch" | "-w" => options.watch = true, 49 51 "--update" => options.update = true, 50 52 "--debug-input" => options.debug_input = true, ··· 71 73 72 74 if options.update { 73 75 let has_non_update_flags = options.watch 76 + || options.picker 74 77 || options.debug_input 75 78 || options.file_arg.is_some() 76 79 || options.theme != ThemePreset::default(); 77 80 if has_non_update_flags { 78 81 anyhow::bail!("--update must be used on its own"); 82 + } 83 + } 84 + 85 + if options.picker { 86 + let has_non_picker_flags = options.watch || options.file_arg.is_some(); 87 + if has_non_picker_flags { 88 + anyhow::bail!("--picker cannot be combined with --watch or a file path"); 79 89 } 80 90 } 81 91
+12 -3
src/main.rs
··· 78 78 return Ok(()); 79 79 } 80 80 let CliOptions { 81 + picker, 81 82 watch, 82 83 debug_input, 83 84 file_arg, ··· 96 97 writeln!(file, "leaf debug input log").ok(); 97 98 } 98 99 99 - let mut open_picker_dir = None; 100 + let mut open_browser_picker_dir = None; 101 + let mut open_fuzzy_picker_dir = None; 100 102 let (src, filename, filepath) = if let Some(f) = file_arg { 101 103 let path = PathBuf::from(&f); 102 104 let content = std::fs::read_to_string(&path) ··· 113 115 .file_name() 114 116 .map(|name| name.to_string_lossy().to_string()) 115 117 .unwrap_or_else(|| cwd.display().to_string()); 116 - open_picker_dir = Some(cwd); 118 + if picker { 119 + open_browser_picker_dir = Some(cwd); 120 + } else { 121 + open_fuzzy_picker_dir = Some(cwd); 122 + } 117 123 (String::new(), label, None) 118 124 } else { 119 125 if watch { ··· 147 153 }, 148 154 ); 149 155 app.set_last_content_hash(last_content_hash); 150 - if let Some(dir) = open_picker_dir { 156 + if let Some(dir) = open_browser_picker_dir { 151 157 app.open_file_picker(dir); 158 + } 159 + if let Some(dir) = open_fuzzy_picker_dir { 160 + app.open_fuzzy_file_picker(dir); 152 161 } 153 162 154 163 let mut stdout = io::stdout();
+151 -34
src/render.rs
··· 271 271 if app.is_search_mode() { 272 272 &["enter confirm", "esc cancel"] 273 273 } else if app.is_file_picker_open() { 274 - &["j/k move", "enter open", "backspace up", "q quit"] 274 + if app.is_fuzzy_file_picker() { 275 + &["j/k move", "enter open", "backspace delete", "ctrl+c quit"] 276 + } else { 277 + &["j/k move", "enter open", "backspace up", "ctrl+c quit"] 278 + } 275 279 } else if app.is_theme_picker_open() { 276 280 &["j/k preview", "enter keep", "esc restore"] 277 281 } else if app.is_help_open() { ··· 468 472 469 473 fn render_file_picker(f: &mut Frame, app: &App) { 470 474 let theme = app_theme(); 471 - let area = centered_rect(70, 18, f.area()); 475 + let area = centered_rect(78, 20, f.area()); 472 476 let title_style = Style::default() 473 477 .fg(theme.markdown.heading_2) 474 478 .add_modifier(Modifier::BOLD); 475 479 let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 476 480 let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 477 481 let inner_height = area.height.saturating_sub(2) as usize; 478 - let visible_slots = inner_height.saturating_sub(5); 479 - let total = app.file_picker_entries().len(); 482 + let header_lines = if app.is_fuzzy_file_picker() { 4 } else { 3 }; 483 + let max_visible_slots = if app.is_fuzzy_file_picker() { 12 } else { 13 }; 484 + let visible_slots = inner_height 485 + .saturating_sub(header_lines + 1) 486 + .min(max_visible_slots); 487 + let total = app.file_picker_filtered_indices().len(); 480 488 let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 481 489 0 482 490 } else { ··· 486 494 487 495 let mut lines = vec![ 488 496 Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 489 - Line::from(vec![Span::styled( 490 - app.file_picker_dir().display().to_string(), 491 - section_style, 492 - )]), 493 - Line::from(""), 497 + Line::from(vec![ 498 + Span::styled("Dir: ", section_style), 499 + Span::styled( 500 + app.file_picker_dir().display().to_string(), 501 + Style::default().fg(theme.ui.toc_primary_inactive), 502 + ), 503 + ]), 494 504 ]; 495 505 496 - if total == 0 { 506 + if app.is_fuzzy_file_picker() { 507 + lines.push(Line::from(vec![ 508 + Span::styled("Query: ", section_style), 509 + Span::styled( 510 + if app.file_picker_query().is_empty() { 511 + " type to filter ".to_string() 512 + } else { 513 + format!(" {} ", app.file_picker_query()) 514 + }, 515 + Style::default() 516 + .fg(if app.file_picker_query().is_empty() { 517 + theme.ui.toc_primary_inactive 518 + } else { 519 + theme.ui.toc_primary_active 520 + }) 521 + .bg(theme.markdown.inline_code_bg), 522 + ), 523 + ])); 524 + } 525 + 526 + lines.push(Line::from("")); 527 + 528 + if app.file_picker_entries().is_empty() { 497 529 lines.push(Line::from(vec![Span::styled( 498 - "No folders or Markdown files here", 530 + if app.is_fuzzy_file_picker() { 531 + "No Markdown file found in this directory or its subdirectories" 532 + } else { 533 + "No folders or Markdown files here" 534 + }, 535 + Style::default().fg(theme.ui.toc_primary_inactive), 536 + )])); 537 + } else if total == 0 { 538 + lines.push(Line::from(vec![Span::styled( 539 + "No match for the current query", 499 540 Style::default().fg(theme.ui.toc_primary_inactive), 500 541 )])); 501 542 } else { 502 - for (idx, entry) in app.file_picker_entries()[start..end].iter().enumerate() { 543 + for (idx, entry_idx) in app.file_picker_filtered_indices()[start..end] 544 + .iter() 545 + .enumerate() 546 + { 503 547 let actual_idx = start + idx; 504 548 let selected = actual_idx == app.file_picker_index(); 549 + let entry = &app.file_picker_entries()[*entry_idx]; 505 550 let bg = if selected { 506 551 theme.ui.toc_active_bg 507 552 } else { 508 553 theme.ui.toc_bg 509 554 }; 510 555 let marker = if selected { "▸ " } else { " " }; 511 - lines.push(Line::from(vec![ 512 - Span::styled( 513 - marker, 514 - Style::default() 515 - .fg(theme.ui.toc_accent) 516 - .bg(bg) 517 - .add_modifier(if selected { 518 - Modifier::BOLD 519 - } else { 520 - Modifier::empty() 521 - }), 522 - ), 523 - Span::styled( 556 + let label_spans = if app.is_fuzzy_file_picker() { 557 + highlighted_picker_label(entry.label(), app.file_picker_match_positions(actual_idx), bg, selected) 558 + } else { 559 + vec![Span::styled( 524 560 entry.label().to_string(), 525 561 Style::default() 526 - .fg(if entry.is_dir() { 527 - theme.ui.toc_primary_active 528 - } else { 529 - theme.ui.toc_primary_inactive 530 - }) 562 + .fg(theme.ui.toc_primary_inactive) 531 563 .bg(bg) 532 564 .add_modifier(if selected { 533 565 Modifier::BOLD 534 566 } else { 535 567 Modifier::empty() 536 568 }), 537 - ), 538 - ])); 569 + )] 570 + }; 571 + let mut spans = vec![Span::styled( 572 + marker, 573 + Style::default() 574 + .fg(theme.ui.toc_accent) 575 + .bg(bg) 576 + .add_modifier(if selected { 577 + Modifier::BOLD 578 + } else { 579 + Modifier::empty() 580 + }), 581 + )]; 582 + spans.extend(label_spans); 583 + lines.push(Line::from(spans)); 539 584 } 540 585 } 541 586 542 - while lines.len() < inner_height.saturating_sub(1) { 587 + while lines.len() < inner_height.saturating_sub(2) { 543 588 lines.push(Line::from("")); 544 589 } 590 + lines.push(Line::from("")); 545 591 lines.push(Line::from(vec![Span::styled( 546 - "Enter open • Backspace up • q quit", 592 + if app.is_fuzzy_file_picker() { 593 + "enter open • type filter • esc clear • ctrl+c quit" 594 + } else { 595 + "enter open • backspace up • ctrl+c quit" 596 + }, 547 597 footer_style.bg(theme.ui.toc_bg), 548 598 )])); 549 599 ··· 559 609 ), 560 610 area, 561 611 ); 612 + } 613 + 614 + fn highlighted_picker_label( 615 + label: &str, 616 + match_positions: &[usize], 617 + bg: Color, 618 + selected: bool, 619 + ) -> Vec<Span<'static>> { 620 + let theme = app_theme(); 621 + let default_style = Style::default() 622 + .fg(theme.ui.toc_primary_inactive) 623 + .bg(bg) 624 + .add_modifier(if selected { 625 + Modifier::BOLD 626 + } else { 627 + Modifier::empty() 628 + }); 629 + let matched_style = Style::default() 630 + .fg(theme.ui.toc_accent) 631 + .bg(bg) 632 + .add_modifier(if selected { 633 + Modifier::BOLD 634 + } else { 635 + Modifier::empty() 636 + }); 637 + 638 + if match_positions.is_empty() { 639 + return vec![Span::styled(label.to_string(), default_style)]; 640 + } 641 + 642 + let match_set = match_positions.iter().copied().collect::<std::collections::BTreeSet<_>>(); 643 + let mut spans = Vec::new(); 644 + let mut buffer = String::new(); 645 + let mut current_matched = None; 646 + 647 + for (idx, ch) in label.chars().enumerate() { 648 + let is_matched = match_set.contains(&idx); 649 + if current_matched == Some(is_matched) || current_matched.is_none() { 650 + buffer.push(ch); 651 + current_matched = Some(is_matched); 652 + continue; 653 + } 654 + 655 + spans.push(Span::styled( 656 + std::mem::take(&mut buffer), 657 + if current_matched == Some(true) { 658 + matched_style 659 + } else { 660 + default_style 661 + }, 662 + )); 663 + buffer.push(ch); 664 + current_matched = Some(is_matched); 665 + } 666 + 667 + if !buffer.is_empty() { 668 + spans.push(Span::styled( 669 + buffer, 670 + if current_matched == Some(true) { 671 + matched_style 672 + } else { 673 + default_style 674 + }, 675 + )); 676 + } 677 + 678 + spans 562 679 } 563 680 564 681 fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
+22 -2
src/runtime.rs
··· 102 102 } 103 103 } else if app.is_file_picker_open() { 104 104 match key.code { 105 - KeyCode::Char('q') => break, 106 105 KeyCode::Char('?') => app.open_help(), 107 106 KeyCode::Enter => { 108 107 state_changed = app.activate_file_picker_selection(ss, themes); 109 108 } 109 + KeyCode::Char('q') if app.is_browser_file_picker() => break, 110 110 KeyCode::Char('j') | KeyCode::Down => app.move_file_picker_down(), 111 111 KeyCode::Char('k') | KeyCode::Up => app.move_file_picker_up(), 112 - KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Left => { 112 + KeyCode::Esc => { 113 + if app.is_browser_file_picker() || app.file_picker_query().is_empty() { 114 + state_changed = false; 115 + } else { 116 + app.clear_file_picker_query(); 117 + } 118 + } 119 + KeyCode::Char('h') | KeyCode::Left if app.is_browser_file_picker() => { 113 120 state_changed = app.open_file_picker_parent(); 121 + } 122 + KeyCode::Backspace if app.is_browser_file_picker() => { 123 + state_changed = app.open_file_picker_parent(); 124 + } 125 + KeyCode::Backspace => app.pop_file_picker_query(), 126 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 127 + break; 128 + } 129 + KeyCode::Char(c) 130 + if app.is_fuzzy_file_picker() 131 + && !key.modifiers.contains(KeyModifiers::CONTROL) => 132 + { 133 + app.push_file_picker_query(c); 114 134 } 115 135 _ => state_changed = false, 116 136 }
+520
src/tests.rs
··· 126 126 } 127 127 128 128 #[test] 129 + fn parse_cli_accepts_picker_on_its_own() { 130 + let args = vec!["leaf".to_string(), "--picker".to_string()]; 131 + let options = parse_cli(&args).unwrap(); 132 + 133 + assert!(options.picker); 134 + assert!(!options.watch); 135 + assert_eq!(options.file_arg, None); 136 + } 137 + 138 + #[test] 139 + fn parse_cli_rejects_picker_with_watch() { 140 + let args = vec![ 141 + "leaf".to_string(), 142 + "--picker".to_string(), 143 + "--watch".to_string(), 144 + ]; 145 + 146 + let err = parse_cli(&args).unwrap_err(); 147 + assert!(err.to_string().contains("--picker cannot be combined")); 148 + } 149 + 150 + #[test] 129 151 fn asset_name_matches_supported_release_targets() { 130 152 assert_eq!(asset_name_for_target("macos", "x86_64"), Some("leaf-macos-x86_64")); 131 153 assert_eq!(asset_name_for_target("macos", "aarch64"), Some("leaf-macos-arm64")); ··· 836 858 let notes_idx = labels.iter().position(|label| *label == "notes/").unwrap(); 837 859 let readme_idx = labels.iter().position(|label| *label == "README.md").unwrap(); 838 860 assert!(notes_idx < readme_idx); 861 + 862 + let _ = fs::remove_dir_all(root); 863 + } 864 + 865 + #[test] 866 + fn fuzzy_file_picker_lists_markdown_files_from_subdirectories() { 867 + let unique = SystemTime::now() 868 + .duration_since(UNIX_EPOCH) 869 + .unwrap() 870 + .as_nanos(); 871 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-test-{unique}")); 872 + let _ = fs::remove_dir_all(&root); 873 + fs::create_dir_all(root.join("docs/nested")).unwrap(); 874 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 875 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 876 + fs::write(root.join("docs/nested/deep.markdown"), "# Deep\n").unwrap(); 877 + fs::write(root.join("ignore.txt"), "nope\n").unwrap(); 878 + 879 + let mut app = App::new_with_source( 880 + Vec::new(), 881 + Vec::new(), 882 + AppConfig { 883 + filename: "picker".to_string(), 884 + source: String::new(), 885 + debug_input: false, 886 + watch: false, 887 + filepath: None, 888 + last_file_state: None, 889 + }, 890 + ); 891 + 892 + assert!(app.open_fuzzy_file_picker(root.clone())); 893 + assert!(app.is_fuzzy_file_picker()); 894 + 895 + let labels: Vec<_> = app 896 + .file_picker_filtered_indices() 897 + .iter() 898 + .map(|idx| app.file_picker_entries()[*idx].label()) 899 + .collect(); 900 + assert!(labels.contains(&"README.md")); 901 + assert!(labels.contains(&"docs/guide.md")); 902 + assert!(labels.contains(&"docs/nested/deep.markdown")); 903 + assert!(!labels.contains(&"ignore.txt")); 904 + 905 + let _ = fs::remove_dir_all(root); 906 + } 907 + 908 + #[test] 909 + fn fuzzy_file_picker_uses_breadth_first_order_with_hidden_first_per_level() { 910 + let unique = SystemTime::now() 911 + .duration_since(UNIX_EPOCH) 912 + .unwrap() 913 + .as_nanos(); 914 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-order-{unique}")); 915 + let _ = fs::remove_dir_all(&root); 916 + fs::create_dir_all(root.join(".private")).unwrap(); 917 + fs::create_dir_all(root.join("docs")).unwrap(); 918 + fs::write(root.join(".draft.md"), "# Hidden\n").unwrap(); 919 + fs::write(root.join(".private/alpha.md"), "# Private\n").unwrap(); 920 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 921 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 922 + 923 + let mut app = App::new_with_source( 924 + Vec::new(), 925 + Vec::new(), 926 + AppConfig { 927 + filename: "picker".to_string(), 928 + source: String::new(), 929 + debug_input: false, 930 + watch: false, 931 + filepath: None, 932 + last_file_state: None, 933 + }, 934 + ); 935 + 936 + assert!(app.open_fuzzy_file_picker(root.clone())); 937 + 938 + let labels: Vec<_> = app 939 + .file_picker_filtered_indices() 940 + .iter() 941 + .map(|idx| app.file_picker_entries()[*idx].label()) 942 + .collect(); 943 + assert_eq!( 944 + labels, 945 + vec![ 946 + ".draft.md", 947 + "README.md", 948 + ".private/alpha.md", 949 + "docs/guide.md", 950 + ] 951 + ); 952 + 953 + let _ = fs::remove_dir_all(root); 954 + } 955 + 956 + #[test] 957 + fn fuzzy_file_picker_uses_breadth_first_file_order() { 958 + let unique = SystemTime::now() 959 + .duration_since(UNIX_EPOCH) 960 + .unwrap() 961 + .as_nanos(); 962 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-bfs-{unique}")); 963 + let _ = fs::remove_dir_all(&root); 964 + fs::create_dir_all(root.join("a/deep")).unwrap(); 965 + fs::create_dir_all(root.join("b")).unwrap(); 966 + fs::write(root.join("z-root.md"), "# Root\n").unwrap(); 967 + fs::write(root.join("a/a-child.md"), "# Child A\n").unwrap(); 968 + fs::write(root.join("b/b-child.md"), "# Child B\n").unwrap(); 969 + fs::write(root.join("a/deep/a-deep.md"), "# Deep\n").unwrap(); 970 + 971 + let mut app = App::new_with_source( 972 + Vec::new(), 973 + Vec::new(), 974 + AppConfig { 975 + filename: "picker".to_string(), 976 + source: String::new(), 977 + debug_input: false, 978 + watch: false, 979 + filepath: None, 980 + last_file_state: None, 981 + }, 982 + ); 983 + 984 + assert!(app.open_fuzzy_file_picker(root.clone())); 985 + 986 + let labels: Vec<_> = app 987 + .file_picker_filtered_indices() 988 + .iter() 989 + .map(|idx| app.file_picker_entries()[*idx].label()) 990 + .collect(); 991 + assert_eq!( 992 + labels, 993 + vec!["z-root.md", "a/a-child.md", "b/b-child.md", "a/deep/a-deep.md"] 994 + ); 995 + 996 + let _ = fs::remove_dir_all(root); 997 + } 998 + 999 + #[test] 1000 + fn fuzzy_file_picker_filters_entries_by_query() { 1001 + let unique = SystemTime::now() 1002 + .duration_since(UNIX_EPOCH) 1003 + .unwrap() 1004 + .as_nanos(); 1005 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-query-{unique}")); 1006 + let _ = fs::remove_dir_all(&root); 1007 + fs::create_dir_all(root.join("docs")).unwrap(); 1008 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 1009 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 1010 + 1011 + let mut app = App::new_with_source( 1012 + Vec::new(), 1013 + Vec::new(), 1014 + AppConfig { 1015 + filename: "picker".to_string(), 1016 + source: String::new(), 1017 + debug_input: false, 1018 + watch: false, 1019 + filepath: None, 1020 + last_file_state: None, 1021 + }, 1022 + ); 1023 + 1024 + assert!(app.open_fuzzy_file_picker(root.clone())); 1025 + app.push_file_picker_query('g'); 1026 + app.push_file_picker_query('u'); 1027 + 1028 + let labels: Vec<_> = app 1029 + .file_picker_filtered_indices() 1030 + .iter() 1031 + .map(|idx| app.file_picker_entries()[*idx].label()) 1032 + .collect(); 1033 + assert_eq!(labels, vec!["docs/guide.md"]); 1034 + 1035 + let _ = fs::remove_dir_all(root); 1036 + } 1037 + 1038 + #[test] 1039 + fn fuzzy_file_picker_does_not_match_directory_segments() { 1040 + let unique = SystemTime::now() 1041 + .duration_since(UNIX_EPOCH) 1042 + .unwrap() 1043 + .as_nanos(); 1044 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-cla-{unique}")); 1045 + let _ = fs::remove_dir_all(&root); 1046 + fs::create_dir_all(root.join(".notes/backup")).unwrap(); 1047 + fs::write(root.join(".notes/backup/PLAN.md"), "# Plan\n").unwrap(); 1048 + fs::write(root.join("claude.md"), "# Claude\n").unwrap(); 1049 + 1050 + let mut app = App::new_with_source( 1051 + Vec::new(), 1052 + Vec::new(), 1053 + AppConfig { 1054 + filename: "picker".to_string(), 1055 + source: String::new(), 1056 + debug_input: false, 1057 + watch: false, 1058 + filepath: None, 1059 + last_file_state: None, 1060 + }, 1061 + ); 1062 + 1063 + assert!(app.open_fuzzy_file_picker(root.clone())); 1064 + app.push_file_picker_query('c'); 1065 + app.push_file_picker_query('l'); 1066 + app.push_file_picker_query('a'); 1067 + 1068 + let labels: Vec<_> = app 1069 + .file_picker_filtered_indices() 1070 + .iter() 1071 + .map(|idx| app.file_picker_entries()[*idx].label()) 1072 + .collect(); 1073 + assert!(labels.contains(&"claude.md")); 1074 + assert!(!labels.contains(&".notes/backup/PLAN.md")); 1075 + 1076 + let _ = fs::remove_dir_all(root); 1077 + } 1078 + 1079 + #[test] 1080 + fn fuzzy_file_picker_tracks_match_positions_for_highlighting() { 1081 + let unique = SystemTime::now() 1082 + .duration_since(UNIX_EPOCH) 1083 + .unwrap() 1084 + .as_nanos(); 1085 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-highlight-{unique}")); 1086 + let _ = fs::remove_dir_all(&root); 1087 + fs::create_dir_all(&root).unwrap(); 1088 + fs::write(root.join("claude.md"), "# Claude\n").unwrap(); 1089 + 1090 + let mut app = App::new_with_source( 1091 + Vec::new(), 1092 + Vec::new(), 1093 + AppConfig { 1094 + filename: "picker".to_string(), 1095 + source: String::new(), 1096 + debug_input: false, 1097 + watch: false, 1098 + filepath: None, 1099 + last_file_state: None, 1100 + }, 1101 + ); 1102 + 1103 + assert!(app.open_fuzzy_file_picker(root.clone())); 1104 + app.push_file_picker_query('c'); 1105 + app.push_file_picker_query('l'); 1106 + app.push_file_picker_query('a'); 1107 + 1108 + let labels: Vec<_> = app 1109 + .file_picker_filtered_indices() 1110 + .iter() 1111 + .map(|idx| app.file_picker_entries()[*idx].label()) 1112 + .collect(); 1113 + assert_eq!(labels, vec!["claude.md"]); 1114 + assert_eq!(app.file_picker_match_positions(0), &[0, 1, 2]); 1115 + 1116 + let _ = fs::remove_dir_all(root); 1117 + } 1118 + 1119 + #[test] 1120 + fn fuzzy_file_picker_prefers_compact_matches() { 1121 + let unique = SystemTime::now() 1122 + .duration_since(UNIX_EPOCH) 1123 + .unwrap() 1124 + .as_nanos(); 1125 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-compact-{unique}")); 1126 + let _ = fs::remove_dir_all(&root); 1127 + fs::create_dir_all(&root).unwrap(); 1128 + fs::write(root.join("case.md"), "# Case\n").unwrap(); 1129 + fs::write(root.join("ciase.md"), "# Ciase\n").unwrap(); 1130 + 1131 + let mut app = App::new_with_source( 1132 + Vec::new(), 1133 + Vec::new(), 1134 + AppConfig { 1135 + filename: "picker".to_string(), 1136 + source: String::new(), 1137 + debug_input: false, 1138 + watch: false, 1139 + filepath: None, 1140 + last_file_state: None, 1141 + }, 1142 + ); 1143 + 1144 + assert!(app.open_fuzzy_file_picker(root.clone())); 1145 + app.push_file_picker_query('c'); 1146 + app.push_file_picker_query('a'); 1147 + 1148 + let labels: Vec<_> = app 1149 + .file_picker_filtered_indices() 1150 + .iter() 1151 + .map(|idx| app.file_picker_entries()[*idx].label()) 1152 + .collect(); 1153 + assert_eq!(labels, vec!["case.md", "ciase.md"]); 1154 + 1155 + let _ = fs::remove_dir_all(root); 1156 + } 1157 + 1158 + #[test] 1159 + fn fuzzy_file_picker_prefers_contiguous_matches_over_earlier_scattered_matches() { 1160 + let unique = SystemTime::now() 1161 + .duration_since(UNIX_EPOCH) 1162 + .unwrap() 1163 + .as_nanos(); 1164 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-contiguous-{unique}")); 1165 + let _ = fs::remove_dir_all(&root); 1166 + fs::create_dir_all(root.join(".notes/todo")).unwrap(); 1167 + fs::create_dir_all(root.join(".notes/tests")).unwrap(); 1168 + fs::write(root.join(".notes/todo/review-chatgpt.md"), "# ChatGPT\n").unwrap(); 1169 + fs::write(root.join(".notes/tests/themes-showcase.md"), "# Showcase\n").unwrap(); 1170 + 1171 + let mut app = App::new_with_source( 1172 + Vec::new(), 1173 + Vec::new(), 1174 + AppConfig { 1175 + filename: "picker".to_string(), 1176 + source: String::new(), 1177 + debug_input: false, 1178 + watch: false, 1179 + filepath: None, 1180 + last_file_state: None, 1181 + }, 1182 + ); 1183 + 1184 + assert!(app.open_fuzzy_file_picker(root.clone())); 1185 + app.push_file_picker_query('c'); 1186 + app.push_file_picker_query('a'); 1187 + 1188 + let labels: Vec<_> = app 1189 + .file_picker_filtered_indices() 1190 + .iter() 1191 + .map(|idx| app.file_picker_entries()[*idx].label()) 1192 + .collect(); 1193 + let showcase_idx = labels 1194 + .iter() 1195 + .position(|label| *label == ".notes/tests/themes-showcase.md") 1196 + .unwrap(); 1197 + let chatgpt_idx = labels 1198 + .iter() 1199 + .position(|label| *label == ".notes/todo/review-chatgpt.md") 1200 + .unwrap(); 1201 + assert!(showcase_idx < chatgpt_idx); 1202 + 1203 + let _ = fs::remove_dir_all(root); 1204 + } 1205 + 1206 + #[test] 1207 + fn fuzzy_file_picker_prefers_filename_prefix_matches() { 1208 + let unique = SystemTime::now() 1209 + .duration_since(UNIX_EPOCH) 1210 + .unwrap() 1211 + .as_nanos(); 1212 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-prefix-{unique}")); 1213 + let _ = fs::remove_dir_all(&root); 1214 + fs::create_dir_all(&root).unwrap(); 1215 + fs::write(root.join("todo-case.md"), "# Todo\n").unwrap(); 1216 + fs::write(root.join("case-study.md"), "# Case\n").unwrap(); 1217 + 1218 + let mut app = App::new_with_source( 1219 + Vec::new(), 1220 + Vec::new(), 1221 + AppConfig { 1222 + filename: "picker".to_string(), 1223 + source: String::new(), 1224 + debug_input: false, 1225 + watch: false, 1226 + filepath: None, 1227 + last_file_state: None, 1228 + }, 1229 + ); 1230 + 1231 + assert!(app.open_fuzzy_file_picker(root.clone())); 1232 + app.push_file_picker_query('c'); 1233 + app.push_file_picker_query('a'); 1234 + 1235 + let labels: Vec<_> = app 1236 + .file_picker_filtered_indices() 1237 + .iter() 1238 + .map(|idx| app.file_picker_entries()[*idx].label()) 1239 + .collect(); 1240 + assert_eq!(labels, vec!["case-study.md", "todo-case.md"]); 1241 + 1242 + let _ = fs::remove_dir_all(root); 1243 + } 1244 + 1245 + #[test] 1246 + fn fuzzy_file_picker_prefers_token_boundary_matches() { 1247 + let unique = SystemTime::now() 1248 + .duration_since(UNIX_EPOCH) 1249 + .unwrap() 1250 + .as_nanos(); 1251 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-boundary-{unique}")); 1252 + let _ = fs::remove_dir_all(&root); 1253 + fs::create_dir_all(&root).unwrap(); 1254 + fs::write(root.join("alpha-case.md"), "# Boundary\n").unwrap(); 1255 + fs::write(root.join("alphacase.md"), "# Plain\n").unwrap(); 1256 + 1257 + let mut app = App::new_with_source( 1258 + Vec::new(), 1259 + Vec::new(), 1260 + AppConfig { 1261 + filename: "picker".to_string(), 1262 + source: String::new(), 1263 + debug_input: false, 1264 + watch: false, 1265 + filepath: None, 1266 + last_file_state: None, 1267 + }, 1268 + ); 1269 + 1270 + assert!(app.open_fuzzy_file_picker(root.clone())); 1271 + app.push_file_picker_query('c'); 1272 + app.push_file_picker_query('a'); 1273 + 1274 + let labels: Vec<_> = app 1275 + .file_picker_filtered_indices() 1276 + .iter() 1277 + .map(|idx| app.file_picker_entries()[*idx].label()) 1278 + .collect(); 1279 + assert_eq!(labels, vec!["alpha-case.md", "alphacase.md"]); 1280 + 1281 + let _ = fs::remove_dir_all(root); 1282 + } 1283 + 1284 + #[test] 1285 + fn fuzzy_file_picker_prefers_shallower_paths_on_equal_scores() { 1286 + let unique = SystemTime::now() 1287 + .duration_since(UNIX_EPOCH) 1288 + .unwrap() 1289 + .as_nanos(); 1290 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-depth-{unique}")); 1291 + let _ = fs::remove_dir_all(&root); 1292 + fs::create_dir_all(root.join("nested/deeper")).unwrap(); 1293 + fs::write(root.join("case.md"), "# Root\n").unwrap(); 1294 + fs::write(root.join("nested/deeper/case.md"), "# Nested\n").unwrap(); 1295 + 1296 + let mut app = App::new_with_source( 1297 + Vec::new(), 1298 + Vec::new(), 1299 + AppConfig { 1300 + filename: "picker".to_string(), 1301 + source: String::new(), 1302 + debug_input: false, 1303 + watch: false, 1304 + filepath: None, 1305 + last_file_state: None, 1306 + }, 1307 + ); 1308 + 1309 + assert!(app.open_fuzzy_file_picker(root.clone())); 1310 + app.push_file_picker_query('c'); 1311 + app.push_file_picker_query('a'); 1312 + app.push_file_picker_query('s'); 1313 + app.push_file_picker_query('e'); 1314 + 1315 + let labels: Vec<_> = app 1316 + .file_picker_filtered_indices() 1317 + .iter() 1318 + .map(|idx| app.file_picker_entries()[*idx].label()) 1319 + .collect(); 1320 + assert_eq!(labels, vec!["case.md", "nested/deeper/case.md"]); 1321 + 1322 + let _ = fs::remove_dir_all(root); 1323 + } 1324 + 1325 + #[test] 1326 + fn fuzzy_file_picker_allows_q_in_query() { 1327 + let unique = SystemTime::now() 1328 + .duration_since(UNIX_EPOCH) 1329 + .unwrap() 1330 + .as_nanos(); 1331 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-q-{unique}")); 1332 + let _ = fs::remove_dir_all(&root); 1333 + fs::create_dir_all(&root).unwrap(); 1334 + fs::write(root.join("query.md"), "# Query\n").unwrap(); 1335 + 1336 + let mut app = App::new_with_source( 1337 + Vec::new(), 1338 + Vec::new(), 1339 + AppConfig { 1340 + filename: "picker".to_string(), 1341 + source: String::new(), 1342 + debug_input: false, 1343 + watch: false, 1344 + filepath: None, 1345 + last_file_state: None, 1346 + }, 1347 + ); 1348 + 1349 + assert!(app.open_fuzzy_file_picker(root.clone())); 1350 + app.push_file_picker_query('q'); 1351 + assert_eq!(app.file_picker_query(), "q"); 1352 + 1353 + let labels: Vec<_> = app 1354 + .file_picker_filtered_indices() 1355 + .iter() 1356 + .map(|idx| app.file_picker_entries()[*idx].label()) 1357 + .collect(); 1358 + assert_eq!(labels, vec!["query.md"]); 839 1359 840 1360 let _ = fs::remove_dir_all(root); 841 1361 }