1 Commits

Author SHA1 Message Date
4529fad61d Reduce memory usage by ~50 MB for large libraries
All checks were successful
Build and Release / build-and-release (push) Successful in 50s
FlattenedItem now stores only essential fields (path, name, is_dir, depth)
instead of cloning entire FileTreeNode structures. For 500,000 files, this
reduces memory from ~100 MB to ~50 MB for the flattened view.

- Extract only needed fields in flatten_tree()
- Add find_node_by_path() helper to look up full nodes when needed
- Update all UI and state code to use new structure
2025-12-11 19:39:26 +01:00
4 changed files with 81 additions and 53 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-player" name = "cm-player"
version = "0.1.23" version = "0.1.24"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -97,8 +97,8 @@ fn main() -> Result<()> {
fn action_toggle_folder(state: &mut AppState) { fn action_toggle_folder(state: &mut AppState) {
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
if state.expanded_dirs.contains(&path) { if state.expanded_dirs.contains(&path) {
// Folder is open, close it // Folder is open, close it
state.collapse_selected(); state.collapse_selected();
@@ -936,7 +936,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if is_double_click { if is_double_click {
// Double click = toggle folder or play file // Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if item.node.is_dir { if item.is_dir {
action_toggle_folder(state); action_toggle_folder(state);
} else { } else {
action_play_selection(state, player)?; action_play_selection(state, player)?;

View File

@@ -1,7 +1,7 @@
use crate::cache::{Cache, FileTreeNode}; use crate::cache::{Cache, FileTreeNode};
use crate::config::Config; use crate::config::Config;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Instant; use std::time::Instant;
// Fuzzy match scoring bonuses // Fuzzy match scoring bonuses
@@ -96,7 +96,9 @@ pub struct AppState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FlattenedItem { pub struct FlattenedItem {
pub node: FileTreeNode, pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize, pub depth: usize,
} }
@@ -336,11 +338,29 @@ impl AppState {
self.flattened_items.get(self.selected_index) 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) { pub fn collapse_selected(&mut self) {
let item = self.get_selected_item().cloned(); let item = self.get_selected_item().cloned();
if let Some(item) = item { if let Some(item) = item {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path); let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded { if was_expanded {
@@ -348,7 +368,7 @@ impl AppState {
self.expanded_dirs.remove(&path); self.expanded_dirs.remove(&path);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the collapsed folder and select it // Find the collapsed folder and select it
if let Some(idx) = self.flattened_items.iter().position(|i| i.node.path == path) { if let Some(idx) = self.flattened_items.iter().position(|i| i.path == path) {
self.selected_index = idx; self.selected_index = idx;
} }
} else { } else {
@@ -358,19 +378,19 @@ impl AppState {
self.expanded_dirs.remove(&parent_buf); self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Jump to parent folder // Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx; self.selected_index = parent_idx;
} }
} }
} }
} else { } else {
// Close parent folder when on a file and jump to it // Close parent folder when on a file and jump to it
if let Some(parent) = item.node.path.parent() { if let Some(parent) = item.path.parent() {
let parent_buf = parent.to_path_buf(); let parent_buf = parent.to_path_buf();
self.expanded_dirs.remove(&parent_buf); self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Jump to parent folder // Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx; self.selected_index = parent_idx;
} }
} }
@@ -380,8 +400,8 @@ impl AppState {
pub fn expand_selected(&mut self) { pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() { if let Some(item) = self.get_selected_item() {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
self.expanded_dirs.insert(path); self.expanded_dirs.insert(path);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
} }
@@ -401,8 +421,8 @@ impl AppState {
self.marked_files.clear(); self.marked_files.clear();
// Mark current file // Mark current file
if let Some(item) = self.get_selected_item() { if let Some(item) = self.get_selected_item() {
if !item.node.is_dir { if !item.is_dir {
self.marked_files.insert(item.node.path.clone()); self.marked_files.insert(item.path.clone());
} }
} }
} }
@@ -418,8 +438,8 @@ impl AppState {
for i in start..=end { for i in start..=end {
if let Some(item) = self.flattened_items.get(i) { if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir { if !item.is_dir {
self.marked_files.insert(item.node.path.clone()); self.marked_files.insert(item.path.clone());
} }
} }
} }
@@ -439,15 +459,19 @@ impl AppState {
files.sort(); files.sort();
self.playlist.extend(files); self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let path = item.path.clone();
if node.is_dir { 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) // Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(&node); let mut files = collect_files_from_node(node);
files.sort(); files.sort();
self.playlist.extend(files); self.playlist.extend(files);
}
} else { } else {
// Add single file (allow duplicates) // Add single file (allow duplicates)
self.playlist.push(node.path.clone()); self.playlist.push(path);
} }
} }
} }
@@ -468,10 +492,12 @@ impl AppState {
self.current_file = None; self.current_file = None;
} }
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let path = item.path.clone();
if node.is_dir { let is_dir = item.is_dir;
// Play all files in directory if is_dir {
self.playlist = collect_files_from_node(&node); // 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_index = 0;
self.playlist_scroll_offset = 0; self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0; self.selected_playlist_index = 0;
@@ -481,9 +507,9 @@ impl AppState {
// Empty directory // Empty directory
self.current_file = None; self.current_file = None;
} }
}
} else { } else {
// Play single file // Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()]; self.playlist = vec![path.clone()];
self.playlist_index = 0; self.playlist_index = 0;
self.playlist_scroll_offset = 0; self.playlist_scroll_offset = 0;
@@ -674,7 +700,7 @@ impl AppState {
} }
// Find the best match in the flattened list and jump to it // Find the best match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -741,7 +767,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find first match in flattened list // Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
self.selected_index = idx; self.selected_index = idx;
} }
} }
@@ -766,7 +792,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the path in current flattened items // Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -813,7 +839,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the path in current flattened items // Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -860,7 +886,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find and select the match // Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -903,7 +929,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find and select the match // Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -1160,7 +1186,9 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<Pa
let is_expanded = expanded_dirs.contains(&node.path); let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem { result.push(FlattenedItem {
node: node.clone(), path: node.path.clone(),
name: node.name.clone(),
is_dir: node.is_dir,
depth, depth,
}); });

View File

@@ -142,15 +142,15 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
.map(|(display_idx, item)| { .map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx; let idx = state.scroll_offset + display_idx;
let indent = " ".repeat(item.depth); let indent = " ".repeat(item.depth);
let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" }; let mark = if state.marked_files.contains(&item.path) { "* " } else { "" };
// Build name with search highlighting // Build name with search highlighting
// Only show selection bar when file panel has focus // Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index; let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add icon for directories and files // Add icon for directories and files
let icon = if item.node.is_dir { let icon = if item.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path); let is_expanded = state.expanded_dirs.contains(&item.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed // Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " }; let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " };
@@ -162,7 +162,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} }
} else { } else {
// File icons based on extension // File icons based on extension
let extension = item.node.path.extension() let extension = item.path.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
@@ -184,12 +184,12 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} }
}; };
let name_spans = if in_search && !search_query.is_empty() { let name_spans = if in_search && !search_query.is_empty() {
highlight_search_matches(&item.node.name, &search_query, is_selected) highlight_search_matches(&item.name, &search_query, is_selected)
} else { } else {
vec![Span::raw(&item.node.name)] vec![Span::raw(&item.name)]
}; };
let suffix = if item.node.is_dir { "/" } else { "" }; let suffix = if item.is_dir { "/" } else { "" };
let base_style = if is_selected { let base_style = if is_selected {
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise // Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
@@ -198,7 +198,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} else { } else {
Theme::selected() Theme::selected()
} }
} else if state.marked_files.contains(&item.node.path) { } else if state.marked_files.contains(&item.path) {
Theme::marked() Theme::marked()
} else { } else {
Theme::secondary() Theme::secondary()