diff --git a/CHANGELOG.md b/CHANGELOG.md index ecec01f..74c3777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Released on - [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 - [Issue 272](https://github.com/veeso/termscp/issues/272): `isolated-tests` feature to run tests for releasing on distributions which run in isolated environments +- [Issue 280](https://github.com/veeso/termscp/issues/280): Autocompletion when pressing `` on the `Go to` popup. ## 0.14.0 diff --git a/src/ui/activities/filetransfer/actions/mod.rs b/src/ui/activities/filetransfer/actions/mod.rs index 6183be5..ad2b004 100644 --- a/src/ui/activities/filetransfer/actions/mod.rs +++ b/src/ui/activities/filetransfer/actions/mod.rs @@ -27,6 +27,7 @@ pub(crate) mod open; mod pending; pub(crate) mod rename; pub(crate) mod save; +pub(crate) mod scan; pub(crate) mod submit; pub(crate) mod symlink; pub(crate) mod walkdir; diff --git a/src/ui/activities/filetransfer/actions/scan.rs b/src/ui/activities/filetransfer/actions/scan.rs new file mode 100644 index 0000000..c187bf5 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/scan.rs @@ -0,0 +1,19 @@ +use std::path::Path; + +use super::{File, FileTransferActivity}; +use crate::ui::activities::filetransfer::lib::browser::FileExplorerTab; + +impl FileTransferActivity { + pub(crate) fn action_scan(&mut self, p: &Path) -> Result, String> { + match self.browser.tab() { + FileExplorerTab::Local | FileExplorerTab::FindLocal => self + .host + .list_dir(p) + .map_err(|e| format!("Failed to list directory: {}", e)), + FileExplorerTab::Remote | FileExplorerTab::FindRemote => self + .client + .list_dir(p) + .map_err(|e| format!("Failed to list directory: {}", e)), + } + } +} diff --git a/src/ui/activities/filetransfer/components/mod.rs b/src/ui/activities/filetransfer/components/mod.rs index 4fa4204..8d1dff9 100644 --- a/src/ui/activities/filetransfer/components/mod.rs +++ b/src/ui/activities/filetransfer/components/mod.rs @@ -17,11 +17,11 @@ mod transfer; pub use misc::FooterBar; pub use popups::{ ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup, FatalPopup, - FileInfoPopup, FilterPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, + FileInfoPopup, FilterPopup, GotoPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote, SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WalkdirWaitPopup, WatchedPathsList, - WatcherPopup, + WatcherPopup, ATTR_FILES, }; pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote}; diff --git a/src/ui/activities/filetransfer/components/popups.rs b/src/ui/activities/filetransfer/components/popups.rs index d32f343..3fba3d8 100644 --- a/src/ui/activities/filetransfer/components/popups.rs +++ b/src/ui/activities/filetransfer/components/popups.rs @@ -2,6 +2,9 @@ //! //! popups components +mod chmod; +mod goto; + use std::time::UNIX_EPOCH; use bytesize::ByteSize; @@ -16,15 +19,13 @@ use tuirealm::{Component, Event, MockComponent, NoUserEvent, State, StateValue}; #[cfg(unix)] use users::{get_group_by_gid, get_user_by_uid}; +pub use self::chmod::ChmodPopup; +pub use self::goto::{GotoPopup, ATTR_FILES}; use super::super::Browser; use super::{Msg, PendingActionMsg, TransferMsg, UiMsg}; use crate::explorer::FileSorting; use crate::utils::fmt::fmt_time; -mod chmod; - -pub use chmod::ChmodPopup; - #[derive(MockComponent)] pub struct CopyPopup { component: Input, @@ -583,90 +584,6 @@ impl Component for FileInfoPopup { } } -#[derive(MockComponent)] -pub struct GoToPopup { - component: Input, -} - -impl GoToPopup { - 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( - "/foo/bar/buzz", - Style::default().fg(Color::Rgb(128, 128, 128)), - ) - .title("Go to…", Alignment::Center), - } - } -} - -impl Component for GoToPopup { - 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::GoTo(i))), - _ => Some(Msg::None), - }, - Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { - Some(Msg::Ui(UiMsg::CloseGotoPopup)) - } - _ => None, - } - } -} - #[derive(MockComponent)] pub struct KeybindingsPopup { component: List, diff --git a/src/ui/activities/filetransfer/components/popups/goto.rs b/src/ui/activities/filetransfer/components/popups/goto.rs new file mode 100644 index 0000000..8f218f3 --- /dev/null +++ b/src/ui/activities/filetransfer/components/popups/goto.rs @@ -0,0 +1,435 @@ +use std::path::PathBuf; + +use tui_realm_stdlib::Input; +use tuirealm::command::{Cmd, CmdResult, Direction, Position}; +use tuirealm::event::{Key, KeyEvent}; +use tuirealm::props::{Alignment, BorderType, Borders, Color, InputType, Style}; +use tuirealm::{ + AttrValue, Attribute, Component, Event, MockComponent, NoUserEvent, State, StateValue, +}; + +use crate::ui::activities::filetransfer::{Msg, TransferMsg, UiMsg}; + +pub const ATTR_FILES: &str = "files"; + +#[derive(Default)] +struct OwnStates { + /// Path and name of the files + files: Vec<(String, String)>, + search: Option, + last_suggestion: Option, +} + +impl OwnStates { + pub fn set_files(&mut self, files: Vec) { + self.files = files + .into_iter() + .map(|f| { + ( + f.clone(), + PathBuf::from(&f) + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or(f), + ) + }) + .collect(); + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +enum Suggestion { + /// No suggestion + None, + /// Suggest a string + Suggest(String), + /// Rescan at `path` is required to satisfy the user input + Rescan(PathBuf), +} + +impl From for Suggestion { + fn from(value: CmdResult) -> Self { + match value { + CmdResult::Batch(v) if v.len() == 1 => { + if let CmdResult::Submit(State::One(StateValue::String(s))) = v.first().unwrap() { + Suggestion::Suggest(s.clone()) + } else { + Suggestion::None + } + } + CmdResult::Batch(v) if v.len() == 2 => { + if let CmdResult::Submit(State::One(StateValue::String(s))) = v.get(1).unwrap() { + Suggestion::Rescan(PathBuf::from(s)) + } else { + Suggestion::None + } + } + _ => Suggestion::None, + } + } +} + +impl From for CmdResult { + fn from(value: Suggestion) -> Self { + match value { + Suggestion::None => CmdResult::None, + Suggestion::Suggest(s) => { + CmdResult::Batch(vec![CmdResult::Submit(State::One(StateValue::String(s)))]) + } + Suggestion::Rescan(p) => CmdResult::Batch(vec![ + CmdResult::None, + CmdResult::Submit(State::One(StateValue::String( + p.to_string_lossy().to_string(), + ))), + ]), + } + } +} + +impl OwnStates { + /// Return the current suggestion if any, otherwise return search + pub fn computed_search(&self) -> String { + match (&self.search, &self.last_suggestion) { + (_, Some(s)) => s.clone(), + (Some(s), _) => s.clone(), + _ => "".to_string(), + } + } + + /// Suggest files based on the input + pub fn suggest(&mut self, input: &str) -> Suggestion { + debug!( + "Suggesting for: {input}; files {files:?}", + files = self.files + ); + + let is_path = PathBuf::from(input).is_absolute(); + + // case 1. search if any file starts with the input; get first if suggestion is `None`, otherwise get first after suggestion + let suggestions: Vec<&String> = self + .files + .iter() + .filter(|(path, file_name)| { + if is_path { + path.contains(input) + } else { + file_name.contains(input) + } + }) + .map(|(path, _)| path) + .collect(); + + debug!("Suggestions for {input}: {:?}", suggestions); + + // case 1. if suggestions not empty; then suggest next + if !suggestions.is_empty() { + let suggestion; + if let Some(last_suggestion) = self.last_suggestion.take() { + suggestion = suggestions + .iter() + .skip_while(|f| **f != &last_suggestion) + .nth(1) + .unwrap_or_else(|| suggestions.first().unwrap()) + .to_string(); + } else { + suggestion = suggestions.first().map(|x| x.to_string()).unwrap(); + } + + debug!("Suggested: {suggestion}"); + self.last_suggestion = Some(suggestion.clone()); + + return Suggestion::Suggest(suggestion); + } + + self.last_suggestion = None; + + // case 2. otherwise convert suggest to a path and get the parent + // to rescan the files + let input_as_path = if input.starts_with('/') { + input.to_string() + } else { + format!("./{}", input) + }; + + let p = PathBuf::from(input_as_path); + let parent = p + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from("/")); + + // if path is `.`, then return None + if parent == PathBuf::from(".") { + return Suggestion::None; + } + + debug!("Rescan required at: {}", parent.display()); + + Suggestion::Rescan(parent) + } +} + +pub struct GotoPopup { + input: Input, + states: OwnStates, +} + +impl GotoPopup { + pub fn new(color: Color, files: Vec) -> Self { + let mut states = OwnStates::default(); + states.set_files(files); + + Self { + input: Input::default() + .borders( + Borders::default() + .color(color) + .modifiers(BorderType::Rounded), + ) + .foreground(color) + .input_type(InputType::Text) + .placeholder( + "/foo/bar/buzz", + Style::default().fg(Color::Rgb(128, 128, 128)), + ) + .title("Go to… (Press for autocompletion)", Alignment::Center), + states, + } + } +} + +impl MockComponent for GotoPopup { + fn view(&mut self, frame: &mut tuirealm::Frame, area: tuirealm::tui::prelude::Rect) { + self.input.view(frame, area); + } + + fn attr(&mut self, attr: Attribute, value: AttrValue) { + match attr { + Attribute::Custom(ATTR_FILES) => { + let files = value + .unwrap_payload() + .unwrap_vec() + .into_iter() + .map(|x| x.unwrap_str()) + .collect(); + + self.states.set_files(files); + // call perform Change + self.perform(Cmd::Change); + } + _ => self.input.attr(attr, value), + } + } + + fn query(&self, attr: Attribute) -> Option { + self.input.query(attr) + } + + fn state(&self) -> State { + State::One(StateValue::String(self.states.computed_search())) + } + + fn perform(&mut self, cmd: Cmd) -> CmdResult { + match cmd { + Cmd::Change => { + let input = self + .states + .search + .as_ref() + .cloned() + .unwrap_or_else(|| self.input.state().unwrap_one().unwrap_string()); + let suggest = self.states.suggest(&input); + if let Suggestion::Suggest(suggestion) = suggest.clone() { + self.input + .attr(Attribute::Value, AttrValue::String(suggestion.clone())); + } + + suggest.into() + } + cmd => { + let res = self.input.perform(cmd); + if let CmdResult::Changed(State::One(StateValue::String(new_text))) = &res { + self.states.search = Some(new_text.clone()); + } + res + } + } + } +} + +impl Component for GotoPopup { + 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::Tab, .. }) => { + if let Suggestion::Rescan(path) = Suggestion::from(self.perform(Cmd::Change)) { + Some(Msg::Transfer(TransferMsg::RescanGotoFiles(path))) + } else { + Some(Msg::None) + } + } + Event::Keyboard(KeyEvent { + code: Key::Enter, .. + }) => match self.state() { + State::One(StateValue::String(i)) => Some(Msg::Transfer(TransferMsg::GoTo(i))), + _ => Some(Msg::None), + }, + Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { + Some(Msg::Ui(UiMsg::CloseGotoPopup)) + } + _ => None, + } + } +} + +#[cfg(test)] +mod test { + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_should_convert_from_and_back_cmd_result() { + let s = Suggestion::Suggest("foo".to_string()); + let cmd: CmdResult = s.clone().into(); + let s2: Suggestion = cmd.into(); + assert_eq!(s, s2); + + let s = Suggestion::Rescan(PathBuf::from("/foo/bar")); + let cmd: CmdResult = s.clone().into(); + let s2: Suggestion = cmd.into(); + assert_eq!(s, s2); + } + + #[test] + fn test_should_suggest_next() { + let mut states = OwnStates { + files: vec![ + ("/home/foo".to_string(), "foo".to_string()), + ("/home/bar".to_string(), "bar".to_string()), + ("/home/buzz".to_string(), "buzz".to_string()), + ("/home/fizz".to_string(), "fizz".to_string()), + ], + search: None, + last_suggestion: None, + }; + + let s = states.suggest("f"); + assert_eq!(Suggestion::Suggest("/home/foo".to_string()), s); + let s = states.suggest("f"); + assert_eq!(Suggestion::Suggest("/home/fizz".to_string()), s); + + let s = states.suggest("f"); + assert_eq!(Suggestion::Suggest("/home/foo".to_string()), s); + } + + #[test] + #[cfg(unix)] + fn test_should_suggest_absolute_path() { + let mut states = OwnStates { + files: vec![ + ("/home/foo".to_string(), "foo".to_string()), + ("/home/bar".to_string(), "bar".to_string()), + ("/home/buzz".to_string(), "buzz".to_string()), + ("/home/fizz".to_string(), "fizz".to_string()), + ], + search: None, + last_suggestion: None, + }; + + let s = states.suggest("/home/f"); + assert_eq!(Suggestion::Suggest("/home/foo".to_string()), s); + } + + #[test] + fn test_should_suggest_rescan() { + let mut states = OwnStates { + files: vec![ + ("/home/foo".to_string(), "foo".to_string()), + ("/home/bar".to_string(), "bar".to_string()), + ("/home/buzz".to_string(), "buzz".to_string()), + ("/home/fizz".to_string(), "fizz".to_string()), + ], + search: None, + last_suggestion: None, + }; + + let s = states.suggest("/home/user"); + assert_eq!(Suggestion::Rescan(PathBuf::from("/home")), s); + } + + #[test] + fn test_should_suggest_none() { + let mut states = OwnStates { + files: vec![ + ("/home/foo".to_string(), "foo".to_string()), + ("/home/bar".to_string(), "bar".to_string()), + ("/home/buzz".to_string(), "buzz".to_string()), + ("/home/fizz".to_string(), "fizz".to_string()), + ], + search: None, + last_suggestion: None, + }; + + let s = states.suggest(""); + assert_eq!(Suggestion::Suggest("/home/foo".to_string()), s); + } + + #[test] + fn test_should_suggest_none_if_dot() { + let mut states = OwnStates { + files: vec![ + ("/home/foo".to_string(), "foo".to_string()), + ("/home/bar".to_string(), "bar".to_string()), + ("/home/buzz".to_string(), "buzz".to_string()), + ("/home/fizz".to_string(), "fizz".to_string()), + ], + search: None, + last_suggestion: None, + }; + + let s = states.suggest("./th"); + assert_eq!(Suggestion::None, s); + } +} diff --git a/src/ui/activities/filetransfer/lib/browser.rs b/src/ui/activities/filetransfer/lib/browser.rs index ce80175..a93a31c 100644 --- a/src/ui/activities/filetransfer/lib/browser.rs +++ b/src/ui/activities/filetransfer/lib/browser.rs @@ -50,6 +50,16 @@ impl Browser { } } + pub fn explorer(&self) -> &FileExplorer { + match self.tab { + FileExplorerTab::Local => &self.local, + FileExplorerTab::Remote => &self.remote, + FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { + self.found.as_ref().map(|x| &x.explorer).unwrap() + } + } + } + pub fn local(&self) -> &FileExplorer { &self.local } diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index 52aaae0..1895c15 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -22,7 +22,7 @@ const LOG_CAPACITY: usize = 256; impl FileTransferActivity { /// Call `Application::tick()` and process messages in `Update` pub(super) fn tick(&mut self) { - match self.app.tick(PollStrategy::UpTo(3)) { + match self.app.tick(PollStrategy::UpTo(1)) { Ok(messages) => { if !messages.is_empty() { self.redraw = true; diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 7752ef9..9ea44ea 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -14,6 +14,7 @@ mod view; // locals use std::collections::VecDeque; +use std::path::PathBuf; use std::time::Duration; // Includes @@ -113,6 +114,7 @@ enum TransferMsg { OpenTextFile, ReloadDir, RenameFile(String), + RescanGotoFiles(PathBuf), SaveFileAs(String), ToggleWatch, ToggleWatchFor(usize), diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 17ca7d5..0e434ea 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -324,6 +324,15 @@ impl FileTransferActivity { // Reload files self.update_browser_file_list() } + TransferMsg::RescanGotoFiles(path) => { + let files = self.action_scan(&path).unwrap_or_default(); + let files = files + .into_iter() + .filter(|f| f.is_dir() || f.is_symlink()) + .map(|f| f.path().to_string_lossy().to_string()) + .collect(); + self.update_goto(files); + } TransferMsg::SaveFileAs(dest) => { self.umount_saveas(); match self.browser.tab() { diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index aa3a8a3..29c55ee 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -13,6 +13,7 @@ use tuirealm::{AttrValue, Attribute, Sub, SubClause, SubEventClause}; use unicode_width::UnicodeWidthStr; use super::browser::{FileExplorerTab, FoundExplorerTab}; +use super::components::ATTR_FILES; use super::{components, Context, FileTransferActivity, Id}; use crate::explorer::FileSorting; use crate::utils::ui::{Popup, Size}; @@ -599,18 +600,40 @@ impl FileTransferActivity { } pub(super) fn mount_goto(&mut self) { + // get files + let files = self + .browser + .explorer() + .iter_files() + .filter(|f| f.is_dir() || f.is_symlink()) + .map(|f| f.path().to_string_lossy().to_string()) + .collect::>(); + let input_color = self.theme().misc_input_dialog; assert!(self .app .remount( Id::GotoPopup, - Box::new(components::GoToPopup::new(input_color)), + Box::new(components::GotoPopup::new(input_color, files)), vec![], ) .is_ok()); assert!(self.app.active(&Id::GotoPopup).is_ok()); } + pub(super) fn update_goto(&mut self, files: Vec) { + let payload = files + .into_iter() + .map(PropValue::Str) + .collect::>(); + + let _ = self.app.attr( + &Id::GotoPopup, + Attribute::Custom(ATTR_FILES), + AttrValue::Payload(PropPayload::Vec(payload)), + ); + } + pub(super) fn umount_goto(&mut self) { let _ = self.app.umount(&Id::GotoPopup); }