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
This commit is contained in:
Christoffer Martinsson 2025-12-11 19:39:26 +01:00
parent 6ad522f27c
commit 4529fad61d
4 changed files with 81 additions and 53 deletions

View File

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

View File

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

View File

@ -1,7 +1,7 @@
use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Instant;
// Fuzzy match scoring bonuses
@ -96,7 +96,9 @@ pub struct AppState {
#[derive(Debug, Clone)]
pub struct FlattenedItem {
pub node: FileTreeNode,
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize,
}
@ -336,11 +338,29 @@ impl AppState {
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.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded {
@ -348,7 +368,7 @@ impl AppState {
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.node.path == path) {
if let Some(idx) = self.flattened_items.iter().position(|i| i.path == path) {
self.selected_index = idx;
}
} else {
@ -358,19 +378,19 @@ impl AppState {
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.node.path == parent_buf) {
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.node.path.parent() {
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.node.path == parent_buf) {
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx;
}
}
@ -380,8 +400,8 @@ impl AppState {
pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
self.expanded_dirs.insert(path);
self.rebuild_flattened_items();
}
@ -401,8 +421,8 @@ impl AppState {
self.marked_files.clear();
// Mark current file
if let Some(item) = self.get_selected_item() {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -418,8 +438,8 @@ impl AppState {
for i in start..=end {
if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -439,15 +459,19 @@ impl AppState {
files.sort();
self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(&node);
files.sort();
self.playlist.extend(files);
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(node.path.clone());
self.playlist.push(path);
}
}
}
@ -468,22 +492,24 @@ impl AppState {
self.current_file = None;
}
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Play all files in directory
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;
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
let path = node.path.clone();
self.playlist = vec![path.clone()];
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
@ -674,7 +700,7 @@ impl AppState {
}
// 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;
// Scroll to show the match
@ -741,7 +767,7 @@ impl AppState {
self.rebuild_flattened_items();
// 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;
}
}
@ -766,7 +792,7 @@ impl AppState {
self.rebuild_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;
// Scroll to show the match
@ -813,7 +839,7 @@ impl AppState {
self.rebuild_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;
// Scroll to show the match
@ -860,7 +886,7 @@ impl AppState {
self.rebuild_flattened_items();
// 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;
// Scroll to show the match
@ -903,7 +929,7 @@ impl AppState {
self.rebuild_flattened_items();
// 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;
// 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);
result.push(FlattenedItem {
node: node.clone(),
path: node.path.clone(),
name: node.name.clone(),
is_dir: node.is_dir,
depth,
});

View File

@ -142,15 +142,15 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
.map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx;
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
// Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add icon for directories and files
let icon = if item.node.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path);
let icon = if item.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
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 {
// File icons based on extension
let extension = item.node.path.extension()
let extension = item.path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.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() {
highlight_search_matches(&item.node.name, &search_query, is_selected)
highlight_search_matches(&item.name, &search_query, is_selected)
} 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 {
// 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 {
Theme::selected()
}
} else if state.marked_files.contains(&item.node.path) {
} else if state.marked_files.contains(&item.path) {
Theme::marked()
} else {
Theme::secondary()