mirror of
https://github.com/veeso/termscp.git
synced 2025-12-06 17:15:35 -08:00
* feat: Import bookmarks from ssh config with a CLI command Use import-ssh-hosts to import all the possible hosts by the configured ssh config or the default one on your machine closes #331
436 lines
13 KiB
Rust
436 lines
13 KiB
Rust
use std::path::{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 == Path::new(".") {
|
|
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::ratatui::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(posix)]
|
|
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);
|
|
}
|
|
}
|