Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

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

chore: split app file picker

RivoLink e3149e23 0c2dbdbf

+835 -823
+819
src/app/file_picker.rs
··· 1 + use super::App; 2 + use std::{ 3 + fs, 4 + path::PathBuf, 5 + sync::mpsc::{self, Receiver, TryRecvError}, 6 + thread, 7 + time::{Duration, Instant}, 8 + }; 9 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 10 + 11 + const MAX_FUZZY_PICKER_DIRS_VISITED: usize = 5_000; 12 + const MAX_FUZZY_PICKER_FILES_INDEXED: usize = 10_000; 13 + const MAX_FUZZY_PICKER_INDEX_DURATION: Duration = Duration::from_secs(5); 14 + const IGNORED_FUZZY_PICKER_DIRS: &[&str] = &[ 15 + ".git", 16 + "node_modules", 17 + "target", 18 + ".venv", 19 + "venv", 20 + "vendor", 21 + "var", 22 + "dist", 23 + "build", 24 + ".next", 25 + ".cache", 26 + ]; 27 + 28 + #[derive(Clone, Debug, PartialEq, Eq)] 29 + pub(crate) struct FilePickerEntry { 30 + label: String, 31 + path: PathBuf, 32 + label_lower: String, 33 + file_name: String, 34 + file_name_lower: String, 35 + file_name_offset: usize, 36 + path_depth: usize, 37 + } 38 + 39 + impl FilePickerEntry { 40 + fn new(label: String, path: PathBuf) -> Self { 41 + let file_name = Self::file_name_component(&label).to_string(); 42 + let file_name_offset = label 43 + .rfind(std::path::MAIN_SEPARATOR) 44 + .map(|idx| label[..idx + 1].chars().count()) 45 + .unwrap_or(0); 46 + let path_depth = label.matches(std::path::MAIN_SEPARATOR).count(); 47 + 48 + Self { 49 + label_lower: label.to_lowercase(), 50 + file_name_lower: file_name.to_lowercase(), 51 + label, 52 + path, 53 + file_name, 54 + file_name_offset, 55 + path_depth, 56 + } 57 + } 58 + 59 + pub(crate) fn label(&self) -> &str { 60 + &self.label 61 + } 62 + 63 + fn label_lower(&self) -> &str { 64 + &self.label_lower 65 + } 66 + 67 + fn file_name_lower(&self) -> &str { 68 + &self.file_name_lower 69 + } 70 + 71 + fn file_name_offset(&self) -> usize { 72 + self.file_name_offset 73 + } 74 + 75 + fn path_depth(&self) -> usize { 76 + self.path_depth 77 + } 78 + 79 + fn file_name_component(path: &str) -> &str { 80 + path.rsplit(std::path::MAIN_SEPARATOR) 81 + .next() 82 + .unwrap_or(path) 83 + } 84 + 85 + fn is_dir_like(&self) -> bool { 86 + self.label == ".." || self.label.ends_with('/') 87 + } 88 + } 89 + 90 + pub(crate) struct FilePickerState { 91 + pub(super) open: bool, 92 + pub(super) mode: FilePickerMode, 93 + pub(super) dir: PathBuf, 94 + pub(super) entries: Vec<FilePickerEntry>, 95 + pub(super) filtered: Vec<usize>, 96 + pub(super) match_positions: Vec<Vec<usize>>, 97 + pub(super) index: usize, 98 + pub(super) query: String, 99 + pub(super) truncation: Option<PickerIndexTruncation>, 100 + } 101 + 102 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 103 + pub(crate) enum FilePickerMode { 104 + Browser, 105 + Fuzzy, 106 + } 107 + 108 + #[derive(Clone, Debug, PartialEq, Eq)] 109 + pub(crate) enum PendingPicker { 110 + None, 111 + Browser(PathBuf), 112 + Fuzzy(PathBuf), 113 + } 114 + 115 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 116 + pub(crate) enum PickerIndexTruncation { 117 + Directory, 118 + File, 119 + Time, 120 + } 121 + 122 + pub(crate) struct PickerIndexResult { 123 + pub(crate) entries: Vec<FilePickerEntry>, 124 + pub(crate) truncated: Option<PickerIndexTruncation>, 125 + } 126 + 127 + pub(crate) enum PickerLoadState { 128 + Idle, 129 + Loading { 130 + mode: FilePickerMode, 131 + dir: PathBuf, 132 + started_at: Instant, 133 + receiver: Receiver<std::io::Result<PickerIndexResult>>, 134 + pending_result: Option<std::io::Result<PickerIndexResult>>, 135 + }, 136 + Failed { 137 + mode: FilePickerMode, 138 + dir: PathBuf, 139 + message: String, 140 + }, 141 + } 142 + 143 + impl App { 144 + pub(super) fn min_picker_loading_duration() -> Duration { 145 + Duration::from_millis(500) 146 + } 147 + 148 + fn is_markdown_path(path: &std::path::Path) -> bool { 149 + matches!( 150 + path.extension().and_then(|ext| ext.to_str()), 151 + Some("md" | "markdown" | "mdown" | "mkd") 152 + ) 153 + } 154 + 155 + fn build_file_picker_entries(dir: &std::path::Path) -> std::io::Result<Vec<FilePickerEntry>> { 156 + let mut entries = Vec::new(); 157 + 158 + if let Some(parent) = dir.parent() { 159 + entries.push(FilePickerEntry::new("..".to_string(), parent.to_path_buf())); 160 + } 161 + 162 + let mut dirs = Vec::new(); 163 + let mut files = Vec::new(); 164 + for entry in fs::read_dir(dir)? { 165 + let entry = entry?; 166 + let path = entry.path(); 167 + let file_type = match entry.file_type() { 168 + Ok(file_type) => file_type, 169 + Err(_) => continue, 170 + }; 171 + let name = entry.file_name().to_string_lossy().to_string(); 172 + 173 + if file_type.is_dir() { 174 + dirs.push(FilePickerEntry::new(format!("{name}/"), path)); 175 + } else if file_type.is_file() && Self::is_markdown_path(&path) { 176 + files.push(FilePickerEntry::new(name, path)); 177 + } 178 + } 179 + 180 + dirs.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 181 + files.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 182 + entries.extend(dirs); 183 + entries.extend(files); 184 + Ok(entries) 185 + } 186 + 187 + fn is_ignored_fuzzy_picker_dir_name(name: &str) -> bool { 188 + IGNORED_FUZZY_PICKER_DIRS.contains(&name) 189 + } 190 + 191 + fn fuzzy_directory_sort_key(root: &std::path::Path, path: &std::path::Path) -> (bool, String) { 192 + let label = path 193 + .strip_prefix(root) 194 + .unwrap_or(path) 195 + .display() 196 + .to_string(); 197 + ( 198 + !label 199 + .split(std::path::MAIN_SEPARATOR) 200 + .next() 201 + .unwrap_or(&label) 202 + .starts_with('.'), 203 + label.to_lowercase(), 204 + ) 205 + } 206 + 207 + fn build_fuzzy_file_picker_entries( 208 + dir: &std::path::Path, 209 + ) -> std::io::Result<PickerIndexResult> { 210 + let mut entries = Vec::new(); 211 + let mut stack = vec![dir.to_path_buf()]; 212 + let started_at = Instant::now(); 213 + let mut dirs_visited = 0usize; 214 + let mut files_indexed = 0usize; 215 + let mut truncated = None; 216 + 217 + while let Some(current_dir) = stack.pop() { 218 + if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 219 + truncated = Some(PickerIndexTruncation::Time); 220 + break; 221 + } 222 + if dirs_visited >= MAX_FUZZY_PICKER_DIRS_VISITED { 223 + truncated = Some(PickerIndexTruncation::Directory); 224 + break; 225 + } 226 + dirs_visited += 1; 227 + 228 + let mut dirs = Vec::new(); 229 + let mut files = Vec::new(); 230 + 231 + let read_dir = match fs::read_dir(&current_dir) { 232 + Ok(read_dir) => read_dir, 233 + Err(err) => { 234 + if current_dir == dir { 235 + return Err(err); 236 + } 237 + continue; 238 + } 239 + }; 240 + 241 + for entry in read_dir { 242 + if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 243 + truncated = Some(PickerIndexTruncation::Time); 244 + break; 245 + } 246 + let entry = match entry { 247 + Ok(entry) => entry, 248 + Err(_) => continue, 249 + }; 250 + let path = entry.path(); 251 + let file_type = match entry.file_type() { 252 + Ok(file_type) => file_type, 253 + Err(_) => continue, 254 + }; 255 + 256 + if file_type.is_dir() { 257 + let name = entry.file_name(); 258 + if Self::is_ignored_fuzzy_picker_dir_name(name.to_string_lossy().as_ref()) { 259 + continue; 260 + } 261 + dirs.push(path); 262 + continue; 263 + } 264 + 265 + if file_type.is_file() && Self::is_markdown_path(&path) { 266 + if files_indexed >= MAX_FUZZY_PICKER_FILES_INDEXED { 267 + truncated = Some(PickerIndexTruncation::File); 268 + break; 269 + } 270 + let label = path 271 + .strip_prefix(dir) 272 + .unwrap_or(&path) 273 + .display() 274 + .to_string(); 275 + files.push(FilePickerEntry::new(label, path)); 276 + files_indexed += 1; 277 + } 278 + } 279 + 280 + files.sort_by(|left, right| { 281 + Self::fuzzy_entry_sort_key(left).cmp(&Self::fuzzy_entry_sort_key(right)) 282 + }); 283 + dirs.sort_by_key(|path| Self::fuzzy_directory_sort_key(dir, path)); 284 + 285 + entries.extend(files); 286 + if truncated.is_some() { 287 + break; 288 + } 289 + dirs.reverse(); 290 + stack.extend(dirs); 291 + } 292 + 293 + Ok(PickerIndexResult { entries, truncated }) 294 + } 295 + 296 + fn fuzzy_entry_sort_key(entry: &FilePickerEntry) -> (bool, &str) { 297 + let first_component = entry 298 + .label 299 + .split(std::path::MAIN_SEPARATOR) 300 + .next() 301 + .unwrap_or(entry.label()); 302 + (!first_component.starts_with('.'), entry.label_lower()) 303 + } 304 + 305 + fn fuzzy_component_match(candidate: &str, query: &str) -> Option<(usize, Vec<usize>)> { 306 + if let Some(start) = candidate.find(query) { 307 + let start_chars = candidate[..start].chars().count(); 308 + let query_len = query.chars().count(); 309 + let len_diff = candidate.chars().count().saturating_sub(query_len); 310 + let prefix_bonus = usize::from(start_chars == 0).saturating_mul(80); 311 + let boundary_bonus = 312 + usize::from(Self::is_match_boundary(candidate, start_chars)).saturating_mul(40); 313 + let score = start_chars 314 + .saturating_mul(10) 315 + .saturating_add(len_diff) 316 + .saturating_sub(prefix_bonus) 317 + .saturating_sub(boundary_bonus); 318 + let positions = (start_chars..start_chars + query_len).collect::<Vec<_>>(); 319 + return Some((score, positions)); 320 + } 321 + 322 + let mut search_from = 0usize; 323 + let mut positions = Vec::with_capacity(query.len()); 324 + 325 + for needle in query.chars() { 326 + let found = candidate[search_from..] 327 + .char_indices() 328 + .find(|(_, ch)| *ch == needle) 329 + .map(|(idx, _)| search_from + idx)?; 330 + let char_pos = candidate[..found].chars().count(); 331 + positions.push(char_pos); 332 + search_from = found + needle.len_utf8(); 333 + } 334 + 335 + let first = *positions.first()?; 336 + let last = *positions.last()?; 337 + let span = last.saturating_sub(first); 338 + let gaps = positions 339 + .windows(2) 340 + .map(|window| window[1].saturating_sub(window[0]).saturating_sub(1)) 341 + .sum::<usize>(); 342 + let len_diff = candidate 343 + .chars() 344 + .count() 345 + .saturating_sub(query.chars().count()); 346 + let prefix_bonus = usize::from(first == 0).saturating_mul(80); 347 + let boundary_bonus = 348 + usize::from(Self::is_match_boundary(candidate, first)).saturating_mul(40); 349 + let score = 1_000usize 350 + .saturating_add(gaps.saturating_mul(120)) 351 + .saturating_add(first.saturating_mul(10)) 352 + .saturating_add(span) 353 + .saturating_add(len_diff) 354 + .saturating_sub(prefix_bonus) 355 + .saturating_sub(boundary_bonus); 356 + Some((score, positions)) 357 + } 358 + 359 + fn is_match_boundary(candidate: &str, char_pos: usize) -> bool { 360 + if char_pos == 0 { 361 + return true; 362 + } 363 + 364 + candidate 365 + .chars() 366 + .nth(char_pos.saturating_sub(1)) 367 + .is_some_and(|ch| matches!(ch, '-' | '_' | '.' | ' ')) 368 + } 369 + 370 + fn fuzzy_match(entry: &FilePickerEntry, query: &str) -> Option<(usize, Vec<usize>)> { 371 + if query.is_empty() { 372 + return Some((0, Vec::new())); 373 + } 374 + 375 + let (score, positions) = Self::fuzzy_component_match(entry.file_name_lower(), query)?; 376 + Some(( 377 + score, 378 + positions 379 + .into_iter() 380 + .map(|position| entry.file_name_offset() + position) 381 + .collect(), 382 + )) 383 + } 384 + 385 + pub(super) fn refresh_file_picker_matches(&mut self) { 386 + if self.is_browser_file_picker() { 387 + self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 388 + self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 389 + self.file_picker.index = self 390 + .file_picker 391 + .index 392 + .min(self.file_picker.filtered.len().saturating_sub(1)); 393 + return; 394 + } 395 + 396 + let query = self.file_picker.query.trim().to_lowercase(); 397 + if query.is_empty() { 398 + self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 399 + self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 400 + self.file_picker.index = self 401 + .file_picker 402 + .index 403 + .min(self.file_picker.filtered.len().saturating_sub(1)); 404 + return; 405 + } 406 + 407 + let mut filtered = self 408 + .file_picker 409 + .entries 410 + .iter() 411 + .enumerate() 412 + .filter_map(|(idx, entry)| { 413 + Self::fuzzy_match(entry, &query).map(|(score, positions)| { 414 + ( 415 + idx, 416 + score, 417 + entry.path_depth(), 418 + entry.file_name_lower(), 419 + entry.label_lower(), 420 + positions, 421 + ) 422 + }) 423 + }) 424 + .collect::<Vec<_>>(); 425 + 426 + filtered.sort_by( 427 + |(left_idx, left_score, left_depth, left_name, left_label, _), 428 + (right_idx, right_score, right_depth, right_name, right_label, _)| { 429 + left_score 430 + .cmp(right_score) 431 + .then_with(|| left_depth.cmp(right_depth)) 432 + .then_with(|| left_name.cmp(right_name)) 433 + .then_with(|| left_label.cmp(right_label)) 434 + .then_with(|| left_idx.cmp(right_idx)) 435 + }, 436 + ); 437 + 438 + self.file_picker.filtered = filtered.iter().map(|(idx, ..)| *idx).collect(); 439 + self.file_picker.match_positions = filtered 440 + .into_iter() 441 + .map(|(_, _, _, _, _, positions)| positions) 442 + .collect(); 443 + if self.file_picker.filtered.is_empty() 444 + || self.file_picker.index >= self.file_picker.filtered.len() 445 + { 446 + self.file_picker.index = 0; 447 + } 448 + } 449 + 450 + fn selected_file_picker_entry(&self) -> Option<&FilePickerEntry> { 451 + let idx = *self.file_picker.filtered.get(self.file_picker.index)?; 452 + self.file_picker.entries.get(idx) 453 + } 454 + 455 + pub(crate) fn open_file_picker(&mut self, dir: PathBuf) -> bool { 456 + self.open_file_picker_with_mode(dir, FilePickerMode::Browser) 457 + } 458 + 459 + #[cfg(test)] 460 + pub(crate) fn open_fuzzy_file_picker(&mut self, dir: PathBuf) -> bool { 461 + self.open_file_picker_with_mode(dir, FilePickerMode::Fuzzy) 462 + } 463 + 464 + pub(crate) fn queue_file_picker(&mut self, dir: PathBuf) { 465 + self.pending_picker = PendingPicker::Browser(dir); 466 + } 467 + 468 + pub(crate) fn queue_fuzzy_file_picker(&mut self, dir: PathBuf) { 469 + self.pending_picker = PendingPicker::Fuzzy(dir); 470 + } 471 + 472 + pub(crate) fn has_pending_picker(&self) -> bool { 473 + !matches!(self.pending_picker, PendingPicker::None) 474 + } 475 + 476 + pub(crate) fn start_pending_picker_loading(&mut self) -> bool { 477 + if !self.has_pending_picker() || !matches!(self.picker_load_state, PickerLoadState::Idle) { 478 + return false; 479 + } 480 + 481 + let pending = std::mem::replace(&mut self.pending_picker, PendingPicker::None); 482 + let (mode, dir) = match pending { 483 + PendingPicker::Browser(dir) => (FilePickerMode::Browser, dir), 484 + PendingPicker::Fuzzy(dir) => (FilePickerMode::Fuzzy, dir), 485 + PendingPicker::None => return false, 486 + }; 487 + 488 + let worker_dir = dir.clone(); 489 + let (tx, rx) = mpsc::channel(); 490 + crate::runtime::debug_log( 491 + self.debug_input, 492 + &format!("picker_loading spawn mode={mode:?} dir={}", dir.display()), 493 + ); 494 + thread::spawn(move || { 495 + let result = match mode { 496 + FilePickerMode::Browser => { 497 + Self::build_file_picker_entries(&worker_dir).map(|entries| PickerIndexResult { 498 + entries, 499 + truncated: None, 500 + }) 501 + } 502 + FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&worker_dir), 503 + }; 504 + let _ = tx.send(result); 505 + }); 506 + 507 + self.picker_load_state = PickerLoadState::Loading { 508 + mode, 509 + dir, 510 + started_at: Instant::now(), 511 + receiver: rx, 512 + pending_result: None, 513 + }; 514 + true 515 + } 516 + 517 + pub(crate) fn is_picker_loading(&self) -> bool { 518 + matches!(self.picker_load_state, PickerLoadState::Loading { .. }) 519 + } 520 + 521 + pub(crate) fn is_picker_load_failed(&self) -> bool { 522 + matches!(self.picker_load_state, PickerLoadState::Failed { .. }) 523 + } 524 + 525 + pub(crate) fn pending_picker_mode(&self) -> Option<FilePickerMode> { 526 + match &self.picker_load_state { 527 + PickerLoadState::Loading { mode, .. } | PickerLoadState::Failed { mode, .. } => { 528 + Some(*mode) 529 + } 530 + PickerLoadState::Idle => match self.pending_picker { 531 + PendingPicker::Browser(..) => Some(FilePickerMode::Browser), 532 + PendingPicker::Fuzzy(..) => Some(FilePickerMode::Fuzzy), 533 + PendingPicker::None => None, 534 + }, 535 + } 536 + } 537 + 538 + pub(crate) fn pending_picker_dir(&self) -> Option<&std::path::Path> { 539 + match &self.picker_load_state { 540 + PickerLoadState::Loading { dir, .. } | PickerLoadState::Failed { dir, .. } => { 541 + Some(dir.as_path()) 542 + } 543 + PickerLoadState::Idle => match &self.pending_picker { 544 + PendingPicker::Browser(dir) | PendingPicker::Fuzzy(dir) => Some(dir.as_path()), 545 + PendingPicker::None => None, 546 + }, 547 + } 548 + } 549 + 550 + pub(crate) fn picker_load_error(&self) -> Option<&str> { 551 + match &self.picker_load_state { 552 + PickerLoadState::Failed { message, .. } => Some(message.as_str()), 553 + PickerLoadState::Idle | PickerLoadState::Loading { .. } => None, 554 + } 555 + } 556 + 557 + fn install_loaded_file_picker( 558 + &mut self, 559 + dir: PathBuf, 560 + mode: FilePickerMode, 561 + result: PickerIndexResult, 562 + ) -> bool { 563 + self.file_picker.open = true; 564 + self.file_picker.mode = mode; 565 + self.file_picker.dir = dir; 566 + self.file_picker.entries = result.entries; 567 + self.file_picker.query.clear(); 568 + self.file_picker.index = 0; 569 + self.file_picker.truncation = if mode == FilePickerMode::Fuzzy { 570 + result.truncated 571 + } else { 572 + None 573 + }; 574 + self.refresh_file_picker_matches(); 575 + true 576 + } 577 + 578 + pub(crate) fn poll_picker_loading(&mut self) -> bool { 579 + let state = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle); 580 + match state { 581 + PickerLoadState::Loading { 582 + mode, 583 + dir, 584 + started_at, 585 + receiver, 586 + mut pending_result, 587 + } => { 588 + if pending_result.is_none() { 589 + pending_result = match receiver.try_recv() { 590 + Ok(result) => { 591 + crate::runtime::debug_log( 592 + self.debug_input, 593 + &format!( 594 + "picker_loading worker_finished mode={mode:?} dir={}", 595 + dir.display() 596 + ), 597 + ); 598 + Some(result) 599 + } 600 + Err(TryRecvError::Empty) => None, 601 + Err(TryRecvError::Disconnected) => Some(Err(std::io::Error::other( 602 + "Picker loading worker disconnected", 603 + ))), 604 + }; 605 + } 606 + 607 + if started_at.elapsed() < Self::min_picker_loading_duration() { 608 + self.picker_load_state = PickerLoadState::Loading { 609 + mode, 610 + dir, 611 + started_at, 612 + receiver, 613 + pending_result, 614 + }; 615 + return false; 616 + } 617 + 618 + match pending_result { 619 + Some(Ok(result)) => { 620 + crate::runtime::debug_log( 621 + self.debug_input, 622 + &format!( 623 + "picker_loading install mode={mode:?} dir={} entries={}", 624 + dir.display(), 625 + result.entries.len() 626 + ), 627 + ); 628 + self.install_loaded_file_picker(dir, mode, result) 629 + } 630 + Some(Err(err)) => { 631 + crate::runtime::debug_log( 632 + self.debug_input, 633 + &format!( 634 + "picker_loading failed mode={mode:?} dir={} error={}", 635 + dir.display(), 636 + err 637 + ), 638 + ); 639 + self.picker_load_state = PickerLoadState::Failed { 640 + mode, 641 + dir, 642 + message: err.to_string(), 643 + }; 644 + true 645 + } 646 + None => { 647 + self.picker_load_state = PickerLoadState::Loading { 648 + mode, 649 + dir, 650 + started_at, 651 + receiver, 652 + pending_result: None, 653 + }; 654 + false 655 + } 656 + } 657 + } 658 + PickerLoadState::Failed { .. } => { 659 + self.picker_load_state = state; 660 + false 661 + } 662 + PickerLoadState::Idle => { 663 + self.picker_load_state = PickerLoadState::Idle; 664 + false 665 + } 666 + } 667 + } 668 + 669 + #[cfg(test)] 670 + pub(crate) fn age_picker_loading_by(&mut self, duration: Duration) { 671 + if let PickerLoadState::Loading { 672 + mode, 673 + dir, 674 + started_at, 675 + receiver, 676 + pending_result, 677 + } = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle) 678 + { 679 + let adjusted = started_at.checked_sub(duration).unwrap_or(started_at); 680 + self.picker_load_state = PickerLoadState::Loading { 681 + mode, 682 + dir, 683 + started_at: adjusted, 684 + receiver, 685 + pending_result, 686 + }; 687 + } 688 + } 689 + 690 + fn open_file_picker_with_mode(&mut self, dir: PathBuf, mode: FilePickerMode) -> bool { 691 + let result = match mode { 692 + FilePickerMode::Browser => { 693 + Self::build_file_picker_entries(&dir).map(|entries| PickerIndexResult { 694 + entries, 695 + truncated: None, 696 + }) 697 + } 698 + FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&dir), 699 + }; 700 + 701 + match result { 702 + Ok(result) => self.install_loaded_file_picker(dir, mode, result), 703 + Err(_) => false, 704 + } 705 + } 706 + 707 + pub(crate) fn is_fuzzy_file_picker(&self) -> bool { 708 + self.file_picker.mode == FilePickerMode::Fuzzy 709 + } 710 + 711 + pub(crate) fn is_browser_file_picker(&self) -> bool { 712 + self.file_picker.mode == FilePickerMode::Browser 713 + } 714 + 715 + pub(crate) fn is_file_picker_open(&self) -> bool { 716 + self.file_picker.open 717 + } 718 + 719 + pub(crate) fn file_picker_dir(&self) -> &std::path::Path { 720 + &self.file_picker.dir 721 + } 722 + 723 + pub(crate) fn file_picker_entries(&self) -> &[FilePickerEntry] { 724 + &self.file_picker.entries 725 + } 726 + 727 + pub(crate) fn file_picker_filtered_indices(&self) -> &[usize] { 728 + &self.file_picker.filtered 729 + } 730 + 731 + pub(crate) fn file_picker_match_positions(&self, filtered_idx: usize) -> &[usize] { 732 + self.file_picker 733 + .match_positions 734 + .get(filtered_idx) 735 + .map(Vec::as_slice) 736 + .unwrap_or(&[]) 737 + } 738 + 739 + pub(crate) fn file_picker_index(&self) -> usize { 740 + self.file_picker.index 741 + } 742 + 743 + pub(crate) fn file_picker_query(&self) -> &str { 744 + &self.file_picker.query 745 + } 746 + 747 + pub(crate) fn file_picker_truncation(&self) -> Option<PickerIndexTruncation> { 748 + self.file_picker.truncation 749 + } 750 + 751 + pub(crate) fn move_file_picker_up(&mut self) { 752 + let total = self.file_picker.filtered.len(); 753 + if total == 0 { 754 + return; 755 + } 756 + if self.file_picker.index == 0 { 757 + self.file_picker.index = total - 1; 758 + } else { 759 + self.file_picker.index -= 1; 760 + } 761 + } 762 + 763 + pub(crate) fn move_file_picker_down(&mut self) { 764 + let total = self.file_picker.filtered.len(); 765 + if total == 0 { 766 + return; 767 + } 768 + self.file_picker.index = (self.file_picker.index + 1) % total; 769 + } 770 + 771 + pub(crate) fn push_file_picker_query(&mut self, ch: char) { 772 + if self.is_browser_file_picker() { 773 + return; 774 + } 775 + self.file_picker.query.push(ch); 776 + self.refresh_file_picker_matches(); 777 + } 778 + 779 + pub(crate) fn pop_file_picker_query(&mut self) { 780 + if self.is_browser_file_picker() { 781 + return; 782 + } 783 + self.file_picker.query.pop(); 784 + self.refresh_file_picker_matches(); 785 + } 786 + 787 + pub(crate) fn clear_file_picker_query(&mut self) { 788 + if self.is_browser_file_picker() { 789 + return; 790 + } 791 + self.file_picker.query.clear(); 792 + self.refresh_file_picker_matches(); 793 + } 794 + 795 + pub(crate) fn open_file_picker_parent(&mut self) -> bool { 796 + if self.is_fuzzy_file_picker() { 797 + return false; 798 + } 799 + let Some(parent) = self.file_picker.dir.parent() else { 800 + return false; 801 + }; 802 + self.open_file_picker(parent.to_path_buf()) 803 + } 804 + 805 + pub(crate) fn activate_file_picker_selection( 806 + &mut self, 807 + ss: &SyntaxSet, 808 + themes: &ThemeSet, 809 + ) -> bool { 810 + let Some(entry) = self.selected_file_picker_entry().cloned() else { 811 + return false; 812 + }; 813 + if self.is_browser_file_picker() && entry.is_dir_like() { 814 + self.open_file_picker(entry.path) 815 + } else { 816 + self.load_path(entry.path, ss, themes) 817 + } 818 + } 819 + }
+16 -823
src/app/mod.rs
··· 12 12 }; 13 13 use ratatui::text::Line; 14 14 use std::{ 15 - fs, 16 15 path::PathBuf, 17 - sync::mpsc::{self, Receiver, TryRecvError}, 18 - thread, 19 16 time::{Duration, Instant, SystemTime}, 20 17 }; 21 18 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; ··· 23 20 pub(super) mod search; 24 21 pub(crate) use search::SearchState; 25 22 26 - const MAX_FUZZY_PICKER_DIRS_VISITED: usize = 5_000; 27 - const MAX_FUZZY_PICKER_FILES_INDEXED: usize = 10_000; 28 - const MAX_FUZZY_PICKER_INDEX_DURATION: Duration = Duration::from_secs(5); 29 - const IGNORED_FUZZY_PICKER_DIRS: &[&str] = &[ 30 - ".git", 31 - "node_modules", 32 - "target", 33 - ".venv", 34 - "venv", 35 - "vendor", 36 - "var", 37 - "dist", 38 - "build", 39 - ".next", 40 - ".cache", 41 - ]; 23 + pub(crate) mod file_picker; 24 + pub(crate) use file_picker::{FilePickerMode, FilePickerState, PickerIndexTruncation}; 25 + use file_picker::{PendingPicker, PickerLoadState}; 42 26 43 27 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 44 28 pub(crate) struct FileState { ··· 72 56 toc: Vec<TocEntry>, 73 57 } 74 58 75 - #[derive(Clone, Debug, PartialEq, Eq)] 76 - pub(crate) struct FilePickerEntry { 77 - label: String, 78 - path: PathBuf, 79 - label_lower: String, 80 - file_name: String, 81 - file_name_lower: String, 82 - file_name_offset: usize, 83 - path_depth: usize, 84 - } 85 - 86 - impl FilePickerEntry { 87 - fn new(label: String, path: PathBuf) -> Self { 88 - let file_name = Self::file_name_component(&label).to_string(); 89 - let file_name_offset = label 90 - .rfind(std::path::MAIN_SEPARATOR) 91 - .map(|idx| label[..idx + 1].chars().count()) 92 - .unwrap_or(0); 93 - let path_depth = label.matches(std::path::MAIN_SEPARATOR).count(); 94 - 95 - Self { 96 - label_lower: label.to_lowercase(), 97 - file_name_lower: file_name.to_lowercase(), 98 - label, 99 - path, 100 - file_name, 101 - file_name_offset, 102 - path_depth, 103 - } 104 - } 105 - 106 - pub(crate) fn label(&self) -> &str { 107 - &self.label 108 - } 109 - 110 - fn label_lower(&self) -> &str { 111 - &self.label_lower 112 - } 113 - 114 - fn file_name_lower(&self) -> &str { 115 - &self.file_name_lower 116 - } 117 - 118 - fn file_name_offset(&self) -> usize { 119 - self.file_name_offset 120 - } 121 - 122 - fn path_depth(&self) -> usize { 123 - self.path_depth 124 - } 125 - 126 - fn file_name_component(path: &str) -> &str { 127 - path.rsplit(std::path::MAIN_SEPARATOR) 128 - .next() 129 - .unwrap_or(path) 130 - } 131 - 132 - fn is_dir_like(&self) -> bool { 133 - self.label == ".." || self.label.ends_with('/') 134 - } 135 - } 136 - 137 - pub(crate) struct FilePickerState { 138 - open: bool, 139 - mode: FilePickerMode, 140 - dir: PathBuf, 141 - entries: Vec<FilePickerEntry>, 142 - filtered: Vec<usize>, 143 - match_positions: Vec<Vec<usize>>, 144 - index: usize, 145 - query: String, 146 - truncation: Option<PickerIndexTruncation>, 147 - } 148 - 149 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 150 - pub(crate) enum FilePickerMode { 151 - Browser, 152 - Fuzzy, 153 - } 154 - 155 - #[derive(Clone, Debug, PartialEq, Eq)] 156 - enum PendingPicker { 157 - None, 158 - Browser(PathBuf), 159 - Fuzzy(PathBuf), 160 - } 161 - 162 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 163 - pub(crate) enum PickerIndexTruncation { 164 - Directory, 165 - File, 166 - Time, 167 - } 168 - 169 - struct PickerIndexResult { 170 - entries: Vec<FilePickerEntry>, 171 - truncated: Option<PickerIndexTruncation>, 172 - } 173 - 174 - enum PickerLoadState { 175 - Idle, 176 - Loading { 177 - mode: FilePickerMode, 178 - dir: PathBuf, 179 - started_at: Instant, 180 - receiver: Receiver<std::io::Result<PickerIndexResult>>, 181 - pending_result: Option<std::io::Result<PickerIndexResult>>, 182 - }, 183 - Failed { 184 - mode: FilePickerMode, 185 - dir: PathBuf, 186 - message: String, 187 - }, 188 - } 189 - 190 59 pub(crate) struct ThemePickerState { 191 60 open: bool, 192 61 index: usize, ··· 212 81 toc_visible: bool, 213 82 pub(super) search: SearchState, 214 83 pub(super) debug_input: bool, 215 - filename: String, 216 - source: String, 84 + pub(super) filename: String, 85 + pub(super) source: String, 217 86 watch: bool, 218 - filepath: Option<PathBuf>, 219 - last_file_state: Option<FileState>, 220 - last_content_hash: u64, 221 - last_hash_check: Option<Instant>, 222 - reload_flash: Option<Instant>, 87 + pub(super) filepath: Option<PathBuf>, 88 + pub(super) last_file_state: Option<FileState>, 89 + pub(super) last_content_hash: u64, 90 + pub(super) last_hash_check: Option<Instant>, 91 + pub(super) reload_flash: Option<Instant>, 223 92 highlighted_line_cache: Option<(usize, Line<'static>)>, 224 93 toc_display_lines: Vec<Line<'static>>, 225 94 toc_header_line: Line<'static>, 226 95 toc_active_idx: Option<usize>, 227 96 status_line: Line<'static>, 228 97 status_cache_key: Option<StatusCacheKey>, 229 - help_open: bool, 230 - file_picker: FilePickerState, 231 - pending_picker: PendingPicker, 232 - picker_load_state: PickerLoadState, 233 - theme_picker: ThemePickerState, 234 - render_width: usize, 98 + pub(super) help_open: bool, 99 + pub(super) file_picker: FilePickerState, 100 + pub(super) pending_picker: PendingPicker, 101 + pub(super) picker_load_state: PickerLoadState, 102 + pub(super) theme_picker: ThemePickerState, 103 + pub(super) render_width: usize, 235 104 } 236 105 237 106 impl App { 238 - fn min_picker_loading_duration() -> Duration { 239 - Duration::from_millis(500) 240 - } 241 - 242 - fn is_markdown_path(path: &std::path::Path) -> bool { 243 - matches!( 244 - path.extension().and_then(|ext| ext.to_str()), 245 - Some("md" | "markdown" | "mdown" | "mkd") 246 - ) 247 - } 248 - 249 - fn build_file_picker_entries(dir: &std::path::Path) -> std::io::Result<Vec<FilePickerEntry>> { 250 - let mut entries = Vec::new(); 251 - 252 - if let Some(parent) = dir.parent() { 253 - entries.push(FilePickerEntry::new("..".to_string(), parent.to_path_buf())); 254 - } 255 - 256 - let mut dirs = Vec::new(); 257 - let mut files = Vec::new(); 258 - for entry in fs::read_dir(dir)? { 259 - let entry = entry?; 260 - let path = entry.path(); 261 - let file_type = match entry.file_type() { 262 - Ok(file_type) => file_type, 263 - Err(_) => continue, 264 - }; 265 - let name = entry.file_name().to_string_lossy().to_string(); 266 - 267 - if file_type.is_dir() { 268 - dirs.push(FilePickerEntry::new(format!("{name}/"), path)); 269 - } else if file_type.is_file() && Self::is_markdown_path(&path) { 270 - files.push(FilePickerEntry::new(name, path)); 271 - } 272 - } 273 - 274 - dirs.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 275 - files.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 276 - entries.extend(dirs); 277 - entries.extend(files); 278 - Ok(entries) 279 - } 280 - 281 - fn is_ignored_fuzzy_picker_dir_name(name: &str) -> bool { 282 - IGNORED_FUZZY_PICKER_DIRS.contains(&name) 283 - } 284 - 285 - fn fuzzy_directory_sort_key(root: &std::path::Path, path: &std::path::Path) -> (bool, String) { 286 - let label = path 287 - .strip_prefix(root) 288 - .unwrap_or(path) 289 - .display() 290 - .to_string(); 291 - ( 292 - !label 293 - .split(std::path::MAIN_SEPARATOR) 294 - .next() 295 - .unwrap_or(&label) 296 - .starts_with('.'), 297 - label.to_lowercase(), 298 - ) 299 - } 300 - 301 - fn build_fuzzy_file_picker_entries( 302 - dir: &std::path::Path, 303 - ) -> std::io::Result<PickerIndexResult> { 304 - let mut entries = Vec::new(); 305 - let mut stack = vec![dir.to_path_buf()]; 306 - let started_at = Instant::now(); 307 - let mut dirs_visited = 0usize; 308 - let mut files_indexed = 0usize; 309 - let mut truncated = None; 310 - 311 - while let Some(current_dir) = stack.pop() { 312 - if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 313 - truncated = Some(PickerIndexTruncation::Time); 314 - break; 315 - } 316 - if dirs_visited >= MAX_FUZZY_PICKER_DIRS_VISITED { 317 - truncated = Some(PickerIndexTruncation::Directory); 318 - break; 319 - } 320 - dirs_visited += 1; 321 - 322 - let mut dirs = Vec::new(); 323 - let mut files = Vec::new(); 324 - 325 - let read_dir = match fs::read_dir(&current_dir) { 326 - Ok(read_dir) => read_dir, 327 - Err(err) => { 328 - if current_dir == dir { 329 - return Err(err); 330 - } 331 - continue; 332 - } 333 - }; 334 - 335 - for entry in read_dir { 336 - if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 337 - truncated = Some(PickerIndexTruncation::Time); 338 - break; 339 - } 340 - let entry = match entry { 341 - Ok(entry) => entry, 342 - Err(_) => continue, 343 - }; 344 - let path = entry.path(); 345 - let file_type = match entry.file_type() { 346 - Ok(file_type) => file_type, 347 - Err(_) => continue, 348 - }; 349 - 350 - if file_type.is_dir() { 351 - let name = entry.file_name(); 352 - if Self::is_ignored_fuzzy_picker_dir_name(name.to_string_lossy().as_ref()) { 353 - continue; 354 - } 355 - dirs.push(path); 356 - continue; 357 - } 358 - 359 - if file_type.is_file() && Self::is_markdown_path(&path) { 360 - if files_indexed >= MAX_FUZZY_PICKER_FILES_INDEXED { 361 - truncated = Some(PickerIndexTruncation::File); 362 - break; 363 - } 364 - let label = path 365 - .strip_prefix(dir) 366 - .unwrap_or(&path) 367 - .display() 368 - .to_string(); 369 - files.push(FilePickerEntry::new(label, path)); 370 - files_indexed += 1; 371 - } 372 - } 373 - 374 - files.sort_by(|left, right| { 375 - Self::fuzzy_entry_sort_key(left).cmp(&Self::fuzzy_entry_sort_key(right)) 376 - }); 377 - dirs.sort_by_key(|path| Self::fuzzy_directory_sort_key(dir, path)); 378 - 379 - entries.extend(files); 380 - if truncated.is_some() { 381 - break; 382 - } 383 - dirs.reverse(); 384 - stack.extend(dirs); 385 - } 386 - 387 - Ok(PickerIndexResult { entries, truncated }) 388 - } 389 - 390 - fn fuzzy_entry_sort_key(entry: &FilePickerEntry) -> (bool, &str) { 391 - let first_component = entry 392 - .label 393 - .split(std::path::MAIN_SEPARATOR) 394 - .next() 395 - .unwrap_or(entry.label()); 396 - (!first_component.starts_with('.'), entry.label_lower()) 397 - } 398 - 399 - fn fuzzy_component_match(candidate: &str, query: &str) -> Option<(usize, Vec<usize>)> { 400 - if let Some(start) = candidate.find(query) { 401 - let start_chars = candidate[..start].chars().count(); 402 - let query_len = query.chars().count(); 403 - let len_diff = candidate.chars().count().saturating_sub(query_len); 404 - let prefix_bonus = usize::from(start_chars == 0).saturating_mul(80); 405 - let boundary_bonus = 406 - usize::from(Self::is_match_boundary(candidate, start_chars)).saturating_mul(40); 407 - let score = start_chars 408 - .saturating_mul(10) 409 - .saturating_add(len_diff) 410 - .saturating_sub(prefix_bonus) 411 - .saturating_sub(boundary_bonus); 412 - let positions = (start_chars..start_chars + query_len).collect::<Vec<_>>(); 413 - return Some((score, positions)); 414 - } 415 - 416 - let mut search_from = 0usize; 417 - let mut positions = Vec::with_capacity(query.len()); 418 - 419 - for needle in query.chars() { 420 - let found = candidate[search_from..] 421 - .char_indices() 422 - .find(|(_, ch)| *ch == needle) 423 - .map(|(idx, _)| search_from + idx)?; 424 - let char_pos = candidate[..found].chars().count(); 425 - positions.push(char_pos); 426 - search_from = found + needle.len_utf8(); 427 - } 428 - 429 - let first = *positions.first()?; 430 - let last = *positions.last()?; 431 - let span = last.saturating_sub(first); 432 - let gaps = positions 433 - .windows(2) 434 - .map(|window| window[1].saturating_sub(window[0]).saturating_sub(1)) 435 - .sum::<usize>(); 436 - let len_diff = candidate 437 - .chars() 438 - .count() 439 - .saturating_sub(query.chars().count()); 440 - let prefix_bonus = usize::from(first == 0).saturating_mul(80); 441 - let boundary_bonus = 442 - usize::from(Self::is_match_boundary(candidate, first)).saturating_mul(40); 443 - let score = 1_000usize 444 - .saturating_add(gaps.saturating_mul(120)) 445 - .saturating_add(first.saturating_mul(10)) 446 - .saturating_add(span) 447 - .saturating_add(len_diff) 448 - .saturating_sub(prefix_bonus) 449 - .saturating_sub(boundary_bonus); 450 - Some((score, positions)) 451 - } 452 - 453 - fn is_match_boundary(candidate: &str, char_pos: usize) -> bool { 454 - if char_pos == 0 { 455 - return true; 456 - } 457 - 458 - candidate 459 - .chars() 460 - .nth(char_pos.saturating_sub(1)) 461 - .is_some_and(|ch| matches!(ch, '-' | '_' | '.' | ' ')) 462 - } 463 - 464 - fn fuzzy_match(entry: &FilePickerEntry, query: &str) -> Option<(usize, Vec<usize>)> { 465 - if query.is_empty() { 466 - return Some((0, Vec::new())); 467 - } 468 - 469 - let (score, positions) = Self::fuzzy_component_match(entry.file_name_lower(), query)?; 470 - Some(( 471 - score, 472 - positions 473 - .into_iter() 474 - .map(|position| entry.file_name_offset() + position) 475 - .collect(), 476 - )) 477 - } 478 - 479 - fn refresh_file_picker_matches(&mut self) { 480 - if self.is_browser_file_picker() { 481 - self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 482 - self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 483 - self.file_picker.index = self 484 - .file_picker 485 - .index 486 - .min(self.file_picker.filtered.len().saturating_sub(1)); 487 - return; 488 - } 489 - 490 - let query = self.file_picker.query.trim().to_lowercase(); 491 - if query.is_empty() { 492 - self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 493 - self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 494 - self.file_picker.index = self 495 - .file_picker 496 - .index 497 - .min(self.file_picker.filtered.len().saturating_sub(1)); 498 - return; 499 - } 500 - 501 - let mut filtered = self 502 - .file_picker 503 - .entries 504 - .iter() 505 - .enumerate() 506 - .filter_map(|(idx, entry)| { 507 - Self::fuzzy_match(entry, &query).map(|(score, positions)| { 508 - ( 509 - idx, 510 - score, 511 - entry.path_depth(), 512 - entry.file_name_lower(), 513 - entry.label_lower(), 514 - positions, 515 - ) 516 - }) 517 - }) 518 - .collect::<Vec<_>>(); 519 - 520 - filtered.sort_by( 521 - |(left_idx, left_score, left_depth, left_name, left_label, _), 522 - (right_idx, right_score, right_depth, right_name, right_label, _)| { 523 - left_score 524 - .cmp(right_score) 525 - .then_with(|| left_depth.cmp(right_depth)) 526 - .then_with(|| left_name.cmp(right_name)) 527 - .then_with(|| left_label.cmp(right_label)) 528 - .then_with(|| left_idx.cmp(right_idx)) 529 - }, 530 - ); 531 - 532 - self.file_picker.filtered = filtered.iter().map(|(idx, ..)| *idx).collect(); 533 - self.file_picker.match_positions = filtered 534 - .into_iter() 535 - .map(|(_, _, _, _, _, positions)| positions) 536 - .collect(); 537 - if self.file_picker.filtered.is_empty() 538 - || self.file_picker.index >= self.file_picker.filtered.len() 539 - { 540 - self.file_picker.index = 0; 541 - } 542 - } 543 - 544 - fn selected_file_picker_entry(&self) -> Option<&FilePickerEntry> { 545 - let idx = *self.file_picker.filtered.get(self.file_picker.index)?; 546 - self.file_picker.entries.get(idx) 547 - } 548 - 549 107 #[cfg(test)] 550 108 pub(crate) fn new( 551 109 lines: Vec<Line<'static>>, ··· 875 433 self.help_open 876 434 } 877 435 878 - pub(crate) fn open_file_picker(&mut self, dir: PathBuf) -> bool { 879 - self.open_file_picker_with_mode(dir, FilePickerMode::Browser) 880 - } 881 - 882 - #[cfg(test)] 883 - pub(crate) fn open_fuzzy_file_picker(&mut self, dir: PathBuf) -> bool { 884 - self.open_file_picker_with_mode(dir, FilePickerMode::Fuzzy) 885 - } 886 - 887 - pub(crate) fn queue_file_picker(&mut self, dir: PathBuf) { 888 - self.pending_picker = PendingPicker::Browser(dir); 889 - } 890 - 891 - pub(crate) fn queue_fuzzy_file_picker(&mut self, dir: PathBuf) { 892 - self.pending_picker = PendingPicker::Fuzzy(dir); 893 - } 894 - 895 - pub(crate) fn has_pending_picker(&self) -> bool { 896 - !matches!(self.pending_picker, PendingPicker::None) 897 - } 898 - 899 - pub(crate) fn start_pending_picker_loading(&mut self) -> bool { 900 - if !self.has_pending_picker() || !matches!(self.picker_load_state, PickerLoadState::Idle) { 901 - return false; 902 - } 903 - 904 - let pending = std::mem::replace(&mut self.pending_picker, PendingPicker::None); 905 - let (mode, dir) = match pending { 906 - PendingPicker::Browser(dir) => (FilePickerMode::Browser, dir), 907 - PendingPicker::Fuzzy(dir) => (FilePickerMode::Fuzzy, dir), 908 - PendingPicker::None => return false, 909 - }; 910 - 911 - let worker_dir = dir.clone(); 912 - let (tx, rx) = mpsc::channel(); 913 - crate::runtime::debug_log( 914 - self.debug_input, 915 - &format!("picker_loading spawn mode={mode:?} dir={}", dir.display()), 916 - ); 917 - thread::spawn(move || { 918 - let result = match mode { 919 - FilePickerMode::Browser => { 920 - Self::build_file_picker_entries(&worker_dir).map(|entries| PickerIndexResult { 921 - entries, 922 - truncated: None, 923 - }) 924 - } 925 - FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&worker_dir), 926 - }; 927 - let _ = tx.send(result); 928 - }); 929 - 930 - self.picker_load_state = PickerLoadState::Loading { 931 - mode, 932 - dir, 933 - started_at: Instant::now(), 934 - receiver: rx, 935 - pending_result: None, 936 - }; 937 - true 938 - } 939 - 940 - pub(crate) fn is_picker_loading(&self) -> bool { 941 - matches!(self.picker_load_state, PickerLoadState::Loading { .. }) 942 - } 943 - 944 - pub(crate) fn is_picker_load_failed(&self) -> bool { 945 - matches!(self.picker_load_state, PickerLoadState::Failed { .. }) 946 - } 947 - 948 - pub(crate) fn pending_picker_mode(&self) -> Option<FilePickerMode> { 949 - match &self.picker_load_state { 950 - PickerLoadState::Loading { mode, .. } | PickerLoadState::Failed { mode, .. } => { 951 - Some(*mode) 952 - } 953 - PickerLoadState::Idle => match self.pending_picker { 954 - PendingPicker::Browser(..) => Some(FilePickerMode::Browser), 955 - PendingPicker::Fuzzy(..) => Some(FilePickerMode::Fuzzy), 956 - PendingPicker::None => None, 957 - }, 958 - } 959 - } 960 - 961 - pub(crate) fn pending_picker_dir(&self) -> Option<&std::path::Path> { 962 - match &self.picker_load_state { 963 - PickerLoadState::Loading { dir, .. } | PickerLoadState::Failed { dir, .. } => { 964 - Some(dir.as_path()) 965 - } 966 - PickerLoadState::Idle => match &self.pending_picker { 967 - PendingPicker::Browser(dir) | PendingPicker::Fuzzy(dir) => Some(dir.as_path()), 968 - PendingPicker::None => None, 969 - }, 970 - } 971 - } 972 - 973 - pub(crate) fn picker_load_error(&self) -> Option<&str> { 974 - match &self.picker_load_state { 975 - PickerLoadState::Failed { message, .. } => Some(message.as_str()), 976 - PickerLoadState::Idle | PickerLoadState::Loading { .. } => None, 977 - } 978 - } 979 - 980 - fn install_loaded_file_picker( 981 - &mut self, 982 - dir: PathBuf, 983 - mode: FilePickerMode, 984 - result: PickerIndexResult, 985 - ) -> bool { 986 - self.file_picker.open = true; 987 - self.file_picker.mode = mode; 988 - self.file_picker.dir = dir; 989 - self.file_picker.entries = result.entries; 990 - self.file_picker.query.clear(); 991 - self.file_picker.index = 0; 992 - self.file_picker.truncation = if mode == FilePickerMode::Fuzzy { 993 - result.truncated 994 - } else { 995 - None 996 - }; 997 - self.refresh_file_picker_matches(); 998 - true 999 - } 1000 - 1001 - pub(crate) fn poll_picker_loading(&mut self) -> bool { 1002 - let state = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle); 1003 - match state { 1004 - PickerLoadState::Loading { 1005 - mode, 1006 - dir, 1007 - started_at, 1008 - receiver, 1009 - mut pending_result, 1010 - } => { 1011 - if pending_result.is_none() { 1012 - pending_result = match receiver.try_recv() { 1013 - Ok(result) => { 1014 - crate::runtime::debug_log( 1015 - self.debug_input, 1016 - &format!( 1017 - "picker_loading worker_finished mode={mode:?} dir={}", 1018 - dir.display() 1019 - ), 1020 - ); 1021 - Some(result) 1022 - } 1023 - Err(TryRecvError::Empty) => None, 1024 - Err(TryRecvError::Disconnected) => Some(Err(std::io::Error::other( 1025 - "Picker loading worker disconnected", 1026 - ))), 1027 - }; 1028 - } 1029 - 1030 - if started_at.elapsed() < Self::min_picker_loading_duration() { 1031 - self.picker_load_state = PickerLoadState::Loading { 1032 - mode, 1033 - dir, 1034 - started_at, 1035 - receiver, 1036 - pending_result, 1037 - }; 1038 - return false; 1039 - } 1040 - 1041 - match pending_result { 1042 - Some(Ok(result)) => { 1043 - crate::runtime::debug_log( 1044 - self.debug_input, 1045 - &format!( 1046 - "picker_loading install mode={mode:?} dir={} entries={}", 1047 - dir.display(), 1048 - result.entries.len() 1049 - ), 1050 - ); 1051 - self.install_loaded_file_picker(dir, mode, result) 1052 - } 1053 - Some(Err(err)) => { 1054 - crate::runtime::debug_log( 1055 - self.debug_input, 1056 - &format!( 1057 - "picker_loading failed mode={mode:?} dir={} error={}", 1058 - dir.display(), 1059 - err 1060 - ), 1061 - ); 1062 - self.picker_load_state = PickerLoadState::Failed { 1063 - mode, 1064 - dir, 1065 - message: err.to_string(), 1066 - }; 1067 - true 1068 - } 1069 - None => { 1070 - self.picker_load_state = PickerLoadState::Loading { 1071 - mode, 1072 - dir, 1073 - started_at, 1074 - receiver, 1075 - pending_result: None, 1076 - }; 1077 - false 1078 - } 1079 - } 1080 - } 1081 - PickerLoadState::Failed { .. } => { 1082 - self.picker_load_state = state; 1083 - false 1084 - } 1085 - PickerLoadState::Idle => { 1086 - self.picker_load_state = PickerLoadState::Idle; 1087 - false 1088 - } 1089 - } 1090 - } 1091 - 1092 - #[cfg(test)] 1093 - pub(crate) fn age_picker_loading_by(&mut self, duration: Duration) { 1094 - if let PickerLoadState::Loading { 1095 - mode, 1096 - dir, 1097 - started_at, 1098 - receiver, 1099 - pending_result, 1100 - } = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle) 1101 - { 1102 - let adjusted = started_at.checked_sub(duration).unwrap_or(started_at); 1103 - self.picker_load_state = PickerLoadState::Loading { 1104 - mode, 1105 - dir, 1106 - started_at: adjusted, 1107 - receiver, 1108 - pending_result, 1109 - }; 1110 - } 1111 - } 1112 - 1113 - fn open_file_picker_with_mode(&mut self, dir: PathBuf, mode: FilePickerMode) -> bool { 1114 - let result = match mode { 1115 - FilePickerMode::Browser => { 1116 - Self::build_file_picker_entries(&dir).map(|entries| PickerIndexResult { 1117 - entries, 1118 - truncated: None, 1119 - }) 1120 - } 1121 - FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&dir), 1122 - }; 1123 - 1124 - match result { 1125 - Ok(result) => self.install_loaded_file_picker(dir, mode, result), 1126 - Err(_) => false, 1127 - } 1128 - } 1129 - 1130 - pub(crate) fn is_fuzzy_file_picker(&self) -> bool { 1131 - self.file_picker.mode == FilePickerMode::Fuzzy 1132 - } 1133 - 1134 - pub(crate) fn is_browser_file_picker(&self) -> bool { 1135 - self.file_picker.mode == FilePickerMode::Browser 1136 - } 1137 - 1138 - pub(crate) fn is_file_picker_open(&self) -> bool { 1139 - self.file_picker.open 1140 - } 1141 - 1142 - pub(crate) fn file_picker_dir(&self) -> &std::path::Path { 1143 - &self.file_picker.dir 1144 - } 1145 - 1146 - pub(crate) fn file_picker_entries(&self) -> &[FilePickerEntry] { 1147 - &self.file_picker.entries 1148 - } 1149 - 1150 - pub(crate) fn file_picker_filtered_indices(&self) -> &[usize] { 1151 - &self.file_picker.filtered 1152 - } 1153 - 1154 - pub(crate) fn file_picker_match_positions(&self, filtered_idx: usize) -> &[usize] { 1155 - self.file_picker 1156 - .match_positions 1157 - .get(filtered_idx) 1158 - .map(Vec::as_slice) 1159 - .unwrap_or(&[]) 1160 - } 1161 - 1162 - pub(crate) fn file_picker_index(&self) -> usize { 1163 - self.file_picker.index 1164 - } 1165 - 1166 - pub(crate) fn file_picker_query(&self) -> &str { 1167 - &self.file_picker.query 1168 - } 1169 - 1170 - pub(crate) fn file_picker_truncation(&self) -> Option<PickerIndexTruncation> { 1171 - self.file_picker.truncation 1172 - } 1173 - 1174 - pub(crate) fn move_file_picker_up(&mut self) { 1175 - let total = self.file_picker.filtered.len(); 1176 - if total == 0 { 1177 - return; 1178 - } 1179 - if self.file_picker.index == 0 { 1180 - self.file_picker.index = total - 1; 1181 - } else { 1182 - self.file_picker.index -= 1; 1183 - } 1184 - } 1185 - 1186 - pub(crate) fn move_file_picker_down(&mut self) { 1187 - let total = self.file_picker.filtered.len(); 1188 - if total == 0 { 1189 - return; 1190 - } 1191 - self.file_picker.index = (self.file_picker.index + 1) % total; 1192 - } 1193 - 1194 - pub(crate) fn push_file_picker_query(&mut self, ch: char) { 1195 - if self.is_browser_file_picker() { 1196 - return; 1197 - } 1198 - self.file_picker.query.push(ch); 1199 - self.refresh_file_picker_matches(); 1200 - } 1201 - 1202 - pub(crate) fn pop_file_picker_query(&mut self) { 1203 - if self.is_browser_file_picker() { 1204 - return; 1205 - } 1206 - self.file_picker.query.pop(); 1207 - self.refresh_file_picker_matches(); 1208 - } 1209 - 1210 - pub(crate) fn clear_file_picker_query(&mut self) { 1211 - if self.is_browser_file_picker() { 1212 - return; 1213 - } 1214 - self.file_picker.query.clear(); 1215 - self.refresh_file_picker_matches(); 1216 - } 1217 - 1218 - pub(crate) fn open_file_picker_parent(&mut self) -> bool { 1219 - if self.is_fuzzy_file_picker() { 1220 - return false; 1221 - } 1222 - let Some(parent) = self.file_picker.dir.parent() else { 1223 - return false; 1224 - }; 1225 - self.open_file_picker(parent.to_path_buf()) 1226 - } 1227 - 1228 436 pub(crate) fn theme_picker_index(&self) -> usize { 1229 437 self.theme_picker.index 1230 438 } ··· 1456 664 self.store_theme_preview(current_theme_preset(), &lines, &toc); 1457 665 self.replace_content(lines, toc); 1458 666 true 1459 - } 1460 - 1461 - pub(crate) fn activate_file_picker_selection( 1462 - &mut self, 1463 - ss: &SyntaxSet, 1464 - themes: &ThemeSet, 1465 - ) -> bool { 1466 - let Some(entry) = self.selected_file_picker_entry().cloned() else { 1467 - return false; 1468 - }; 1469 - if self.is_browser_file_picker() && entry.is_dir_like() { 1470 - self.open_file_picker(entry.path) 1471 - } else { 1472 - self.load_path(entry.path, ss, themes) 1473 - } 1474 667 } 1475 668 1476 669 pub(crate) fn reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool {