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,
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::<Tab>(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<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> {
let needs_reload: Box<[_]> = self
.tab_model
.iter()
.filter_map(|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();
@ -1453,19 +1440,35 @@ impl App {
let mut title_location_opt = None;
if let Some(tab) = self.tab_model.data_mut::<Tab>(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::<Tab>(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(

View file

@ -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<String>) -> Task<Message> {
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,
},
};

View file

@ -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<cosmic::widget::Id> =
@ -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;

View file

@ -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<Vec<(String, PathBuf)>, Box<dyn Error>> {
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<String, String> {
Ok(match path.file_name() {
Some(name_os) => name_os
@ -1047,8 +1075,8 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
items
}
pub fn scan_search<F: Fn(&Path, &str, Metadata) -> bool + Sync>(
tab_path: &PathBuf,
pub fn scan_search<F: Fn(SearchItem) -> bool + Sync>(
search_location: &SearchLocation,
term: &str,
show_hidden: bool,
callback: F,
@ -1069,48 +1097,99 @@ pub fn scan_search<F: Fn(&Path, &str, Metadata) -> 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, &regex);
}
}
}
// 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")
)
)))]
pub fn scan_trash(_sizes: IconSizes) -> Vec<Item> {
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<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
@ -1138,76 +1239,85 @@ pub fn scan_trash(_sizes: IconSizes) -> Vec<Item> {
not(target_os = "android")
)
))]
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();
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<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> {
@ -1263,7 +1373,7 @@ pub fn scan_recents(sizes: IconSizes) -> Vec<Item> {
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<Location> for EditLocation {
fn from(location: Location) -> Self {
Self {
@ -1452,7 +1585,7 @@ pub enum Location {
Network(String, String, Option<PathBuf>),
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<SearchItem>,
ready: Arc<atomic::AtomicBool>,
last_modified_opt: Arc<RwLock<Option<SystemTime>>>,
}
@ -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<Element<_>> = 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(),
);
})