From bba95c3fc036c0aa3362a704843d970ade1eb6e1 Mon Sep 17 00:00:00 2001 From: Jason Rodney Hansen Date: Sat, 14 Feb 2026 11:56:52 -0700 Subject: [PATCH] feat: search in Recents and Trash --- src/app.rs | 111 +++++----- src/dialog.rs | 42 ++-- src/menu.rs | 24 ++- src/tab.rs | 557 ++++++++++++++++++++++++++++++++------------------ 4 files changed, 459 insertions(+), 275 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2aee93b..d62d14c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -94,7 +94,8 @@ use crate::{ }, spawn_detached::spawn_detached, tab::{ - self, HOVER_DURATION, HeadingOptions, ItemMetadata, Location, SORT_OPTION_FALLBACK, Tab, + self, HOVER_DURATION, HeadingOptions, ItemMetadata, Location, SORT_OPTION_FALLBACK, + SearchLocation, Tab, }, zoom::{zoom_in_view, zoom_out_view, zoom_to_default}, }; @@ -1384,7 +1385,9 @@ impl App { .iter() .filter_map(|entity| { let tab = self.tab_model.data::(entity)?; - (tab.location == Location::Trash).then_some((entity, Location::Trash)) + tab.location + .is_trash() + .then_some((entity, tab.location.clone())) }) .collect(); @@ -1395,31 +1398,15 @@ impl App { Task::batch(commands) } - /// Refresh all tabs that are opened in [`Location::Recents`]. - fn refresh_recents_tabs(&mut self) -> Task { - let commands: Box<[_]> = self - .tab_model - .iter() - .filter_map(|entity| { - let tab = self.tab_model.data::(entity)?; - (tab.location == Location::Recents).then_some(entity) - }) - .collect(); - - let commands = commands - .into_iter() - .map(|entity| self.update_tab(entity, Location::Recents, None)); - - Task::batch(commands) - } - fn rescan_recents(&mut self) -> Task { let needs_reload: Box<[_]> = self .tab_model .iter() .filter_map(|entity| { let tab = self.tab_model.data::(entity)?; - (tab.location == Location::Recents).then_some((entity, Location::Recents)) + tab.location + .is_recents() + .then_some((entity, tab.location.clone())) }) .collect(); @@ -1453,19 +1440,35 @@ impl App { let mut title_location_opt = None; if let Some(tab) = self.tab_model.data_mut::(tab) { let location_opt = match term_opt { - Some(term) => tab.location.path_opt().map(|path| { - ( - Location::Search( - path.clone(), - term, - tab.config.show_hidden, - Instant::now(), - ), - true, - ) - }), + Some(term) => { + let search_location = if let Some(path) = tab.location.path_opt() { + Some(SearchLocation::Path(path.clone())) + } else if tab.location.is_recents() { + Some(SearchLocation::Recents) + } else if tab.location.is_trash() { + Some(SearchLocation::Trash) + } else { + None + }; + + search_location.map(|search_location| { + return ( + Location::Search( + search_location, + term, + tab.config.show_hidden, + Instant::now(), + ), + true, + ); + }) + } None => match &tab.location { - Location::Search(path, ..) => Some((Location::Path(path.clone()), false)), + Location::Search(search_location, ..) => match search_location { + SearchLocation::Path(path) => Some((Location::Path(path.clone()), false)), + SearchLocation::Recents => Some((Location::Recents, false)), + SearchLocation::Trash => Some((Location::Trash, false)), + }, _ => None, }, }; @@ -1625,7 +1628,7 @@ impl App { nav_model = nav_model.insert(|b| { b.text(fl!("trash")) - .icon(icon::icon(tab::trash_icon_symbolic(16))) + .icon(icon::icon(tab::trash_helpers::trash_icon_symbolic(16))) .data(Location::Trash) .divider_above() }); @@ -2840,7 +2843,7 @@ impl Application for App { Message::Delete(entity_opt) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(tab) = self.tab_model.data::(entity) { - if tab.location == Location::Trash { + if tab.location.is_trash() { if let Some(items) = tab.items_opt() { let mut trash_items = Vec::new(); for item in items { @@ -4056,7 +4059,7 @@ impl Application for App { return self.operation(Operation::RemoveFromRecents { paths }); } Message::RescanRecents => { - return self.refresh_recents_tabs(); + return self.rescan_recents(); } Message::RescanTrash => { // Update trash icon if empty/full @@ -4066,8 +4069,10 @@ impl Application for App { .is_some_and(|loc| matches!(loc, Location::Trash)) }); if let Some(entity) = maybe_entity { - self.nav_model - .icon_set(entity, icon::icon(tab::trash_icon_symbolic(16))); + self.nav_model.icon_set( + entity, + icon::icon(tab::trash_helpers::trash_icon_symbolic(16)), + ); } return Task::batch([self.rescan_trash(), self.update_desktop()]); @@ -4659,17 +4664,17 @@ impl Application for App { Some( Location::Desktop(path, ..) | Location::Path(path) - | Location::Search(path, ..), + | Location::Search(SearchLocation::Path(path), ..), ) => { command.arg(path); } Some(Location::Network(uri, ..)) => { command.arg(uri); } - Some(Location::Recents) => { + Some(Location::Recents | Location::Search(SearchLocation::Recents, ..)) => { command.arg("--recents"); } - Some(Location::Trash) => { + Some(Location::Trash | Location::Search(SearchLocation::Trash, ..)) => { command.arg("--trash"); } None => {} @@ -5915,21 +5920,19 @@ impl Application for App { dialog .control( widget::checkbox( - format!("{} ({})" ,fl!("apply-to-all"), *conflict_count), + format!("{} ({})", fl!("apply-to-all"), *conflict_count), *apply_to_all, ) - .on_toggle( - |apply_to_all| { - Message::DialogUpdate(DialogPage::Replace { - from: from.clone(), - to: to.clone(), - multiple: *multiple, - apply_to_all, - conflict_count: *conflict_count, - tx: tx.clone(), - }) - }, - ), + .on_toggle(|apply_to_all| { + Message::DialogUpdate(DialogPage::Replace { + from: from.clone(), + to: to.clone(), + multiple: *multiple, + apply_to_all, + conflict_count: *conflict_count, + tx: tx.clone(), + }) + }), ) .secondary_action( widget::button::standard(fl!("skip")).on_press(Message::ReplaceResult( diff --git a/src/dialog.rs b/src/dialog.rs index f496b8c..33d3ff9 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -48,7 +48,7 @@ use crate::{ localize::LANGUAGE_SORTER, menu, mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage}, - tab::{self, ItemMetadata, Location, Tab}, + tab::{self, ItemMetadata, Location, SearchLocation, Tab}, zoom::{zoom_in_view, zoom_out_view, zoom_to_default}, }; @@ -780,19 +780,35 @@ impl App { fn search_set(&mut self, term_opt: Option) -> Task { let location_opt = match term_opt { - Some(term) => self.tab.location.path_opt().map(|path| { - ( - Location::Search( - path.clone(), - term, - self.tab.config.show_hidden, - Instant::now(), - ), - true, - ) - }), + Some(term) => { + let search_location = if let Some(path) = self.tab.location.path_opt() { + Some(SearchLocation::Path(path.clone())) + } else if self.tab.location.is_recents() { + Some(SearchLocation::Recents) + } else if self.tab.location.is_trash() { + Some(SearchLocation::Trash) + } else { + None + }; + + search_location.map(|search_location| { + return ( + Location::Search( + search_location, + term, + self.tab.config.show_hidden, + Instant::now(), + ), + true, + ); + }) + } None => match &self.tab.location { - Location::Search(path, ..) => Some((Location::Path(path.clone()), false)), + Location::Search(search_location, ..) => match search_location { + SearchLocation::Path(path) => Some((Location::Path(path.clone()), false)), + SearchLocation::Recents => Some((Location::Recents, false)), + SearchLocation::Trash => Some((Location::Trash, false)), + }, _ => None, }, }; diff --git a/src/menu.rs b/src/menu.rs index 2a98a98..eda7588 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -22,7 +22,7 @@ use crate::{ app::{Action, Message}, config::Config, fl, - tab::{self, HeadingOptions, Location, LocationMenuAction, Tab}, + tab::{self, HeadingOptions, Location, LocationMenuAction, SearchLocation, Tab}, }; static MENU_ID: LazyLock = @@ -138,7 +138,9 @@ pub fn context_menu<'a>( selected_dir += 1; } match &item.location_opt { - Some(Location::Trash) => selected_trash_only = true, + Some(Location::Trash) | Some(Location::Search(SearchLocation::Trash, ..)) => { + selected_trash_only = true + } Some(Location::Path(path)) => { if selected == 1 && path.extension().and_then(|s| s.to_str()) == Some("desktop") @@ -174,7 +176,8 @@ pub fn context_menu<'a>( tab::Mode::App | tab::Mode::Desktop, Location::Desktop(..) | Location::Path(..) - | Location::Search(..) + | Location::Search(SearchLocation::Path(..), ..) + | Location::Search(SearchLocation::Recents, ..) | Location::Recents | Location::Network(_, _, Some(_)), ) => { @@ -212,7 +215,7 @@ pub fn context_menu<'a>( .push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into()); } } - if matches!(tab.location, Location::Search(..) | Location::Recents) { + if tab.location.is_recents() { children.push( menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), ); @@ -255,7 +258,7 @@ pub fn context_menu<'a>( children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into()); } children.push(divider::horizontal::light().into()); - if matches!(tab.location, Location::Recents) { + if tab.location.is_recents() { children.push( menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(), ); @@ -322,7 +325,8 @@ pub fn context_menu<'a>( tab::Mode::Dialog(dialog_kind), Location::Desktop(..) | Location::Path(..) - | Location::Search(..) + | Location::Search(SearchLocation::Path(..), ..) + | Location::Search(SearchLocation::Recents, ..) | Location::Recents | Location::Network(_, _, Some(_)), ) => { @@ -330,7 +334,7 @@ pub fn context_menu<'a>( if selected_dir == 1 && selected == 1 || selected_dir == 0 { children.push(menu_item(fl!("open"), Action::Open).into()); } - if matches!(tab.location, Location::Search(..) | Location::Recents) { + if matches!(tab.location, Location::Search(..)) || tab.location.is_recents() { children.push( menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), ); @@ -369,7 +373,7 @@ pub fn context_menu<'a>( children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size)); } } - (_, Location::Trash) => { + (_, Location::Trash | Location::Search(SearchLocation::Trash, ..)) => { if tab.mode.multiple() { children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); } @@ -428,7 +432,7 @@ pub fn dialog_menu( Action::SetSort(sort, dir), ) }; - let in_trash = tab.location == Location::Trash; + let in_trash = tab.location.is_trash(); let mut selected_gallery = 0; if let Some(items) = tab.items_opt() { @@ -578,7 +582,7 @@ pub fn menu_bar<'a>( Action::SetSort(sort, dir), ) }; - let in_trash = tab_opt.is_some_and(|tab| tab.location == Location::Trash); + let in_trash = tab_opt.is_some_and(|tab| tab.location.is_trash()); let mut selected_dir = 0; let mut selected = 0; diff --git a/src/tab.rs b/src/tab.rs index 01c2ea0..6bdde1f 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -52,6 +52,7 @@ use icu::{ use image::{DynamicImage, ImageDecoder, ImageReader}; use jxl_oxide::integration::JxlDecoder; use mime_guess::{Mime, mime}; +use regex::Regex; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::{ @@ -72,7 +73,7 @@ use std::{ }; use tempfile::NamedTempFile; use tokio::sync::mpsc; -use trash::TrashItemSize; +use trash::{TrashItem, TrashItemMetadata, TrashItemSize}; use walkdir::WalkDir; use crate::{ @@ -362,39 +363,6 @@ fn tab_complete(path: &Path) -> Result, Box> { Ok(completions) } -#[cfg(target_os = "macos")] -pub fn trash_entries() -> usize { - 0 -} - -#[cfg(not(target_os = "macos"))] -pub fn trash_entries() -> usize { - match trash::os_limited::list() { - Ok(entries) => entries.len(), - Err(_err) => 0, - } -} - -pub fn trash_icon(icon_size: u16) -> widget::icon::Handle { - widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) { - "user-trash" - } else { - "user-trash-full" - }) - .size(icon_size) - .handle() -} - -pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle { - widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) { - "user-trash-symbolic" - } else { - "user-trash-full-symbolic" - }) - .size(icon_size) - .handle() -} - //TODO: translate, add more levels? fn format_size(size: u64) -> String { const KB: u64 = 1000; @@ -787,6 +755,13 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS } } +pub fn item_from_search_item(search_item: SearchItem, sizes: IconSizes) -> Item { + match search_item { + SearchItem::Path(path, name, metadata) => item_from_entry(path, name, metadata, sizes), + SearchItem::Trash(entry, metadata) => item_from_trash_entry(entry, metadata, sizes), + } +} + pub fn item_from_entry( path: PathBuf, name: String, @@ -910,6 +885,59 @@ pub fn item_from_entry( } } +pub fn item_from_trash_entry( + entry: TrashItem, + metadata: TrashItemMetadata, + sizes: IconSizes, +) -> Item { + let original_path = entry.original_path(); + let name = entry.name.to_string_lossy().into_owned(); + let display_name = Item::display_name(&name); + + let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size + { + trash::TrashItemSize::Entries(_) => ( + //TODO: make this a static + "inode/directory".parse().unwrap(), + folder_icon(&original_path, sizes.grid()), + folder_icon(&original_path, sizes.list()), + folder_icon(&original_path, sizes.list_condensed()), + ), + trash::TrashItemSize::Bytes(_) => { + // This passes remote = true so it does not read from the original path + let mime = mime_for_path(&original_path, None, true); + ( + mime.clone(), + mime_icon(mime.clone(), sizes.grid()), + mime_icon(mime.clone(), sizes.list()), + mime_icon(mime, sizes.list_condensed()), + ) + } + }; + + Item { + name, + display_name, + is_mount_point: false, + metadata: ItemMetadata::Trash { metadata, entry }, + hidden: false, + location_opt: None, + mime, + icon_handle_grid, + icon_handle_list, + icon_handle_list_condensed, + thumbnail_opt: Some(ItemThumbnail::NotImage), + button_id: widget::Id::unique(), + pos_opt: Cell::new(None), + rect_opt: Cell::new(None), + selected: false, + highlighted: false, + overlaps_drag_rect: false, + dir_size: DirSize::NotDirectory, + cut: false, + } +} + fn get_filename_from_path(path: &Path) -> Result { Ok(match path.file_name() { Some(name_os) => name_os @@ -1047,8 +1075,8 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { items } -pub fn scan_search bool + Sync>( - tab_path: &PathBuf, +pub fn scan_search bool + Sync>( + search_location: &SearchLocation, term: &str, show_hidden: bool, callback: F, @@ -1069,48 +1097,99 @@ pub fn scan_search bool + Sync>( } }; - ignore::WalkBuilder::new(tab_path) - .standard_filters(false) - .hidden(!show_hidden) - //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; - }; + match search_location { + SearchLocation::Path(tab_path) => { + ignore::WalkBuilder::new(tab_path) + .standard_filters(false) + .hidden(!show_hidden) + //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; - }; + 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(); + 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.display(), - err - ); - return ignore::WalkState::Continue; + let metadata = match entry.metadata() { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to read metadata for entry at {}: {}", + path.display(), + err + ); + return ignore::WalkState::Continue; + } + }; + + if !callback(SearchItem::Path( + path.to_path_buf(), + file_name.to_string(), + metadata, + )) { + return ignore::WalkState::Quit; + } } - }; - //TODO: use entry.into_path? - if !callback(path, file_name, metadata) { - return ignore::WalkState::Quit; + ignore::WalkState::Continue + }) + }); + } + SearchLocation::Recents => { + let recent_files = match recently_used_xbel::parse_file() { + Ok(recent_files) => recent_files, + Err(err) => { + log::warn!("Error reading recent files: {err:?}"); + return; + } + }; + + for bookmark in recent_files.bookmarks { + let path = uri_to_path(bookmark.href); + if let Some(path) = path + && path.exists() + { + let file_name = path.file_name(); + if let Some(file_name) = file_name { + let file_name = file_name.to_string_lossy(); + if regex.is_match(&file_name) { + match path.metadata() { + Ok(metadata) => { + if !callback(SearchItem::Path( + path.to_path_buf(), + file_name.to_string(), + metadata, + )) { + break; + } + } + Err(err) => { + log::warn!( + "failed to read metadata for entry at {}: {}", + path.display(), + err + ); + } + }; + } } } - - ignore::WalkState::Continue - }) - }); + } + } + SearchLocation::Trash => { + trash_helpers::scan_search_trash(callback, ®ex); + } + } } // This config statement is from trash::os_limited, inverted @@ -1123,9 +1202,31 @@ pub fn scan_search bool + Sync>( not(target_os = "android") ) )))] -pub fn scan_trash(_sizes: IconSizes) -> Vec { - log::warn!("viewing trash not supported on this platform"); - Vec::new() +mod trash_helpers { + use super::*; + + pub fn trash_entries() -> usize { + 0 + } + + pub fn trash_icon(icon_size: u16) -> widget::icon::Handle { + widget::icon::from_name("user-trash") + .size(icon_size) + .handle() + } + + pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle { + widget::icon::from_name("user-trash-symbolic") + .size(icon_size) + .handle() + } + + pub fn scan_trash(_sizes: IconSizes) -> Vec { + log::warn!("viewing trash not supported on this platform"); + Vec::new() + } + + pub fn scan_search_trash bool + Sync>(callback: F, regex: &Regex) {} } // This config statement is from trash::os_limited @@ -1138,76 +1239,85 @@ pub fn scan_trash(_sizes: IconSizes) -> Vec { not(target_os = "android") ) ))] -pub fn scan_trash(sizes: IconSizes) -> Vec { - let entries = match trash::os_limited::list() { - Ok(entry) => entry, - Err(err) => { - log::warn!("failed to read trash items: {err}"); - return Vec::new(); +pub mod trash_helpers { + use super::*; + + pub fn trash_entries() -> usize { + match trash::os_limited::list() { + Ok(entries) => entries.len(), + Err(_err) => 0, } - }; - let mut items: Vec<_> = entries - .into_iter() - .filter_map(|entry| { - let metadata = trash::os_limited::metadata(&entry) - .inspect_err(|err| { - log::warn!("failed to get metadata for trash item {entry:?}: {err}") - }) - .ok()?; - let original_path = entry.original_path(); - let name = entry.name.to_string_lossy().into_owned(); - let display_name = Item::display_name(&name); + } - let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = - match metadata.size { - trash::TrashItemSize::Entries(_) => ( - //TODO: make this a static - "inode/directory".parse().unwrap(), - folder_icon(&original_path, sizes.grid()), - folder_icon(&original_path, sizes.list()), - folder_icon(&original_path, sizes.list_condensed()), - ), - trash::TrashItemSize::Bytes(_) => { - // This passes remote = true so it does not read from the original path - let mime = mime_for_path(&original_path, None, true); - ( - mime.clone(), - mime_icon(mime.clone(), sizes.grid()), - mime_icon(mime.clone(), sizes.list()), - mime_icon(mime, sizes.list_condensed()), - ) - } - }; - - Some(Item { - name, - display_name, - is_mount_point: false, - metadata: ItemMetadata::Trash { metadata, entry }, - hidden: false, - location_opt: None, - mime, - icon_handle_grid, - icon_handle_list, - icon_handle_list_condensed, - thumbnail_opt: Some(ItemThumbnail::NotImage), - button_id: widget::Id::unique(), - pos_opt: Cell::new(None), - rect_opt: Cell::new(None), - selected: false, - highlighted: false, - overlaps_drag_rect: false, - dir_size: DirSize::NotDirectory, - cut: false, - }) + pub fn trash_icon(icon_size: u16) -> widget::icon::Handle { + widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) { + "user-trash" + } else { + "user-trash-full" }) - .collect(); - items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) { - (true, false) => Ordering::Less, - (false, true) => Ordering::Greater, - _ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name), - }); - items + .size(icon_size) + .handle() + } + + pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle { + widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) { + "user-trash-symbolic" + } else { + "user-trash-full-symbolic" + }) + .size(icon_size) + .handle() + } + + pub fn scan_trash(sizes: IconSizes) -> Vec { + let entries = match trash::os_limited::list() { + Ok(entry) => entry, + Err(err) => { + log::warn!("failed to read trash items: {err}"); + return Vec::new(); + } + }; + let mut items: Vec<_> = entries + .into_iter() + .filter_map(|entry| { + let metadata = trash::os_limited::metadata(&entry) + .inspect_err(|err| { + log::warn!("failed to get metadata for trash item {entry:?}: {err}") + }) + .ok()?; + Some(item_from_trash_entry(entry, metadata, sizes)) + }) + .collect(); + items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name), + }); + items + } + + pub fn scan_search_trash bool + Sync>(callback: F, regex: &Regex) { + let entries = match trash::os_limited::list() { + Ok(entries) => entries, + Err(err) => { + log::warn!("failed to read trash items: {err}"); + return; + } + }; + + for entry in entries { + if let Ok(metadata) = trash::os_limited::metadata(&entry).inspect_err(|err| { + log::warn!("failed to get metadata for trash item {entry:?}: {err}") + }) { + let name = entry.name.to_string_lossy(); + if regex.is_match(&name) { + if !callback(SearchItem::Trash(entry, metadata)) { + break; + } + } + } + } + } } fn uri_to_path(uri: String) -> Option { @@ -1263,7 +1373,7 @@ pub fn scan_recents(sizes: IconSizes) -> Vec { let item = item_from_entry(path, name, metadata, sizes); Some((item, last_edit.min(last_visit))) } else { - log::warn!("recent file path not exist: {}", path.display()); + log::warn!("recent file path does not exist: {}", path.display()); None } }) @@ -1343,15 +1453,15 @@ pub fn scan_desktop( let display_name = Item::display_name(&name); let metadata = ItemMetadata::SimpleDir { - entries: trash_entries() as u64, + entries: trash_helpers::trash_entries() as u64, }; let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = { ( "inode/directory".parse().unwrap(), - trash_icon(sizes.grid()), - trash_icon(sizes.list()), - trash_icon(sizes.list_condensed()), + trash_helpers::trash_icon(sizes.grid()), + trash_helpers::trash_icon(sizes.list()), + trash_helpers::trash_icon(sizes.list_condensed()), ) }; @@ -1436,6 +1546,29 @@ impl EditLocation { } } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum SearchLocation { + Path(PathBuf), + Recents, + Trash, +} + +impl std::fmt::Display for SearchLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Path(path) => write!(f, "{}", path.display()), + Self::Recents => write!(f, "recents"), + Self::Trash => write!(f, "trash"), + } + } +} + +#[derive(Clone, Debug)] +pub enum SearchItem { + Path(PathBuf, String, fs::Metadata), + Trash(TrashItem, TrashItemMetadata), +} + impl From for EditLocation { fn from(location: Location) -> Self { Self { @@ -1452,7 +1585,7 @@ pub enum Location { Network(String, String, Option), Path(PathBuf), Recents, - Search(PathBuf, String, bool, Instant), + Search(SearchLocation, String, bool, Instant), Trash, } @@ -1465,7 +1598,9 @@ impl std::fmt::Display for Location { Self::Network(uri, ..) => write!(f, "{uri}"), Self::Path(path) => write!(f, "{}", path.display()), Self::Recents => write!(f, "recents"), - Self::Search(path, term, ..) => write!(f, "search {} for {}", path.display(), term), + Self::Search(location, term, ..) => { + write!(f, "search {} for {}", location, term) + } Self::Trash => write!(f, "trash"), } } @@ -1514,7 +1649,7 @@ impl Location { match self { Self::Desktop(path, ..) => Some(path), Self::Path(path) => Some(path), - Self::Search(path, ..) => Some(path), + Self::Search(SearchLocation::Path(path), ..) => Some(path), Self::Network(_, _, path) => path.as_ref(), _ => None, } @@ -1524,7 +1659,7 @@ impl Location { match self { Self::Desktop(path, ..) => Some(path), Self::Path(path) => Some(path), - Self::Search(path, ..) => Some(path), + Self::Search(SearchLocation::Path(path), ..) => Some(path), Self::Network(_, _, path) => path, _ => None, } @@ -1537,9 +1672,12 @@ impl Location { Self::Desktop(path, display.clone(), *desktop_config) } Self::Path(..) => Self::Path(path), - Self::Search(_, term, show_hidden, time) => { - Self::Search(path, term.clone(), *show_hidden, *time) - } + Self::Search(SearchLocation::Path(_), term, show_hidden, time) => Self::Search( + SearchLocation::Path(path), + term.clone(), + *show_hidden, + *time, + ), other => other.clone(), } @@ -1563,7 +1701,7 @@ impl Location { // Search is done incrementally Vec::new() } - Self::Trash => scan_trash(sizes), + Self::Trash => trash_helpers::scan_trash(sizes), Self::Recents => scan_recents(sizes), Self::Network(uri, _, _) => scan_network(uri, sizes), }; @@ -1591,9 +1729,14 @@ impl Location { let (name, _) = folder_name(path); name } - Self::Search(path, term, ..) => { + Self::Search(location, term, ..) => { + let name = match location { + SearchLocation::Path(path) => folder_name(path).0, + SearchLocation::Trash => fl!("trash"), + SearchLocation::Recents => fl!("recents"), + }; + //TODO: translate - let (name, _) = folder_name(path); format!("Search \"{term}\": {name}") } Self::Trash => { @@ -1622,6 +1765,20 @@ impl Location { } } + pub fn is_trash(&self) -> bool { + matches!( + self, + Location::Trash | Location::Search(SearchLocation::Trash, ..) + ) + } + + pub fn is_recents(&self) -> bool { + matches!( + self, + Location::Recents | Location::Search(SearchLocation::Recents, ..) + ) + } + /// Returns true if this location supports paste operations (not Trash) pub fn supports_paste(&self) -> bool { matches!( @@ -2241,9 +2398,9 @@ impl Item { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { widget::text::body(name) .wrapping(text::Wrapping::WordOrGlyph) - .ellipsize(text::Ellipsize::Middle( - text::EllipsizeHeightLimit::Lines(3), - )) + .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines( + 3, + ))) } /// Text widget for a filename in list view: word-or-glyph wrapping, middle-ellipsized to 1 line. @@ -2252,9 +2409,9 @@ impl Item { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { widget::text::body(name) .wrapping(text::Wrapping::WordOrGlyph) - .ellipsize(text::Ellipsize::Middle( - text::EllipsizeHeightLimit::Lines(1), - )) + .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines( + 1, + ))) } pub fn path_opt(&self) -> Option<&PathBuf> { @@ -2614,7 +2771,7 @@ impl Mode { } struct SearchContext { - results_rx: mpsc::Receiver<(PathBuf, String, Metadata)>, + results_rx: mpsc::Receiver, ready: Arc, last_modified_opt: Arc>>, } @@ -4080,21 +4237,26 @@ impl Tab { if let Some(items) = &mut self.items_opt { if finished || context.ready.swap(false, atomic::Ordering::SeqCst) { let duration = Instant::now(); - while let Ok((path, name, metadata)) = context.results_rx.try_recv() { + while let Ok(search_item) = context.results_rx.try_recv() { //TODO: combine this with column_sort logic, they must match! - let item_modified = metadata.modified().ok(); - let index = match items.binary_search_by(|other| { - item_modified.cmp(&other.metadata.modified()) - }) { - Ok(index) => index, - Err(index) => index, - }; + let index = + if let SearchItem::Path(_, _, ref metadata) = search_item { + let item_modified = metadata.modified().ok(); + match items.binary_search_by(|other| { + item_modified.cmp(&other.metadata.modified()) + }) { + Ok(index) => index, + Err(index) => index, + } + } else { + items.len() + }; + if index < MAX_SEARCH_RESULTS { //TODO: use correct IconSizes - items.insert( - index, - item_from_entry(path, name, metadata, IconSizes::default()), - ); + let item = + item_from_search_item(search_item, IconSizes::default()); + items.insert(index, item); } // Ensure that updates make it to the GUI in a timely manner if !finished && duration.elapsed() >= MAX_SEARCH_LATENCY { @@ -4822,7 +4984,7 @@ impl Tab { let heading_row = widget::row::with_children([ heading_item(fl!("name"), Length::Fill, HeadingOptions::Name), - if self.location == Location::Trash { + if self.location.is_trash() { heading_item( fl!("trashed-on"), Length::Fixed(modified_width), @@ -4946,7 +5108,9 @@ impl Tab { let mut children: Vec> = Vec::new(); match &self.location { - Location::Desktop(path, ..) | Location::Path(path) | Location::Search(path, ..) => { + Location::Desktop(path, ..) + | Location::Path(path) + | Location::Search(SearchLocation::Path(path), ..) => { let excess_str = "..."; let excess_width = text_width_body(excess_str); for (index, ancestor) in path.ancestors().enumerate() { @@ -5033,7 +5197,7 @@ impl Tab { } children.reverse(); } - Location::Trash => { + Location::Trash | Location::Search(SearchLocation::Trash, ..) => { children.push( widget::button::custom(widget::text::heading(fl!("trash"))) .padding(space_xxxs) @@ -5042,7 +5206,7 @@ impl Tab { .into(), ); } - Location::Recents => { + Location::Recents | Location::Search(SearchLocation::Recents, ..) => { children.push( widget::button::custom(widget::text::heading(fl!("recents"))) .padding(space_xxxs) @@ -5422,9 +5586,9 @@ impl Tab { false, false, )), - widget::button::custom( - Item::grid_display_name(item.display_name.clone()), - ) + widget::button::custom(Item::grid_display_name( + item.display_name.clone(), + )) .id(item.button_id.clone()) .on_press(Message::Click(Some(*i))) .padding([0, space_xxxs]) @@ -5978,8 +6142,7 @@ impl Tab { tab_column = tab_column.push(popover); } match &self.location { - Location::Recents => {} - Location::Trash => { + Location::Trash | Location::Search(SearchLocation::Trash, ..) => { if let Some(items) = self.items_opt() && !items.is_empty() { @@ -6358,9 +6521,9 @@ impl Tab { } // Load search items incrementally - if let Location::Search(path, term, show_hidden, start) = &self.location { + if let Location::Search(search_location, term, show_hidden, start) = &self.location { let location = self.location.clone(); - let path = path.clone(); + let search_location = search_location.clone(); let term = term.clone(); let show_hidden = *show_hidden; let start = *start; @@ -6389,27 +6552,25 @@ impl Tab { let output = output.clone(); tokio::task::spawn_blocking(move || { scan_search( - &path, + &search_location, &term, show_hidden, - move |path, name, metadata| -> bool { + move |search_item| -> bool { // Don't send if the result is too old if let Some(last_modified) = *last_modified_opt.read().unwrap() { - if let Ok(modified) = metadata.modified() { - if modified < last_modified { + if let SearchItem::Path(_, _, ref metadata) = search_item { + if let Ok(modified) = metadata.modified() { + if modified < last_modified { + return true; + } + } else { return true; } - } else { - return true; } } - match results_tx.blocking_send(( - path.to_path_buf(), - name.to_string(), - metadata, - )) { + match results_tx.blocking_send(search_item) { Ok(()) => { if ready.swap(true, atomic::Ordering::SeqCst) { true @@ -6432,7 +6593,7 @@ impl Tab { log::info!( "searched for {:?} in {} in {:?}", term, - path.display(), + search_location, start.elapsed(), ); })