Implement search backend

This commit is contained in:
Jeremy Soller 2024-05-31 14:54:19 -06:00
parent e6f8fdd1e6
commit c5bff149c0
5 changed files with 302 additions and 74 deletions

42
Cargo.lock generated
View file

@ -725,6 +725,16 @@ dependencies = [
"piper",
]
[[package]]
name = "bstr"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
@ -1123,6 +1133,7 @@ dependencies = [
"gio",
"i18n-embed",
"i18n-embed-fl",
"ignore",
"image",
"lexical-sort",
"libc",
@ -1133,6 +1144,8 @@ dependencies = [
"once_cell",
"open",
"paste",
"rayon",
"regex",
"rust-embed",
"serde",
"shlex",
@ -2288,6 +2301,19 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "globset"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata 0.4.6",
"regex-syntax 0.8.3",
]
[[package]]
name = "glow"
version = "0.13.1"
@ -2795,6 +2821,22 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "ignore"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata 0.4.6",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "image"
version = "0.24.9"

View file

@ -16,6 +16,7 @@ env_logger = "0.11"
freedesktop_entry_parser = { version = "1.3", optional = true }
fs_extra = "1.3"
gio = { version = "0.19", optional = true }
ignore = "0.4"
image = "0.24"
once_cell = "1.19"
open = "5.0.2"
@ -25,6 +26,8 @@ log = "0.4"
mime_guess = "2"
notify-debouncer-full = "0.3"
paste = "1.0"
rayon = "1"
regex = "1"
serde = { version = "1", features = ["serde_derive"] }
shlex = { version = "1.3" }
tokio = { version = "1", features = ["sync"] }

View file

@ -29,6 +29,7 @@ use notify_debouncer_full::{
notify::{self, RecommendedWatcher, Watcher},
DebouncedEvent, Debouncer, FileIdMap,
};
use rayon::slice::ParallelSliceMut;
use slotmap::Key as SlotMapKey;
use std::{
any::TypeId,
@ -37,7 +38,7 @@ use std::{
num::NonZeroU16,
path::PathBuf,
process,
sync::Arc,
sync::{Arc, Mutex},
time::{self, Instant},
};
@ -85,6 +86,7 @@ pub enum Action {
Properties,
Rename,
RestoreFromTrash,
SearchActivate,
SelectAll,
Settings,
TabClose,
@ -129,6 +131,7 @@ impl Action {
Action::Properties => Message::ToggleContextPage(ContextPage::Properties(None)),
Action::Rename => Message::Rename(entity_opt),
Action::RestoreFromTrash => Message::RestoreFromTrash(entity_opt),
Action::SearchActivate => Message::SearchActivate,
Action::SelectAll => Message::TabMessage(entity_opt, tab::Message::SelectAll),
Action::Settings => Message::ToggleContextPage(ContextPage::Settings),
Action::TabClose => Message::TabClose(entity_opt),
@ -222,6 +225,11 @@ pub enum Message {
RescanTrash,
Rename(Option<Entity>),
RestoreFromTrash(Option<Entity>),
SearchActivate,
SearchClear,
SearchInput(String),
SearchResults(String, Vec<tab::Item>),
SearchSubmit,
SystemThemeModeChange(cosmic_theme::ThemeMode),
TabActivate(Entity),
TabNext,
@ -331,6 +339,10 @@ pub struct App {
pending_operations: BTreeMap<u64, (Operation, f32)>,
complete_operations: BTreeMap<u64, Operation>,
failed_operations: BTreeMap<u64, (Operation, String)>,
search_active: bool,
search_id: widget::Id,
search_input: String,
search_results: Option<Vec<tab::Item>>,
watcher_opt: Option<(Debouncer<RecommendedWatcher, FileIdMap>, HashSet<PathBuf>)>,
nav_dnd_hover: Option<(Location, Instant)>,
tab_dnd_hover: Option<(Entity, Instant)>,
@ -395,6 +407,105 @@ impl App {
Command::batch(commands)
}
fn search(&self) -> Command<Message> {
let input = self.search_input.clone();
let pattern = regex::escape(&input);
let regex = match regex::RegexBuilder::new(&pattern)
.case_insensitive(true)
.build()
{
Ok(ok) => ok,
Err(err) => {
log::warn!("failed to parse regex {:?}: {}", pattern, err);
return Command::none();
}
};
Command::perform(
async move {
tokio::task::spawn_blocking(move || {
let start = Instant::now();
let results_arc = Arc::new(Mutex::new(Vec::new()));
//TODO: do we want to ignore files?
ignore::WalkBuilder::new(home_dir())
//TODO: only use this on supported targets
.same_file_system(true)
.build_parallel()
.run(|| {
Box::new(|entry_res| {
let Ok(entry) = entry_res else {
// Skip invalid entries
return ignore::WalkState::Skip;
};
let Some(file_name) = entry.file_name().to_str() else {
// Skip anything with an invalid name
return ignore::WalkState::Skip;
};
if regex.is_match(file_name) {
let path = entry.path();
let metadata = match entry.metadata() {
Ok(ok) => ok,
Err(err) => {
log::warn!(
"failed to read metadata for entry at {:?}: {}",
path,
err
);
return ignore::WalkState::Continue;
}
};
let mut results = results_arc.lock().unwrap();
results.push(tab::item_from_entry(
path.to_path_buf(),
file_name.to_string(),
metadata,
IconSizes::default(),
));
}
ignore::WalkState::Continue
})
});
let mut results = Arc::into_inner(results_arc).unwrap().into_inner().unwrap();
let duration = start.elapsed();
log::info!(
"searched for {:?} in {:?}, found {} results",
input,
duration,
results.len()
);
let start = Instant::now();
results.par_sort_unstable_by(|a, b| {
let get_modified = |x: &tab::Item| match &x.metadata {
ItemMetadata::Path { metadata, .. } => metadata.modified().ok(),
ItemMetadata::Trash { .. } => None,
};
// Sort with latest modified first
let a_modified = get_modified(a);
let b_modified = get_modified(b);
b_modified.cmp(&a_modified)
});
let duration = start.elapsed();
log::info!("sorted results in {:?}", duration);
message::app(Message::SearchResults(input, results))
})
.await
.unwrap_or(message::none())
},
|x| x,
)
}
fn selected_paths(&self, entity_opt: Option<Entity>) -> Vec<PathBuf> {
let mut paths = Vec::new();
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
@ -894,6 +1005,10 @@ impl Application for App {
pending_operations: BTreeMap::new(),
complete_operations: BTreeMap::new(),
failed_operations: BTreeMap::new(),
search_active: false,
search_id: widget::Id::unique(),
search_input: String::new(),
search_results: None,
watcher_opt: None,
nav_dnd_hover: None,
tab_dnd_hover: None,
@ -994,6 +1109,12 @@ impl Application for App {
self.core.window.show_context = false;
return Command::none();
}
if self.search_active {
// Close search if open
self.search_active = false;
self.search_results = None;
return Command::none();
}
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
if tab.context_menu.is_some() {
tab.context_menu = None;
@ -1481,6 +1602,36 @@ impl Application for App {
self.operation(Operation::Restore { paths });
}
}
Message::SearchActivate => {
self.search_active = true;
return widget::text_input::focus(self.search_id.clone());
}
Message::SearchClear => {
self.search_active = false;
self.search_input.clear();
self.search_results = None;
}
Message::SearchInput(input) => {
if input != self.search_input {
self.search_input = input;
/*TODO: live search? (probably needs subscription for streaming results)
// This performs live search
if !self.search_input.is_empty() {
return self.search();
}
*/
}
}
Message::SearchResults(input, results) => {
if input == self.search_input {
self.search_results = Some(results);
}
}
Message::SearchSubmit => {
if !self.search_input.is_empty() {
return self.search();
}
}
Message::SystemThemeModeChange(_theme_mode) => {
return self.update_config();
}
@ -2045,13 +2196,39 @@ impl Application for App {
}
fn header_end(&self) -> Vec<Element<Self::Message>> {
vec![]
vec![if self.search_active {
widget::text_input::search_input("", &self.search_input)
.width(Length::Fixed(240.0))
.id(self.search_id.clone())
.on_clear(Message::SearchClear)
.on_input(Message::SearchInput)
.on_submit(Message::SearchSubmit)
.into()
} else {
widget::button::icon(widget::icon::from_name("system-search-symbolic"))
.on_press(Message::SearchActivate)
.into()
}]
}
/// Creates a view after each update.
fn view(&self) -> Element<Self::Message> {
let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing;
if let Some(results) = &self.search_results {
//TODO: pages?
let count = results.len().min(100);
if count == 0 {
//TODO: make it nicer
return widget::text("NO RESULTS").into();
}
let mut column = widget::column::with_capacity(count);
for item in results.iter().take(count) {
column = column.push(widget::text(&item.name));
}
return widget::scrollable(column.width(Length::Fill)).into();
}
let mut tab_column = widget::column::with_capacity(1);
if self.tab_model.iter().count() > 1 {

View file

@ -43,6 +43,7 @@ pub fn key_binds() -> HashMap<KeyBind, Action> {
bind!([Ctrl], Key::Character("v".into()), Paste);
bind!([], Key::Named(Named::Space), Properties);
bind!([], Key::Named(Named::F2), Rename);
bind!([Ctrl], Key::Character("f".into()), SearchActivate);
bind!([Ctrl], Key::Character("a".into()), SelectAll);
bind!([Ctrl], Key::Character("w".into()), TabClose);
bind!([Ctrl], Key::Character("t".into()), TabNew);

View file

@ -193,6 +193,77 @@ fn hidden_attribute(metadata: &Metadata) -> bool {
metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN
}
pub fn item_from_entry(
path: PathBuf,
name: String,
metadata: fs::Metadata,
sizes: IconSizes,
) -> Item {
let hidden = name.starts_with(".") || hidden_attribute(&metadata);
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
if metadata.is_dir() {
(
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&path, sizes.grid()),
folder_icon(&path, sizes.list()),
folder_icon(&path, sizes.list_condensed()),
)
} else {
let mime = mime_for_path(&path);
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
)
};
let open_with = mime_apps(&mime);
let thumbnail_opt = if mime.type_() == mime::IMAGE {
if mime.subtype() == mime::SVG {
Some(ItemThumbnail::Svg)
} else {
None
}
} else {
Some(ItemThumbnail::NotImage)
};
let children = if metadata.is_dir() {
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => entries.count(),
Err(err) => {
log::warn!("failed to read directory {:?}: {}", path, err);
0
}
}
} else {
0
};
Item {
name,
metadata: ItemMetadata::Path { metadata, children },
hidden,
path_opt: Some(path),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
open_with,
thumbnail_opt,
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
overlaps_drag_rect: false,
}
}
pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
let mut items = Vec::new();
match fs::read_dir(tab_path) {
@ -206,12 +277,14 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
}
};
let path = entry.path();
let name = match entry.file_name().into_string() {
Ok(ok) => ok,
Err(name_os) => {
log::warn!(
"failed to parse entry in {:?}: {:?} is not valid UTF-8",
tab_path,
"failed to parse entry at {:?}: {:?} is not valid UTF-8",
path,
name_os,
);
continue;
@ -221,80 +294,12 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
let metadata = match entry.metadata() {
Ok(ok) => ok,
Err(err) => {
log::warn!(
"failed to read metadata for entry in {:?}: {}",
tab_path,
err
);
log::warn!("failed to read metadata for entry at {:?}: {}", path, err);
continue;
}
};
let hidden = name.starts_with(".") || hidden_attribute(&metadata);
let path = entry.path();
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
if metadata.is_dir() {
(
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&path, sizes.grid()),
folder_icon(&path, sizes.list()),
folder_icon(&path, sizes.list_condensed()),
)
} else {
let mime = mime_for_path(&path);
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
)
};
let open_with = mime_apps(&mime);
let thumbnail_opt = if mime.type_() == mime::IMAGE {
if mime.subtype() == mime::SVG {
Some(ItemThumbnail::Svg)
} else {
None
}
} else {
Some(ItemThumbnail::NotImage)
};
let children = if metadata.is_dir() {
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => entries.count(),
Err(err) => {
log::warn!("failed to read directory {:?}: {}", path, err);
0
}
}
} else {
0
};
items.push(Item {
name,
metadata: ItemMetadata::Path { metadata, children },
hidden,
path_opt: Some(path),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
open_with,
thumbnail_opt,
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
overlaps_drag_rect: false,
});
items.push(item_from_entry(path, name, metadata, sizes));
}
}
Err(err) => {