280 feature request go to path auto completion (#287)

This commit is contained in:
Christian Visintin
2024-10-03 17:11:25 +02:00
committed by GitHub
parent 8e2ffeabce
commit 3f01be3baa
11 changed files with 509 additions and 92 deletions

View File

@@ -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;

View File

@@ -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<Vec<File>, 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)),
}
}
}

View File

@@ -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};

View File

@@ -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<Msg, NoUserEvent> 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<Msg, NoUserEvent> for GoToPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
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,

View File

@@ -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<String>,
last_suggestion: Option<String>,
}
impl OwnStates {
pub fn set_files(&mut self, files: Vec<String>) {
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<CmdResult> 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<Suggestion> 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<String>) -> 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 <TAB> 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<AttrValue> {
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<Msg, NoUserEvent> for GotoPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
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);
}
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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() {

View File

@@ -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::<Vec<String>>();
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<String>) {
let payload = files
.into_iter()
.map(PropValue::Str)
.collect::<Vec<PropValue>>();
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);
}