feat: search in Recents and Trash

This commit is contained in:
Jason Rodney Hansen 2026-02-14 11:56:52 -07:00
parent 49e3d95e7a
commit bba95c3fc0
4 changed files with 459 additions and 275 deletions

View file

@ -94,7 +94,8 @@ use crate::{
}, },
spawn_detached::spawn_detached, spawn_detached::spawn_detached,
tab::{ 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}, zoom::{zoom_in_view, zoom_out_view, zoom_to_default},
}; };
@ -1384,7 +1385,9 @@ impl App {
.iter() .iter()
.filter_map(|entity| { .filter_map(|entity| {
let tab = self.tab_model.data::<Tab>(entity)?; let tab = self.tab_model.data::<Tab>(entity)?;
(tab.location == Location::Trash).then_some((entity, Location::Trash)) tab.location
.is_trash()
.then_some((entity, tab.location.clone()))
}) })
.collect(); .collect();
@ -1395,31 +1398,15 @@ impl App {
Task::batch(commands) Task::batch(commands)
} }
/// Refresh all tabs that are opened in [`Location::Recents`].
fn refresh_recents_tabs(&mut self) -> Task<Message> {
let commands: Box<[_]> = self
.tab_model
.iter()
.filter_map(|entity| {
let tab = self.tab_model.data::<Tab>(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<Message> { fn rescan_recents(&mut self) -> Task<Message> {
let needs_reload: Box<[_]> = self let needs_reload: Box<[_]> = self
.tab_model .tab_model
.iter() .iter()
.filter_map(|entity| { .filter_map(|entity| {
let tab = self.tab_model.data::<Tab>(entity)?; let tab = self.tab_model.data::<Tab>(entity)?;
(tab.location == Location::Recents).then_some((entity, Location::Recents)) tab.location
.is_recents()
.then_some((entity, tab.location.clone()))
}) })
.collect(); .collect();
@ -1453,19 +1440,35 @@ impl App {
let mut title_location_opt = None; let mut title_location_opt = None;
if let Some(tab) = self.tab_model.data_mut::<Tab>(tab) { if let Some(tab) = self.tab_model.data_mut::<Tab>(tab) {
let location_opt = match term_opt { let location_opt = match term_opt {
Some(term) => tab.location.path_opt().map(|path| { Some(term) => {
( let search_location = if let Some(path) = tab.location.path_opt() {
Location::Search( Some(SearchLocation::Path(path.clone()))
path.clone(), } else if tab.location.is_recents() {
term, Some(SearchLocation::Recents)
tab.config.show_hidden, } else if tab.location.is_trash() {
Instant::now(), Some(SearchLocation::Trash)
), } else {
true, None
) };
}),
search_location.map(|search_location| {
return (
Location::Search(
search_location,
term,
tab.config.show_hidden,
Instant::now(),
),
true,
);
})
}
None => match &tab.location { 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, _ => None,
}, },
}; };
@ -1625,7 +1628,7 @@ impl App {
nav_model = nav_model.insert(|b| { nav_model = nav_model.insert(|b| {
b.text(fl!("trash")) 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) .data(Location::Trash)
.divider_above() .divider_above()
}); });
@ -2840,7 +2843,7 @@ impl Application for App {
Message::Delete(entity_opt) => { Message::Delete(entity_opt) => {
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
if let Some(tab) = self.tab_model.data::<Tab>(entity) { if let Some(tab) = self.tab_model.data::<Tab>(entity) {
if tab.location == Location::Trash { if tab.location.is_trash() {
if let Some(items) = tab.items_opt() { if let Some(items) = tab.items_opt() {
let mut trash_items = Vec::new(); let mut trash_items = Vec::new();
for item in items { for item in items {
@ -4056,7 +4059,7 @@ impl Application for App {
return self.operation(Operation::RemoveFromRecents { paths }); return self.operation(Operation::RemoveFromRecents { paths });
} }
Message::RescanRecents => { Message::RescanRecents => {
return self.refresh_recents_tabs(); return self.rescan_recents();
} }
Message::RescanTrash => { Message::RescanTrash => {
// Update trash icon if empty/full // Update trash icon if empty/full
@ -4066,8 +4069,10 @@ impl Application for App {
.is_some_and(|loc| matches!(loc, Location::Trash)) .is_some_and(|loc| matches!(loc, Location::Trash))
}); });
if let Some(entity) = maybe_entity { if let Some(entity) = maybe_entity {
self.nav_model self.nav_model.icon_set(
.icon_set(entity, icon::icon(tab::trash_icon_symbolic(16))); entity,
icon::icon(tab::trash_helpers::trash_icon_symbolic(16)),
);
} }
return Task::batch([self.rescan_trash(), self.update_desktop()]); return Task::batch([self.rescan_trash(), self.update_desktop()]);
@ -4659,17 +4664,17 @@ impl Application for App {
Some( Some(
Location::Desktop(path, ..) Location::Desktop(path, ..)
| Location::Path(path) | Location::Path(path)
| Location::Search(path, ..), | Location::Search(SearchLocation::Path(path), ..),
) => { ) => {
command.arg(path); command.arg(path);
} }
Some(Location::Network(uri, ..)) => { Some(Location::Network(uri, ..)) => {
command.arg(uri); command.arg(uri);
} }
Some(Location::Recents) => { Some(Location::Recents | Location::Search(SearchLocation::Recents, ..)) => {
command.arg("--recents"); command.arg("--recents");
} }
Some(Location::Trash) => { Some(Location::Trash | Location::Search(SearchLocation::Trash, ..)) => {
command.arg("--trash"); command.arg("--trash");
} }
None => {} None => {}
@ -5915,21 +5920,19 @@ impl Application for App {
dialog dialog
.control( .control(
widget::checkbox( widget::checkbox(
format!("{} ({})" ,fl!("apply-to-all"), *conflict_count), format!("{} ({})", fl!("apply-to-all"), *conflict_count),
*apply_to_all, *apply_to_all,
) )
.on_toggle( .on_toggle(|apply_to_all| {
|apply_to_all| { Message::DialogUpdate(DialogPage::Replace {
Message::DialogUpdate(DialogPage::Replace { from: from.clone(),
from: from.clone(), to: to.clone(),
to: to.clone(), multiple: *multiple,
multiple: *multiple, apply_to_all,
apply_to_all, conflict_count: *conflict_count,
conflict_count: *conflict_count, tx: tx.clone(),
tx: tx.clone(), })
}) }),
},
),
) )
.secondary_action( .secondary_action(
widget::button::standard(fl!("skip")).on_press(Message::ReplaceResult( widget::button::standard(fl!("skip")).on_press(Message::ReplaceResult(

View file

@ -48,7 +48,7 @@ use crate::{
localize::LANGUAGE_SORTER, localize::LANGUAGE_SORTER,
menu, menu,
mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage}, 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}, zoom::{zoom_in_view, zoom_out_view, zoom_to_default},
}; };
@ -780,19 +780,35 @@ impl App {
fn search_set(&mut self, term_opt: Option<String>) -> Task<Message> { fn search_set(&mut self, term_opt: Option<String>) -> Task<Message> {
let location_opt = match term_opt { let location_opt = match term_opt {
Some(term) => self.tab.location.path_opt().map(|path| { Some(term) => {
( let search_location = if let Some(path) = self.tab.location.path_opt() {
Location::Search( Some(SearchLocation::Path(path.clone()))
path.clone(), } else if self.tab.location.is_recents() {
term, Some(SearchLocation::Recents)
self.tab.config.show_hidden, } else if self.tab.location.is_trash() {
Instant::now(), Some(SearchLocation::Trash)
), } else {
true, 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 { 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, _ => None,
}, },
}; };

View file

@ -22,7 +22,7 @@ use crate::{
app::{Action, Message}, app::{Action, Message},
config::Config, config::Config,
fl, fl,
tab::{self, HeadingOptions, Location, LocationMenuAction, Tab}, tab::{self, HeadingOptions, Location, LocationMenuAction, SearchLocation, Tab},
}; };
static MENU_ID: LazyLock<cosmic::widget::Id> = static MENU_ID: LazyLock<cosmic::widget::Id> =
@ -138,7 +138,9 @@ pub fn context_menu<'a>(
selected_dir += 1; selected_dir += 1;
} }
match &item.location_opt { 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)) => { Some(Location::Path(path)) => {
if selected == 1 if selected == 1
&& path.extension().and_then(|s| s.to_str()) == Some("desktop") && 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, tab::Mode::App | tab::Mode::Desktop,
Location::Desktop(..) Location::Desktop(..)
| Location::Path(..) | Location::Path(..)
| Location::Search(..) | Location::Search(SearchLocation::Path(..), ..)
| Location::Search(SearchLocation::Recents, ..)
| Location::Recents | Location::Recents
| Location::Network(_, _, Some(_)), | Location::Network(_, _, Some(_)),
) => { ) => {
@ -212,7 +215,7 @@ pub fn context_menu<'a>(
.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into()); .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( children.push(
menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), 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(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
} }
children.push(divider::horizontal::light().into()); children.push(divider::horizontal::light().into());
if matches!(tab.location, Location::Recents) { if tab.location.is_recents() {
children.push( children.push(
menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(), menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
); );
@ -322,7 +325,8 @@ pub fn context_menu<'a>(
tab::Mode::Dialog(dialog_kind), tab::Mode::Dialog(dialog_kind),
Location::Desktop(..) Location::Desktop(..)
| Location::Path(..) | Location::Path(..)
| Location::Search(..) | Location::Search(SearchLocation::Path(..), ..)
| Location::Search(SearchLocation::Recents, ..)
| Location::Recents | Location::Recents
| Location::Network(_, _, Some(_)), | Location::Network(_, _, Some(_)),
) => { ) => {
@ -330,7 +334,7 @@ pub fn context_menu<'a>(
if selected_dir == 1 && selected == 1 || selected_dir == 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 {
children.push(menu_item(fl!("open"), Action::Open).into()); 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( children.push(
menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), 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)); children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
} }
} }
(_, Location::Trash) => { (_, Location::Trash | Location::Search(SearchLocation::Trash, ..)) => {
if tab.mode.multiple() { if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
} }
@ -428,7 +432,7 @@ pub fn dialog_menu(
Action::SetSort(sort, dir), Action::SetSort(sort, dir),
) )
}; };
let in_trash = tab.location == Location::Trash; let in_trash = tab.location.is_trash();
let mut selected_gallery = 0; let mut selected_gallery = 0;
if let Some(items) = tab.items_opt() { if let Some(items) = tab.items_opt() {
@ -578,7 +582,7 @@ pub fn menu_bar<'a>(
Action::SetSort(sort, dir), 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_dir = 0;
let mut selected = 0; let mut selected = 0;

View file

@ -52,6 +52,7 @@ use icu::{
use image::{DynamicImage, ImageDecoder, ImageReader}; use image::{DynamicImage, ImageDecoder, ImageReader};
use jxl_oxide::integration::JxlDecoder; use jxl_oxide::integration::JxlDecoder;
use mime_guess::{Mime, mime}; use mime_guess::{Mime, mime};
use regex::Regex;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
@ -72,7 +73,7 @@ use std::{
}; };
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use trash::TrashItemSize; use trash::{TrashItem, TrashItemMetadata, TrashItemSize};
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::{ use crate::{
@ -362,39 +363,6 @@ fn tab_complete(path: &Path) -> Result<Vec<(String, PathBuf)>, Box<dyn Error>> {
Ok(completions) 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? //TODO: translate, add more levels?
fn format_size(size: u64) -> String { fn format_size(size: u64) -> String {
const KB: u64 = 1000; 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( pub fn item_from_entry(
path: PathBuf, path: PathBuf,
name: String, 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<String, String> { fn get_filename_from_path(path: &Path) -> Result<String, String> {
Ok(match path.file_name() { Ok(match path.file_name() {
Some(name_os) => name_os Some(name_os) => name_os
@ -1047,8 +1075,8 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
items items
} }
pub fn scan_search<F: Fn(&Path, &str, Metadata) -> bool + Sync>( pub fn scan_search<F: Fn(SearchItem) -> bool + Sync>(
tab_path: &PathBuf, search_location: &SearchLocation,
term: &str, term: &str,
show_hidden: bool, show_hidden: bool,
callback: F, callback: F,
@ -1069,48 +1097,99 @@ pub fn scan_search<F: Fn(&Path, &str, Metadata) -> bool + Sync>(
} }
}; };
ignore::WalkBuilder::new(tab_path) match search_location {
.standard_filters(false) SearchLocation::Path(tab_path) => {
.hidden(!show_hidden) ignore::WalkBuilder::new(tab_path)
//TODO: only use this on supported targets .standard_filters(false)
.same_file_system(true) .hidden(!show_hidden)
.build_parallel() //TODO: only use this on supported targets
.run(|| { .same_file_system(true)
Box::new(|entry_res| { .build_parallel()
let Ok(entry) = entry_res else { .run(|| {
// Skip invalid entries Box::new(|entry_res| {
return ignore::WalkState::Skip; let Ok(entry) = entry_res else {
}; // Skip invalid entries
return ignore::WalkState::Skip;
};
let Some(file_name) = entry.file_name().to_str() else { let Some(file_name) = entry.file_name().to_str() else {
// Skip anything with an invalid name // Skip anything with an invalid name
return ignore::WalkState::Skip; return ignore::WalkState::Skip;
}; };
if regex.is_match(file_name) { if regex.is_match(file_name) {
let path = entry.path(); let path = entry.path();
let metadata = match entry.metadata() { let metadata = match entry.metadata() {
Ok(ok) => ok, Ok(ok) => ok,
Err(err) => { Err(err) => {
log::warn!( log::warn!(
"failed to read metadata for entry at {}: {}", "failed to read metadata for entry at {}: {}",
path.display(), path.display(),
err err
); );
return ignore::WalkState::Continue; 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? ignore::WalkState::Continue
if !callback(path, file_name, metadata) { })
return ignore::WalkState::Quit; });
}
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, &regex);
}
}
} }
// This config statement is from trash::os_limited, inverted // This config statement is from trash::os_limited, inverted
@ -1123,9 +1202,31 @@ pub fn scan_search<F: Fn(&Path, &str, Metadata) -> bool + Sync>(
not(target_os = "android") not(target_os = "android")
) )
)))] )))]
pub fn scan_trash(_sizes: IconSizes) -> Vec<Item> { mod trash_helpers {
log::warn!("viewing trash not supported on this platform"); use super::*;
Vec::new()
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<Item> {
log::warn!("viewing trash not supported on this platform");
Vec::new()
}
pub fn scan_search_trash<F: Fn(SearchItem) -> bool + Sync>(callback: F, regex: &Regex) {}
} }
// This config statement is from trash::os_limited // This config statement is from trash::os_limited
@ -1138,76 +1239,85 @@ pub fn scan_trash(_sizes: IconSizes) -> Vec<Item> {
not(target_os = "android") not(target_os = "android")
) )
))] ))]
pub fn scan_trash(sizes: IconSizes) -> Vec<Item> { pub mod trash_helpers {
let entries = match trash::os_limited::list() { use super::*;
Ok(entry) => entry,
Err(err) => { pub fn trash_entries() -> usize {
log::warn!("failed to read trash items: {err}"); match trash::os_limited::list() {
return Vec::new(); 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) = pub fn trash_icon(icon_size: u16) -> widget::icon::Handle {
match metadata.size { widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) {
trash::TrashItemSize::Entries(_) => ( "user-trash"
//TODO: make this a static } else {
"inode/directory".parse().unwrap(), "user-trash-full"
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,
})
}) })
.collect(); .size(icon_size)
items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) { .handle()
(true, false) => Ordering::Less, }
(false, true) => Ordering::Greater,
_ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name), pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle {
}); widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) {
items "user-trash-symbolic"
} else {
"user-trash-full-symbolic"
})
.size(icon_size)
.handle()
}
pub fn scan_trash(sizes: IconSizes) -> Vec<Item> {
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<F: Fn(SearchItem) -> 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<PathBuf> { fn uri_to_path(uri: String) -> Option<PathBuf> {
@ -1263,7 +1373,7 @@ pub fn scan_recents(sizes: IconSizes) -> Vec<Item> {
let item = item_from_entry(path, name, metadata, sizes); let item = item_from_entry(path, name, metadata, sizes);
Some((item, last_edit.min(last_visit))) Some((item, last_edit.min(last_visit)))
} else { } else {
log::warn!("recent file path not exist: {}", path.display()); log::warn!("recent file path does not exist: {}", path.display());
None None
} }
}) })
@ -1343,15 +1453,15 @@ pub fn scan_desktop(
let display_name = Item::display_name(&name); let display_name = Item::display_name(&name);
let metadata = ItemMetadata::SimpleDir { 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) = { let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {
( (
"inode/directory".parse().unwrap(), "inode/directory".parse().unwrap(),
trash_icon(sizes.grid()), trash_helpers::trash_icon(sizes.grid()),
trash_icon(sizes.list()), trash_helpers::trash_icon(sizes.list()),
trash_icon(sizes.list_condensed()), 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<Location> for EditLocation { impl From<Location> for EditLocation {
fn from(location: Location) -> Self { fn from(location: Location) -> Self {
Self { Self {
@ -1452,7 +1585,7 @@ pub enum Location {
Network(String, String, Option<PathBuf>), Network(String, String, Option<PathBuf>),
Path(PathBuf), Path(PathBuf),
Recents, Recents,
Search(PathBuf, String, bool, Instant), Search(SearchLocation, String, bool, Instant),
Trash, Trash,
} }
@ -1465,7 +1598,9 @@ impl std::fmt::Display for Location {
Self::Network(uri, ..) => write!(f, "{uri}"), Self::Network(uri, ..) => write!(f, "{uri}"),
Self::Path(path) => write!(f, "{}", path.display()), Self::Path(path) => write!(f, "{}", path.display()),
Self::Recents => write!(f, "recents"), 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"), Self::Trash => write!(f, "trash"),
} }
} }
@ -1514,7 +1649,7 @@ impl Location {
match self { match self {
Self::Desktop(path, ..) => Some(path), Self::Desktop(path, ..) => Some(path),
Self::Path(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(), Self::Network(_, _, path) => path.as_ref(),
_ => None, _ => None,
} }
@ -1524,7 +1659,7 @@ impl Location {
match self { match self {
Self::Desktop(path, ..) => Some(path), Self::Desktop(path, ..) => Some(path),
Self::Path(path) => Some(path), Self::Path(path) => Some(path),
Self::Search(path, ..) => Some(path), Self::Search(SearchLocation::Path(path), ..) => Some(path),
Self::Network(_, _, path) => path, Self::Network(_, _, path) => path,
_ => None, _ => None,
} }
@ -1537,9 +1672,12 @@ impl Location {
Self::Desktop(path, display.clone(), *desktop_config) Self::Desktop(path, display.clone(), *desktop_config)
} }
Self::Path(..) => Self::Path(path), Self::Path(..) => Self::Path(path),
Self::Search(_, term, show_hidden, time) => { Self::Search(SearchLocation::Path(_), term, show_hidden, time) => Self::Search(
Self::Search(path, term.clone(), *show_hidden, *time) SearchLocation::Path(path),
} term.clone(),
*show_hidden,
*time,
),
other => other.clone(), other => other.clone(),
} }
@ -1563,7 +1701,7 @@ impl Location {
// Search is done incrementally // Search is done incrementally
Vec::new() Vec::new()
} }
Self::Trash => scan_trash(sizes), Self::Trash => trash_helpers::scan_trash(sizes),
Self::Recents => scan_recents(sizes), Self::Recents => scan_recents(sizes),
Self::Network(uri, _, _) => scan_network(uri, sizes), Self::Network(uri, _, _) => scan_network(uri, sizes),
}; };
@ -1591,9 +1729,14 @@ impl Location {
let (name, _) = folder_name(path); let (name, _) = folder_name(path);
name 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 //TODO: translate
let (name, _) = folder_name(path);
format!("Search \"{term}\": {name}") format!("Search \"{term}\": {name}")
} }
Self::Trash => { 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) /// Returns true if this location supports paste operations (not Trash)
pub fn supports_paste(&self) -> bool { pub fn supports_paste(&self) -> bool {
matches!( matches!(
@ -2241,9 +2398,9 @@ impl Item {
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
widget::text::body(name) widget::text::body(name)
.wrapping(text::Wrapping::WordOrGlyph) .wrapping(text::Wrapping::WordOrGlyph)
.ellipsize(text::Ellipsize::Middle( .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
text::EllipsizeHeightLimit::Lines(3), 3,
)) )))
} }
/// Text widget for a filename in list view: word-or-glyph wrapping, middle-ellipsized to 1 line. /// 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<'a, cosmic::Theme, cosmic::Renderer> {
widget::text::body(name) widget::text::body(name)
.wrapping(text::Wrapping::WordOrGlyph) .wrapping(text::Wrapping::WordOrGlyph)
.ellipsize(text::Ellipsize::Middle( .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
text::EllipsizeHeightLimit::Lines(1), 1,
)) )))
} }
pub fn path_opt(&self) -> Option<&PathBuf> { pub fn path_opt(&self) -> Option<&PathBuf> {
@ -2614,7 +2771,7 @@ impl Mode {
} }
struct SearchContext { struct SearchContext {
results_rx: mpsc::Receiver<(PathBuf, String, Metadata)>, results_rx: mpsc::Receiver<SearchItem>,
ready: Arc<atomic::AtomicBool>, ready: Arc<atomic::AtomicBool>,
last_modified_opt: Arc<RwLock<Option<SystemTime>>>, last_modified_opt: Arc<RwLock<Option<SystemTime>>>,
} }
@ -4080,21 +4237,26 @@ impl Tab {
if let Some(items) = &mut self.items_opt { if let Some(items) = &mut self.items_opt {
if finished || context.ready.swap(false, atomic::Ordering::SeqCst) { if finished || context.ready.swap(false, atomic::Ordering::SeqCst) {
let duration = Instant::now(); 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! //TODO: combine this with column_sort logic, they must match!
let item_modified = metadata.modified().ok(); let index =
let index = match items.binary_search_by(|other| { if let SearchItem::Path(_, _, ref metadata) = search_item {
item_modified.cmp(&other.metadata.modified()) let item_modified = metadata.modified().ok();
}) { match items.binary_search_by(|other| {
Ok(index) => index, item_modified.cmp(&other.metadata.modified())
Err(index) => index, }) {
}; Ok(index) => index,
Err(index) => index,
}
} else {
items.len()
};
if index < MAX_SEARCH_RESULTS { if index < MAX_SEARCH_RESULTS {
//TODO: use correct IconSizes //TODO: use correct IconSizes
items.insert( let item =
index, item_from_search_item(search_item, IconSizes::default());
item_from_entry(path, name, metadata, IconSizes::default()), items.insert(index, item);
);
} }
// Ensure that updates make it to the GUI in a timely manner // Ensure that updates make it to the GUI in a timely manner
if !finished && duration.elapsed() >= MAX_SEARCH_LATENCY { if !finished && duration.elapsed() >= MAX_SEARCH_LATENCY {
@ -4822,7 +4984,7 @@ impl Tab {
let heading_row = widget::row::with_children([ let heading_row = widget::row::with_children([
heading_item(fl!("name"), Length::Fill, HeadingOptions::Name), heading_item(fl!("name"), Length::Fill, HeadingOptions::Name),
if self.location == Location::Trash { if self.location.is_trash() {
heading_item( heading_item(
fl!("trashed-on"), fl!("trashed-on"),
Length::Fixed(modified_width), Length::Fixed(modified_width),
@ -4946,7 +5108,9 @@ impl Tab {
let mut children: Vec<Element<_>> = Vec::new(); let mut children: Vec<Element<_>> = Vec::new();
match &self.location { 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_str = "...";
let excess_width = text_width_body(excess_str); let excess_width = text_width_body(excess_str);
for (index, ancestor) in path.ancestors().enumerate() { for (index, ancestor) in path.ancestors().enumerate() {
@ -5033,7 +5197,7 @@ impl Tab {
} }
children.reverse(); children.reverse();
} }
Location::Trash => { Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
children.push( children.push(
widget::button::custom(widget::text::heading(fl!("trash"))) widget::button::custom(widget::text::heading(fl!("trash")))
.padding(space_xxxs) .padding(space_xxxs)
@ -5042,7 +5206,7 @@ impl Tab {
.into(), .into(),
); );
} }
Location::Recents => { Location::Recents | Location::Search(SearchLocation::Recents, ..) => {
children.push( children.push(
widget::button::custom(widget::text::heading(fl!("recents"))) widget::button::custom(widget::text::heading(fl!("recents")))
.padding(space_xxxs) .padding(space_xxxs)
@ -5422,9 +5586,9 @@ impl Tab {
false, false,
false, false,
)), )),
widget::button::custom( widget::button::custom(Item::grid_display_name(
Item::grid_display_name(item.display_name.clone()), item.display_name.clone(),
) ))
.id(item.button_id.clone()) .id(item.button_id.clone())
.on_press(Message::Click(Some(*i))) .on_press(Message::Click(Some(*i)))
.padding([0, space_xxxs]) .padding([0, space_xxxs])
@ -5978,8 +6142,7 @@ impl Tab {
tab_column = tab_column.push(popover); tab_column = tab_column.push(popover);
} }
match &self.location { match &self.location {
Location::Recents => {} Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
Location::Trash => {
if let Some(items) = self.items_opt() if let Some(items) = self.items_opt()
&& !items.is_empty() && !items.is_empty()
{ {
@ -6358,9 +6521,9 @@ impl Tab {
} }
// Load search items incrementally // 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 location = self.location.clone();
let path = path.clone(); let search_location = search_location.clone();
let term = term.clone(); let term = term.clone();
let show_hidden = *show_hidden; let show_hidden = *show_hidden;
let start = *start; let start = *start;
@ -6389,27 +6552,25 @@ impl Tab {
let output = output.clone(); let output = output.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
scan_search( scan_search(
&path, &search_location,
&term, &term,
show_hidden, show_hidden,
move |path, name, metadata| -> bool { move |search_item| -> bool {
// Don't send if the result is too old // Don't send if the result is too old
if let Some(last_modified) = *last_modified_opt.read().unwrap() if let Some(last_modified) = *last_modified_opt.read().unwrap()
{ {
if let Ok(modified) = metadata.modified() { if let SearchItem::Path(_, _, ref metadata) = search_item {
if modified < last_modified { if let Ok(modified) = metadata.modified() {
if modified < last_modified {
return true;
}
} else {
return true; return true;
} }
} else {
return true;
} }
} }
match results_tx.blocking_send(( match results_tx.blocking_send(search_item) {
path.to_path_buf(),
name.to_string(),
metadata,
)) {
Ok(()) => { Ok(()) => {
if ready.swap(true, atomic::Ordering::SeqCst) { if ready.swap(true, atomic::Ordering::SeqCst) {
true true
@ -6432,7 +6593,7 @@ impl Tab {
log::info!( log::info!(
"searched for {:?} in {} in {:?}", "searched for {:?} in {} in {:?}",
term, term,
path.display(), search_location,
start.elapsed(), start.elapsed(),
); );
}) })