Implement search backend
This commit is contained in:
parent
e6f8fdd1e6
commit
c5bff149c0
5 changed files with 302 additions and 74 deletions
42
Cargo.lock
generated
42
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
181
src/app.rs
181
src/app.rs
|
|
@ -29,6 +29,7 @@ use notify_debouncer_full::{
|
|||
notify::{self, RecommendedWatcher, Watcher},
|
||||
DebouncedEvent, Debouncer, FileIdMap,
|
||||
};
|
||||
use rayon::slice::ParallelSliceMut;
|
||||
use slotmap::Key as SlotMapKey;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
|
|
@ -37,7 +38,7 @@ use std::{
|
|||
num::NonZeroU16,
|
||||
path::PathBuf,
|
||||
process,
|
||||
sync::Arc,
|
||||
sync::{Arc, Mutex},
|
||||
time::{self, Instant},
|
||||
};
|
||||
|
||||
|
|
@ -85,6 +86,7 @@ pub enum Action {
|
|||
Properties,
|
||||
Rename,
|
||||
RestoreFromTrash,
|
||||
SearchActivate,
|
||||
SelectAll,
|
||||
Settings,
|
||||
TabClose,
|
||||
|
|
@ -129,6 +131,7 @@ impl Action {
|
|||
Action::Properties => Message::ToggleContextPage(ContextPage::Properties(None)),
|
||||
Action::Rename => Message::Rename(entity_opt),
|
||||
Action::RestoreFromTrash => Message::RestoreFromTrash(entity_opt),
|
||||
Action::SearchActivate => Message::SearchActivate,
|
||||
Action::SelectAll => Message::TabMessage(entity_opt, tab::Message::SelectAll),
|
||||
Action::Settings => Message::ToggleContextPage(ContextPage::Settings),
|
||||
Action::TabClose => Message::TabClose(entity_opt),
|
||||
|
|
@ -222,6 +225,11 @@ pub enum Message {
|
|||
RescanTrash,
|
||||
Rename(Option<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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
149
src/tab.rs
149
src/tab.rs
|
|
@ -193,6 +193,77 @@ fn hidden_attribute(metadata: &Metadata) -> bool {
|
|||
metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN
|
||||
}
|
||||
|
||||
pub fn item_from_entry(
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
metadata: fs::Metadata,
|
||||
sizes: IconSizes,
|
||||
) -> Item {
|
||||
let hidden = name.starts_with(".") || hidden_attribute(&metadata);
|
||||
|
||||
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
|
||||
if metadata.is_dir() {
|
||||
(
|
||||
//TODO: make this a static
|
||||
"inode/directory".parse().unwrap(),
|
||||
folder_icon(&path, sizes.grid()),
|
||||
folder_icon(&path, sizes.list()),
|
||||
folder_icon(&path, sizes.list_condensed()),
|
||||
)
|
||||
} else {
|
||||
let mime = mime_for_path(&path);
|
||||
(
|
||||
mime.clone(),
|
||||
mime_icon(mime.clone(), sizes.grid()),
|
||||
mime_icon(mime.clone(), sizes.list()),
|
||||
mime_icon(mime, sizes.list_condensed()),
|
||||
)
|
||||
};
|
||||
|
||||
let open_with = mime_apps(&mime);
|
||||
|
||||
let thumbnail_opt = if mime.type_() == mime::IMAGE {
|
||||
if mime.subtype() == mime::SVG {
|
||||
Some(ItemThumbnail::Svg)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(ItemThumbnail::NotImage)
|
||||
};
|
||||
|
||||
let children = if metadata.is_dir() {
|
||||
//TODO: calculate children in the background (and make it cancellable?)
|
||||
match fs::read_dir(&path) {
|
||||
Ok(entries) => entries.count(),
|
||||
Err(err) => {
|
||||
log::warn!("failed to read directory {:?}: {}", path, err);
|
||||
0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Item {
|
||||
name,
|
||||
metadata: ItemMetadata::Path { metadata, children },
|
||||
hidden,
|
||||
path_opt: Some(path),
|
||||
mime,
|
||||
icon_handle_grid,
|
||||
icon_handle_list,
|
||||
icon_handle_list_condensed,
|
||||
open_with,
|
||||
thumbnail_opt,
|
||||
button_id: widget::Id::unique(),
|
||||
pos_opt: Cell::new(None),
|
||||
rect_opt: Cell::new(None),
|
||||
selected: false,
|
||||
overlaps_drag_rect: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue