diff --git a/CHANGELOG.md b/CHANGELOG.md index c9abdac..c6801c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,9 @@ Released on -- [Issue 268](https://github.com/veeso/termscp/issues/268): Pods and container explorer for Kube protocol. - - BREAKING ‼️ Kube address argument has changed; see manual! +- [Issue 249](https://github.com/veeso/termscp/issues/249): The old *find* command has been replaced with a brand new explorer with support to 🪄 **Fuzzy search** 🪄. The command is still ``. +- [Issue 268](https://github.com/veeso/termscp/issues/268): 📦 **Pods and container explorer** 🐳 for Kube protocol. + - BREAKING ‼️ Kube address argument has changed to `namespace[@][$]` - Pod and container argumets have been removed; from now on you will connect with the following syntax to the provided namespace: `/pod-name/container-name/path/to/file` - [Issue 279](https://github.com/veeso/termscp/issues/279): do not clear screen - [Issue 277](https://github.com/veeso/termscp/issues/277): Fix a bug in the configuration page, which caused being stuck if the added SSH key was empty diff --git a/Cargo.lock b/Cargo.lock index 0346006..b63a093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -1828,6 +1847,27 @@ dependencies = [ "tauri-winrt-notification", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot 0.12.3", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -2441,6 +2481,26 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -3497,6 +3557,7 @@ dependencies = [ "magic-crypt", "notify", "notify-rust", + "nucleo", "open", "pretty_assertions", "rand", @@ -4167,9 +4228,9 @@ dependencies = [ [[package]] name = "wildmatch" -version = "2.3.4" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3928939971918220fed093266b809d1ee4ec6c1a2d72692ff6876898f3b16c19" +checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" [[package]] name = "winapi" diff --git a/Cargo.toml b/Cargo.toml index 40fdc20..0fdf7f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,7 @@ description = "termscp is a feature rich terminal file transfer and explorer wit edition = "2021" homepage = "https://termscp.veeso.dev" include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"] -keywords = [ - "scp-client", - "sftp-client", - "ftp-client", - "winscp", - "command-line-utility", -] +keywords = ["terminal", "ftp", "scp", "sftp", "tui"] license = "MIT" name = "termscp" readme = "README.md" @@ -46,13 +40,18 @@ dirs = "^5.0" edit = "^0.1" filetime = "^0.2" hostname = "^0.4" -keyring = { version = "^3", optional = true, features = ["apple-native", "windows-native", "sync-secret-service"] } +keyring = { version = "^3", optional = true, features = [ + "apple-native", + "windows-native", + "sync-secret-service", +] } lazy-regex = "^3" lazy_static = "^1" log = "^0.4" magic-crypt = "^3" notify = "6" notify-rust = { version = "^4.5", default-features = false, features = ["d"] } +nucleo = "0.5" open = "^5.0" rand = "^0.8.5" regex = "^1" @@ -83,7 +82,7 @@ tuirealm = "^1.9" unicode-width = "^0.2" version-compare = "^0.2" whoami = "^1.5" -wildmatch = "^2.3" +wildmatch = "^2" [dev-dependencies] pretty_assertions = "^1" diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index 33dfe35..298cf1d 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -30,6 +30,7 @@ pub enum FileSorting { ModifyTime, CreationTime, Size, + None, } /// GroupDirs defines how directories should be grouped in sorting files @@ -178,6 +179,7 @@ impl FileExplorer { FileSorting::CreationTime => self.sort_files_by_creation_time(), FileSorting::ModifyTime => self.sort_files_by_mtime(), FileSorting::Size => self.sort_files_by_size(), + FileSorting::None => {} } // Directories first (NOTE: MUST COME AFTER OTHER SORTING) // Group directories if necessary @@ -245,6 +247,7 @@ impl std::fmt::Display for FileSorting { FileSorting::ModifyTime => "by_mtime", FileSorting::Name => "by_name", FileSorting::Size => "by_size", + FileSorting::None => "none", } ) } diff --git a/src/ui/activities/filetransfer/actions/find.rs b/src/ui/activities/filetransfer/actions/find.rs index a0f65be..86f01ef 100644 --- a/src/ui/activities/filetransfer/actions/find.rs +++ b/src/ui/activities/filetransfer/actions/find.rs @@ -9,15 +9,15 @@ use super::super::browser::FileExplorerTab; use super::{File, FileTransferActivity, LogLevel, SelectedFile, TransferOpts, TransferPayload}; impl FileTransferActivity { - pub(crate) fn action_local_find(&mut self, input: String) -> Result, String> { - match self.host.find(input.as_str()) { + pub(crate) fn action_walkdir_local(&mut self) -> Result, String> { + match self.host.find("*") { Ok(entries) => Ok(entries), Err(err) => Err(format!("Could not search for files: {err}")), } } - pub(crate) fn action_remote_find(&mut self, input: String) -> Result, String> { - match self.client.as_mut().find(input.as_str()) { + pub(crate) fn action_walkdir_remote(&mut self) -> Result, String> { + match self.client.as_mut().find("*") { Ok(entries) => Ok(entries), Err(err) => Err(format!("Could not search for files: {err}")), } @@ -26,6 +26,7 @@ impl FileTransferActivity { pub(crate) fn action_find_changedir(&mut self) { // Match entry if let SelectedFile::One(entry) = self.get_found_selected_entries() { + debug!("Changedir to: {}", entry.name()); // Get path: if a directory, use directory path; if it is a File, get parent path let path = if entry.is_dir() { entry.path().to_path_buf() diff --git a/src/ui/activities/filetransfer/components/mod.rs b/src/ui/activities/filetransfer/components/mod.rs index 9aafea4..541a000 100644 --- a/src/ui/activities/filetransfer/components/mod.rs +++ b/src/ui/activities/filetransfer/components/mod.rs @@ -17,12 +17,12 @@ mod transfer; pub use misc::FooterBar; pub use popups::{ ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup, FatalPopup, - FileInfoPopup, FilterPopup, FindPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, + FileInfoPopup, FilterPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote, SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WatchedPathsList, WatcherPopup, }; -pub use transfer::{ExplorerFind, ExplorerLocal, ExplorerRemote}; +pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote}; pub use self::log::Log; diff --git a/src/ui/activities/filetransfer/components/popups.rs b/src/ui/activities/filetransfer/components/popups.rs index b9a4f14..792d1e7 100644 --- a/src/ui/activities/filetransfer/components/popups.rs +++ b/src/ui/activities/filetransfer/components/popups.rs @@ -583,89 +583,6 @@ impl Component for FileInfoPopup { } } -#[derive(MockComponent)] -pub struct FindPopup { - component: Input, -} - -impl FindPopup { - 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("*.txt", Style::default().fg(Color::Rgb(128, 128, 128))) - .title("Search files by name or wildmatch", Alignment::Center), - } - } -} - -impl Component for FindPopup { - 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(i)) => { - Some(Msg::Transfer(TransferMsg::SearchFile(i))) - } - _ => Some(Msg::None), - }, - Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { - Some(Msg::Ui(UiMsg::CloseFindPopup)) - } - _ => None, - } - } -} - #[derive(MockComponent)] pub struct GoToPopup { component: Input, @@ -1675,6 +1592,7 @@ impl SortingPopup { FileSorting::ModifyTime => 1, FileSorting::Name => 0, FileSorting::Size => 3, + FileSorting::None => 0, }), } } @@ -1778,6 +1696,7 @@ fn file_sorting_label(sorting: FileSorting) -> &'static str { FileSorting::ModifyTime => "By modify time", FileSorting::Name => "By name", FileSorting::Size => "By size", + FileSorting::None => "", } } diff --git a/src/ui/activities/filetransfer/components/transfer/file_list_with_search.rs b/src/ui/activities/filetransfer/components/transfer/file_list_with_search.rs new file mode 100644 index 0000000..ab00131 --- /dev/null +++ b/src/ui/activities/filetransfer/components/transfer/file_list_with_search.rs @@ -0,0 +1,160 @@ +use tui_realm_stdlib::Input; +use tuirealm::command::{Cmd, CmdResult}; +use tuirealm::props::{Alignment, AttrValue, Attribute, Borders, Color, Table}; +use tuirealm::tui::layout::{Constraint, Direction, Layout}; +use tuirealm::{MockComponent, State}; + +use super::file_list::FileList; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + List, + #[default] + Search, +} + +#[derive(Default)] +struct OwnStates { + focus: Focus, +} + +impl OwnStates { + pub fn next(&mut self) { + self.focus = match self.focus { + Focus::List => Focus::Search, + Focus::Search => Focus::List, + }; + } +} + +#[derive(Default)] +pub struct FileListWithSearch { + file_list: FileList, + search: Input, + states: OwnStates, +} + +impl FileListWithSearch { + pub fn focus(&self) -> Focus { + self.states.focus + } + + pub fn foreground(mut self, fg: Color) -> Self { + self.file_list + .attr(Attribute::Foreground, AttrValue::Color(fg)); + self.search + .attr(Attribute::Foreground, AttrValue::Color(fg)); + self + } + + pub fn background(mut self, bg: Color) -> Self { + self.file_list + .attr(Attribute::Background, AttrValue::Color(bg)); + self.search + .attr(Attribute::Background, AttrValue::Color(bg)); + self + } + + pub fn borders(mut self, b: Borders) -> Self { + self.file_list + .attr(Attribute::Borders, AttrValue::Borders(b.clone())); + self.search.attr(Attribute::Borders, AttrValue::Borders(b)); + self + } + + pub fn title>(mut self, t: S, a: Alignment) -> Self { + self.file_list.attr( + Attribute::Title, + AttrValue::Title((t.as_ref().to_string(), a)), + ); + self.search.attr( + Attribute::Title, + AttrValue::Title(("Fuzzy search".to_string(), a)), + ); + self + } + + pub fn highlighted_color(mut self, c: Color) -> Self { + self.file_list + .attr(Attribute::HighlightedColor, AttrValue::Color(c)); + self + } + + pub fn rows(mut self, rows: Table) -> Self { + self.file_list + .attr(Attribute::Content, AttrValue::Table(rows)); + self + } +} + +impl MockComponent for FileListWithSearch { + fn view(&mut self, frame: &mut tuirealm::Frame, area: tuirealm::tui::layout::Rect) { + // split the area in two + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), // Search + Constraint::Fill(1), // File list + ] + .as_ref(), + ) + .split(area); + + // render the search input + self.search.view(frame, chunks[0]); + // render the file list + self.file_list.view(frame, chunks[1]); + } + + fn query(&self, attr: Attribute) -> Option { + self.file_list.query(attr) + } + + fn attr(&mut self, attr: Attribute, value: AttrValue) { + if attr == Attribute::Focus { + let value = value.unwrap_flag(); + match value { + true => self.states.focus = Focus::Search, + false => self.states.focus = Focus::List, + } + self.search.attr( + Attribute::Focus, + AttrValue::Flag(self.states.focus == Focus::Search), + ); + self.file_list.attr( + Attribute::Focus, + AttrValue::Flag(self.states.focus == Focus::List), + ); + } else { + self.file_list.attr(attr, value); + } + } + + fn state(&self) -> State { + match self.states.focus { + Focus::List => self.file_list.state(), + Focus::Search => self.search.state(), + } + } + + fn perform(&mut self, cmd: Cmd) -> CmdResult { + match cmd { + Cmd::Change => { + self.states.next(); + self.search.attr( + Attribute::Focus, + AttrValue::Flag(self.states.focus == Focus::Search), + ); + self.file_list.attr( + Attribute::Focus, + AttrValue::Flag(self.states.focus == Focus::List), + ); + + CmdResult::None + } + cmd if self.states.focus == Focus::Search => self.search.perform(cmd), + cmd => self.file_list.perform(cmd), + } + } +} diff --git a/src/ui/activities/filetransfer/components/transfer/mod.rs b/src/ui/activities/filetransfer/components/transfer/mod.rs index fa0d04c..8868593 100644 --- a/src/ui/activities/filetransfer/components/transfer/mod.rs +++ b/src/ui/activities/filetransfer/components/transfer/mod.rs @@ -2,14 +2,220 @@ //! //! file transfer components -use super::{Msg, TransferMsg, UiMsg}; - mod file_list; -use file_list::FileList; -use tuirealm::command::{Cmd, Direction, Position}; +mod file_list_with_search; + +use tuirealm::command::{Cmd, CmdResult, Direction, Position}; use tuirealm::event::{Key, KeyEvent, KeyModifiers}; use tuirealm::props::{Alignment, Borders, Color, TextSpan}; -use tuirealm::{Component, Event, MockComponent, NoUserEvent}; +use tuirealm::{Component, Event, MockComponent, NoUserEvent, State, StateValue}; + +use self::file_list::FileList; +use self::file_list_with_search::FileListWithSearch; +use super::{Msg, TransferMsg, UiMsg}; + +#[derive(MockComponent)] +pub struct ExplorerFuzzy { + component: FileListWithSearch, +} + +impl ExplorerFuzzy { + pub fn new>(title: S, files: &[&str], bg: Color, fg: Color, hg: Color) -> Self { + Self { + component: FileListWithSearch::default() + .background(bg) + .borders(Borders::default().color(hg)) + .foreground(fg) + .highlighted_color(hg) + .title(title, Alignment::Left) + .rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect()), + } + } + + fn on_search(&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::Tab | Key::Up | Key::Down, + .. + }) => { + self.perform(Cmd::Change); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Char(ch), + .. + }) => match self.perform(Cmd::Type(ch)) { + CmdResult::Changed(State::One(StateValue::String(search))) => { + Some(Msg::Ui(UiMsg::FuzzySearch(search))) + } + _ => Some(Msg::None), + }, + Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { + Some(Msg::Ui(UiMsg::CloseFindExplorer)) + } + _ => None, + } + } + + fn on_file_list(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(KeyEvent { + code: Key::Down, .. + }) => { + self.perform(Cmd::Move(Direction::Down)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { code: Key::Up, .. }) => { + self.perform(Cmd::Move(Direction::Up)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::PageDown, + .. + }) => { + self.perform(Cmd::Scroll(Direction::Down)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::PageUp, .. + }) => { + self.perform(Cmd::Scroll(Direction::Up)); + 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::Char('a'), + modifiers: KeyModifiers::CONTROL, + }) => { + let _ = self.perform(Cmd::Custom(file_list::FILE_LIST_CMD_SELECT_ALL)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Char('a'), + modifiers: KeyModifiers::ALT, + }) => { + let _ = self.perform(Cmd::Custom(file_list::FILE_LIST_CMD_DESELECT_ALL)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Char('m'), + modifiers: KeyModifiers::NONE, + }) => { + let _ = self.perform(Cmd::Toggle); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => { + self.perform(Cmd::Change); + Some(Msg::None) + } + // -- comp msg + Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { + Some(Msg::Ui(UiMsg::CloseFindExplorer)) + } + Event::Keyboard(KeyEvent { + code: Key::Left | Key::Right, + .. + }) => Some(Msg::Ui(UiMsg::ChangeTransferWindow)), + Event::Keyboard(KeyEvent { + code: Key::Enter, .. + }) => Some(Msg::Transfer(TransferMsg::EnterDirectory)), + Event::Keyboard(KeyEvent { + code: Key::Char(' '), + .. + }) => Some(Msg::Transfer(TransferMsg::TransferFile)), + Event::Keyboard(KeyEvent { + code: Key::Backspace, + .. + }) => Some(Msg::Transfer(TransferMsg::GoToPreviousDirectory)), + Event::Keyboard(KeyEvent { + code: Key::Char('a'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ToggleHiddenFiles)), + Event::Keyboard(KeyEvent { + code: Key::Char('b'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowFileSortingPopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('e') | Key::Delete | Key::Function(8), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowDeletePopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('i'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowFileInfoPopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('s') | Key::Function(2), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowSaveAsPopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('v') | Key::Function(3), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Transfer(TransferMsg::OpenFile)), + Event::Keyboard(KeyEvent { + code: Key::Char('w'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowOpenWithPopup)), + Event::Keyboard(KeyEvent { + code: Key::Char('z'), + modifiers: KeyModifiers::NONE, + }) => Some(Msg::Ui(UiMsg::ShowChmodPopup)), + _ => None, + } + } +} + +impl Component for ExplorerFuzzy { + fn on(&mut self, ev: Event) -> Option { + match self.component.focus() { + file_list_with_search::Focus::List => self.on_file_list(ev), + file_list_with_search::Focus::Search => self.on_search(ev), + } + } +} #[derive(MockComponent)] pub struct ExplorerFind { @@ -261,7 +467,7 @@ impl Component for ExplorerLocal { Event::Keyboard(KeyEvent { code: Key::Char('f'), modifiers: KeyModifiers::NONE, - }) => Some(Msg::Ui(UiMsg::ShowFindPopup)), + }) => Some(Msg::Transfer(TransferMsg::InitFuzzySearch)), Event::Keyboard(KeyEvent { code: Key::Char('g'), modifiers: KeyModifiers::NONE, @@ -457,7 +663,7 @@ impl Component for ExplorerRemote { Event::Keyboard(KeyEvent { code: Key::Char('f'), modifiers: KeyModifiers::NONE, - }) => Some(Msg::Ui(UiMsg::ShowFindPopup)), + }) => Some(Msg::Transfer(TransferMsg::InitFuzzySearch)), Event::Keyboard(KeyEvent { code: Key::Char('g'), modifiers: KeyModifiers::NONE, diff --git a/src/ui/activities/filetransfer/lib/browser.rs b/src/ui/activities/filetransfer/lib/browser.rs index 8e3f530..ce80175 100644 --- a/src/ui/activities/filetransfer/lib/browser.rs +++ b/src/ui/activities/filetransfer/lib/browser.rs @@ -4,12 +4,15 @@ use std::path::Path; +use nucleo::Utf32String; use remotefs::File; use crate::explorer::builder::FileExplorerBuilder; -use crate::explorer::{FileExplorer, FileSorting, GroupDirs}; +use crate::explorer::{FileExplorer, FileSorting}; use crate::system::config_client::ConfigClient; +const FUZZY_SEARCH_THRESHOLD: u16 = 50; + /// File explorer tab #[derive(Clone, Copy, PartialEq, Eq)] pub enum FileExplorerTab { @@ -28,10 +31,10 @@ pub enum FoundExplorerTab { /// Browser contains the browser options pub struct Browser { - local: FileExplorer, // Local File explorer state - remote: FileExplorer, // Remote File explorer state - found: Option<(FoundExplorerTab, FileExplorer)>, // File explorer for find result - tab: FileExplorerTab, // Current selected tab + local: FileExplorer, // Local File explorer state + remote: FileExplorer, // Remote File explorer state + found: Option, // File explorer for find result + tab: FileExplorerTab, // Current selected tab pub sync_browsing: bool, } @@ -64,17 +67,35 @@ impl Browser { } pub fn found(&self) -> Option<&FileExplorer> { - self.found.as_ref().map(|x| &x.1) + self.found.as_ref().map(|x| &x.explorer) } pub fn found_mut(&mut self) -> Option<&mut FileExplorer> { - self.found.as_mut().map(|x| &mut x.1) + self.found.as_mut().map(|x| &mut x.explorer) + } + + /// Perform fuzzy search on found tab + pub fn fuzzy_search(&mut self, needle: &str) { + if let Some(x) = self.found.as_mut() { + x.fuzzy_search(needle) + } + } + + /// Initialize fuzzy search + pub fn init_fuzzy_search(&mut self) { + if let Some(explorer) = self.found_mut() { + explorer.set_files(vec![]); + } } pub fn set_found(&mut self, tab: FoundExplorerTab, files: Vec, wrkdir: &Path) { let mut explorer = Self::build_found_explorer(wrkdir); - explorer.set_files(files); - self.found = Some((tab, explorer)); + explorer.set_files(files.clone()); + self.found = Some(Found { + tab, + explorer, + search_results: files, + }); } pub fn del_found(&mut self) { @@ -83,7 +104,7 @@ impl Browser { /// Returns found tab if any pub fn found_tab(&self) -> Option { - self.found.as_ref().map(|x| x.0) + self.found.as_ref().map(|x| x.tab) } pub fn tab(&self) -> FileExplorerTab { @@ -129,8 +150,8 @@ impl Browser { /// Build explorer reading from `ConfigClient`, for found result (has some differences) fn build_found_explorer(wrkdir: &Path) -> FileExplorer { FileExplorerBuilder::new() - .with_file_sorting(FileSorting::Name) - .with_group_dirs(Some(GroupDirs::First)) + .with_file_sorting(FileSorting::None) + .with_group_dirs(None) .with_hidden_files(true) .with_stack_size(0) .with_formatter(Some( @@ -139,3 +160,48 @@ impl Browser { .build() } } + +/// Found state +struct Found { + explorer: FileExplorer, + /// Search results; original copy of files + search_results: Vec, + tab: FoundExplorerTab, +} + +impl Found { + /// Fuzzy search from `search_results` and update `explorer.files` with the results. + pub fn fuzzy_search(&mut self, needle: &str) { + let search = Utf32String::from(needle); + let mut nucleo = nucleo::Matcher::new(nucleo::Config::DEFAULT.match_paths()); + + // get scores + let mut fuzzy_results_with_score = self + .search_results + .iter() + .map(|f| { + ( + Utf32String::from(f.path().to_string_lossy().into_owned()), + f, + ) + }) + .filter_map(|(path, file)| { + nucleo + .fuzzy_match(path.slice(..), search.slice(..)) + .map(|score| (path, file, score)) + }) + .filter(|(_, _, score)| *score >= FUZZY_SEARCH_THRESHOLD) + .collect::>(); + + // sort by score; highest first + fuzzy_results_with_score.sort_by(|(_, _, a), (_, _, b)| b.cmp(a)); + + // update files + self.explorer.set_files( + fuzzy_results_with_score + .into_iter() + .map(|(_, file, _)| file.clone()) + .collect(), + ); + } +} diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 6467254..0b1a366 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -50,7 +50,6 @@ enum Id { FatalPopup, FileInfoPopup, FilterPopup, - FindPopup, FooterBar, GlobalListener, GotoPopup, @@ -104,6 +103,7 @@ enum TransferMsg { GoTo(String), GoToParentDirectory, GoToPreviousDirectory, + InitFuzzySearch, Mkdir(String), NewFile(String), OpenFile, @@ -112,7 +112,6 @@ enum TransferMsg { ReloadDir, RenameFile(String), SaveFileAs(String), - SearchFile(String), ToggleWatch, ToggleWatchFor(usize), TransferFile, @@ -133,7 +132,6 @@ enum UiMsg { CloseFileSortingPopup, CloseFilterPopup, CloseFindExplorer, - CloseFindPopup, CloseGotoPopup, CloseKeybindingsPopup, CloseMkdirPopup, @@ -147,6 +145,7 @@ enum UiMsg { CloseWatcherPopup, Disconnect, FilterFiles(String), + FuzzySearch(String), LogBackTabbed, Quit, ReplacePopupTabbed, @@ -158,7 +157,6 @@ enum UiMsg { ShowFileInfoPopup, ShowFileSortingPopup, ShowFilterPopup, - ShowFindPopup, ShowGotoPopup, ShowKeybindingsPopup, ShowLogPanel, diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 5ed47d1..633d3d2 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -211,6 +211,56 @@ impl FileTransferActivity { _ => {} } } + TransferMsg::InitFuzzySearch => { + // Mount wait + self.mount_blocking_wait("Scanning current directory…"); + // Find + let res: Result, String> = match self.browser.tab() { + FileExplorerTab::Local => self.action_walkdir_local(), + FileExplorerTab::Remote => self.action_walkdir_remote(), + _ => panic!("Trying to search for files, while already in a find result"), + }; + // Umount wait + self.umount_wait(); + // Match result + match res { + Err(err) => { + // Mount error + self.mount_error(err.as_str()); + } + Ok(files) if files.is_empty() => { + // If no file has been found notify user + self.mount_info("There are no files in the current directory"); + } + Ok(files) => { + // 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(), + ); + // init fuzzy search to display nothing + self.browser.init_fuzzy_search(); + // Mount result widget + self.mount_find(format!(r#"Searching at "{}""#, wrkdir.display()), true); + self.update_find_list(); + // Initialize tab + self.browser.change_tab(match self.browser.tab() { + FileExplorerTab::Local => FileExplorerTab::FindLocal, + FileExplorerTab::Remote => FileExplorerTab::FindRemote, + _ => FileExplorerTab::FindLocal, + }); + } + } + } TransferMsg::Mkdir(dir) => { match self.browser.tab() { FileExplorerTab::Local => self.action_local_mkdir(dir), @@ -281,57 +331,7 @@ impl FileTransferActivity { // Reload files self.update_browser_file_list_swapped(); } - TransferMsg::SearchFile(search) => { - self.umount_find_input(); - // Mount wait - self.mount_blocking_wait(format!(r#"Searching for "{search}"…"#).as_str()); - // Find - let res: Result, String> = match self.browser.tab() { - FileExplorerTab::Local => self.action_local_find(search.clone()), - FileExplorerTab::Remote => self.action_remote_find(search.clone()), - _ => panic!("Trying to search for files, while already in a find result"), - }; - // Umount wait - self.umount_wait(); - // Match result - match res { - Err(err) => { - // Mount error - self.mount_error(err.as_str()); - } - Ok(files) if files.is_empty() => { - // If no file has been found notify user - self.mount_info( - format!(r#"Could not find any file matching "{search}""#).as_str(), - ); - } - Ok(files) => { - // 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(&search); - self.update_find_list(); - // Initialize tab - self.browser.change_tab(match self.browser.tab() { - FileExplorerTab::Local => FileExplorerTab::FindLocal, - FileExplorerTab::Remote => FileExplorerTab::FindRemote, - _ => FileExplorerTab::FindLocal, - }); - } - } - } + TransferMsg::ToggleWatch => self.action_toggle_watch(), TransferMsg::ToggleWatchFor(index) => self.action_toggle_watch_for(index), TransferMsg::TransferFile => { @@ -405,7 +405,6 @@ impl FileTransferActivity { self.finalize_find(); self.umount_find(); } - UiMsg::CloseFindPopup => self.umount_find_input(), UiMsg::CloseGotoPopup => self.umount_goto(), UiMsg::CloseKeybindingsPopup => self.umount_help(), UiMsg::CloseMkdirPopup => self.umount_mkdir(), @@ -439,7 +438,7 @@ impl FileTransferActivity { wrkdir.as_path(), ); // Mount result widget - self.mount_find(&filter); + self.mount_find(&filter, false); self.update_find_list(); // Initialize tab self.browser.change_tab(match self.browser.tab() { @@ -448,6 +447,10 @@ impl FileTransferActivity { _ => FileExplorerTab::FindLocal, }); } + UiMsg::FuzzySearch(needle) => { + self.browser.fuzzy_search(&needle); + self.update_find_list(); + } UiMsg::ShowLogPanel => { assert!(self.app.active(&Id::Log).is_ok()); } @@ -514,7 +517,6 @@ 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(), UiMsg::ShowMkdirPopup => self.mount_mkdir(), diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index d7d2bdd..5aa78e5 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -177,11 +177,6 @@ impl FileTransferActivity { 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); - // make popup - self.app.view(&Id::FindPopup, f, popup); } else if self.app.mounted(&Id::GotoPopup) { let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.size()); f.render_widget(Clear, popup); @@ -515,7 +510,7 @@ impl FileTransferActivity { let _ = self.app.umount(&Id::ExecPopup); } - pub(super) fn mount_find(&mut self, search: &str) { + pub(super) fn mount_find(&mut self, msg: impl ToString, fuzzy_search: bool) { // Get color let (bg, fg, hg) = match self.browser.tab() { FileExplorerTab::Local | FileExplorerTab::FindLocal => ( @@ -529,18 +524,29 @@ impl FileTransferActivity { self.theme().transfer_remote_explorer_highlighted, ), }; + // Mount component assert!(self .app .remount( Id::ExplorerFind, - Box::new(components::ExplorerFind::new( - format!(r#"Search results for "{search}""#), - &[], - bg, - fg, - hg - )), + if fuzzy_search { + Box::new(components::ExplorerFuzzy::new( + msg.to_string(), + &[], + bg, + fg, + hg, + )) + } else { + Box::new(components::ExplorerFind::new( + msg.to_string(), + &[], + bg, + fg, + hg, + )) + }, vec![], ) .is_ok()); @@ -551,24 +557,6 @@ impl FileTransferActivity { let _ = self.app.umount(&Id::ExplorerFind); } - pub(super) fn mount_find_input(&mut self) { - let input_color = self.theme().misc_input_dialog; - assert!(self - .app - .remount( - Id::FindPopup, - Box::new(components::FindPopup::new(input_color)), - vec![], - ) - .is_ok()); - assert!(self.app.active(&Id::FindPopup).is_ok()); - } - - pub(super) fn umount_find_input(&mut self) { - // Umount input find - let _ = self.app.umount(&Id::FindPopup); - } - pub(super) fn mount_goto(&mut self) { let input_color = self.theme().misc_input_dialog; assert!(self @@ -1094,38 +1082,33 @@ impl FileTransferActivity { Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Id::SortingPopup, )))), - Box::new(SubClause::And( - Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::FindPopup, - )))), Box::new(SubClause::And( Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Id::SyncBrowsingMkdirPopup, + )))), + Box::new(SubClause::And( + Box::new(SubClause::Not(Box::new(SubClause::IsMounted( + Id::SymlinkPopup, )))), Box::new(SubClause::And( Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::SymlinkPopup, + Id::WatcherPopup, )))), Box::new(SubClause::And( Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::WatcherPopup, + Id::WatchedPathsList, )))), Box::new(SubClause::And( Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::WatchedPathsList, + Id::ChmodPopup, )))), Box::new(SubClause::And( Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::ChmodPopup, + Id::WaitPopup, + )))), + Box::new(SubClause::Not(Box::new(SubClause::IsMounted( + Id::FilterPopup, )))), - Box::new(SubClause::And( - Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::WaitPopup, - )))), - Box::new(SubClause::Not(Box::new(SubClause::IsMounted( - Id::FilterPopup, - )))), - )), )), )), )),