From b8d0ad852a3730d0e4861c53cdb089e53e901905 Mon Sep 17 00:00:00 2001 From: avdpav Date: Sat, 11 Jul 2026 19:25:41 +0300 Subject: [PATCH] feat: basic directory navigation Directories are no longer skipped when loading the library. Introduces `DirectoryState` to manage and remember the state of an open directory. It is now also responsible for loading the books inside a directory, `BooManager` simply delegates the tasks to the current state. --- src/book_manager.rs | 502 ++++++++++++++++++---------- src/main_app.rs | 8 + src/widget/navigation_panel/mod.rs | 14 +- tests/keybinding_actions.rs | 8 +- tests/svg_snapshots.rs | 6 +- tests/vim_motion_component_tests.rs | 2 +- 6 files changed, 350 insertions(+), 190 deletions(-) diff --git a/src/book_manager.rs b/src/book_manager.rs index ac294852..aded446a 100644 --- a/src/book_manager.rs +++ b/src/book_manager.rs @@ -4,6 +4,7 @@ use crate::settings::is_pdf_enabled; use crate::settings::{BookSortOrder, get_book_sort_order}; use epub::doc::EpubDoc; use log::{error, info}; +use std::collections::HashMap; use std::io::BufReader; use std::path::Path; use walkdir::WalkDir; @@ -15,11 +16,17 @@ pub enum LibraryMode { } pub struct BookManager { - pub books: Vec, scan_directory: String, - pub library_mode: LibraryMode, + root_directory: String, #[cfg(feature = "pdf")] pub supports_graphics: bool, + states: HashMap, +} + +struct DirectoryState { + scan_directory: String, + books: Vec, + library_mode: LibraryMode, } /// Format of a book file @@ -31,6 +38,7 @@ pub enum BookFormat { Pdf, #[cfg(feature = "pdf")] Djvu, + Dir, } #[derive(Clone)] @@ -52,192 +60,65 @@ impl BookManager { } pub fn new_with_directory(directory: &str) -> Self { + let state = DirectoryState::new_with_directory(directory); let scan_directory = directory.to_string(); - let library_mode = if Self::is_calibre_library(&scan_directory) { - info!("Detected Calibre library at {scan_directory}"); - LibraryMode::Calibre - } else { - LibraryMode::Standard - }; - let mut books = match library_mode { - LibraryMode::Calibre => Self::discover_books_in_calibre_library(&scan_directory), - LibraryMode::Standard => Self::discover_books_in_dir(&scan_directory), - }; - books.sort_by(|a, b| { - a.display_name - .to_lowercase() - .cmp(&b.display_name.to_lowercase()) - }); + let mut states = HashMap::new(); + states.insert(scan_directory.clone(), state); + Self { - books, - scan_directory, - library_mode, + scan_directory: scan_directory.clone(), + root_directory: scan_directory, #[cfg(feature = "pdf")] supports_graphics: false, + states, } } - fn is_calibre_library(dir: &str) -> bool { - Path::new(dir).join("metadata.db").exists() + pub fn set_books(&mut self, books: Vec) { + self.current_state_mut().books = books; } - pub fn is_calibre_mode(&self) -> bool { - self.library_mode == LibraryMode::Calibre + fn current_state(&self) -> &DirectoryState { + self.states.get(&self.scan_directory).unwrap() } - fn discover_books_in_dir(dir: &str) -> Vec { - std::fs::read_dir(dir) - .unwrap_or_else(|e| { - error!("Failed to read directory {dir}: {e}"); - panic!("Failed to read directory {dir}: {e}"); - }) - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - let extension = path.extension()?.to_str()?.to_lowercase(); - let format = match extension.as_str() { - "epub" => Some(BookFormat::Epub), - "html" | "htm" => Some(BookFormat::Html), - #[cfg(feature = "pdf")] - "pdf" => Some(BookFormat::Pdf), - #[cfg(feature = "pdf")] - "djvu" | "djv" => Some(BookFormat::Djvu), - _ => None, - }?; - let path_str = path.to_str()?.to_string(); - let display_name = Self::extract_display_name(&path_str); - Some(BookInfo { - path: path_str, - display_name, - format, - }) - }) - .collect() + fn current_state_mut(&mut self) -> &mut DirectoryState { + self.states.get_mut(&self.scan_directory).unwrap() } - fn discover_books_in_calibre_library(dir: &str) -> Vec { - let start = std::time::Instant::now(); - let mut books = Vec::new(); - let mut files_visited: u64 = 0; - - // Calibre structure is always: Author/Book Title (id)/file.epub — depth 3 max. - // Without a limit, WalkDir would descend into temp_images/, .git/, cloud-synced - // dirs, etc., which can stall or take minutes on large filesystems. - let mut last_log_time = start; - for entry in WalkDir::new(dir) - .max_depth(3) - .into_iter() - .filter_map(Result::ok) - .filter(|e| e.file_type().is_file()) - { - files_visited += 1; - let now = std::time::Instant::now(); - if now.duration_since(last_log_time).as_secs() >= 5 { - info!( - "Calibre scan in progress: {} books found so far, {} files visited ({:.1}s elapsed)", - books.len(), - files_visited, - now.duration_since(start).as_secs_f64() - ); - last_log_time = now; - } - - let path = entry.path(); - let path_str = match path.to_str() { - Some(s) => s.to_string(), - None => continue, - }; - - let format = match Self::detect_format(&path_str) { - Some(BookFormat::Epub) => Some(BookFormat::Epub), - #[cfg(feature = "pdf")] - Some(BookFormat::Pdf) => Some(BookFormat::Pdf), - #[cfg(feature = "pdf")] - Some(BookFormat::Djvu) => Some(BookFormat::Djvu), - _ => None, - }; - - let Some(format) = format else { - continue; - }; - - let display_name = path - .parent() - .and_then(Self::parse_calibre_opf) - .unwrap_or_else(|| Self::extract_display_name(&path_str)); - - books.push(BookInfo { - path: path_str, - display_name, - format, - }); - } - - info!( - "Calibre library scan: {} books found, {} files visited in {:.2}s", - books.len(), - files_visited, - start.elapsed().as_secs_f64() - ); - - books + pub fn is_calibre_mode(&self) -> bool { + self.current_state().is_calibre_mode() } - fn parse_calibre_opf(book_dir: &Path) -> Option { - let opf_path = book_dir.join("metadata.opf"); - let content = std::fs::read_to_string(&opf_path).ok()?; - let doc = roxmltree::Document::parse(&content).ok()?; - - let mut title: Option = None; - let mut author: Option = None; - - for node in doc.descendants() { - if node.tag_name().name() == "title" && title.is_none() { - title = node.text().map(|s| s.trim().to_string()); - } - if node.tag_name().name() == "creator" && author.is_none() { - author = node.text().map(|s| s.trim().to_string()); - } - if title.is_some() && author.is_some() { - break; - } - } - - let title = title?; - Some(match author { - Some(a) if !a.is_empty() => format!("{title} - {a}"), - _ => title, - }) + pub fn navigate_to(&mut self, new_dir: &str) { + self.states + .entry(new_dir.to_string()) + .or_insert_with(|| DirectoryState::new_with_directory(new_dir)); + self.scan_directory = new_dir.to_string(); } - fn extract_display_name(file_path: &str) -> String { - let path = Path::new(file_path); - - // For HTML files, preserve the full filename with extension - if let Some(extension) = path.extension() { - if extension == "html" || extension == "htm" { - return path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - } + fn back_entry(&self) -> Option { + if self.scan_directory == self.root_directory { + return None; } - - // For other files (like EPUB), remove the extension - path.file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string() + let parent = Path::new(&self.scan_directory).parent()?; + Some(BookInfo { + path: parent.to_str()?.to_string(), + display_name: "← Go Back".to_string(), + format: BookFormat::Dir, + }) } pub fn get_book_info(&self, index: usize) -> Option<&BookInfo> { - self.books.get(index) + self.current_state().books.get(index) } pub fn get_book_by_path(&self, path: &str) -> Option<&BookInfo> { - self.books.iter().find(|book| book.path == path) + self.current_state() + .books + .iter() + .find(|book| book.path == path) } pub fn load_epub(&self, path: &str) -> Result>, String> { @@ -561,15 +442,7 @@ impl BookManager { } pub fn refresh_books(&mut self) { - self.books = match self.library_mode { - LibraryMode::Calibre => Self::discover_books_in_calibre_library(&self.scan_directory), - LibraryMode::Standard => Self::discover_books_in_dir(&self.scan_directory), - }; - self.books.sort_by(|a, b| { - a.display_name - .to_lowercase() - .cmp(&b.display_name.to_lowercase()) - }); + self.current_state_mut().refresh_books(); } /// Refresh and get filtered books list @@ -584,6 +457,7 @@ impl BookManager { { if !is_pdf_enabled() || !self.supports_graphics { books = self + .current_state() .books .iter() .filter(|book| { @@ -592,12 +466,12 @@ impl BookManager { .cloned() .collect(); } else { - books = self.books.clone(); + books = self.current_state().books.clone(); } } #[cfg(not(feature = "pdf"))] { - books = self.books.clone(); + books = self.current_state().books.clone(); } if get_book_sort_order() == BookSortOrder::ByType { @@ -610,6 +484,7 @@ impl BookManager { BookFormat::Djvu => 0, BookFormat::Epub => 1, BookFormat::Html => 2, + BookFormat::Dir => 3, } }; type_order(&a.format) @@ -622,20 +497,31 @@ impl BookManager { }); } + if let Some(back_entry) = self.back_entry() { + books.insert(0, back_entry); + } + books } pub fn find_book_index_by_path(&self, path: &str) -> Option { - self.books.iter().position(|book| book.path == path) + self.current_state() + .books + .iter() + .position(|book| book.path == path) } pub fn contains_book(&self, path: &str) -> bool { - self.books.iter().any(|book| book.path == path) + self.current_state() + .books + .iter() + .any(|book| book.path == path) } /// Get the format of a book by path pub fn get_format(&self, path: &str) -> Option { - self.books + self.current_state() + .books .iter() .find(|book| book.path == path) .map(|book| book.format) @@ -671,6 +557,217 @@ impl BookManager { } } +impl DirectoryState { + pub fn new_with_directory(directory: &str) -> Self { + let scan_directory = directory.to_string(); + let library_mode = if Self::is_calibre_library(&scan_directory) { + info!("Detected Calibre library at {scan_directory}"); + LibraryMode::Calibre + } else { + LibraryMode::Standard + }; + + let mut books = match library_mode { + LibraryMode::Calibre => Self::discover_books_in_calibre_library(&scan_directory), + LibraryMode::Standard => Self::discover_books_in_dir(&scan_directory), + }; + books.sort_by(|a, b| { + a.display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()) + }); + + Self { + scan_directory, + books, + library_mode, + } + } + + fn discover_books_in_calibre_library(dir: &str) -> Vec { + let start = std::time::Instant::now(); + let mut books = Vec::new(); + let mut files_visited: u64 = 0; + + // Calibre structure is always: Author/Book Title (id)/file.epub — depth 3 max. + // Without a limit, WalkDir would descend into temp_images/, .git/, cloud-synced + // dirs, etc., which can stall or take minutes on large filesystems. + let mut last_log_time = start; + for entry in WalkDir::new(dir) + .max_depth(3) + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_file()) + { + files_visited += 1; + let now = std::time::Instant::now(); + if now.duration_since(last_log_time).as_secs() >= 5 { + info!( + "Calibre scan in progress: {} books found so far, {} files visited ({:.1}s elapsed)", + books.len(), + files_visited, + now.duration_since(start).as_secs_f64() + ); + last_log_time = now; + } + + let path = entry.path(); + let path_str = match path.to_str() { + Some(s) => s.to_string(), + None => continue, + }; + + let format = match BookManager::detect_format(&path_str) { + Some(BookFormat::Epub) => Some(BookFormat::Epub), + #[cfg(feature = "pdf")] + Some(BookFormat::Pdf) => Some(BookFormat::Pdf), + #[cfg(feature = "pdf")] + Some(BookFormat::Djvu) => Some(BookFormat::Djvu), + _ => None, + }; + + let Some(format) = format else { + continue; + }; + + let display_name = path + .parent() + .and_then(Self::parse_calibre_opf) + .unwrap_or_else(|| Self::extract_display_name(&path_str, false)); + + books.push(BookInfo { + path: path_str, + display_name, + format, + }); + } + + info!( + "Calibre library scan: {} books found, {} files visited in {:.2}s", + books.len(), + files_visited, + start.elapsed().as_secs_f64() + ); + + books + } + + fn discover_books_in_dir(dir: &str) -> Vec { + std::fs::read_dir(dir) + .unwrap_or_else(|e| { + error!("Failed to read directory {dir}: {e}"); + panic!("Failed to read directory {dir}: {e}"); + }) + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + + let mut format = BookFormat::Dir; + if path.is_file() { + let extension = path.extension()?.to_str()?.to_lowercase(); + format = match extension.as_str() { + "epub" => Some(BookFormat::Epub), + "html" | "htm" => Some(BookFormat::Html), + #[cfg(feature = "pdf")] + "pdf" => Some(BookFormat::Pdf), + #[cfg(feature = "pdf")] + "djvu" | "djv" => Some(BookFormat::Djvu), + _ => None, + }?; + } + + let path_str = path.to_str()?.to_string(); + let display_name = Self::extract_display_name(&path_str, format == BookFormat::Dir); + + Some(BookInfo { + path: path_str, + display_name, + format, + }) + }) + .collect() + } + + fn is_calibre_library(dir: &str) -> bool { + Path::new(dir).join("metadata.db").exists() + } + + fn is_calibre_mode(&self) -> bool { + self.library_mode == LibraryMode::Calibre + } + + fn extract_display_name(file_path: &str, is_dir: bool) -> String { + let path = Path::new(file_path); + + // For HTML files, preserve the full filename with extension + if let Some(extension) = path.extension() { + if extension == "html" || extension == "htm" { + return path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + } + } + + // Keep the original file name for dirs adding a visual indicator `/` + // and for other files (like EPUB), remove the extension + if is_dir { + let mut name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + name.push('/'); + name + } else { + path.file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string() + } + } + + fn parse_calibre_opf(book_dir: &Path) -> Option { + let opf_path = book_dir.join("metadata.opf"); + let content = std::fs::read_to_string(&opf_path).ok()?; + let doc = roxmltree::Document::parse(&content).ok()?; + + let mut title: Option = None; + let mut author: Option = None; + + for node in doc.descendants() { + if node.tag_name().name() == "title" && title.is_none() { + title = node.text().map(|s| s.trim().to_string()); + } + if node.tag_name().name() == "creator" && author.is_none() { + author = node.text().map(|s| s.trim().to_string()); + } + if title.is_some() && author.is_some() { + break; + } + } + + let title = title?; + Some(match author { + Some(a) if !a.is_empty() => format!("{title} - {a}"), + _ => title, + }) + } + + fn refresh_books(&mut self) { + self.books = match self.library_mode { + LibraryMode::Calibre => Self::discover_books_in_calibre_library(&self.scan_directory), + LibraryMode::Standard => Self::discover_books_in_dir(&self.scan_directory), + }; + self.books.sort_by(|a, b| { + a.display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()) + }); + } +} + #[cfg(test)] mod tests { use super::*; @@ -696,7 +793,11 @@ mod tests { fs::write(temp_dir.path().join("scan.djvu"), b"fake").unwrap(); let manager = BookManager::new_with_directory(temp_dir.path().to_str().unwrap()); - assert_eq!(manager.books.len(), 3, "all 3 files should be discovered"); + assert_eq!( + manager.current_state().books.len(), + 3, + "all 3 files should be discovered" + ); let prev = is_pdf_enabled(); set_pdf_enabled(false); @@ -728,7 +829,11 @@ mod tests { fs::write(temp_dir.path().join("scan.djvu"), b"fake").unwrap(); let manager = BookManager::new_with_directory(temp_dir.path().to_str().unwrap()); - assert_eq!(manager.books.len(), 3, "all 3 files should be discovered"); + assert_eq!( + manager.current_state().books.len(), + 3, + "all 3 files should be discovered" + ); // pdf_enabled is true (default) — simulating a user who has never toggled the setting. // But the terminal doesn't support graphics, so PDFs/DJVUs should still be hidden. @@ -836,4 +941,43 @@ mod tests { assert!(doc.get_num_chapters() >= 1); assert!(doc.get_current_str().is_some()); } + + #[test] + fn can_navigate_directories() { + let temp_dir = TempDir::new().unwrap(); + let child_dir = temp_dir.path().join("subdir"); + fs::create_dir_all(&child_dir).unwrap(); + // Create a dummy file so discover_books_in_dir picks it up + fs::write(child_dir.join("novel.epub"), b"fake").unwrap(); + + let mut manager = BookManager::new_with_directory(temp_dir.path().to_str().unwrap()); + manager.navigate_to(child_dir.to_str().unwrap()); + + let books = manager.get_books(); + assert_eq!(books.len(), 2, "the go-back entry plus the discovered epub",); + assert_eq!( + books[0].path, + temp_dir.path().to_str().unwrap().to_string(), + "Parent path is the first item in the list of books", + ); + assert_eq!(books[0].format, BookFormat::Dir); + } + + #[test] + fn back_entry_survives_refresh() { + let temp_dir = TempDir::new().unwrap(); + let child_dir = temp_dir.path().join("subdir"); + fs::create_dir_all(&child_dir).unwrap(); + + let mut manager = BookManager::new_with_directory(temp_dir.path().to_str().unwrap()); + manager.navigate_to(child_dir.to_str().unwrap()); + manager.refresh_books(); + + let books = manager.get_books(); + assert_eq!( + books.first().map(|b| b.path.as_str()), + Some(temp_dir.path().to_str().unwrap()), + "go-back entry must still be present after refresh_books()" + ); + } } diff --git a/src/main_app.rs b/src/main_app.rs index 927ba681..3729d739 100644 --- a/src/main_app.rs +++ b/src/main_app.rs @@ -1092,6 +1092,7 @@ impl App { BookFormat::Epub | BookFormat::Html => { self.load_epub(&path_owned, skip_bookmarks)?; } + _ => {} } self.navigation_panel.current_book_path = Some(path_owned); @@ -3930,6 +3931,13 @@ impl App { self.switch_to_book_list_mode(); false } + NavigationPanelAction::NavigateToDir { dir_path } => { + self.book_manager.navigate_to(dir_path.as_str()); + self.navigation_panel + .book_list + .set_books(self.book_manager.get_books()); + false + } NavigationPanelAction::NavigateToChapter { href, anchor } => { // Check if this is a PDF navigation #[cfg(feature = "pdf")] diff --git a/src/widget/navigation_panel/mod.rs b/src/widget/navigation_panel/mod.rs index 9a00d0c0..86973d85 100644 --- a/src/widget/navigation_panel/mod.rs +++ b/src/widget/navigation_panel/mod.rs @@ -4,7 +4,7 @@ pub mod table_of_contents; pub use book_list::BookList; pub use table_of_contents::{SelectedTocItem, TableOfContents, TocItem}; -use crate::book_manager::BookManager; +use crate::book_manager::{BookFormat, BookManager}; use crate::inputs::KeySeq; use crate::main_app::VimNavMotions; use crate::markdown_text_reader::ActiveSection; @@ -36,6 +36,9 @@ pub enum NavigationPanelAction { ToggleSection, TocExpansionChanged, SwitchToBookList, + NavigateToDir { + dir_path: String, + }, ToggleSortOrder, Bypass, // when the component assumes the upper layer should handle the action } @@ -185,8 +188,13 @@ impl NavigationPanel { NavigationMode::BookSelection => { self.book_list .get_selected_book() - .map(|book| NavigationPanelAction::SelectBook { - book_path: book.path.clone(), + .map(|book| match book.format { + BookFormat::Dir => NavigationPanelAction::NavigateToDir { + dir_path: book.path.clone(), + }, + _ => NavigationPanelAction::SelectBook { + book_path: book.path.clone(), + }, }) } NavigationMode::TableOfContents => match self.table_of_contents.get_selected_item() { diff --git a/tests/keybinding_actions.rs b/tests/keybinding_actions.rs index 79566509..165965f8 100644 --- a/tests/keybinding_actions.rs +++ b/tests/keybinding_actions.rs @@ -50,12 +50,12 @@ fn create_app_content_focused() -> (App, TempDir) { } fn open_book(app: &mut App) { - let path = app - .book_manager - .books + let books = app.book_manager.get_books(); + let fallback_book = books.first().expect("no test books"); + let path = books .iter() .find(|b| b.path.ends_with("digital_frontier.epub")) - .unwrap_or_else(|| app.book_manager.books.first().expect("no test books")) + .unwrap_or_else(|| fallback_book) .path .clone(); let _ = app.open_book_for_reading_by_path(&path, None); diff --git a/tests/svg_snapshots.rs b/tests/svg_snapshots.rs index 4ba057c4..a7588ac8 100644 --- a/tests/svg_snapshots.rs +++ b/tests/svg_snapshots.rs @@ -145,7 +145,7 @@ fn create_test_failure_handler( fn open_test_book(app: &mut App, filename: &str) { let path = app .book_manager - .books + .get_books() .iter() .find(|b| b.path.ends_with(filename)) .unwrap_or_else(|| panic!("test book {filename} not found in testdata")) @@ -157,7 +157,7 @@ fn open_test_book(app: &mut App, filename: &str) { fn open_first_book(app: &mut App) { let path = app .book_manager - .books + .get_books() .first() .expect("no books found in test directory") .path @@ -976,7 +976,7 @@ fn test_open_at_chapter_svg() { let path = app .book_manager - .books + .get_books() .iter() .find(|b| b.path.ends_with("digital_frontier.epub")) .expect("digital_frontier.epub not found") diff --git a/tests/vim_motion_component_tests.rs b/tests/vim_motion_component_tests.rs index e422b221..269d2dce 100644 --- a/tests/vim_motion_component_tests.rs +++ b/tests/vim_motion_component_tests.rs @@ -59,7 +59,7 @@ fn create_test_book_manager() -> BookManager { format: BookFormat::Epub, }) } - book_manager.books = books; + book_manager.set_books(books); book_manager }