All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Skip directories that contain no media files or non-empty subdirectories. This prevents empty folders from appearing in the file list, which can occur when NFS cache is stale or when directories are emptied.
1356 lines
49 KiB
Rust
1356 lines
49 KiB
Rust
use crate::cache::{Cache, FileTreeNode};
|
|
use crate::config::Config;
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Instant;
|
|
|
|
// Fuzzy match scoring bonuses
|
|
const FUZZY_CONSECUTIVE_BONUS: i32 = 10;
|
|
const FUZZY_WORD_START_BONUS: i32 = 15;
|
|
const FUZZY_FOLDER_BONUS: i32 = 50;
|
|
|
|
// Helper to calculate effective height accounting for "X more below" indicator
|
|
fn calculate_effective_height(scroll_offset: usize, visible_height: usize, total_items: usize) -> usize {
|
|
let visible_end = scroll_offset + visible_height;
|
|
let items_below = if visible_end < total_items {
|
|
total_items - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
if items_below > 0 {
|
|
visible_height.saturating_sub(1)
|
|
} else {
|
|
visible_height
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PlayerState {
|
|
Stopped,
|
|
Playing,
|
|
Paused,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PlayMode {
|
|
Normal, // Play through once
|
|
Loop, // Repeat playlist
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ContextMenu {
|
|
pub menu_type: ContextMenuType,
|
|
pub x: u16,
|
|
pub y: u16,
|
|
pub selected_index: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ContextMenuType {
|
|
FilePanel, // Shows "Play" and "Add"
|
|
Playlist, // Shows "Remove" and "Randomise"
|
|
TitleBar, // Shows "Stop" and "Loop"
|
|
}
|
|
|
|
pub struct AppState {
|
|
pub cache: Cache,
|
|
pub config: Config,
|
|
pub selected_index: usize,
|
|
pub scroll_offset: usize,
|
|
pub file_panel_visible_height: usize,
|
|
pub playlist_visible_height: usize,
|
|
pub current_file: Option<PathBuf>,
|
|
pub current_position: f64,
|
|
pub current_duration: f64,
|
|
pub volume: i64,
|
|
pub should_quit: bool,
|
|
pub flattened_items: Vec<FlattenedItem>,
|
|
pub expanded_dirs: HashSet<PathBuf>,
|
|
pub marked_files: HashSet<PathBuf>,
|
|
pub playlist: Vec<PathBuf>,
|
|
pub playlist_index: usize,
|
|
pub playlist_scroll_offset: usize,
|
|
pub selected_playlist_index: usize,
|
|
pub is_refreshing: bool,
|
|
pub search_mode: bool,
|
|
pub search_query: String,
|
|
pub search_matches: Vec<PathBuf>,
|
|
pub search_match_index: usize,
|
|
pub tab_search_results: Vec<PathBuf>,
|
|
pub tab_search_index: usize,
|
|
pub playlist_search_matches: Vec<usize>,
|
|
pub playlist_search_match_index: usize,
|
|
pub playlist_tab_search_results: Vec<usize>,
|
|
pub playlist_tab_search_index: usize,
|
|
pub visual_mode: bool,
|
|
pub visual_anchor: usize,
|
|
pub saved_expanded_dirs: HashSet<PathBuf>,
|
|
pub show_refresh_confirm: bool,
|
|
pub focus_playlist: bool,
|
|
pub last_click_time: Option<Instant>,
|
|
pub last_click_index: Option<usize>,
|
|
pub last_click_is_playlist: bool,
|
|
pub context_menu: Option<ContextMenu>,
|
|
pub play_mode: PlayMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FlattenedItem {
|
|
pub path: PathBuf,
|
|
pub name: String,
|
|
pub is_dir: bool,
|
|
pub depth: usize,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn new(cache: Cache, config: Config) -> Self {
|
|
// Start with all directories collapsed (vifm-style)
|
|
let expanded_dirs = HashSet::new();
|
|
|
|
let flattened_items = flatten_tree(&cache.file_tree, 0, &expanded_dirs);
|
|
|
|
Self {
|
|
cache,
|
|
config,
|
|
selected_index: 0,
|
|
scroll_offset: 0,
|
|
file_panel_visible_height: 20,
|
|
playlist_visible_height: 20,
|
|
current_file: None,
|
|
current_position: 0.0,
|
|
current_duration: 0.0,
|
|
volume: 100,
|
|
should_quit: false,
|
|
flattened_items,
|
|
expanded_dirs,
|
|
marked_files: HashSet::new(),
|
|
playlist: Vec::new(),
|
|
playlist_index: 0,
|
|
playlist_scroll_offset: 0,
|
|
selected_playlist_index: 0,
|
|
is_refreshing: false,
|
|
search_mode: false,
|
|
search_query: String::new(),
|
|
search_matches: Vec::new(),
|
|
search_match_index: 0,
|
|
tab_search_results: Vec::new(),
|
|
tab_search_index: 0,
|
|
playlist_search_matches: Vec::new(),
|
|
playlist_search_match_index: 0,
|
|
playlist_tab_search_results: Vec::new(),
|
|
playlist_tab_search_index: 0,
|
|
visual_mode: false,
|
|
visual_anchor: 0,
|
|
saved_expanded_dirs: HashSet::new(),
|
|
show_refresh_confirm: false,
|
|
focus_playlist: false,
|
|
last_click_time: None,
|
|
last_click_index: None,
|
|
last_click_is_playlist: false,
|
|
context_menu: None,
|
|
play_mode: PlayMode::Normal,
|
|
}
|
|
}
|
|
|
|
pub fn move_selection_up(&mut self) {
|
|
if self.selected_index > 0 {
|
|
self.selected_index -= 1;
|
|
// Scroll up when selection reaches top
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
}
|
|
// Update visual selection if in visual mode
|
|
if self.visual_mode {
|
|
self.update_visual_selection();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn move_selection_down(&mut self) {
|
|
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
|
|
self.selected_index += 1;
|
|
|
|
let effective_height = calculate_effective_height(
|
|
self.scroll_offset,
|
|
self.file_panel_visible_height,
|
|
self.flattened_items.len()
|
|
);
|
|
|
|
// Scroll down when selection reaches bottom
|
|
if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
// Update visual selection if in visual mode
|
|
if self.visual_mode {
|
|
self.update_visual_selection();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn scroll_view_up(&mut self) {
|
|
// Scroll view up without changing selection
|
|
if self.scroll_offset > 0 {
|
|
self.scroll_offset -= 1;
|
|
}
|
|
}
|
|
|
|
pub fn scroll_view_down(&mut self, visible_height: usize) {
|
|
// Scroll view down without changing selection
|
|
let max_scroll = self.flattened_items.len().saturating_sub(visible_height);
|
|
if self.scroll_offset < max_scroll {
|
|
self.scroll_offset += 1;
|
|
}
|
|
}
|
|
|
|
pub fn scroll_playlist_up(&mut self) {
|
|
// Scroll playlist view up
|
|
if self.playlist_scroll_offset > 0 {
|
|
self.playlist_scroll_offset -= 1;
|
|
}
|
|
}
|
|
|
|
pub fn scroll_playlist_down(&mut self, visible_height: usize) {
|
|
// Scroll playlist view down
|
|
let max_scroll = self.playlist.len().saturating_sub(visible_height);
|
|
if self.playlist_scroll_offset < max_scroll {
|
|
self.playlist_scroll_offset += 1;
|
|
}
|
|
}
|
|
|
|
pub fn update_playlist_scroll(&mut self, visible_height: usize) {
|
|
// Auto-scroll playlist to keep current track visible
|
|
if self.playlist_index >= self.playlist_scroll_offset + visible_height {
|
|
// Track is below visible area, scroll down
|
|
self.playlist_scroll_offset = self.playlist_index - visible_height + 1;
|
|
} else if self.playlist_index < self.playlist_scroll_offset {
|
|
// Track is above visible area, scroll up
|
|
self.playlist_scroll_offset = self.playlist_index;
|
|
}
|
|
}
|
|
|
|
pub fn move_playlist_selection_up(&mut self) {
|
|
if self.selected_playlist_index > 0 {
|
|
self.selected_playlist_index -= 1;
|
|
// Scroll up when selection reaches top
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn move_playlist_selection_down(&mut self, visible_height: usize) {
|
|
if self.selected_playlist_index < self.playlist.len().saturating_sub(1) {
|
|
self.selected_playlist_index += 1;
|
|
|
|
let effective_height = calculate_effective_height(
|
|
self.playlist_scroll_offset,
|
|
visible_height,
|
|
self.playlist.len()
|
|
);
|
|
|
|
// Scroll down when selection reaches bottom
|
|
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn remove_selected_playlist_item(&mut self) {
|
|
if self.selected_playlist_index < self.playlist.len() {
|
|
self.playlist.remove(self.selected_playlist_index);
|
|
|
|
// Adjust playlist_index if necessary
|
|
if self.playlist_index > self.selected_playlist_index {
|
|
self.playlist_index -= 1;
|
|
} else if self.playlist_index == self.selected_playlist_index {
|
|
// Keep same index (which is now the next track)
|
|
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
|
|
self.playlist_index = self.playlist.len() - 1;
|
|
}
|
|
}
|
|
|
|
// Adjust selected_playlist_index if at end
|
|
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
|
|
self.selected_playlist_index = self.playlist.len() - 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn playlist_page_down(&mut self) {
|
|
// Move down by half page (vim Ctrl-D behavior)
|
|
let half_page = self.playlist_visible_height / 2;
|
|
let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1));
|
|
self.selected_playlist_index = new_index;
|
|
|
|
let effective_height = calculate_effective_height(
|
|
self.playlist_scroll_offset,
|
|
self.playlist_visible_height,
|
|
self.playlist.len()
|
|
);
|
|
|
|
// Adjust scroll if needed
|
|
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
|
|
pub fn playlist_page_up(&mut self) {
|
|
// Move up by half page (vim Ctrl-U behavior)
|
|
let half_page = self.playlist_visible_height / 2;
|
|
let new_index = self.selected_playlist_index.saturating_sub(half_page);
|
|
self.selected_playlist_index = new_index;
|
|
// Adjust scroll if needed
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
}
|
|
}
|
|
|
|
pub fn page_down(&mut self) {
|
|
// Move down by half page (vim Ctrl-D behavior)
|
|
let half_page = self.file_panel_visible_height / 2;
|
|
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
|
|
self.selected_index = new_index;
|
|
|
|
let effective_height = calculate_effective_height(
|
|
self.scroll_offset,
|
|
self.file_panel_visible_height,
|
|
self.flattened_items.len()
|
|
);
|
|
|
|
// Adjust scroll if needed
|
|
if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
}
|
|
|
|
pub fn page_up(&mut self) {
|
|
// Move up by half page (vim Ctrl-U behavior)
|
|
let half_page = self.file_panel_visible_height / 2;
|
|
let new_index = self.selected_index.saturating_sub(half_page);
|
|
self.selected_index = new_index;
|
|
// Adjust scroll if needed
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
}
|
|
}
|
|
|
|
pub fn get_selected_item(&self) -> Option<&FlattenedItem> {
|
|
self.flattened_items.get(self.selected_index)
|
|
}
|
|
|
|
// Helper to find a node in the tree by path
|
|
fn find_node_by_path<'a>(&'a self, path: &Path) -> Option<&'a FileTreeNode> {
|
|
fn search_nodes<'a>(nodes: &'a [FileTreeNode], path: &Path) -> Option<&'a FileTreeNode> {
|
|
for node in nodes {
|
|
if node.path == path {
|
|
return Some(node);
|
|
}
|
|
if node.is_dir {
|
|
if let Some(found) = search_nodes(&node.children, path) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
search_nodes(&self.cache.file_tree, path)
|
|
}
|
|
|
|
pub fn collapse_selected(&mut self) {
|
|
let item = self.get_selected_item().cloned();
|
|
if let Some(item) = item {
|
|
if item.is_dir {
|
|
let path = item.path.clone();
|
|
let was_expanded = self.expanded_dirs.contains(&path);
|
|
|
|
if was_expanded {
|
|
// Close the expanded folder and keep selection on it
|
|
self.expanded_dirs.remove(&path);
|
|
self.rebuild_flattened_items();
|
|
// Find the collapsed folder and select it
|
|
if let Some(idx) = self.flattened_items.iter().position(|i| i.path == path) {
|
|
self.selected_index = idx;
|
|
}
|
|
} else {
|
|
// Folder is collapsed, close parent instead and jump to it
|
|
if let Some(parent) = path.parent() {
|
|
let parent_buf = parent.to_path_buf();
|
|
self.expanded_dirs.remove(&parent_buf);
|
|
self.rebuild_flattened_items();
|
|
// Jump to parent folder
|
|
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
|
|
self.selected_index = parent_idx;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Close parent folder when on a file and jump to it
|
|
if let Some(parent) = item.path.parent() {
|
|
let parent_buf = parent.to_path_buf();
|
|
self.expanded_dirs.remove(&parent_buf);
|
|
self.rebuild_flattened_items();
|
|
// Jump to parent folder
|
|
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
|
|
self.selected_index = parent_idx;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn expand_selected(&mut self) {
|
|
if let Some(item) = self.get_selected_item() {
|
|
if item.is_dir {
|
|
let path = item.path.clone();
|
|
self.expanded_dirs.insert(path);
|
|
self.rebuild_flattened_items();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_mark(&mut self) {
|
|
if self.visual_mode {
|
|
// Exit visual mode and mark all files in range
|
|
self.update_visual_selection();
|
|
self.visual_mode = false;
|
|
} else {
|
|
// Enter visual mode
|
|
self.visual_mode = true;
|
|
self.visual_anchor = self.selected_index;
|
|
// Clear previous marks when entering visual mode
|
|
self.marked_files.clear();
|
|
// Mark current file
|
|
if let Some(item) = self.get_selected_item() {
|
|
if !item.is_dir {
|
|
self.marked_files.insert(item.path.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_visual_selection(&mut self) {
|
|
// Clear marks
|
|
self.marked_files.clear();
|
|
|
|
// Mark all files between anchor and current position
|
|
let start = self.visual_anchor.min(self.selected_index);
|
|
let end = self.visual_anchor.max(self.selected_index);
|
|
|
|
for i in start..=end {
|
|
if let Some(item) = self.flattened_items.get(i) {
|
|
if !item.is_dir {
|
|
self.marked_files.insert(item.path.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn clear_playlist(&mut self) {
|
|
self.playlist.clear();
|
|
self.playlist_index = 0;
|
|
self.current_file = None;
|
|
}
|
|
|
|
pub fn add_to_playlist(&mut self) {
|
|
// Add marked files or current selection to playlist
|
|
if !self.marked_files.is_empty() {
|
|
// Add marked files (allow duplicates)
|
|
let mut files: Vec<PathBuf> = self.marked_files.iter().cloned().collect();
|
|
files.sort();
|
|
self.playlist.extend(files);
|
|
} else if let Some(item) = self.get_selected_item() {
|
|
let path = item.path.clone();
|
|
let is_dir = item.is_dir;
|
|
if is_dir {
|
|
// Look up the full node to get children
|
|
if let Some(node) = self.find_node_by_path(&path) {
|
|
// Add all files in directory (allow duplicates)
|
|
let mut files = collect_files_from_node(node);
|
|
files.sort();
|
|
self.playlist.extend(files);
|
|
}
|
|
} else {
|
|
// Add single file (allow duplicates)
|
|
self.playlist.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn play_selection(&mut self) {
|
|
// Priority: marked files > directory > single file
|
|
if !self.marked_files.is_empty() {
|
|
// Play marked files
|
|
self.playlist = self.marked_files.iter().cloned().collect();
|
|
self.playlist.sort();
|
|
self.playlist_index = 0;
|
|
self.playlist_scroll_offset = 0;
|
|
self.selected_playlist_index = 0;
|
|
if let Some(first) = self.playlist.first() {
|
|
self.current_file = Some(first.clone());
|
|
} else {
|
|
// Empty playlist
|
|
self.current_file = None;
|
|
}
|
|
} else if let Some(item) = self.get_selected_item() {
|
|
let path = item.path.clone();
|
|
let is_dir = item.is_dir;
|
|
if is_dir {
|
|
// Play all files in directory - look up node to get children
|
|
if let Some(node) = self.find_node_by_path(&path) {
|
|
self.playlist = collect_files_from_node(node);
|
|
self.playlist_index = 0;
|
|
self.playlist_scroll_offset = 0;
|
|
self.selected_playlist_index = 0;
|
|
if let Some(first) = self.playlist.first() {
|
|
self.current_file = Some(first.clone());
|
|
} else {
|
|
// Empty directory
|
|
self.current_file = None;
|
|
}
|
|
}
|
|
} else {
|
|
// Play single file
|
|
self.playlist = vec![path.clone()];
|
|
self.playlist_index = 0;
|
|
self.playlist_scroll_offset = 0;
|
|
self.selected_playlist_index = 0;
|
|
self.current_file = Some(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn play_next(&mut self) -> bool {
|
|
if self.playlist.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
match self.play_mode {
|
|
PlayMode::Normal => {
|
|
// Play through once, stop at end
|
|
if self.playlist_index + 1 < self.playlist.len() {
|
|
self.playlist_index += 1;
|
|
if self.playlist_index < self.playlist.len() {
|
|
self.current_file = Some(self.playlist[self.playlist_index].clone());
|
|
return true; // Should continue playing
|
|
}
|
|
}
|
|
// Reached end, should stop
|
|
false
|
|
}
|
|
PlayMode::Loop => {
|
|
// Loop back to beginning when reaching end
|
|
self.playlist_index = (self.playlist_index + 1) % self.playlist.len();
|
|
self.current_file = Some(self.playlist[self.playlist_index].clone());
|
|
true // Should continue playing
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn cycle_play_mode(&mut self) {
|
|
self.play_mode = match self.play_mode {
|
|
PlayMode::Normal => PlayMode::Loop,
|
|
PlayMode::Loop => PlayMode::Normal,
|
|
};
|
|
}
|
|
|
|
pub fn shuffle_playlist(&mut self) {
|
|
if self.playlist.is_empty() {
|
|
return;
|
|
}
|
|
|
|
use rand::seq::SliceRandom;
|
|
let mut rng = rand::thread_rng();
|
|
|
|
// Remember the currently playing track
|
|
let current_track = if self.playlist_index < self.playlist.len() {
|
|
Some(self.playlist[self.playlist_index].clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Shuffle the playlist
|
|
self.playlist.shuffle(&mut rng);
|
|
|
|
// Find the new position of the currently playing track
|
|
if let Some(track) = current_track {
|
|
if let Some(new_index) = self.playlist.iter().position(|p| p == &track) {
|
|
self.playlist_index = new_index;
|
|
self.selected_playlist_index = new_index;
|
|
} else {
|
|
self.playlist_index = 0;
|
|
self.selected_playlist_index = 0;
|
|
}
|
|
} else {
|
|
self.playlist_index = 0;
|
|
self.selected_playlist_index = 0;
|
|
}
|
|
}
|
|
|
|
pub fn refresh_flattened_items(&mut self) {
|
|
// Clean up expanded_dirs - remove paths that no longer exist in new cache
|
|
self.cleanup_expanded_dirs();
|
|
|
|
// Rebuild view with cleaned expanded state
|
|
self.rebuild_flattened_items();
|
|
|
|
// Clean up playlist - remove files that no longer exist in cache
|
|
self.cleanup_playlist();
|
|
}
|
|
|
|
fn cleanup_expanded_dirs(&mut self) {
|
|
// Build a set of valid directory paths from the cache
|
|
let mut valid_dirs = std::collections::HashSet::new();
|
|
fn collect_dirs(node: &crate::cache::FileTreeNode, dirs: &mut std::collections::HashSet<std::path::PathBuf>) {
|
|
if node.is_dir {
|
|
dirs.insert(node.path.clone());
|
|
}
|
|
for child in &node.children {
|
|
collect_dirs(child, dirs);
|
|
}
|
|
}
|
|
for root in &self.cache.file_tree {
|
|
collect_dirs(root, &mut valid_dirs);
|
|
}
|
|
|
|
// Remove invalid paths from expanded_dirs
|
|
let original_len = self.expanded_dirs.len();
|
|
self.expanded_dirs.retain(|path| valid_dirs.contains(path));
|
|
|
|
if self.expanded_dirs.len() < original_len {
|
|
tracing::info!("Cleaned up expanded_dirs: removed {} invalid paths", original_len - self.expanded_dirs.len());
|
|
}
|
|
}
|
|
|
|
fn cleanup_playlist(&mut self) {
|
|
// Build a set of valid paths from the cache for fast lookup
|
|
let mut valid_paths = std::collections::HashSet::new();
|
|
fn collect_paths(node: &crate::cache::FileTreeNode, paths: &mut std::collections::HashSet<std::path::PathBuf>) {
|
|
if !node.is_dir {
|
|
paths.insert(node.path.clone());
|
|
}
|
|
for child in &node.children {
|
|
collect_paths(child, paths);
|
|
}
|
|
}
|
|
for root in &self.cache.file_tree {
|
|
collect_paths(root, &mut valid_paths);
|
|
}
|
|
|
|
// Check if current file is invalid
|
|
let current_file_invalid = if let Some(ref current) = self.current_file {
|
|
!valid_paths.contains(current)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if current_file_invalid {
|
|
self.current_file = None;
|
|
tracing::info!("Current playing file was deleted, cleared current_file");
|
|
}
|
|
|
|
// Remove files from playlist that don't exist in cache
|
|
let original_len = self.playlist.len();
|
|
self.playlist.retain(|path| valid_paths.contains(path));
|
|
|
|
// Adjust indices if playlist was modified
|
|
if self.playlist.len() < original_len {
|
|
// Ensure playlist_index is valid
|
|
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
|
|
self.playlist_index = self.playlist.len() - 1;
|
|
}
|
|
// Ensure selected_playlist_index is valid
|
|
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
|
|
self.selected_playlist_index = self.playlist.len() - 1;
|
|
}
|
|
|
|
tracing::info!("Cleaned up playlist: removed {} deleted files", original_len - self.playlist.len());
|
|
}
|
|
}
|
|
|
|
pub fn rebuild_flattened_items(&mut self) {
|
|
self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs);
|
|
if self.selected_index >= self.flattened_items.len() {
|
|
self.selected_index = self.flattened_items.len().saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
pub fn enter_search_mode(&mut self) {
|
|
self.search_mode = true;
|
|
self.search_query.clear();
|
|
|
|
if self.focus_playlist {
|
|
// Clear playlist search state
|
|
self.playlist_search_matches.clear();
|
|
self.playlist_search_match_index = 0;
|
|
self.playlist_tab_search_results.clear();
|
|
self.playlist_tab_search_index = 0;
|
|
} else {
|
|
// Clear file search state
|
|
self.search_matches.clear();
|
|
self.search_match_index = 0;
|
|
self.tab_search_results.clear();
|
|
self.tab_search_index = 0;
|
|
// Save current folder state
|
|
self.saved_expanded_dirs = self.expanded_dirs.clone();
|
|
}
|
|
}
|
|
|
|
pub fn exit_search_mode(&mut self) {
|
|
self.search_mode = false;
|
|
|
|
if self.focus_playlist {
|
|
// Clear playlist search state
|
|
self.playlist_tab_search_results.clear();
|
|
self.playlist_tab_search_index = 0;
|
|
} else {
|
|
// Clear file search state
|
|
self.tab_search_results.clear();
|
|
self.tab_search_index = 0;
|
|
// Restore folder state from before search
|
|
self.expanded_dirs = self.saved_expanded_dirs.clone();
|
|
self.rebuild_flattened_items();
|
|
}
|
|
}
|
|
|
|
pub fn append_search_char(&mut self, c: char) {
|
|
self.search_query.push(c);
|
|
self.perform_incremental_search();
|
|
}
|
|
|
|
pub fn backspace_search(&mut self) {
|
|
self.search_query.pop();
|
|
self.perform_incremental_search();
|
|
}
|
|
|
|
fn perform_incremental_search(&mut self) {
|
|
if self.search_query.is_empty() {
|
|
self.tab_search_results.clear();
|
|
self.tab_search_index = 0;
|
|
// Don't rebuild tree on every keystroke - only when exiting search
|
|
return;
|
|
}
|
|
|
|
// Collect all matching paths with scores from the entire tree
|
|
let mut matching_paths_with_scores = Vec::new();
|
|
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
|
|
|
|
if matching_paths_with_scores.is_empty() {
|
|
self.tab_search_results.clear();
|
|
self.tab_search_index = 0;
|
|
return;
|
|
}
|
|
|
|
// Add index to preserve original tree order when scores are equal
|
|
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(idx, (path, score))| (path, score, idx))
|
|
.collect();
|
|
|
|
// Sort by score (highest first), then by original index to prefer first occurrence
|
|
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
|
|
|
|
// Store all matches for tab completion
|
|
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
|
|
self.tab_search_index = 0;
|
|
|
|
// Only expand and rebuild if this is a new best match
|
|
let best_match = self.tab_search_results[0].clone();
|
|
|
|
// Check if we need to expand folders for this match
|
|
let needs_expand = best_match.ancestors()
|
|
.skip(1) // Skip the file itself
|
|
.any(|p| !self.expanded_dirs.contains(p));
|
|
|
|
if needs_expand {
|
|
// Close all folders and expand only for the best match
|
|
self.expanded_dirs.clear();
|
|
let mut parent = best_match.parent();
|
|
while let Some(p) = parent {
|
|
self.expanded_dirs.insert(p.to_path_buf());
|
|
parent = p.parent();
|
|
}
|
|
|
|
// Rebuild flattened items
|
|
self.rebuild_flattened_items();
|
|
}
|
|
|
|
// Find the best match in the flattened list and jump to it
|
|
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) {
|
|
self.selected_index = idx;
|
|
|
|
// Scroll to show the match
|
|
let effective_height = calculate_effective_height(
|
|
self.scroll_offset,
|
|
self.file_panel_visible_height,
|
|
self.flattened_items.len()
|
|
);
|
|
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
} else if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn execute_search(&mut self) {
|
|
if self.search_query.is_empty() {
|
|
self.search_mode = false;
|
|
return;
|
|
}
|
|
|
|
// Collect all matching paths with scores and preserve order
|
|
let mut matching_paths_with_scores = Vec::new();
|
|
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
|
|
|
|
if matching_paths_with_scores.is_empty() {
|
|
self.search_mode = false;
|
|
return;
|
|
}
|
|
|
|
// Add index to preserve original tree order when scores are equal
|
|
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(idx, (path, score))| (path, score, idx))
|
|
.collect();
|
|
|
|
// Sort by score (highest first), then by original index to prefer first occurrence
|
|
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
|
|
|
|
let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches
|
|
.into_iter()
|
|
.map(|(path, score, _)| (path, score))
|
|
.collect();
|
|
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
|
|
|
|
// Store matching paths (not indices, as they change when folders collapse)
|
|
self.search_matches = matching_paths;
|
|
|
|
if !self.search_matches.is_empty() {
|
|
self.search_match_index = 0;
|
|
// Close all folders and expand only for first match
|
|
self.expanded_dirs.clear();
|
|
let first_match = self.search_matches[0].clone();
|
|
let mut parent = first_match.parent();
|
|
while let Some(p) = parent {
|
|
self.expanded_dirs.insert(p.to_path_buf());
|
|
parent = p.parent();
|
|
}
|
|
|
|
// Rebuild flattened items
|
|
self.rebuild_flattened_items();
|
|
|
|
// Find first match in flattened list
|
|
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
|
|
self.selected_index = idx;
|
|
}
|
|
}
|
|
|
|
self.search_mode = false;
|
|
}
|
|
|
|
pub fn next_search_match(&mut self) {
|
|
if !self.search_matches.is_empty() {
|
|
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
|
|
let target_path = self.search_matches[self.search_match_index].clone();
|
|
|
|
// Close all folders and expand only for this match
|
|
self.expanded_dirs.clear();
|
|
let mut parent = target_path.parent();
|
|
while let Some(p) = parent {
|
|
self.expanded_dirs.insert(p.to_path_buf());
|
|
parent = p.parent();
|
|
}
|
|
|
|
// Rebuild flattened items
|
|
self.rebuild_flattened_items();
|
|
|
|
// Find the path in current flattened items
|
|
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
|
|
self.selected_index = idx;
|
|
|
|
// Scroll to show the match
|
|
// Account for "... X more below" indicator which takes one line
|
|
let visible_end = self.scroll_offset + self.file_panel_visible_height;
|
|
let items_below = if visible_end < self.flattened_items.len() {
|
|
self.flattened_items.len() - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
let effective_height = if items_below > 0 {
|
|
self.file_panel_visible_height.saturating_sub(1)
|
|
} else {
|
|
self.file_panel_visible_height
|
|
};
|
|
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
} else if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn prev_search_match(&mut self) {
|
|
if !self.search_matches.is_empty() {
|
|
if self.search_match_index == 0 {
|
|
self.search_match_index = self.search_matches.len() - 1;
|
|
} else {
|
|
self.search_match_index -= 1;
|
|
}
|
|
let target_path = self.search_matches[self.search_match_index].clone();
|
|
|
|
// Close all folders and expand only for this match
|
|
self.expanded_dirs.clear();
|
|
let mut parent = target_path.parent();
|
|
while let Some(p) = parent {
|
|
self.expanded_dirs.insert(p.to_path_buf());
|
|
parent = p.parent();
|
|
}
|
|
|
|
// Rebuild flattened items
|
|
self.rebuild_flattened_items();
|
|
|
|
// Find the path in current flattened items
|
|
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
|
|
self.selected_index = idx;
|
|
|
|
// Scroll to show the match
|
|
// Account for "... X more below" indicator which takes one line
|
|
let visible_end = self.scroll_offset + self.file_panel_visible_height;
|
|
let items_below = if visible_end < self.flattened_items.len() {
|
|
self.flattened_items.len() - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
let effective_height = if items_below > 0 {
|
|
self.file_panel_visible_height.saturating_sub(1)
|
|
} else {
|
|
self.file_panel_visible_height
|
|
};
|
|
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
} else if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn tab_search_next(&mut self) {
|
|
if self.tab_search_results.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Cycle to next match
|
|
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
|
|
let next_match = self.tab_search_results[self.tab_search_index].clone();
|
|
|
|
// Close all folders and expand only for this match
|
|
self.expanded_dirs.clear();
|
|
let mut parent = next_match.parent();
|
|
while let Some(p) = parent {
|
|
self.expanded_dirs.insert(p.to_path_buf());
|
|
parent = p.parent();
|
|
}
|
|
|
|
// Rebuild flattened items
|
|
self.rebuild_flattened_items();
|
|
|
|
// Find and select the match
|
|
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
|
|
self.selected_index = idx;
|
|
|
|
// Scroll to show the match
|
|
let effective_height = calculate_effective_height(
|
|
self.scroll_offset,
|
|
self.file_panel_visible_height,
|
|
self.flattened_items.len()
|
|
);
|
|
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
} else if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn tab_search_prev(&mut self) {
|
|
if self.tab_search_results.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Cycle to previous match
|
|
if self.tab_search_index == 0 {
|
|
self.tab_search_index = self.tab_search_results.len() - 1;
|
|
} else {
|
|
self.tab_search_index -= 1;
|
|
}
|
|
let prev_match = self.tab_search_results[self.tab_search_index].clone();
|
|
|
|
// Close all folders and expand only for this match
|
|
self.expanded_dirs.clear();
|
|
let mut parent = prev_match.parent();
|
|
while let Some(p) = parent {
|
|
self.expanded_dirs.insert(p.to_path_buf());
|
|
parent = p.parent();
|
|
}
|
|
|
|
// Rebuild flattened items
|
|
self.rebuild_flattened_items();
|
|
|
|
// Find and select the match
|
|
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
|
|
self.selected_index = idx;
|
|
|
|
// Scroll to show the match
|
|
let effective_height = calculate_effective_height(
|
|
self.scroll_offset,
|
|
self.file_panel_visible_height,
|
|
self.flattened_items.len()
|
|
);
|
|
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
} else if self.selected_index >= self.scroll_offset + effective_height {
|
|
self.scroll_offset = self.selected_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn append_playlist_search_char(&mut self, c: char) {
|
|
self.search_query.push(c);
|
|
self.perform_playlist_incremental_search();
|
|
}
|
|
|
|
pub fn backspace_playlist_search(&mut self) {
|
|
self.search_query.pop();
|
|
self.perform_playlist_incremental_search();
|
|
}
|
|
|
|
fn perform_playlist_incremental_search(&mut self) {
|
|
if self.search_query.is_empty() {
|
|
self.playlist_tab_search_results.clear();
|
|
self.playlist_tab_search_index = 0;
|
|
return;
|
|
}
|
|
|
|
// Collect all matching indices with scores
|
|
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, path)| {
|
|
let filename = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("");
|
|
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
|
|
})
|
|
.collect();
|
|
|
|
if matching_indices_with_scores.is_empty() {
|
|
self.playlist_tab_search_results.clear();
|
|
self.playlist_tab_search_index = 0;
|
|
return;
|
|
}
|
|
|
|
// Sort by score (highest first)
|
|
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
|
|
|
// Store all matches for tab completion
|
|
self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
|
|
self.playlist_tab_search_index = 0;
|
|
|
|
// Jump to best match
|
|
let best_match_idx = self.playlist_tab_search_results[0];
|
|
self.selected_playlist_index = best_match_idx;
|
|
|
|
// Scroll to show the match
|
|
let effective_height = calculate_effective_height(
|
|
self.playlist_scroll_offset,
|
|
self.playlist_visible_height,
|
|
self.playlist.len()
|
|
);
|
|
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
|
|
pub fn playlist_tab_search_next(&mut self) {
|
|
if self.playlist_tab_search_results.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Cycle to next match
|
|
self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len();
|
|
let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
|
|
self.selected_playlist_index = next_match_idx;
|
|
|
|
// Scroll to show the match
|
|
let effective_height = calculate_effective_height(
|
|
self.playlist_scroll_offset,
|
|
self.playlist_visible_height,
|
|
self.playlist.len()
|
|
);
|
|
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
|
|
pub fn playlist_tab_search_prev(&mut self) {
|
|
if self.playlist_tab_search_results.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Cycle to previous match
|
|
if self.playlist_tab_search_index == 0 {
|
|
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1;
|
|
} else {
|
|
self.playlist_tab_search_index -= 1;
|
|
}
|
|
let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
|
|
self.selected_playlist_index = prev_match_idx;
|
|
|
|
// Scroll to show the match
|
|
let effective_height = calculate_effective_height(
|
|
self.playlist_scroll_offset,
|
|
self.playlist_visible_height,
|
|
self.playlist.len()
|
|
);
|
|
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
|
|
pub fn execute_playlist_search(&mut self) {
|
|
if self.search_query.is_empty() {
|
|
self.search_mode = false;
|
|
return;
|
|
}
|
|
|
|
// Collect all matching indices with scores
|
|
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, path)| {
|
|
let filename = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("");
|
|
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
|
|
})
|
|
.collect();
|
|
|
|
if matching_indices_with_scores.is_empty() {
|
|
self.search_mode = false;
|
|
return;
|
|
}
|
|
|
|
// Sort by score (highest first)
|
|
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
|
|
|
// Store matching indices
|
|
self.playlist_search_matches = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
|
|
|
|
if !self.playlist_search_matches.is_empty() {
|
|
self.playlist_search_match_index = 0;
|
|
let first_match_idx = self.playlist_search_matches[0];
|
|
self.selected_playlist_index = first_match_idx;
|
|
|
|
// Scroll to show the match
|
|
// Account for "... X more below" indicator which takes one line
|
|
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
|
|
let items_below = if visible_end < self.playlist.len() {
|
|
self.playlist.len() - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
let effective_height = if items_below > 0 {
|
|
self.playlist_visible_height.saturating_sub(1)
|
|
} else {
|
|
self.playlist_visible_height
|
|
};
|
|
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
|
|
self.search_mode = false;
|
|
}
|
|
|
|
pub fn next_playlist_search_match(&mut self) {
|
|
if !self.playlist_search_matches.is_empty() {
|
|
self.playlist_search_match_index = (self.playlist_search_match_index + 1) % self.playlist_search_matches.len();
|
|
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
|
|
self.selected_playlist_index = match_idx;
|
|
|
|
// Scroll to show the match
|
|
// Account for "... X more below" indicator which takes one line
|
|
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
|
|
let items_below = if visible_end < self.playlist.len() {
|
|
self.playlist.len() - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
let effective_height = if items_below > 0 {
|
|
self.playlist_visible_height.saturating_sub(1)
|
|
} else {
|
|
self.playlist_visible_height
|
|
};
|
|
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn prev_playlist_search_match(&mut self) {
|
|
if !self.playlist_search_matches.is_empty() {
|
|
if self.playlist_search_match_index == 0 {
|
|
self.playlist_search_match_index = self.playlist_search_matches.len() - 1;
|
|
} else {
|
|
self.playlist_search_match_index -= 1;
|
|
}
|
|
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
|
|
self.selected_playlist_index = match_idx;
|
|
|
|
// Scroll to show the match
|
|
// Account for "... X more below" indicator which takes one line
|
|
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
|
|
let items_below = if visible_end < self.playlist.len() {
|
|
self.playlist.len() - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
let effective_height = if items_below > 0 {
|
|
self.playlist_visible_height.saturating_sub(1)
|
|
} else {
|
|
self.playlist_visible_height
|
|
};
|
|
|
|
if self.selected_playlist_index < self.playlist_scroll_offset {
|
|
self.playlist_scroll_offset = self.selected_playlist_index;
|
|
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
|
|
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<PathBuf>) -> Vec<FlattenedItem> {
|
|
let mut result = Vec::new();
|
|
|
|
for node in nodes {
|
|
let is_expanded = expanded_dirs.contains(&node.path);
|
|
|
|
result.push(FlattenedItem {
|
|
path: node.path.clone(),
|
|
name: node.name.clone(),
|
|
is_dir: node.is_dir,
|
|
depth,
|
|
});
|
|
|
|
if node.is_dir && !node.children.is_empty() && is_expanded {
|
|
result.extend(flatten_tree(&node.children, depth + 1, expanded_dirs));
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
|
|
let mut files = Vec::new();
|
|
|
|
if node.is_dir {
|
|
for child in &node.children {
|
|
files.extend(collect_files_from_node(child));
|
|
}
|
|
} else {
|
|
files.push(node.path.clone());
|
|
}
|
|
|
|
files
|
|
}
|
|
|
|
fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
|
|
// Avoid allocations by comparing chars directly with case-insensitive logic
|
|
let mut text_chars = text.chars();
|
|
let mut score = 0;
|
|
let mut prev_match_idx = 0;
|
|
let mut consecutive_bonus = 0;
|
|
let mut prev_char = '\0';
|
|
|
|
for query_char in query.chars() {
|
|
// Lowercase query char inline
|
|
let query_char_lower = query_char.to_lowercase().next().unwrap_or(query_char);
|
|
let mut found = false;
|
|
let mut current_idx = prev_match_idx;
|
|
|
|
for text_char in text_chars.by_ref() {
|
|
current_idx += 1;
|
|
// Lowercase text char inline for comparison
|
|
let text_char_lower = text_char.to_lowercase().next().unwrap_or(text_char);
|
|
|
|
if text_char_lower == query_char_lower {
|
|
found = true;
|
|
// Bonus for consecutive matches
|
|
if current_idx == prev_match_idx + 1 {
|
|
consecutive_bonus += FUZZY_CONSECUTIVE_BONUS;
|
|
} else {
|
|
consecutive_bonus = 0;
|
|
}
|
|
// Bonus for matching at word start
|
|
if current_idx == 1 || !prev_char.is_alphanumeric() {
|
|
score += FUZZY_WORD_START_BONUS;
|
|
}
|
|
score += consecutive_bonus;
|
|
// Penalty for gap
|
|
score -= (current_idx - prev_match_idx - 1) as i32;
|
|
prev_match_idx = current_idx;
|
|
prev_char = text_char;
|
|
break;
|
|
}
|
|
prev_char = text_char;
|
|
}
|
|
|
|
if !found {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
Some(score)
|
|
}
|
|
|
|
fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec<(PathBuf, i32)>) {
|
|
for node in nodes {
|
|
if let Some(mut score) = fuzzy_match(&node.name, query) {
|
|
// Give folders a significant boost so they appear before files
|
|
if node.is_dir {
|
|
score += FUZZY_FOLDER_BONUS;
|
|
}
|
|
matches.push((node.path.clone(), score));
|
|
}
|
|
if node.is_dir && !node.children.is_empty() {
|
|
collect_matching_paths(&node.children, query, matches);
|
|
}
|
|
}
|
|
}
|