From c5bff149c03985a3558abebf15bbf39ea2cce533 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Fri, 31 May 2024 14:54:19 -0600 Subject: [PATCH] Implement search backend --- Cargo.lock | 42 +++++++++++ Cargo.toml | 3 + src/app.rs | 181 +++++++++++++++++++++++++++++++++++++++++++++++- src/key_bind.rs | 1 + src/tab.rs | 149 ++++++++++++++++++++------------------- 5 files changed, 302 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb1f490..062324c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,6 +725,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -1123,6 +1133,7 @@ dependencies = [ "gio", "i18n-embed", "i18n-embed-fl", + "ignore", "image", "lexical-sort", "libc", @@ -1133,6 +1144,8 @@ dependencies = [ "once_cell", "open", "paste", + "rayon", + "regex", "rust-embed", "serde", "shlex", @@ -2288,6 +2301,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.6", + "regex-syntax 0.8.3", +] + [[package]] name = "glow" version = "0.13.1" @@ -2795,6 +2821,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.6", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.24.9" diff --git a/Cargo.toml b/Cargo.toml index 5beae96..b5f752a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ env_logger = "0.11" freedesktop_entry_parser = { version = "1.3", optional = true } fs_extra = "1.3" gio = { version = "0.19", optional = true } +ignore = "0.4" image = "0.24" once_cell = "1.19" open = "5.0.2" @@ -25,6 +26,8 @@ log = "0.4" mime_guess = "2" notify-debouncer-full = "0.3" paste = "1.0" +rayon = "1" +regex = "1" serde = { version = "1", features = ["serde_derive"] } shlex = { version = "1.3" } tokio = { version = "1", features = ["sync"] } diff --git a/src/app.rs b/src/app.rs index 0fc2964..94aa21b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -29,6 +29,7 @@ use notify_debouncer_full::{ notify::{self, RecommendedWatcher, Watcher}, DebouncedEvent, Debouncer, FileIdMap, }; +use rayon::slice::ParallelSliceMut; use slotmap::Key as SlotMapKey; use std::{ any::TypeId, @@ -37,7 +38,7 @@ use std::{ num::NonZeroU16, path::PathBuf, process, - sync::Arc, + sync::{Arc, Mutex}, time::{self, Instant}, }; @@ -85,6 +86,7 @@ pub enum Action { Properties, Rename, RestoreFromTrash, + SearchActivate, SelectAll, Settings, TabClose, @@ -129,6 +131,7 @@ impl Action { Action::Properties => Message::ToggleContextPage(ContextPage::Properties(None)), Action::Rename => Message::Rename(entity_opt), Action::RestoreFromTrash => Message::RestoreFromTrash(entity_opt), + Action::SearchActivate => Message::SearchActivate, Action::SelectAll => Message::TabMessage(entity_opt, tab::Message::SelectAll), Action::Settings => Message::ToggleContextPage(ContextPage::Settings), Action::TabClose => Message::TabClose(entity_opt), @@ -222,6 +225,11 @@ pub enum Message { RescanTrash, Rename(Option), RestoreFromTrash(Option), + SearchActivate, + SearchClear, + SearchInput(String), + SearchResults(String, Vec), + SearchSubmit, SystemThemeModeChange(cosmic_theme::ThemeMode), TabActivate(Entity), TabNext, @@ -331,6 +339,10 @@ pub struct App { pending_operations: BTreeMap, complete_operations: BTreeMap, failed_operations: BTreeMap, + search_active: bool, + search_id: widget::Id, + search_input: String, + search_results: Option>, watcher_opt: Option<(Debouncer, HashSet)>, nav_dnd_hover: Option<(Location, Instant)>, tab_dnd_hover: Option<(Entity, Instant)>, @@ -395,6 +407,105 @@ impl App { Command::batch(commands) } + fn search(&self) -> Command { + let input = self.search_input.clone(); + let pattern = regex::escape(&input); + let regex = match regex::RegexBuilder::new(&pattern) + .case_insensitive(true) + .build() + { + Ok(ok) => ok, + Err(err) => { + log::warn!("failed to parse regex {:?}: {}", pattern, err); + return Command::none(); + } + }; + Command::perform( + async move { + tokio::task::spawn_blocking(move || { + let start = Instant::now(); + + let results_arc = Arc::new(Mutex::new(Vec::new())); + //TODO: do we want to ignore files? + ignore::WalkBuilder::new(home_dir()) + //TODO: only use this on supported targets + .same_file_system(true) + .build_parallel() + .run(|| { + Box::new(|entry_res| { + let Ok(entry) = entry_res else { + // Skip invalid entries + return ignore::WalkState::Skip; + }; + + let Some(file_name) = entry.file_name().to_str() else { + // Skip anything with an invalid name + return ignore::WalkState::Skip; + }; + + if regex.is_match(file_name) { + let path = entry.path(); + + let metadata = match entry.metadata() { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to read metadata for entry at {:?}: {}", + path, + err + ); + return ignore::WalkState::Continue; + } + }; + + let mut results = results_arc.lock().unwrap(); + results.push(tab::item_from_entry( + path.to_path_buf(), + file_name.to_string(), + metadata, + IconSizes::default(), + )); + } + + ignore::WalkState::Continue + }) + }); + + let mut results = Arc::into_inner(results_arc).unwrap().into_inner().unwrap(); + let duration = start.elapsed(); + log::info!( + "searched for {:?} in {:?}, found {} results", + input, + duration, + results.len() + ); + + let start = Instant::now(); + + results.par_sort_unstable_by(|a, b| { + let get_modified = |x: &tab::Item| match &x.metadata { + ItemMetadata::Path { metadata, .. } => metadata.modified().ok(), + ItemMetadata::Trash { .. } => None, + }; + + // Sort with latest modified first + let a_modified = get_modified(a); + let b_modified = get_modified(b); + b_modified.cmp(&a_modified) + }); + + let duration = start.elapsed(); + log::info!("sorted results in {:?}", duration); + + message::app(Message::SearchResults(input, results)) + }) + .await + .unwrap_or(message::none()) + }, + |x| x, + ) + } + fn selected_paths(&self, entity_opt: Option) -> Vec { let mut paths = Vec::new(); let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); @@ -894,6 +1005,10 @@ impl Application for App { pending_operations: BTreeMap::new(), complete_operations: BTreeMap::new(), failed_operations: BTreeMap::new(), + search_active: false, + search_id: widget::Id::unique(), + search_input: String::new(), + search_results: None, watcher_opt: None, nav_dnd_hover: None, tab_dnd_hover: None, @@ -994,6 +1109,12 @@ impl Application for App { self.core.window.show_context = false; return Command::none(); } + if self.search_active { + // Close search if open + self.search_active = false; + self.search_results = None; + return Command::none(); + } if let Some(tab) = self.tab_model.data_mut::(entity) { if tab.context_menu.is_some() { tab.context_menu = None; @@ -1481,6 +1602,36 @@ impl Application for App { self.operation(Operation::Restore { paths }); } } + Message::SearchActivate => { + self.search_active = true; + return widget::text_input::focus(self.search_id.clone()); + } + Message::SearchClear => { + self.search_active = false; + self.search_input.clear(); + self.search_results = None; + } + Message::SearchInput(input) => { + if input != self.search_input { + self.search_input = input; + /*TODO: live search? (probably needs subscription for streaming results) + // This performs live search + if !self.search_input.is_empty() { + return self.search(); + } + */ + } + } + Message::SearchResults(input, results) => { + if input == self.search_input { + self.search_results = Some(results); + } + } + Message::SearchSubmit => { + if !self.search_input.is_empty() { + return self.search(); + } + } Message::SystemThemeModeChange(_theme_mode) => { return self.update_config(); } @@ -2045,13 +2196,39 @@ impl Application for App { } fn header_end(&self) -> Vec> { - vec![] + vec![if self.search_active { + widget::text_input::search_input("", &self.search_input) + .width(Length::Fixed(240.0)) + .id(self.search_id.clone()) + .on_clear(Message::SearchClear) + .on_input(Message::SearchInput) + .on_submit(Message::SearchSubmit) + .into() + } else { + widget::button::icon(widget::icon::from_name("system-search-symbolic")) + .on_press(Message::SearchActivate) + .into() + }] } /// Creates a view after each update. fn view(&self) -> Element { let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; + if let Some(results) = &self.search_results { + //TODO: pages? + let count = results.len().min(100); + if count == 0 { + //TODO: make it nicer + return widget::text("NO RESULTS").into(); + } + let mut column = widget::column::with_capacity(count); + for item in results.iter().take(count) { + column = column.push(widget::text(&item.name)); + } + return widget::scrollable(column.width(Length::Fill)).into(); + } + let mut tab_column = widget::column::with_capacity(1); if self.tab_model.iter().count() > 1 { diff --git a/src/key_bind.rs b/src/key_bind.rs index 72d5659..b02062e 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -43,6 +43,7 @@ pub fn key_binds() -> HashMap { bind!([Ctrl], Key::Character("v".into()), Paste); bind!([], Key::Named(Named::Space), Properties); bind!([], Key::Named(Named::F2), Rename); + bind!([Ctrl], Key::Character("f".into()), SearchActivate); bind!([Ctrl], Key::Character("a".into()), SelectAll); bind!([Ctrl], Key::Character("w".into()), TabClose); bind!([Ctrl], Key::Character("t".into()), TabNew); diff --git a/src/tab.rs b/src/tab.rs index 68a5a97..5032ba1 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -193,6 +193,77 @@ fn hidden_attribute(metadata: &Metadata) -> bool { metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN } +pub fn item_from_entry( + path: PathBuf, + name: String, + metadata: fs::Metadata, + sizes: IconSizes, +) -> Item { + let hidden = name.starts_with(".") || hidden_attribute(&metadata); + + let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = + if metadata.is_dir() { + ( + //TODO: make this a static + "inode/directory".parse().unwrap(), + folder_icon(&path, sizes.grid()), + folder_icon(&path, sizes.list()), + folder_icon(&path, sizes.list_condensed()), + ) + } else { + let mime = mime_for_path(&path); + ( + mime.clone(), + mime_icon(mime.clone(), sizes.grid()), + mime_icon(mime.clone(), sizes.list()), + mime_icon(mime, sizes.list_condensed()), + ) + }; + + let open_with = mime_apps(&mime); + + let thumbnail_opt = if mime.type_() == mime::IMAGE { + if mime.subtype() == mime::SVG { + Some(ItemThumbnail::Svg) + } else { + None + } + } else { + Some(ItemThumbnail::NotImage) + }; + + let children = if metadata.is_dir() { + //TODO: calculate children in the background (and make it cancellable?) + match fs::read_dir(&path) { + Ok(entries) => entries.count(), + Err(err) => { + log::warn!("failed to read directory {:?}: {}", path, err); + 0 + } + } + } else { + 0 + }; + + Item { + name, + metadata: ItemMetadata::Path { metadata, children }, + hidden, + path_opt: Some(path), + mime, + icon_handle_grid, + icon_handle_list, + icon_handle_list_condensed, + open_with, + thumbnail_opt, + button_id: widget::Id::unique(), + pos_opt: Cell::new(None), + rect_opt: Cell::new(None), + selected: false, + overlaps_drag_rect: false, + } +} + pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { let mut items = Vec::new(); match fs::read_dir(tab_path) { @@ -206,12 +277,14 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { } }; + let path = entry.path(); + let name = match entry.file_name().into_string() { Ok(ok) => ok, Err(name_os) => { log::warn!( - "failed to parse entry in {:?}: {:?} is not valid UTF-8", - tab_path, + "failed to parse entry at {:?}: {:?} is not valid UTF-8", + path, name_os, ); continue; @@ -221,80 +294,12 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { let metadata = match entry.metadata() { Ok(ok) => ok, Err(err) => { - log::warn!( - "failed to read metadata for entry in {:?}: {}", - tab_path, - err - ); + log::warn!("failed to read metadata for entry at {:?}: {}", path, err); continue; } }; - let hidden = name.starts_with(".") || hidden_attribute(&metadata); - - let path = entry.path(); - - let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = - if metadata.is_dir() { - ( - //TODO: make this a static - "inode/directory".parse().unwrap(), - folder_icon(&path, sizes.grid()), - folder_icon(&path, sizes.list()), - folder_icon(&path, sizes.list_condensed()), - ) - } else { - let mime = mime_for_path(&path); - ( - mime.clone(), - mime_icon(mime.clone(), sizes.grid()), - mime_icon(mime.clone(), sizes.list()), - mime_icon(mime, sizes.list_condensed()), - ) - }; - - let open_with = mime_apps(&mime); - - let thumbnail_opt = if mime.type_() == mime::IMAGE { - if mime.subtype() == mime::SVG { - Some(ItemThumbnail::Svg) - } else { - None - } - } else { - Some(ItemThumbnail::NotImage) - }; - - let children = if metadata.is_dir() { - //TODO: calculate children in the background (and make it cancellable?) - match fs::read_dir(&path) { - Ok(entries) => entries.count(), - Err(err) => { - log::warn!("failed to read directory {:?}: {}", path, err); - 0 - } - } - } else { - 0 - }; - - items.push(Item { - name, - metadata: ItemMetadata::Path { metadata, children }, - hidden, - path_opt: Some(path), - mime, - icon_handle_grid, - icon_handle_list, - icon_handle_list_condensed, - open_with, - thumbnail_opt, - button_id: widget::Id::unique(), - pos_opt: Cell::new(None), - rect_opt: Cell::new(None), - selected: false, - overlaps_drag_rect: false, - }); + items.push(item_from_entry(path, name, metadata, sizes)); } } Err(err) => {