From 631f09b9a840dd4a073f4f390bba74bfb543f30c Mon Sep 17 00:00:00 2001 From: Christian Visintin Date: Mon, 15 Jul 2024 15:08:22 +0200 Subject: [PATCH] feat: issue 256 - filter files (#266) --- CHANGELOG.md | 1 + Cargo.lock | 1 + Cargo.toml | 1 + docs/es/man.md | 1 + docs/fr/man.md | 1 + docs/it/man.md | 1 + docs/man.md | 1 + docs/ptbr/man.md | 1 + docs/zh-CN/man.md | 1 + src/explorer/mod.rs | 7 -- .../activities/filetransfer/actions/filter.rs | 51 +++++++++++ src/ui/activities/filetransfer/actions/mod.rs | 1 + .../activities/filetransfer/components/mod.rs | 4 +- .../filetransfer/components/popups.rs | 87 +++++++++++++++++++ .../filetransfer/components/transfer/mod.rs | 8 ++ src/ui/activities/filetransfer/misc.rs | 1 + src/ui/activities/filetransfer/mod.rs | 4 + src/ui/activities/filetransfer/update.rs | 29 +++++++ src/ui/activities/filetransfer/view.rs | 33 ++++++- 19 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 src/ui/activities/filetransfer/actions/filter.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index baf6fdf..e5e1b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Released on ?? - [Issue 226](https://github.com/veeso/termscp/issues/226): Use ssh-agent - [Issue 241](https://github.com/veeso/termscp/issues/241): Jump to next entry after select - [Issue 255](https://github.com/veeso/termscp/issues/255): New keybindings `Ctrl + Shift + A` to deselect all files +- [Issue 256](https://github.com/veeso/termscp/issues/256): Filter files in current folder. You can now filter files by pressing `/`. Both wildmatch and regex are accepted to filter files. - [Issue 257](https://github.com/veeso/termscp/issues/257): CLI remote args cannot handle '@' in the username ## 0.13.0 diff --git a/Cargo.lock b/Cargo.lock index eb9c857..2a58ec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3553,6 +3553,7 @@ dependencies = [ "open", "pretty_assertions", "rand", + "regex", "remotefs", "remotefs-aws-s3", "remotefs-ftp", diff --git a/Cargo.toml b/Cargo.toml index 991f9cc..0a77b8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ notify = "=4.0.17" notify-rust = { version = "^4.5", default-features = false, features = ["d"] } open = "^5.0" rand = "^0.8.5" +regex = "^1" remotefs = "^0.2.0" remotefs-aws-s3 = { version = "^0.2.4", default-features = false, features = [ "find", diff --git a/docs/es/man.md b/docs/es/man.md index 207ee9a..8fd0651 100644 --- a/docs/es/man.md +++ b/docs/es/man.md @@ -244,6 +244,7 @@ Para cambiar de panel, debe escribir `` para mover el panel del explorador | `` | Ejecutar un comando | eXecute | | `` | Alternar navegación sincronizada | sYnc | | `` | Cambiar ppermisos de archivo | | +| `` | Filtrar archivos (se admite tanto regex como coincidencias con comodines) | | | `` | Seleccionar todos los archivos | | | `` | Deseleccionar todos los archivos | | | `` | Abortar el proceso de transferencia de archivos | | diff --git a/docs/fr/man.md b/docs/fr/man.md index 48d41b5..c5e016c 100644 --- a/docs/fr/man.md +++ b/docs/fr/man.md @@ -243,6 +243,7 @@ Pour changer de panneau, vous devez taper `` pour déplacer le panneau de | `` | Exécuter une commande | eXecute | | `` | Basculer la navigation synchronisée | sYnc | | `` | Changer permissions de fichier | | +| `` | Filtrer les fichiers (les expressions régulières et les correspondances génériques sont prises en charge) | | | `` | Sélectionner tous les fichiers | | | `` | Desélectionner tous les fichiers | | | `` | Abandonner le processus de transfert de fichiers | | diff --git a/docs/it/man.md b/docs/it/man.md index 086166a..4f54a15 100644 --- a/docs/it/man.md +++ b/docs/it/man.md @@ -239,6 +239,7 @@ Per cambiare pannello ti puoi muovere con le frecce, `` per andare sul pan | `` | Esegui comando shell | eXecute | | `` | Abilita/disabilita Sync-Browsing | sYnc | | `` | Modifica permessi file | | +| `` | Filtra i file (supporta sia regex che wildmatch ) | | | `` | Seleziona tutti i file | | | `` | Deseleziona tutti i file | | | `` | Annulla trasferimento file | | diff --git a/docs/man.md b/docs/man.md index e1641fb..f444d50 100644 --- a/docs/man.md +++ b/docs/man.md @@ -255,6 +255,7 @@ In order to change panel you need to type `` to move the remote explorer p | `` | Execute a command | eXecute | | `` | Toggle synchronized browsing | sYnc | | `` | Change file mode | | +| `` | Filter files (both regex and wildmatch is supported) | | | `` | Select all files | | | `` | Deselect all files | | | `` | Abort file transfer process | | diff --git a/docs/ptbr/man.md b/docs/ptbr/man.md index 1d570a2..7514014 100644 --- a/docs/ptbr/man.md +++ b/docs/ptbr/man.md @@ -257,6 +257,7 @@ Para trocar de painel, você precisa pressionar `` para mover para o paine | `` | Executar um comando | Executar | | `` | Alternar navegação sincronizada | Sincronizar | | `` | Alterar modo de arquivo | | +| `` | Filtrar arquivos (suporte tanto para regex quanto para coringa) | | | `` | Selecionar todos os arquivos | | | `` | Deselecionar todos os arquivos | | | `` | Abortir processo de transferência de arquivo | | diff --git a/docs/zh-CN/man.md b/docs/zh-CN/man.md index 2cbc0b1..40eeb58 100644 --- a/docs/zh-CN/man.md +++ b/docs/zh-CN/man.md @@ -239,6 +239,7 @@ termscp中的文件资源管理器是指你与远程建立连接后可以看到 | `` | 运行命令 | eXecute | | `` | 是否开启同步浏览 | sYnc | | `` | 更改文件权限 | | +| `` | 过滤文件(支持正则表达式和通配符匹配) | | | `` | 选中所有文件 | | | `` | 取消选择所有文件 | | | `` | 终止文件传输 | | diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index cfb119e..33dfe35 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -98,13 +98,6 @@ impl FileExplorer { } } - /* - /// Return amount of files - pub fn count(&self) -> usize { - self.files.len() - } - */ - /// Iterate over files /// Filters are applied based on current options (e.g. hidden files not returned) pub fn iter_files(&self) -> impl Iterator + '_ { diff --git a/src/ui/activities/filetransfer/actions/filter.rs b/src/ui/activities/filetransfer/actions/filter.rs new file mode 100644 index 0000000..bf1d3bf --- /dev/null +++ b/src/ui/activities/filetransfer/actions/filter.rs @@ -0,0 +1,51 @@ +use std::str::FromStr; + +use regex::Regex; +use remotefs::File; +use wildmatch::WildMatch; + +use crate::ui::activities::filetransfer::lib::browser::FileExplorerTab; +use crate::ui::activities::filetransfer::FileTransferActivity; + +#[derive(Clone, Debug)] +pub enum Filter { + Regex(Regex), + Wildcard(WildMatch), +} + +impl FromStr for Filter { + type Err = (); + fn from_str(s: &str) -> Result { + // try as regex + if let Ok(regex) = Regex::new(s) { + Ok(Self::Regex(regex)) + } else { + Ok(Self::Wildcard(WildMatch::new(s))) + } + } +} + +impl Filter { + fn matches(&self, s: &str) -> bool { + debug!("matching '{s}' with {:?}", self); + match self { + Self::Regex(re) => re.is_match(s), + Self::Wildcard(wm) => wm.matches(s), + } + } +} + +impl FileTransferActivity { + pub fn filter(&self, filter: &str) -> Vec { + let filter = Filter::from_str(filter).unwrap(); + + match self.browser.tab() { + FileExplorerTab::Local => self.browser.local().iter_files(), + FileExplorerTab::Remote => self.browser.remote().iter_files(), + _ => return vec![], + } + .filter(|f| filter.matches(&f.name())) + .cloned() + .collect() + } +} diff --git a/src/ui/activities/filetransfer/actions/mod.rs b/src/ui/activities/filetransfer/actions/mod.rs index 6d30493..3f45f1b 100644 --- a/src/ui/activities/filetransfer/actions/mod.rs +++ b/src/ui/activities/filetransfer/actions/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod copy; pub(crate) mod delete; pub(crate) mod edit; pub(crate) mod exec; +pub(crate) mod filter; pub(crate) mod find; pub(crate) mod mkdir; pub(crate) mod newfile; diff --git a/src/ui/activities/filetransfer/components/mod.rs b/src/ui/activities/filetransfer/components/mod.rs index 3354081..9aafea4 100644 --- a/src/ui/activities/filetransfer/components/mod.rs +++ b/src/ui/activities/filetransfer/components/mod.rs @@ -17,8 +17,8 @@ mod transfer; pub use misc::FooterBar; pub use popups::{ ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup, FatalPopup, - FileInfoPopup, FindPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, OpenWithPopup, - ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, + FileInfoPopup, FilterPopup, FindPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, + OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote, SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WatchedPathsList, WatcherPopup, }; diff --git a/src/ui/activities/filetransfer/components/popups.rs b/src/ui/activities/filetransfer/components/popups.rs index d67d2d6..76cb886 100644 --- a/src/ui/activities/filetransfer/components/popups.rs +++ b/src/ui/activities/filetransfer/components/popups.rs @@ -111,6 +111,90 @@ impl Component for CopyPopup { } } +#[derive(MockComponent)] +pub struct FilterPopup { + component: Input, +} + +impl FilterPopup { + pub fn new(color: Color) -> Self { + Self { + component: Input::default() + .borders( + Borders::default() + .color(color) + .modifiers(BorderType::Rounded), + ) + .foreground(color) + .input_type(InputType::Text) + .placeholder( + "regex or wildmatch", + Style::default().fg(Color::Rgb(128, 128, 128)), + ) + .title("Filter files by regex or wildmatch", Alignment::Center), + } + } +} + +impl Component for FilterPopup { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(KeyEvent { + code: Key::Left, .. + }) => { + self.perform(Cmd::Move(Direction::Left)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Right, .. + }) => { + self.perform(Cmd::Move(Direction::Right)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Home, .. + }) => { + self.perform(Cmd::GoTo(Position::Begin)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { code: Key::End, .. }) => { + self.perform(Cmd::GoTo(Position::End)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Delete, .. + }) => { + self.perform(Cmd::Cancel); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Backspace, + .. + }) => { + self.perform(Cmd::Delete); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Char(ch), + .. + }) => { + self.perform(Cmd::Type(ch)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Enter, .. + }) => match self.state() { + State::One(StateValue::String(filter)) => Some(Msg::Ui(UiMsg::FilterFiles(filter))), + _ => Some(Msg::None), + }, + Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { + Some(Msg::Ui(UiMsg::CloseFilterPopup)) + } + _ => None, + } + } +} + #[derive(MockComponent)] pub struct DeletePopup { component: Radio, @@ -790,6 +874,9 @@ impl KeybindingsPopup { .add_col(TextSpan::new("").bold().fg(key_color)) .add_col(TextSpan::from(" Change file permissions")) .add_row() + .add_col(TextSpan::new("").bold().fg(key_color)) + .add_col(TextSpan::from(" Filter files")) + .add_row() .add_col(TextSpan::new("").bold().fg(key_color)) .add_col(TextSpan::from(" Delete selected file")) .add_row() diff --git a/src/ui/activities/filetransfer/components/transfer/mod.rs b/src/ui/activities/filetransfer/components/transfer/mod.rs index de89c5d..fa0d04c 100644 --- a/src/ui/activities/filetransfer/components/transfer/mod.rs +++ b/src/ui/activities/filetransfer/components/transfer/mod.rs @@ -330,6 +330,10 @@ impl Component for ExplorerLocal { code: Key::Char('z'), modifiers: KeyModifiers::NONE, }) => Some(Msg::Ui(UiMsg::ShowChmodPopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('/'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowFilterPopup)), _ => None, } } @@ -522,6 +526,10 @@ impl Component for ExplorerRemote { code: Key::Char('z'), modifiers: KeyModifiers::NONE, }) => Some(Msg::Ui(UiMsg::ShowChmodPopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('/'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowFilterPopup)), _ => None, } } diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index 8505019..caddd34 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -260,6 +260,7 @@ impl FileTransferActivity { /// Update remote file list pub(super) fn update_remote_filelist(&mut self) { self.reload_remote_dir(); + let width = self .context_mut() .terminal() diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 3f4d05e..6467254 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -49,6 +49,7 @@ enum Id { ExplorerRemote, FatalPopup, FileInfoPopup, + FilterPopup, FindPopup, FooterBar, GlobalListener, @@ -130,6 +131,7 @@ enum UiMsg { CloseFatalPopup, CloseFileInfoPopup, CloseFileSortingPopup, + CloseFilterPopup, CloseFindExplorer, CloseFindPopup, CloseGotoPopup, @@ -144,6 +146,7 @@ enum UiMsg { CloseWatchedPathsList, CloseWatcherPopup, Disconnect, + FilterFiles(String), LogBackTabbed, Quit, ReplacePopupTabbed, @@ -154,6 +157,7 @@ enum UiMsg { ShowExecPopup, ShowFileInfoPopup, ShowFileSortingPopup, + ShowFilterPopup, ShowFindPopup, ShowGotoPopup, ShowKeybindingsPopup, diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 6bf84fc..5ed47d1 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -400,6 +400,7 @@ impl FileTransferActivity { } UiMsg::CloseFileInfoPopup => self.umount_file_info(), UiMsg::CloseFileSortingPopup => self.umount_file_sorting(), + UiMsg::CloseFilterPopup => self.umount_filter(), UiMsg::CloseFindExplorer => { self.finalize_find(); self.umount_find(); @@ -420,6 +421,33 @@ impl FileTransferActivity { self.disconnect(); self.umount_disconnect(); } + UiMsg::FilterFiles(filter) => { + self.umount_filter(); + let files = self.filter(&filter); + // Get wrkdir + let wrkdir = match self.browser.tab() { + FileExplorerTab::Local => self.local().wrkdir.clone(), + _ => self.remote().wrkdir.clone(), + }; + // Create explorer and load files + self.browser.set_found( + match self.browser.tab() { + FileExplorerTab::Local => FoundExplorerTab::Local, + _ => FoundExplorerTab::Remote, + }, + files, + wrkdir.as_path(), + ); + // Mount result widget + self.mount_find(&filter); + self.update_find_list(); + // Initialize tab + self.browser.change_tab(match self.browser.tab() { + FileExplorerTab::Local => FileExplorerTab::FindLocal, + FileExplorerTab::Remote => FileExplorerTab::FindRemote, + _ => FileExplorerTab::FindLocal, + }); + } UiMsg::ShowLogPanel => { assert!(self.app.active(&Id::Log).is_ok()); } @@ -485,6 +513,7 @@ impl FileTransferActivity { } } UiMsg::ShowFileSortingPopup => self.mount_file_sorting(), + UiMsg::ShowFilterPopup => self.mount_filter(), UiMsg::ShowFindPopup => self.mount_find_input(), UiMsg::ShowGotoPopup => self.mount_goto(), UiMsg::ShowKeybindingsPopup => self.mount_help(), diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 5658880..182c5ac 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -172,6 +172,11 @@ impl FileTransferActivity { f.render_widget(Clear, popup); // make popup self.app.view(&Id::ChmodPopup, f, popup); + } else if self.app.mounted(&Id::FilterPopup) { + let popup = Popup(Size::Percentage(50), Size::Unit(3)).draw_in(f.size()); + f.render_widget(Clear, popup); + // make popup + self.app.view(&Id::FilterPopup, f, popup); } else if self.app.mounted(&Id::FindPopup) { let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.size()); f.render_widget(Clear, popup); @@ -459,6 +464,23 @@ impl FileTransferActivity { let _ = self.app.umount(&Id::ChmodPopup); } + pub(super) fn umount_filter(&mut self) { + let _ = self.app.umount(&Id::FilterPopup); + } + + pub(super) fn mount_filter(&mut self) { + let input_color = self.theme().misc_input_dialog; + assert!(self + .app + .remount( + Id::FilterPopup, + Box::new(components::FilterPopup::new(input_color)), + vec![], + ) + .is_ok()); + assert!(self.app.active(&Id::FilterPopup).is_ok()); + } + pub(super) fn mount_copy(&mut self) { let input_color = self.theme().misc_input_dialog; assert!(self @@ -1096,9 +1118,14 @@ impl FileTransferActivity { Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Id::ChmodPopup, )))), - Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::WaitPopup, - )))), + Box::new(SubClause::And( + Box::new(SubClause::Not(Box::new(SubClause::IsMounted( + Id::WaitPopup, + )))), + Box::new(SubClause::Not(Box::new(SubClause::IsMounted( + Id::FilterPopup, + )))), + )), )), )), )),