diff --git a/yazi-adaptor/src/adaptor.rs b/yazi-adaptor/src/adaptor.rs index 1d721641..05fe017f 100644 --- a/yazi-adaptor/src/adaptor.rs +++ b/yazi-adaptor/src/adaptor.rs @@ -1,4 +1,4 @@ -use std::{env, path::Path, sync::{atomic::Ordering, Arc}}; +use std::{env, path::Path, sync::Arc}; use anyhow::{anyhow, Result}; use ratatui::prelude::Rect; diff --git a/yazi-config/preset/keymap.toml b/yazi-config/preset/keymap.toml index 1dc00d37..9675fd11 100644 --- a/yazi-config/preset/keymap.toml +++ b/yazi-config/preset/keymap.toml @@ -89,6 +89,9 @@ keymap = [ { on = [ "c", "f" ], exec = "copy filename", desc = "Copy the name of the file" }, { on = [ "c", "n" ], exec = "copy name_without_ext", desc = "Copy the name of the file without the extension" }, + # Filter + { on = [ "f" ], exec = "filter" }, + # Find { on = [ "/" ], exec = "find --smart" }, { on = [ "?" ], exec = "find --previous --smart" }, diff --git a/yazi-config/preset/yazi.toml b/yazi-config/preset/yazi.toml index 4db32843..bd5b4439 100644 --- a/yazi-config/preset/yazi.toml +++ b/yazi-config/preset/yazi.toml @@ -139,6 +139,11 @@ delete_title = "Delete {n} selected file{s} permanently? (y/N)" delete_origin = "top-center" delete_offset = [ 0, 2, 50, 3 ] +# filter +filter_title = "Filter:" +filter_origin = "top-center" +filter_offset = [ 0, 2, 50, 3 ] + # find find_title = [ "Find next:", "Find previous:" ] find_origin = "top-center" diff --git a/yazi-config/src/popup/input.rs b/yazi-config/src/popup/input.rs index 6453b11f..4b1f9d52 100644 --- a/yazi-config/src/popup/input.rs +++ b/yazi-config/src/popup/input.rs @@ -30,6 +30,11 @@ pub struct Input { pub delete_origin: Origin, pub delete_offset: Offset, + // filter + pub filter_title: String, + pub filter_origin: Origin, + pub filter_offset: Offset, + // find pub find_title: [String; 2], pub find_origin: Origin, diff --git a/yazi-config/src/popup/options.rs b/yazi-config/src/popup/options.rs index 177788c9..9ef592e4 100644 --- a/yazi-config/src/popup/options.rs +++ b/yazi-config/src/popup/options.rs @@ -67,6 +67,16 @@ impl InputCfg { } } + #[inline] + pub fn filter() -> Self { + Self { + title: INPUT.filter_title.to_owned(), + position: Position::new(INPUT.filter_origin, INPUT.filter_offset), + realtime: true, + ..Default::default() + } + } + #[inline] pub fn find(prev: bool) -> Self { Self { diff --git a/yazi-core/src/folder/files.rs b/yazi-core/src/folder/files.rs index 90ad88c2..5ba6b340 100644 --- a/yazi-core/src/folder/files.rs +++ b/yazi-core/src/folder/files.rs @@ -5,7 +5,7 @@ use tokio::{fs, select, sync::mpsc::{self, UnboundedReceiver}}; use yazi_config::{manager::SortBy, MANAGER}; use yazi_shared::fs::{File, Url, FILES_TICKET}; -use super::FilesSorter; +use super::{FilesSorter, Filter}; pub struct Files { hidden: Vec, @@ -18,6 +18,7 @@ pub struct Files { selected: BTreeSet, sorter: FilesSorter, + filter: Option, show_hidden: bool, } @@ -34,6 +35,7 @@ impl Default for Files { selected: Default::default(), sorter: Default::default(), + filter: Default::default(), show_hidden: MANAGER.show_hidden, } } @@ -136,11 +138,7 @@ impl Files { self.ticket = FILES_TICKET.fetch_add(1, Ordering::Relaxed); self.revision += 1; - (self.hidden, self.items) = if self.show_hidden { - (vec![], files) - } else { - files.into_iter().partition(|f| f.is_hidden()) - }; + (self.hidden, self.items) = self.split_files(files); } pub fn update_part(&mut self, files: Vec, ticket: u64) { @@ -150,14 +148,9 @@ impl Files { } self.revision += 1; - if self.show_hidden { - self.hidden.clear(); - self.items.extend(files); - } else { - let (hidden, items): (Vec<_>, Vec<_>) = files.into_iter().partition(|f| f.is_hidden()); - self.hidden.extend(hidden); - self.items.extend(items); - } + let (hidden, items) = self.split_files(files); + self.hidden.extend(hidden); + self.items.extend(items); return; } @@ -200,12 +193,7 @@ impl Files { }; } - let (hidden, items) = if self.show_hidden { - (vec![], files) - } else { - files.into_iter().partition(|f| f.is_hidden()) - }; - + let (hidden, items) = self.split_files(files); if !items.is_empty() { go!(self.items, items); } @@ -231,8 +219,15 @@ impl Files { }; } - let (hidden, items) = - if self.show_hidden { (vec![], urls) } else { urls.into_iter().partition(|u| u.is_hidden()) }; + let (hidden, items) = if let Some(filter) = &self.filter { + urls.into_iter().partition(|u| { + (!self.show_hidden && u.is_hidden()) || !u.file_name().is_some_and(|s| filter.matches(s)) + }) + } else if self.show_hidden { + (vec![], urls) + } else { + urls.into_iter().partition(|u| u.is_hidden()) + }; if !items.is_empty() { go!(self.items, items); @@ -242,7 +237,10 @@ impl Files { } } - pub fn update_updating(&mut self, files: BTreeMap) -> [BTreeMap; 2] { + pub fn update_updating( + &mut self, + files: BTreeMap, + ) -> (BTreeMap, BTreeMap) { if files.is_empty() { return Default::default(); } @@ -264,7 +262,12 @@ impl Files { }; } - let (mut hidden, mut items) = if self.show_hidden { + let (mut hidden, mut items) = if let Some(filter) = &self.filter { + files.into_iter().partition(|(_, f)| { + (f.is_hidden() && !self.show_hidden) + || !f.url.file_name().is_some_and(|s| filter.matches(s)) + }) + } else if self.show_hidden { (BTreeMap::new(), files) } else { files.into_iter().partition(|(_, f)| f.is_hidden()) @@ -276,7 +279,7 @@ impl Files { if !hidden.is_empty() { go!(self.hidden, hidden); } - [hidden, items] + (hidden, items) } pub fn update_upserting(&mut self, files: BTreeMap) { @@ -284,7 +287,7 @@ impl Files { return; } - let [hidden, items] = self.update_updating(files); + let (hidden, items) = self.update_updating(files); if hidden.is_empty() && items.is_empty() { return; } @@ -307,6 +310,19 @@ impl Files { self.sorter.sort(&mut self.items, &self.sizes); true } + + fn split_files(&self, files: impl IntoIterator) -> (Vec, Vec) { + if let Some(filter) = &self.filter { + files.into_iter().partition(|f| { + (f.is_hidden() && !self.show_hidden) + || !f.url.file_name().is_some_and(|s| filter.matches(s)) + }) + } else if self.show_hidden { + (vec![], files.into_iter().collect()) + } else { + files.into_iter().partition(|f| f.is_hidden()) + } + } } impl Files { @@ -374,6 +390,31 @@ impl Files { } } + // --- Filter + pub fn set_filter(&mut self, filter: Option) -> bool { + if self.filter == filter { + return false; + } + + self.filter = filter; + if self.filter.is_none() { + let take = mem::take(&mut self.hidden); + let (hidden, items) = self.split_files(take); + + self.hidden = hidden; + if !items.is_empty() { + self.items.extend(items); + self.sorter.sort(&mut self.items, &self.sizes); + } + return true; + } + + let it = mem::take(&mut self.items).into_iter().chain(mem::take(&mut self.hidden)); + (self.hidden, self.items) = self.split_files(it); + self.sorter.sort(&mut self.items, &self.sizes); + true + } + // --- Show hidden pub fn set_show_hidden(&mut self, state: bool) { if self.show_hidden == state { @@ -387,12 +428,12 @@ impl Files { return; } + let take = + if self.show_hidden { mem::take(&mut self.hidden) } else { mem::take(&mut self.items) }; + let (hidden, items) = self.split_files(take); + self.revision += 1; - if self.show_hidden { - self.items.append(&mut self.hidden); - } else { - let items = mem::take(&mut self.items); - (self.hidden, self.items) = items.into_iter().partition(|f| f.is_hidden()); - } + self.hidden.extend(hidden); + self.items.extend(items); } } diff --git a/yazi-core/src/folder/filter.rs b/yazi-core/src/folder/filter.rs new file mode 100644 index 00000000..10d3823c --- /dev/null +++ b/yazi-core/src/folder/filter.rs @@ -0,0 +1,54 @@ +use std::{ffi::OsStr, ops::Range}; + +use anyhow::Result; +use regex::bytes::{Regex, RegexBuilder}; +use yazi_shared::event::Exec; + +pub struct Filter { + raw: String, + regex: Regex, +} + +impl PartialEq for Filter { + fn eq(&self, other: &Self) -> bool { self.raw == other.raw } +} + +impl Filter { + pub fn new(s: &str, case: FilterCase) -> Result { + let regex = match case { + FilterCase::Smart => { + let uppercase = s.chars().any(|c| c.is_uppercase()); + RegexBuilder::new(s).case_insensitive(!uppercase).build()? + } + FilterCase::Sensitive => Regex::new(s)?, + FilterCase::Insensitive => RegexBuilder::new(s).case_insensitive(true).build()?, + }; + Ok(Self { raw: s.to_owned(), regex }) + } + + #[inline] + pub fn matches(&self, name: &OsStr) -> bool { self.regex.is_match(name.as_encoded_bytes()) } + + #[inline] + pub fn highlighted(&self, name: &OsStr) -> Option>> { + self.regex.find(name.as_encoded_bytes()).map(|m| vec![m.range()]) + } +} + +#[derive(Default, PartialEq, Eq)] +pub enum FilterCase { + Smart, + #[default] + Sensitive, + Insensitive, +} + +impl From<&Exec> for FilterCase { + fn from(e: &Exec) -> Self { + match (e.named.contains_key("smart"), e.named.contains_key("insensitive")) { + (true, _) => Self::Smart, + (_, false) => Self::Sensitive, + (_, true) => Self::Insensitive, + } + } +} diff --git a/yazi-core/src/folder/mod.rs b/yazi-core/src/folder/mod.rs index f40a0ab7..e6bce8d1 100644 --- a/yazi-core/src/folder/mod.rs +++ b/yazi-core/src/folder/mod.rs @@ -1,7 +1,9 @@ mod files; +mod filter; mod folder; mod sorter; pub use files::*; +pub use filter::*; pub use folder::*; pub use sorter::*; diff --git a/yazi-core/src/tab/commands/escape.rs b/yazi-core/src/tab/commands/escape.rs index c6e13721..fd552244 100644 --- a/yazi-core/src/tab/commands/escape.rs +++ b/yazi-core/src/tab/commands/escape.rs @@ -5,10 +5,11 @@ use crate::tab::{Mode, Tab}; bitflags! { pub struct Opt: u8 { - const FIND = 0b0001; - const VISUAL = 0b0010; - const SELECT = 0b0100; - const SEARCH = 0b1000; + const FIND = 0b00001; + const VISUAL = 0b00010; + const SELECT = 0b00100; + const FILTER = 0b01000; + const SEARCH = 0b10000; } } @@ -19,6 +20,7 @@ impl From<&Exec> for Opt { b"find" => acc | Self::FIND, b"visual" => acc | Self::VISUAL, b"select" => acc | Self::SELECT, + b"filter" => acc | Self::FILTER, b"search" => acc | Self::SEARCH, _ => acc, }) @@ -42,6 +44,11 @@ impl Tab { #[inline] fn escape_select(&mut self) -> bool { self.select_all(Some(false)) } + #[inline] + fn escape_filter(&mut self) -> bool { + self.filter_do(super::filter::Opt { query: "", ..Default::default() }) + } + #[inline] fn escape_search(&mut self) -> bool { self.search_stop() } @@ -51,6 +58,7 @@ impl Tab { return self.escape_find() || self.escape_visual() || self.escape_select() + || self.escape_filter() || self.escape_search(); } @@ -64,6 +72,9 @@ impl Tab { if opt.contains(Opt::SELECT) { b |= self.escape_select(); } + if opt.contains(Opt::FILTER) { + b |= self.escape_filter(); + } if opt.contains(Opt::SEARCH) { b |= self.escape_search(); } diff --git a/yazi-core/src/tab/commands/filter.rs b/yazi-core/src/tab/commands/filter.rs new file mode 100644 index 00000000..c471b40f --- /dev/null +++ b/yazi-core/src/tab/commands/filter.rs @@ -0,0 +1,65 @@ +use std::time::Duration; + +use tokio::pin; +use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; +use yazi_config::popup::InputCfg; +use yazi_shared::{emit, event::Exec, Debounce, InputError, Layer}; + +use crate::{folder::{Filter, FilterCase}, input::Input, manager::Manager, tab::Tab}; + +#[derive(Default)] +pub struct Opt<'a> { + pub query: &'a str, + pub case: FilterCase, +} + +impl<'a> From<&'a Exec> for Opt<'a> { + fn from(e: &'a Exec) -> Self { + Self { query: e.args.first().map(|s| s.as_str()).unwrap_or_default(), case: e.into() } + } +} + +impl Tab { + pub fn filter<'a>(&mut self, opt: impl Into>) -> bool { + let opt = opt.into() as Opt; + tokio::spawn(async move { + let rx = Input::_show(InputCfg::filter()); + + let rx = Debounce::new(UnboundedReceiverStream::new(rx), Duration::from_millis(50)); + pin!(rx); + + while let Some(Ok(s)) | Some(Err(InputError::Typed(s))) = rx.next().await { + emit!(Call( + Exec::call("filter_do", vec![s]) + .with_bool("smart", opt.case == FilterCase::Smart) + .with_bool("insensitive", opt.case == FilterCase::Insensitive) + .vec(), + Layer::Manager + )); + } + }); + false + } + + pub fn filter_do<'a>(&mut self, opt: impl Into>) -> bool { + let opt = opt.into() as Opt; + + let filter = if opt.query.is_empty() { + None + } else if let Ok(f) = Filter::new(opt.query, opt.case) { + Some(f) + } else { + return false; + }; + + let hovered = self.current.hovered().map(|f| f.url()); + if !self.current.files.set_filter(filter) { + return false; + } + + if self.current.repos(hovered) { + Manager::_hover(None); + } + true + } +} diff --git a/yazi-core/src/tab/commands/find.rs b/yazi-core/src/tab/commands/find.rs index e95a330f..dba8e3d3 100644 --- a/yazi-core/src/tab/commands/find.rs +++ b/yazi-core/src/tab/commands/find.rs @@ -5,12 +5,12 @@ use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use yazi_config::popup::InputCfg; use yazi_shared::{emit, event::Exec, Debounce, InputError, Layer}; -use crate::{input::Input, tab::{Finder, FinderCase, Tab}}; +use crate::{folder::FilterCase, input::Input, tab::{Finder, Tab}}; pub struct Opt<'a> { query: Option<&'a str>, prev: bool, - case: FinderCase, + case: FilterCase, } impl<'a> From<&'a Exec> for Opt<'a> { @@ -18,11 +18,7 @@ impl<'a> From<&'a Exec> for Opt<'a> { Self { query: e.args.first().map(|s| s.as_str()), prev: e.named.contains_key("previous"), - case: match (e.named.contains_key("smart"), e.named.contains_key("insensitive")) { - (true, _) => FinderCase::Smart, - (_, false) => FinderCase::Sensitive, - (_, true) => FinderCase::Insensitive, - }, + case: e.into(), } } } @@ -48,8 +44,8 @@ impl Tab { emit!(Call( Exec::call("find_do", vec![s]) .with_bool("previous", opt.prev) - .with_bool("smart", opt.case == FinderCase::Smart) - .with_bool("insensitive", opt.case == FinderCase::Insensitive) + .with_bool("smart", opt.case == FilterCase::Smart) + .with_bool("insensitive", opt.case == FilterCase::Insensitive) .vec(), Layer::Manager )); @@ -63,10 +59,16 @@ impl Tab { let Some(query) = opt.query else { return false; }; + if query.is_empty() { + return self.escape(super::escape::Opt::FIND); + } let Ok(finder) = Finder::new(query, opt.case) else { return false; }; + if matches!(&self.finder, Some(f) if f.filter == finder.filter) { + return false; + } let step = if opt.prev { finder.prev(&self.current.files, self.current.cursor, true) diff --git a/yazi-core/src/tab/commands/mod.rs b/yazi-core/src/tab/commands/mod.rs index 86834755..5170b0a5 100644 --- a/yazi-core/src/tab/commands/mod.rs +++ b/yazi-core/src/tab/commands/mod.rs @@ -4,6 +4,7 @@ mod cd; mod copy; mod enter; mod escape; +mod filter; mod find; mod hidden; mod jump; diff --git a/yazi-core/src/tab/finder.rs b/yazi-core/src/tab/finder.rs index c537a0c8..e8145823 100644 --- a/yazi-core/src/tab/finder.rs +++ b/yazi-core/src/tab/finder.rs @@ -1,41 +1,25 @@ -use std::{collections::BTreeMap, ffi::OsStr, ops::Range}; +use std::collections::BTreeMap; use anyhow::Result; -use regex::bytes::{Regex, RegexBuilder}; use yazi_shared::fs::Url; -use crate::folder::Files; - -#[derive(PartialEq, Eq)] -pub enum FinderCase { - Smart, - Sensitive, - Insensitive, -} +use crate::folder::{Files, Filter, FilterCase}; pub struct Finder { - query: Regex, - matched: BTreeMap, - revision: u64, + pub filter: Filter, + matched: BTreeMap, + revision: u64, } impl Finder { - pub(super) fn new(s: &str, case: FinderCase) -> Result { - let query = match case { - FinderCase::Smart => { - let uppercase = s.chars().any(|c| c.is_uppercase()); - RegexBuilder::new(s).case_insensitive(!uppercase).build()? - } - FinderCase::Sensitive => Regex::new(s)?, - FinderCase::Insensitive => RegexBuilder::new(s).case_insensitive(true).build()?, - }; - Ok(Self { query, matched: Default::default(), revision: 0 }) + pub(super) fn new(s: &str, case: FilterCase) -> Result { + Ok(Self { filter: Filter::new(s, case)?, matched: Default::default(), revision: 0 }) } pub(super) fn prev(&self, files: &Files, cursor: usize, include: bool) -> Option { for i in !include as usize..files.len() { let idx = (cursor + files.len() - i) % files.len(); - if files[idx].name().is_some_and(|n| self.matches(n)) { + if files[idx].name().is_some_and(|n| self.filter.matches(n)) { return Some(idx as isize - cursor as isize); } } @@ -45,7 +29,7 @@ impl Finder { pub(super) fn next(&self, files: &Files, cursor: usize, include: bool) -> Option { for i in !include as usize..files.len() { let idx = (cursor + i) % files.len(); - if files[idx].name().is_some_and(|n| self.matches(n)) { + if files[idx].name().is_some_and(|n| self.filter.matches(n)) { return Some(idx as isize - cursor as isize); } } @@ -60,7 +44,7 @@ impl Finder { let mut i = 0u8; for file in files.iter() { - if file.name().map(|n| self.matches(n)) != Some(true) { + if file.name().map(|n| self.filter.matches(n)) != Some(true) { continue; } @@ -75,15 +59,6 @@ impl Finder { self.revision = files.revision; true } - - #[inline] - fn matches(&self, name: &OsStr) -> bool { self.query.is_match(name.as_encoded_bytes()) } - - /// Explode the name into three parts: head, body, tail. - #[inline] - pub fn highlighted(&self, name: &OsStr) -> Option>> { - self.query.find(name.as_encoded_bytes()).map(|m| vec![m.range()]) - } } impl Finder { diff --git a/yazi-fm/src/executor.rs b/yazi-fm/src/executor.rs index 58a8202b..17a327d2 100644 --- a/yazi-fm/src/executor.rs +++ b/yazi-fm/src/executor.rs @@ -152,6 +152,10 @@ impl<'a> Executor<'a> { on!(ACTIVE, search); on!(ACTIVE, jump); + // Filter + on!(ACTIVE, filter); + on!(ACTIVE, filter_do); + // Find on!(ACTIVE, find); on!(ACTIVE, find_do); diff --git a/yazi-fm/src/lives/folder.rs b/yazi-fm/src/lives/folder.rs index 62478fa4..5f27635c 100644 --- a/yazi-fm/src/lives/folder.rs +++ b/yazi-fm/src/lives/folder.rs @@ -147,7 +147,7 @@ impl<'a, 'b> Folder<'a, 'b> { }; let file = me.borrow::()?; - let Some(h) = file.name().and_then(|n| finder.highlighted(n)) else { + let Some(h) = file.name().and_then(|n| finder.filter.highlighted(n)) else { return Ok(None); };