diff --git a/.gitignore b/.gitignore index 24397ca..b03583c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ **/*.rs.bk # End of https://www.gitignore.io/api/rust + +*.rpm +*.deb diff --git a/CHANGELOG.md b/CHANGELOG.md index b258a4a..77d4fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - [Changelog](#changelog) - [0.2.0](#020) + - [0.1.3](#013) - [0.1.2](#012) - [0.1.1](#011) - [0.1.0](#010) @@ -12,6 +13,31 @@ Released on ?? +- **Bookmarks** + - Bookmarks and recent connections are now displayed in the home page + - Bookmarks are saved at + - Linux: `/home/alice/.config/termscp/bookmarks.toml` + - Windows: `C:\Users\Alice\AppData\Roaming\termscp\bookmarks.toml` + - MacOS: `/Users/Alice/Library/Application Support/termscp/bookmarks.toml` + +## 0.1.3 + +Released on 14/12/2020 + +- Enhancements: + - File transfer: + - Read buffer is now 65536 bytes long + - File explorer: + - Fixed color mismatch in local explorer + - Explorer tabs have now 70% of layout height, while logging area is 30% + - Highlight selected entry in tabs, only when the tab is active + - Auth page: + - align popup text to center +- Keybindings: + - `L`: Refresh directory content +- Bugfix: + - Fixed memory vulnerability in Windows version + ## 0.1.2 Released on 13/12/2020 diff --git a/Cargo.lock b/Cargo.lock index 070b0a9..731c519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,18 +9,47 @@ dependencies = [ "memchr", ] +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "bumpalo" version = "3.4.0" @@ -79,6 +108,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "core-foundation" version = "0.9.1" @@ -95,6 +130,17 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +[[package]] +name = "crossbeam-utils" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d96d1e189ef58269ebe5b97953da3274d83a93af647c2ddd6f9dab28cedb8d" +dependencies = [ + "autocfg", + "cfg-if 1.0.0", + "lazy_static", +] + [[package]] name = "crossterm" version = "0.18.2" @@ -120,6 +166,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142995ed02755914747cc6ca76fc7e4583cd18578746716d0508ea6ed558b9ff" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -501,6 +567,17 @@ version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom", + "redox_syscall", + "rust-argon2", +] + [[package]] name = "regex" version = "1.4.2" @@ -538,6 +615,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "schannel" version = "0.1.19" @@ -577,6 +666,26 @@ dependencies = [ "libc", ] +[[package]] +name = "serde" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "signal-hook" version = "0.1.16" @@ -665,15 +774,18 @@ dependencies = [ "bytesize", "chrono", "crossterm", + "dirs", "ftp4", "getopts", "hostname", "lazy_static", "regex", "rpassword", + "serde", "ssh2", "tempfile", "textwrap", + "toml", "tui", "unicode-width", "users", @@ -710,6 +822,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "toml" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cf45bb0bef80604d001caaec0d09da99611b3c0fd39d3080468875cdb65645" +dependencies = [ + "serde", +] + [[package]] name = "tui" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index ddf7c87..23a9311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,20 +15,23 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +bytesize = "1.0.1" +chrono = "0.4.19" crossterm = "0.18.2" +dirs = "3.0.1" ftp4 = { version = "^4.0.1", features = ["secure"] } getopts = "0.2.21" -ssh2 = "0.9.0" -tui = { version = "0.13.0", features = ["crossterm"], default-features = false } -whoami = "1.0.0" -rpassword = "5.0.0" -unicode-width = "0.1.7" -chrono = "0.4.19" -bytesize = "1.0.1" -textwrap = "0.13.0" -regex = "1.4.2" -lazy_static = "1.4.0" hostname = "0.3.1" +lazy_static = "1.4.0" +regex = "1.4.2" +rpassword = "5.0.0" +serde = { version = "1.0.118", features = ["derive"] } +ssh2 = "0.9.0" +textwrap = "0.13.0" +toml = "0.5.7" +tui = { version = "0.13.0", features = ["crossterm"], default-features = false } +unicode-width = "0.1.7" +whoami = "1.0.0" [target.'cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))'.dependencies] users = "0.11.0" diff --git a/README.md b/README.md index a284bc3..eb519f6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # TermSCP -[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Stars](https://img.shields.io/github/stars/ChristianVisintin/TermSCP.svg)](https://github.com/ChristianVisintin/TermSCP) [![Issues](https://img.shields.io/github/issues/ChristianVisintin/TermSCP.svg)](https://github.com/ChristianVisintin/TermSCP/issues) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.1.2-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Stars](https://img.shields.io/github/stars/ChristianVisintin/TermSCP.svg)](https://github.com/ChristianVisintin/TermSCP) [![Issues](https://img.shields.io/github/issues/ChristianVisintin/TermSCP.svg)](https://github.com/ChristianVisintin/TermSCP/issues) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.1.3-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp) [![Build](https://github.com/ChristianVisintin/TermSCP/workflows/Linux/badge.svg)](https://github.com/ChristianVisintin/TermSCP/actions) [![Build](https://github.com/ChristianVisintin/TermSCP/workflows/MacOS/badge.svg)](https://github.com/ChristianVisintin/TermSCP/actions) [![Build](https://github.com/ChristianVisintin/TermSCP/workflows/Windows/badge.svg)](https://github.com/ChristianVisintin/TermSCP/actions) ~ Basically, WinSCP on a terminal ~ Developed by Christian Visintin -Current version: 0.1.2 (13/12/2020) +Current version: 0.1.3 (13/12/2020) --- @@ -21,8 +21,9 @@ Current version: 0.1.2 (13/12/2020) - [Chocolatey ๐Ÿซ](#chocolatey-) - [Brew ๐Ÿป](#brew-) - [Usage โ“](#usage-) - - [Address argument](#address-argument) - - [How Password can be provided](#how-password-can-be-provided) + - [Address argument ๐ŸŒŽ](#address-argument-) + - [How Password can be provided ๐Ÿ”](#how-password-can-be-provided-) + - [Bookmarks โญ](#bookmarks-) - [Keybindings โŒจ](#keybindings-) - [Documentation ๐Ÿ“š](#documentation-) - [Known issues ๐Ÿงป](#known-issues-) @@ -74,8 +75,8 @@ cargo install termscp ### Deb package ๐Ÿ“ฆ -Get `deb` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.2_amd64.deb) -or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.2_amd64.deb` +Get `deb` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.3_amd64.deb) +or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.3_amd64.deb` then install through dpkg: @@ -87,8 +88,8 @@ gdebi termscp_*.deb ### RPM package ๐Ÿ“ฆ -Get `rpm` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.2-1.x86_64.rpm) -or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.2-1.x86_64.rpm` +Get `rpm` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.3-1.x86_64.rpm) +or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.3-1.x86_64.rpm` then install through rpm: @@ -106,7 +107,7 @@ Start PowerShell as administrator and run choco install termscp ``` -Alternatively you can download the ZIP file from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp.0.1.2.nupkg) +Alternatively you can download the ZIP file from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp.0.1.3.nupkg) and then with PowerShell started with administrator previleges, run: @@ -139,7 +140,7 @@ TermSCP can be started in two different mode, if no extra arguments is provided, Alternatively, the user can provide an address as argument to skip the authentication form and starting directly the connection to the remote server. -### Address argument +### Address argument ๐ŸŒŽ The address argument has the following syntax: @@ -167,7 +168,7 @@ Let's see some example of this particular syntax, since it's very comfortable an termscp scp://omar@192.168.1.31:4022 ``` -#### How Password can be provided +#### How Password can be provided ๐Ÿ” You have probably noticed, that, when providing the address as argument, there's no way to provide the password. Password can be basically provided through 3 ways when address argument is provided: @@ -178,6 +179,18 @@ Password can be basically provided through 3 ways when address argument is provi --- +## Bookmarks โญ + +Since TermSCP 0.2.0, it is possible to save favourites hosts, which can be then loaded quickly from the main layout of termscp. +TermSCP will also save the last 16 hosts you connected to. +This feature allows you to load all the parameters required to connect to a certain remote, simply selecting the bookmark in the tab under the authentication form. +For safety reason, termscp **WILL NEVER** save your passwords; you'll need to provide it everytime (Don't worry, every time you select a bookmark, the cursor will be placed in the password form to speed up this process ๐Ÿ˜‰). + +To create a bookmark, just fulfill the authentication form and then input `CTRL+S`; you'll then be asked to give a name to your bookmark, and tadah, the bookmark has been created. +If you go to [galler](#gallery-), there is a GIF showing how bookmarks work ๐Ÿ’ช. + +--- + ## Keybindings โŒจ | Key | Command | @@ -198,11 +211,12 @@ Password can be basically provided through 3 ways when address argument is provi | `` | Go to supplied path | | `` | Show help | | `` | Show info about selected file or directory | +| `` | Reload current directory's content | | `` | Quit TermSCP | | `` | Rename file | | `` | Go to parent directory | | `` | Delete file | -| `CTRL+C` | Abort file transfer process | +| `` | Abort file transfer process | --- diff --git a/assets/images/explorer.gif b/assets/images/explorer.gif index dfcec99..6d0b54a 100644 Binary files a/assets/images/explorer.gif and b/assets/images/explorer.gif differ diff --git a/dist/build/deploy.sh b/dist/build/deploy.sh new file mode 100755 index 0000000..d286f35 --- /dev/null +++ b/dist/build/deploy.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +if [ -z "$1" ]; then + echo "Usage: deploy.sh " + exit 1 +fi + +VERSION=$1 + +# Build x86_64 +cd x86_64/ +docker build --tag termscp-${VERSION}-x86_64 . +# Get pkgs +cd - +# Create container +CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64 termscp-${VERSION}-x86_64) +docker cp ${CONTAINER_NAME}:/usr/src/TermSCP/target/debian/termscp_${VERSION}_amd64.deb . +docker cp ${CONTAINER_NAME}:/usr/src/TermSCP/target/release/rpmbuild/RPMS/x86_64/termscp-${VERSION}-1.x86_64.rpm . + +exit $? diff --git a/dist/build/x86_64/Dockerfile b/dist/build/x86_64/Dockerfile new file mode 100644 index 0000000..4e754da --- /dev/null +++ b/dist/build/x86_64/Dockerfile @@ -0,0 +1,19 @@ +FROM rust:1.48.0 AS builder + +WORKDIR /usr/src/ +# Add toolchains +RUN rustup target add x86_64-unknown-linux-gnu +# Install dependencies +RUN apt update && apt install -y rpm +# Clone repository +RUN git clone https://github.com/ChristianVisintin/TermSCP.git +# Set workdir to termscp +WORKDIR /usr/src/TermSCP/ +# Install cargo RPM/Deb +RUN cargo install cargo-deb cargo-rpm +# Build for x86_64 +RUN cargo build --release --target x86_64-unknown-linux-gnu +# Build pkgs +RUN cargo deb && cargo rpm init && cargo rpm build + +CMD ["sh"] diff --git a/src/bookmarks/mod.rs b/src/bookmarks/mod.rs new file mode 100644 index 0000000..a3ed49b --- /dev/null +++ b/src/bookmarks/mod.rs @@ -0,0 +1,112 @@ +//! ## Bookmarks +//! +//! `bookmarks` is the module which provides data types and de/serializer for bookmarks + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +pub mod serializer; + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Deserialize, Serialize, std::fmt::Debug)] +/// ## UserHosts +/// +/// UserHosts contains all the hosts saved by the user in the data storage +/// It contains both `Bookmark` +pub struct UserHosts { + pub bookmarks: HashMap, + pub recents: HashMap, +} + +#[derive(Deserialize, Serialize, std::fmt::Debug, PartialEq)] +/// ## Bookmark +/// +/// Bookmark describes a single bookmark entry in the user hosts storage +pub struct Bookmark { + pub address: String, + pub port: u16, + pub protocol: String, + pub username: String, +} + +// Errors + +/// ## SerializerError +/// +/// Contains the error for serializer/deserializer +#[derive(std::fmt::Debug)] +pub struct SerializerError { + kind: SerializerErrorKind, + msg: Option, +} + +/// ## SerializerErrorKind +/// +/// Describes the kind of error for the serializer/deserializer +#[derive(std::fmt::Debug)] +pub enum SerializerErrorKind { + IoError, + SerializationError, + SyntaxError, +} + +impl Default for UserHosts { + fn default() -> Self { + UserHosts { + bookmarks: HashMap::new(), + recents: HashMap::new(), + } + } +} + +impl SerializerError { + /// ### new + /// + /// Instantiate a new `SerializerError` + pub fn new(kind: SerializerErrorKind) -> SerializerError { + SerializerError { kind, msg: None } + } + + /// ### new_ex + /// + /// Instantiates a new `SerializerError` with description message + pub fn new_ex(kind: SerializerErrorKind, msg: String) -> SerializerError { + let mut err: SerializerError = SerializerError::new(kind); + err.msg = Some(msg); + err + } +} + +impl std::fmt::Display for SerializerError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let err: String = match &self.kind { + SerializerErrorKind::IoError => String::from("IO Error"), + SerializerErrorKind::SerializationError => String::from("Serialization error"), + SerializerErrorKind::SyntaxError => String::from("Syntax error"), + }; + match &self.msg { + Some(msg) => write!(f, "{} ({})", err, msg), + None => write!(f, "{}", err), + } + } +} diff --git a/src/bookmarks/serializer.rs b/src/bookmarks/serializer.rs new file mode 100644 index 0000000..acb5cd5 --- /dev/null +++ b/src/bookmarks/serializer.rs @@ -0,0 +1,213 @@ +//! ## Serializer +//! +//! `serializer` is the module which provides the serializer/deserializer for bookmarks + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +use super::{SerializerError, SerializerErrorKind, UserHosts}; + +use std::io::{Read, Write}; + +pub struct BookmarkSerializer {} + +impl BookmarkSerializer { + /// ### serialize + /// + /// Serialize `UserHosts` into TOML and write content to writable + pub fn serialize( + &self, + mut writable: Box, + hosts: &UserHosts, + ) -> Result<(), SerializerError> { + // Serialize content + let data: String = match toml::ser::to_string(hosts) { + Ok(dt) => dt, + Err(err) => { + return Err(SerializerError::new_ex( + SerializerErrorKind::SerializationError, + err.to_string(), + )) + } + }; + // Write file + match writable.write_all(data.as_bytes()) { + Ok(_) => Ok(()), + Err(err) => Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )), + } + } + + /// ### deserialize + /// + /// Read data from readable and deserialize its content as TOML + pub fn deserialize(&self, mut readable: Box) -> Result { + // Read file content + let mut data: String = String::new(); + if let Err(err) = readable.read_to_string(&mut data) { + return Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )); + } + // Deserialize + match toml::de::from_str(data.as_str()) { + Ok(hosts) => Ok(hosts), + Err(err) => Err(SerializerError::new_ex( + SerializerErrorKind::SyntaxError, + err.to_string(), + )), + } + } +} + +// Tests + +#[cfg(test)] +mod tests { + + use super::super::Bookmark; + use super::*; + + use std::collections::HashMap; + use std::io::{Seek, SeekFrom}; + + #[test] + fn test_bookmarks_serializer_deserialize_ok() { + let toml_file: tempfile::NamedTempFile = create_good_toml(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + let deserializer: BookmarkSerializer = BookmarkSerializer {}; + let hosts = deserializer.deserialize(Box::new(toml_file)); + assert!(hosts.is_ok()); + let hosts: UserHosts = hosts.ok().unwrap(); + // Verify hosts + // Verify recents + assert_eq!(hosts.recents.len(), 1); + let host: &Bookmark = hosts.recents.get("ISO20201215T094000Z").unwrap(); + assert_eq!(host.address, String::from("172.16.104.10")); + assert_eq!(host.port, 22); + assert_eq!(host.protocol, String::from("SCP")); + assert_eq!(host.username, String::from("root")); + // Verify bookmarks + assert_eq!(hosts.bookmarks.len(), 3); + let host: &Bookmark = hosts.bookmarks.get("raspberrypi2").unwrap(); + assert_eq!(host.address, String::from("192.168.1.31")); + assert_eq!(host.port, 22); + assert_eq!(host.protocol, String::from("SFTP")); + assert_eq!(host.username, String::from("root")); + let host: &Bookmark = hosts.bookmarks.get("msi-estrem").unwrap(); + assert_eq!(host.address, String::from("192.168.1.30")); + assert_eq!(host.port, 22); + assert_eq!(host.protocol, String::from("SFTP")); + assert_eq!(host.username, String::from("cvisintin")); + let host: &Bookmark = hosts.bookmarks.get("aws-server-prod1").unwrap(); + assert_eq!(host.address, String::from("51.23.67.12")); + assert_eq!(host.port, 21); + assert_eq!(host.protocol, String::from("FTPS")); + assert_eq!(host.username, String::from("aws001")); + } + + #[test] + fn test_bookmarks_serializer_deserialize_nok() { + let toml_file: tempfile::NamedTempFile = create_bad_toml(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + let deserializer: BookmarkSerializer = BookmarkSerializer {}; + assert!(deserializer.deserialize(Box::new(toml_file)).is_err()); + } + + #[test] + fn test_bookmarks_serializer_serialize() { + let mut bookmarks: HashMap = HashMap::with_capacity(2); + // Push two samples + bookmarks.insert( + String::from("raspberrypi2"), + Bookmark { + address: String::from("192.168.1.31"), + port: 22, + protocol: String::from("SFTP"), + username: String::from("root"), + }, + ); + bookmarks.insert( + String::from("msi-estrem"), + Bookmark { + address: String::from("192.168.1.30"), + port: 4022, + protocol: String::from("SFTP"), + username: String::from("cvisintin"), + }, + ); + let mut recents: HashMap = HashMap::with_capacity(1); + recents.insert( + String::from("ISO20201215T094000Z"), + Bookmark { + address: String::from("192.168.1.254"), + port: 3022, + protocol: String::from("SCP"), + username: String::from("omar"), + }, + ); + let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + // Serialize + let deserializer: BookmarkSerializer = BookmarkSerializer {}; + let hosts: UserHosts = UserHosts { bookmarks, recents }; + assert!(deserializer.serialize(Box::new(tmpfile), &hosts).is_ok()); + } + + fn create_good_toml() -> tempfile::NamedTempFile { + // Write + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + [bookmarks] + raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root" } + msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP", username = "cvisintin" } + aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" } + + [recents] + ISO20201215T094000Z = { address = "172.16.104.10", port = 22, protocol = "SCP", username = "root" } + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + //write!(tmpfile, "[bookmarks]\nraspberrypi2 = {{ address = \"192.168.1.31\", port = 22, protocol = \"SFTP\", username = \"root\" }}\nmsi-estrem = {{ address = \"192.168.1.30\", port = 22, protocol = \"SFTP\", username = \"cvisintin\" }}\naws-server-prod1 = {{ address = \"51.23.67.12\", port = 21, protocol = \"FTPS\", username = \"aws001\" }}\n\n[recents]\nISO20201215T094000Z = {{ address = \"172.16.104.10\", port = 22, protocol = \"SCP\", username = \"root\" }}\n"); + tmpfile + } + + fn create_bad_toml() -> tempfile::NamedTempFile { + // Write + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + [bookmarks] + raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root"} + msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP" } + aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" } + + [recents] + ISO20201215T094000Z = { address = "172.16.104.10", protocol = "SCP", username = "root", port = 22 } + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } +} diff --git a/src/host/mod.rs b/src/host/mod.rs index daefa0a..3916a13 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -324,7 +324,7 @@ impl Localhost { #[cfg(target_os = "windows")] #[cfg(not(tarpaulin_include))] pub fn stat(&self, path: &Path) -> Result { - let attr: Metadata = match fs::metadata(path.clone()) { + let attr: Metadata = match fs::metadata(path) { Ok(metadata) => metadata, Err(err) => return Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))), }; @@ -418,7 +418,7 @@ impl Localhost { /// ### file_exists /// /// Returns whether provided file path exists - fn file_exists(&self, path: &Path) -> bool { + pub fn file_exists(&self, path: &Path) -> bool { path.exists() } diff --git a/src/lib.rs b/src/lib.rs index 6c82b9d..f40a0c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ #[macro_use] extern crate lazy_static; pub mod activity_manager; +pub mod bookmarks; pub mod filetransfer; pub mod fs; pub mod host; diff --git a/src/main.rs b/src/main.rs index fa0f29c..f41067d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ use std::time::Duration; // Include mod activity_manager; +mod bookmarks; mod filetransfer; mod fs; mod host; diff --git a/src/ui/activities/auth_activity.rs b/src/ui/activities/auth_activity.rs deleted file mode 100644 index 6ba23f1..0000000 --- a/src/ui/activities/auth_activity.rs +++ /dev/null @@ -1,542 +0,0 @@ -//! ## AuthActivity -//! -//! `auth_activity` is the module which implements the authentication activity - -/* -* -* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com -* -* This file is part of "TermSCP" -* -* TermSCP is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* TermSCP is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with TermSCP. If not, see . -* -*/ - -// Dependencies -extern crate crossterm; -extern crate tui; -extern crate unicode_width; - -// locals -use super::{Activity, Context}; -use crate::filetransfer::FileTransferProtocol; - -// Includes -use crossterm::event::Event as InputEvent; -use crossterm::event::KeyCode; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; -use tui::{ - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, Borders, Clear, Paragraph, Tabs}, -}; -use unicode_width::UnicodeWidthStr; - -/// ### InputField -/// -/// InputField describes the current input field to edit -#[derive(std::cmp::PartialEq)] -enum InputField { - Address, - Port, - Protocol, - Username, - Password, -} - -/// ### InputMode -/// -/// InputMode describes the current input mode -/// Each input mode handle the input events in a different way -#[derive(std::cmp::PartialEq)] -enum InputMode { - Text, - Popup, -} - -/// ### AuthActivity -/// -/// AuthActivity is the data holder for the authentication activity -pub struct AuthActivity { - pub address: String, - pub port: String, - pub protocol: FileTransferProtocol, - pub username: String, - pub password: String, - pub submit: bool, // becomes true after user has submitted fields - pub quit: bool, // Becomes true if user has pressed esc - context: Option, - selected_field: InputField, - input_mode: InputMode, - popup_message: Option, - password_placeholder: String, - redraw: bool, // Should ui actually be redrawned? -} - -impl Default for AuthActivity { - fn default() -> Self { - Self::new() - } -} - -impl AuthActivity { - /// ### new - /// - /// Instantiates a new AuthActivity - pub fn new() -> AuthActivity { - AuthActivity { - address: String::new(), - port: String::from("22"), - protocol: FileTransferProtocol::Sftp, - username: String::new(), - password: String::new(), - submit: false, - quit: false, - context: None, - selected_field: InputField::Address, - input_mode: InputMode::Text, - popup_message: None, - password_placeholder: String::new(), - redraw: true, // True at startup - } - } - - /// ### set_input_mode - /// - /// Update input mode based on current parameters - fn select_input_mode(&mut self) -> InputMode { - if self.popup_message.is_some() { - return InputMode::Popup; - } - // Default to text - InputMode::Text - } - - /// ### handle_input_event - /// - /// Handle input event, based on current input mode - fn handle_input_event(&mut self, ev: &InputEvent) { - match self.input_mode { - InputMode::Text => self.handle_input_event_mode_text(ev), - InputMode::Popup => self.handle_input_event_mode_popup(ev), - } - } - - /// ### handle_input_event_mode_text - /// - /// Handler for input event when in textmode - fn handle_input_event_mode_text(&mut self, ev: &InputEvent) { - if let InputEvent::Key(key) = ev { - match key.code { - KeyCode::Esc => { - self.quit = true; - } - KeyCode::Enter => { - // Handle submit - // Check form - // Check address - if self.address.is_empty() { - self.popup_message = Some(String::from("Invalid address")); - return; - } - // Check port - // Convert port to number - match self.port.parse::() { - Ok(val) => { - if val > 65535 { - self.popup_message = - Some(String::from("Specified port must be in range 0-65535")); - return; - } - } - Err(_) => { - self.popup_message = - Some(String::from("Specified port is not a number")); - return; - } - } - // Check username - //if self.username.len() == 0 { - // self.popup_message = Some(String::from("Invalid username")); - // return; - //} - // Everything OK, set enter - self.submit = true; - self.popup_message = - Some(format!("Connecting to {}:{}...", self.address, self.port)); - } - KeyCode::Backspace => { - // Pop last char - match self.selected_field { - InputField::Address => { - let _ = self.address.pop(); - } - InputField::Password => { - let _ = self.password.pop(); - } - InputField::Username => { - let _ = self.username.pop(); - } - InputField::Port => { - let _ = self.port.pop(); - } - _ => { /* Nothing to do */ } - }; - } - KeyCode::Up => { - // Move item up - self.selected_field = match self.selected_field { - InputField::Address => InputField::Password, // End of list (wrap) - InputField::Port => InputField::Address, - InputField::Protocol => InputField::Port, - InputField::Username => InputField::Protocol, - InputField::Password => InputField::Username, - } - } - KeyCode::Down | KeyCode::Tab => { - // Move item down - self.selected_field = match self.selected_field { - InputField::Address => InputField::Port, - InputField::Port => InputField::Protocol, - InputField::Protocol => InputField::Username, - InputField::Username => InputField::Password, - InputField::Password => InputField::Address, // End of list (wrap) - } - } - KeyCode::Char(ch) => { - match self.selected_field { - InputField::Address => self.address.push(ch), - InputField::Password => self.password.push(ch), - InputField::Username => self.username.push(ch), - InputField::Port => { - // Value must be numeric - if ch.is_numeric() { - self.port.push(ch); - } - } - _ => { /* Nothing to do */ } - } - } - KeyCode::Left => { - // If current field is Protocol handle event... (move element left) - if self.selected_field == InputField::Protocol { - self.protocol = match self.protocol { - FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap) - FileTransferProtocol::Scp => FileTransferProtocol::Sftp, - FileTransferProtocol::Ftp(ftps) => match ftps { - false => FileTransferProtocol::Scp, - true => FileTransferProtocol::Ftp(false), - }, - }; - } - } - KeyCode::Right => { - // If current field is Protocol handle event... ( move element right ) - if self.selected_field == InputField::Protocol { - self.protocol = match self.protocol { - FileTransferProtocol::Sftp => FileTransferProtocol::Scp, - FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false), - FileTransferProtocol::Ftp(ftps) => match ftps { - false => FileTransferProtocol::Ftp(true), - true => FileTransferProtocol::Sftp, // End of list (wrap) - }, - }; - } - } - _ => { /* Nothing to do */ } - } - } - } - - /// ### handle_input_event_mode_text - /// - /// Handler for input event when in popup mode - fn handle_input_event_mode_popup(&mut self, ev: &InputEvent) { - // Only enter should be allowed here - if let InputEvent::Key(key) = ev { - if let KeyCode::Enter = key.code { - self.popup_message = None; // Hide popup - } - } - } - - /// ### draw_remote_address - /// - /// Draw remote address block - fn draw_remote_address(&self) -> Paragraph { - Paragraph::new(self.address.as_ref()) - .style(match self.selected_field { - InputField::Address => Style::default().fg(Color::Yellow), - _ => Style::default(), - }) - .block( - Block::default() - .borders(Borders::ALL) - .title("Remote address"), - ) - } - - /// ### draw_remote_port - /// - /// Draw remote port block - fn draw_remote_port(&self) -> Paragraph { - Paragraph::new(self.port.as_ref()) - .style(match self.selected_field { - InputField::Port => Style::default().fg(Color::Cyan), - _ => Style::default(), - }) - .block(Block::default().borders(Borders::ALL).title("Remote port")) - } - - /// ### draw_protocol_select - /// - /// Draw protocol select - fn draw_protocol_select(&self) -> Tabs { - let protocols: Vec = vec![ - Spans::from("SFTP"), - Spans::from("SCP"), - Spans::from("FTP"), - Spans::from("FTPS"), - ]; - let index: usize = match self.protocol { - FileTransferProtocol::Sftp => 0, - FileTransferProtocol::Scp => 1, - FileTransferProtocol::Ftp(ftps) => match ftps { - false => 2, - true => 3, - }, - }; - Tabs::new(protocols) - .block(Block::default().borders(Borders::ALL).title("Protocol")) - .select(index) - .style(match self.selected_field { - InputField::Protocol => Style::default().fg(Color::Green), - _ => Style::default(), - }) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .bg(Color::Green) - .fg(Color::Black), - ) - } - - /// ### draw_protocol_username - /// - /// Draw username block - fn draw_protocol_username(&self) -> Paragraph { - Paragraph::new(self.username.as_ref()) - .style(match self.selected_field { - InputField::Username => Style::default().fg(Color::Magenta), - _ => Style::default(), - }) - .block(Block::default().borders(Borders::ALL).title("Username")) - } - - /// ### draw_protocol_password - /// - /// Draw password block - fn draw_protocol_password(&mut self) -> Paragraph { - // Create password secret - self.password_placeholder = (0..self.password.width()).map(|_| "*").collect::(); - Paragraph::new(self.password_placeholder.as_ref()) - .style(match self.selected_field { - InputField::Password => Style::default().fg(Color::LightBlue), - _ => Style::default(), - }) - .block(Block::default().borders(Borders::ALL).title("Password")) - } - - /// ### draw_header - /// - /// Draw header - fn draw_header(&self) -> Paragraph { - Paragraph::new(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n") - .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)) - } - - /// ### draw_footer - /// - /// Draw authentication page footer - fn draw_footer(&self) -> Paragraph { - // Write header - let (footer, h_style) = ( - vec![ - Span::raw("Press "), - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit, "), - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to change input field, "), - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to submit form"), - ], - Style::default().add_modifier(Modifier::BOLD), - ); - let mut footer_text = Text::from(Spans::from(footer)); - footer_text.patch_style(h_style); - Paragraph::new(footer_text) - } - - /// ### draw_popup - /// - /// Draw popup block - fn draw_popup(&self, r: Rect) -> (Paragraph, Rect) { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Percentage((80) / 2), - Constraint::Percentage(20), - Constraint::Percentage((80) / 2), - ] - .as_ref(), - ) - .split(r); - let area: Rect = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage((80) / 2), - Constraint::Percentage(20), - Constraint::Percentage((80) / 2), - ] - .as_ref(), - ) - .split(popup_layout[1])[1]; - let popup: Paragraph = Paragraph::new(self.popup_message.as_ref().unwrap().as_ref()) - .style(Style::default().fg(Color::Red)) - .block(Block::default().borders(Borders::ALL).title("Alert")); - (popup, area) - } -} - -impl Activity for AuthActivity { - /// ### on_create - /// - /// `on_create` is the function which must be called to initialize the activity. - /// `on_create` must initialize all the data structures used by the activity - /// Context is taken from activity manager and will be released only when activity is destroyed - fn on_create(&mut self, context: Context) { - // Set context - self.context = Some(context); - // Clear terminal - let _ = self.context.as_mut().unwrap().terminal.clear(); - // Put raw mode on enabled - let _ = enable_raw_mode(); - } - - /// ### on_draw - /// - /// `on_draw` is the function which draws the graphical interface. - /// This function must be called at each tick to refresh the interface - fn on_draw(&mut self) { - // Context must be something - if self.context.is_none() { - return; - } - // Start catching Input Events - if let Ok(input_events) = self.context.as_ref().unwrap().input_hnd.fetch_events() { - if !input_events.is_empty() { - self.redraw = true; // Set redraw to true if there is at least one event - } - // Iterate over input events - for event in input_events.iter() { - self.handle_input_event(event); - } - } - // Redraw if necessary - if self.redraw { - // Determine input mode - self.input_mode = self.select_input_mode(); - // draw interface - let mut ctx: Context = self.context.take().unwrap(); - let _ = ctx.terminal.draw(|f| { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints( - [ - Constraint::Length(5), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - ] - .as_ref(), - ) - .split(f.size()); - // Draw header - f.render_widget(self.draw_header(), chunks[0]); - // Draw input fields - f.render_widget(self.draw_remote_address(), chunks[1]); - f.render_widget(self.draw_remote_port(), chunks[2]); - f.render_widget(self.draw_protocol_select(), chunks[3]); - f.render_widget(self.draw_protocol_username(), chunks[4]); - f.render_widget(self.draw_protocol_password(), chunks[5]); - // Draw footer - f.render_widget(self.draw_footer(), chunks[6]); - if self.popup_message.is_some() { - let (popup, popup_area): (Paragraph, Rect) = self.draw_popup(f.size()); - f.render_widget(Clear, popup_area); //this clears out the background - f.render_widget(popup, popup_area); - } - // Set cursor - match self.selected_field { - InputField::Address => f.set_cursor( - chunks[1].x + self.address.width() as u16 + 1, - chunks[1].y + 1, - ), - InputField::Port => { - f.set_cursor(chunks[2].x + self.port.width() as u16 + 1, chunks[2].y + 1) - } - InputField::Username => f.set_cursor( - chunks[4].x + self.username.width() as u16 + 1, - chunks[4].y + 1, - ), - InputField::Password => f.set_cursor( - chunks[5].x + self.password_placeholder.width() as u16 + 1, - chunks[5].y + 1, - ), - _ => {} - } - }); - // Reset ctx - self.context = Some(ctx); - // Set redraw to false - self.redraw = false; - } - } - - /// ### on_destroy - /// - /// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity. - /// This function must be called once before terminating the activity. - /// This function finally releases the context - fn on_destroy(&mut self) -> Option { - // Disable raw mode - let _ = disable_raw_mode(); - self.context.as_ref()?; - // Clear terminal and return - match self.context.take() { - Some(mut ctx) => { - let _ = ctx.terminal.clear(); - Some(ctx) - } - None => None, - } - } -} diff --git a/src/ui/activities/auth_activity/bookmarks.rs b/src/ui/activities/auth_activity/bookmarks.rs new file mode 100644 index 0000000..99211a4 --- /dev/null +++ b/src/ui/activities/auth_activity/bookmarks.rs @@ -0,0 +1,400 @@ +//! ## AuthActivity +//! +//! `auth_activity` is the module which implements the authentication activity + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +// Dependencies +extern crate dirs; + +// Locals +use super::{AuthActivity, Color, FileTransferProtocol, InputMode, PopupType, UserHosts}; +use crate::bookmarks::serializer::BookmarkSerializer; +use crate::bookmarks::Bookmark; +use crate::utils::time_to_str; + +// Ext +use std::path::PathBuf; +use std::time::SystemTime; + +impl AuthActivity { + /// ### read_bookmarks + /// + /// Read bookmarks from data file; Show popup if necessary + pub(super) fn read_bookmarks(&mut self) { + // Init bookmarks + if let Some(bookmark_file) = self.init_bookmarks() { + // Read + if self.context.is_some() { + match self + .context + .as_ref() + .unwrap() + .local + .open_file_read(bookmark_file.as_path()) + { + Ok(reader) => { + // Read bookmarks + let deserializer: BookmarkSerializer = BookmarkSerializer {}; + match deserializer.deserialize(Box::new(reader)) { + Ok(bookmarks) => self.bookmarks = Some(bookmarks), + Err(err) => { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not read bookmarks from \"{}\": {}", + bookmark_file.display(), + err + ), + )) + } + } + } + Err(err) => { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not read bookmarks from \"{}\": {}", + bookmark_file.display(), + err + ), + )) + } + } + } + } + } + + /// ### del_bookmark + /// + /// Delete bookmark + pub(super) fn del_bookmark(&mut self, idx: usize) { + if let Some(hosts) = self.bookmarks.as_mut() { + // Iterate over kyes + let mut name: Option = None; + for (i, key) in hosts.bookmarks.keys().enumerate() { + if i == idx { + name = Some(key.clone()); + break; + } + } + if let Some(name) = name { + hosts.bookmarks.remove(name.as_str()); + } + } + } + + /// ### load_bookmark + /// + /// Load selected bookmark (at index) to input fields + pub(super) fn load_bookmark(&mut self, idx: usize) { + if let Some(hosts) = self.bookmarks.as_mut() { + // Iterate over bookmarks + for (i, bookmark) in hosts.bookmarks.values().enumerate() { + if i == idx { + // Load parameters + self.address = bookmark.address.clone(); + self.port = bookmark.port.to_string(); + self.protocol = match bookmark.protocol.as_str().to_uppercase().as_str() { + "FTP" => FileTransferProtocol::Ftp(false), + "FTPS" => FileTransferProtocol::Ftp(true), + "SCP" => FileTransferProtocol::Scp, + _ => FileTransferProtocol::Sftp, // Default to SFTP + }; + self.username = bookmark.username.clone(); + // Break + break; + } + } + } + } + + /// ### save_bookmark + /// + /// Save current input fields as a bookmark + pub(super) fn save_bookmark(&mut self, name: String) { + if let Ok(host) = self.make_user_host() { + if let Some(hosts) = self.bookmarks.as_mut() { + hosts.bookmarks.insert(name, host); + // Write bookmarks + self.write_bookmarks(); + } + } + } + /// ### del_recent + /// + /// Delete recent + pub(super) fn del_recent(&mut self, idx: usize) { + if let Some(hosts) = self.bookmarks.as_mut() { + // Iterate over kyes + let mut name: Option = None; + for (i, key) in hosts.recents.keys().enumerate() { + if i == idx { + name = Some(key.clone()); + break; + } + } + if let Some(name) = name { + hosts.recents.remove(name.as_str()); + } + } + } + + /// ### load_recent + /// + /// Load selected recent (at index) to input fields + pub(super) fn load_recent(&mut self, idx: usize) { + if let Some(hosts) = self.bookmarks.as_mut() { + // Iterate over bookmarks + for (i, bookmark) in hosts.recents.values().enumerate() { + if i == idx { + // Load parameters + self.address = bookmark.address.clone(); + self.port = bookmark.port.to_string(); + self.protocol = match bookmark.protocol.as_str().to_uppercase().as_str() { + "FTP" => FileTransferProtocol::Ftp(false), + "FTPS" => FileTransferProtocol::Ftp(true), + "SCP" => FileTransferProtocol::Scp, + _ => FileTransferProtocol::Sftp, // Default to SFTP + }; + self.username = bookmark.username.clone(); + // Break + break; + } + } + } + } + + /// ### save_recent + /// + /// Save current input fields as a "recent" + pub(super) fn save_recent(&mut self) { + if let Ok(host) = self.make_user_host() { + if let Some(hosts) = self.bookmarks.as_mut() { + // Check if duplicated + for recent_host in hosts.recents.values() { + if *recent_host == host { + // Don't save duplicates + return; + } + } + // If hosts size is bigger than 16; pop last + if hosts.recents.len() >= 16 { + let mut keys: Vec = Vec::with_capacity(hosts.recents.len()); + for key in hosts.recents.keys() { + keys.push(key.clone()); + } + // Sort keys; NOTE: most recent is the last element + keys.sort(); + // Delete keys starting from the last one + for key in keys.iter() { + let _ = hosts.recents.remove(key); + // If length is < 16; break + if hosts.recents.len() < 16 { + break; + } + } + } + // Create name + let name: String = time_to_str(SystemTime::now(), "ISO%Y%m%dT%H%M%S"); + // Save host to recents + hosts.recents.insert(name, host); + // Write bookmarks + self.write_bookmarks(); + } + } + } + + /// ### make_user_host + /// + /// Make user host from current input fields + fn make_user_host(&mut self) -> Result { + // Check port + let port: u16 = match self.port.parse::() { + Ok(val) => { + if val > 65535 { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Red, + String::from("Specified port must be in range 0-65535"), + )); + return Err(()); + } + val as u16 + } + Err(_) => { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Red, + String::from("Specified port is not a number"), + )); + return Err(()); + } + }; + Ok(Bookmark { + address: self.address.clone(), + port, + protocol: match self.protocol { + FileTransferProtocol::Ftp(secure) => match secure { + true => String::from("FTPS"), + false => String::from("FTP"), + }, + FileTransferProtocol::Scp => String::from("SCP"), + FileTransferProtocol::Sftp => String::from("SFTP"), + }, + username: self.username.clone(), + }) + } + + /// ### write_bookmarks + /// + /// Write bookmarks to file + fn write_bookmarks(&mut self) { + if self.bookmarks.is_some() && self.context.is_some() { + // Open file for write + if let Some(bookmarks_file) = self.init_bookmarks() { + match self + .context + .as_ref() + .unwrap() + .local + .open_file_write(bookmarks_file.as_path()) + { + Ok(writer) => { + let serializer: BookmarkSerializer = BookmarkSerializer {}; + if let Err(err) = serializer + .serialize(Box::new(writer), &self.bookmarks.as_ref().unwrap()) + { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not write default bookmarks at \"{}\": {}", + bookmarks_file.display(), + err + ), + )); + } + } + Err(err) => { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not write default bookmarks at \"{}\": {}", + bookmarks_file.display(), + err + ), + )) + } + } + } + } + } + + /// ### init_bookmarks + /// + /// Initialize bookmarks directory + /// Returns bookmark path + fn init_bookmarks(&mut self) -> Option { + // Get file + lazy_static! { + static ref CONF_DIR: Option = dirs::config_dir(); + } + if CONF_DIR.is_some() { + // Get path of bookmarks + let mut p: PathBuf = CONF_DIR.as_ref().unwrap().clone(); + // Append termscp dir + p.push("termscp/"); + // Mkdir if doesn't exist + if self.context.is_some() { + if let Err(err) = self + .context + .as_mut() + .unwrap() + .local + .mkdir_ex(p.as_path(), true) + { + // Show popup + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not create configuration directory at \"{}\": {}", + p.display(), + err + ), + )); + // Return None + return None; + } + } + // Append bookmarks.toml + p.push("bookmarks.toml"); + // If bookmarks.toml doesn't exist, initializae it + if self.context.is_some() + && !self + .context + .as_ref() + .unwrap() + .local + .file_exists(p.as_path()) + { + // Write file + let default_hosts: UserHosts = Default::default(); + match self + .context + .as_ref() + .unwrap() + .local + .open_file_write(p.as_path()) + { + Ok(writer) => { + let serializer: BookmarkSerializer = BookmarkSerializer {}; + // Serialize and write + if let Err(err) = serializer.serialize(Box::new(writer), &default_hosts) { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not write default bookmarks at \"{}\": {}", + p.display(), + err + ), + )); + return None; + } + } + Err(err) => { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Yellow, + format!( + "Could not write default bookmarks at \"{}\": {}", + p.display(), + err + ), + )); + return None; + } + } + } + // return path + Some(p) + } else { + None + } + } +} diff --git a/src/ui/activities/auth_activity/callbacks.rs b/src/ui/activities/auth_activity/callbacks.rs new file mode 100644 index 0000000..ca859e1 --- /dev/null +++ b/src/ui/activities/auth_activity/callbacks.rs @@ -0,0 +1,58 @@ +//! ## AuthActivity +//! +//! `auth_activity` is the module which implements the authentication activity + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +use super::{AuthActivity, InputForm}; + +impl AuthActivity { + /// ### callback_nothing_to_do + /// + /// Self titled + pub(super) fn callback_nothing_to_do(&mut self) {} + + /// ### callback_quit + /// + /// Self titled + pub(super) fn callback_quit(&mut self) { + self.quit = true; + } + + /// ### callback_del_bookmark + /// + /// Callback which deletes recent or bookmark based on current form + pub(super) fn callback_del_bookmark(&mut self) { + match self.input_form { + InputForm::Bookmarks => self.del_bookmark(self.bookmarks_idx), + InputForm::Recents => self.del_recent(self.recents_idx), + _ => { /* Nothing to do */ } + } + } + + /// ### callback_save_bookmark + /// + /// Callback used to save bookmark with name + pub(super) fn callback_save_bookmark(&mut self, input: String) { + self.save_bookmark(input); + } +} diff --git a/src/ui/activities/auth_activity/input.rs b/src/ui/activities/auth_activity/input.rs new file mode 100644 index 0000000..3bcc7c5 --- /dev/null +++ b/src/ui/activities/auth_activity/input.rs @@ -0,0 +1,487 @@ +//! ## AuthActivity +//! +//! `auth_activity` is the module which implements the authentication activity + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +use super::{ + AuthActivity, DialogCallback, DialogYesNoOption, FileTransferProtocol, InputEvent, InputField, + InputForm, InputMode, OnInputSubmitCallback, PopupType, +}; + +use crossterm::event::{KeyCode, KeyModifiers}; +use tui::style::Color; + +impl AuthActivity { + /// ### handle_input_event + /// + /// Handle input event, based on current input mode + pub(super) fn handle_input_event(&mut self, ev: &InputEvent) { + let popup: Option = match &self.input_mode { + InputMode::Popup(ptype) => Some(ptype.clone()), + _ => None, + }; + match self.input_mode { + InputMode::Form => self.handle_input_event_mode_form(ev), + InputMode::Popup(_) => { + if let Some(ptype) = popup { + self.handle_input_event_mode_popup(ev, ptype) + } + } + } + } + + /// ### handle_input_event_mode_form + /// + /// Handler for input event when in form mode + pub(super) fn handle_input_event_mode_form(&mut self, ev: &InputEvent) { + match self.input_form { + InputForm::AuthCredentials => self.handle_input_event_mode_form_auth(ev), + InputForm::Bookmarks => self.handle_input_event_mode_form_bookmarks(ev), + InputForm::Recents => self.handle_input_event_mode_form_recents(ev), + } + } + + /// ### handle_input_event_mode_form_auth + /// + /// Handle input event when input mode is Form and Tab is Auth + pub(super) fn handle_input_event_mode_form_auth(&mut self, ev: &InputEvent) { + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Esc => { + // Show quit dialog + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to quit termscp?"), + AuthActivity::callback_quit, + AuthActivity::callback_nothing_to_do, + )); + } + KeyCode::Tab => self.input_form = InputForm::Bookmarks, // Move to bookmarks + KeyCode::Enter => { + // Handle submit + // Check form + // Check address + if self.address.is_empty() { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Red, + String::from("Invalid address"), + )); + return; + } + // Check port + // Convert port to number + match self.port.parse::() { + Ok(val) => { + if val > 65535 { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Red, + String::from("Specified port must be in range 0-65535"), + )); + return; + } + } + Err(_) => { + self.input_mode = InputMode::Popup(PopupType::Alert( + Color::Red, + String::from("Specified port is not a number"), + )); + return; + } + } + // Save recent + self.save_recent(); + // Everything OK, set enter + self.submit = true; + } + KeyCode::Backspace => { + // Pop last char + match self.selected_field { + InputField::Address => { + let _ = self.address.pop(); + } + InputField::Password => { + let _ = self.password.pop(); + } + InputField::Username => { + let _ = self.username.pop(); + } + InputField::Port => { + let _ = self.port.pop(); + } + _ => { /* Nothing to do */ } + }; + } + KeyCode::Up => { + // Move item up + self.selected_field = match self.selected_field { + InputField::Address => InputField::Password, // End of list (wrap) + InputField::Port => InputField::Address, + InputField::Protocol => InputField::Port, + InputField::Username => InputField::Protocol, + InputField::Password => InputField::Username, + } + } + KeyCode::Down => { + // Move item down + self.selected_field = match self.selected_field { + InputField::Address => InputField::Port, + InputField::Port => InputField::Protocol, + InputField::Protocol => InputField::Username, + InputField::Username => InputField::Password, + InputField::Password => InputField::Address, // End of list (wrap) + } + } + KeyCode::Char(ch) => { + // Check if Ctrl is enabled + if key.modifiers.intersects(KeyModifiers::CONTROL) { + // If 'S', save bookmark as... + match ch { + 'H' | 'h' => { + // Show help + self.input_mode = InputMode::Popup(PopupType::Help); + } + 'S' | 's' => { + // Save bookmark as... + self.input_mode = InputMode::Popup(PopupType::Input( + String::from("Save bookmark as..."), + AuthActivity::callback_save_bookmark, + )); + } + _ => { /* Nothing to do */ } + } + } else { + match self.selected_field { + InputField::Address => self.address.push(ch), + InputField::Password => self.password.push(ch), + InputField::Username => self.username.push(ch), + InputField::Port => { + // Value must be numeric + if ch.is_numeric() { + self.port.push(ch); + } + } + _ => { /* Nothing to do */ } + } + } + } + KeyCode::Left => { + // If current field is Protocol handle event... (move element left) + if self.selected_field == InputField::Protocol { + self.protocol = match self.protocol { + FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap) + FileTransferProtocol::Scp => FileTransferProtocol::Sftp, + FileTransferProtocol::Ftp(ftps) => match ftps { + false => FileTransferProtocol::Scp, + true => FileTransferProtocol::Ftp(false), + }, + }; + } + } + KeyCode::Right => { + // If current field is Protocol handle event... ( move element right ) + if self.selected_field == InputField::Protocol { + self.protocol = match self.protocol { + FileTransferProtocol::Sftp => FileTransferProtocol::Scp, + FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false), + FileTransferProtocol::Ftp(ftps) => match ftps { + false => FileTransferProtocol::Ftp(true), + true => FileTransferProtocol::Sftp, // End of list (wrap) + }, + }; + } + } + _ => { /* Nothing to do */ } + } + } + } + + /// ### handle_input_event_mode_form_bookmarks + /// + /// Handle input event when input mode is Form and Tab is Bookmarks + pub(super) fn handle_input_event_mode_form_bookmarks(&mut self, ev: &InputEvent) { + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Esc => { + // Show quit dialog + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to quit termscp?"), + AuthActivity::callback_quit, + AuthActivity::callback_nothing_to_do, + )); + } + KeyCode::Tab => self.input_form = InputForm::AuthCredentials, // Move to Auth credentials + KeyCode::Right => self.input_form = InputForm::Recents, // Move to recents + KeyCode::Up => { + // Move bookmarks index up + if self.bookmarks_idx > 0 { + self.bookmarks_idx -= 1; + } else if let Some(hosts) = &self.bookmarks { + // Put to last index (wrap) + self.bookmarks_idx = hosts.bookmarks.len() - 1; + } + } + KeyCode::Down => { + if let Some(hosts) = &self.bookmarks { + let size: usize = hosts.bookmarks.len(); + // Check if can move down + if self.bookmarks_idx + 1 >= size { + // Move bookmarks index down + self.bookmarks_idx = 0; + } else { + // Set index to first element (wrap) + self.bookmarks_idx += 1; + } + } + } + KeyCode::Delete => { + // Ask if user wants to delete bookmark + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to delete the selected bookmark?"), + AuthActivity::callback_del_bookmark, + AuthActivity::callback_nothing_to_do, + )); + } + KeyCode::Enter => { + // Load bookmark + self.load_bookmark(self.bookmarks_idx); + // Set input form to Auth + self.input_form = InputForm::AuthCredentials; + // Set input field to password (very comfy) + self.selected_field = InputField::Password; + } + KeyCode::Char(ch) => match ch { + 'E' | 'e' => { + // Ask if user wants to delete bookmark; NOTE: same as + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to delete the selected bookmark?"), + AuthActivity::callback_del_bookmark, + AuthActivity::callback_nothing_to_do, + )); + } + 'H' | 'h' => { + // Show help + self.input_mode = InputMode::Popup(PopupType::Help); + } + 'S' | 's' => { + // Save bookmark as... + self.input_mode = InputMode::Popup(PopupType::Input( + String::from("Save bookmark as..."), + AuthActivity::callback_save_bookmark, + )); + } + _ => { /* Nothing to do */ } + }, + _ => { /* Nothing to do */ } + } + } + } + + /// ### handle_input_event_mode_form_recents + /// + /// Handle input event when input mode is Form and Tab is Recents + pub(super) fn handle_input_event_mode_form_recents(&mut self, ev: &InputEvent) { + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Esc => { + // Show quit dialog + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to quit termscp?"), + AuthActivity::callback_quit, + AuthActivity::callback_nothing_to_do, + )); + } + KeyCode::Tab => self.input_form = InputForm::AuthCredentials, // Move to Auth credentials + KeyCode::Left => self.input_form = InputForm::Bookmarks, // Move to bookmarks + KeyCode::Up => { + // Move bookmarks index up + if self.recents_idx > 0 { + self.recents_idx -= 1; + } else if let Some(hosts) = &self.bookmarks { + // Put to last index (wrap) + self.recents_idx = hosts.recents.len() - 1; + } + } + KeyCode::Down => { + if let Some(hosts) = &self.bookmarks { + let size: usize = hosts.recents.len(); + // Check if can move down + if self.recents_idx + 1 >= size { + // Move bookmarks index down + self.recents_idx = 0; + } else { + // Set index to first element (wrap) + self.recents_idx += 1; + } + } + } + KeyCode::Delete => { + // Ask if user wants to delete bookmark + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to delete the selected host?"), + AuthActivity::callback_del_bookmark, + AuthActivity::callback_nothing_to_do, + )); + } + KeyCode::Enter => { + // Load bookmark + self.load_recent(self.recents_idx); + // Set input form to Auth + self.input_form = InputForm::AuthCredentials; + // Set input field to password (very comfy) + self.selected_field = InputField::Password; + } + KeyCode::Char(ch) => match ch { + 'E' | 'e' => { + // Ask if user wants to delete bookmark; NOTE: same as + self.input_mode = InputMode::Popup(PopupType::YesNo( + String::from("Are you sure you want to delete the selected host?"), + AuthActivity::callback_del_bookmark, + AuthActivity::callback_nothing_to_do, + )); + } + 'H' | 'h' => { + // Show help + self.input_mode = InputMode::Popup(PopupType::Help); + } + 'S' | 's' => { + // Save bookmark as... + self.input_mode = InputMode::Popup(PopupType::Input( + String::from("Save bookmark as..."), + AuthActivity::callback_save_bookmark, + )); + } + _ => { /* Nothing to do */ } + }, + _ => { /* Nothing to do */ } + } + } + } + + /// ### handle_input_event_mode_text + /// + /// Handler for input event when in popup mode + pub(super) fn handle_input_event_mode_popup(&mut self, ev: &InputEvent, ptype: PopupType) { + match ptype { + PopupType::Alert(_, _) => self.handle_input_event_mode_popup_alert(ev), + PopupType::Help => self.handle_input_event_mode_popup_help(ev), + PopupType::Input(_, cb) => self.handle_input_event_mode_popup_input(ev, cb), + PopupType::YesNo(_, yes_cb, no_cb) => { + self.handle_input_event_mode_popup_yesno(ev, yes_cb, no_cb) + } + } + } + + /// ### handle_input_event_mode_popup_alert + /// + /// Handle input event when the input mode is popup, and popup type is alert + pub(super) fn handle_input_event_mode_popup_alert(&mut self, ev: &InputEvent) { + // Only enter should be allowed here + if let InputEvent::Key(key) = ev { + if let KeyCode::Enter = key.code { + self.input_mode = InputMode::Form; // Hide popup + } + } + } + + /// ### handle_input_event_mode_popup_help + /// + /// Input event handler for popup help + pub(super) fn handle_input_event_mode_popup_help(&mut self, ev: &InputEvent) { + // If enter, close popup + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Enter | KeyCode::Esc => { + // Set input mode back to form + self.input_mode = InputMode::Form; + } + _ => { /* Nothing to do */ } + } + } + } + + /// ### handle_input_event_mode_popup_input + /// + /// Input event handler for input popup + pub(super) fn handle_input_event_mode_popup_input( + &mut self, + ev: &InputEvent, + cb: OnInputSubmitCallback, + ) { + // If enter, close popup, otherwise push chars to input + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Esc => { + // Abort input + // Clear current input text + self.input_txt.clear(); + // Set mode back to form + self.input_mode = InputMode::Form; + } + KeyCode::Enter => { + // Submit + let input_text: String = self.input_txt.clone(); + // Clear current input text + self.input_txt.clear(); + // Set mode back to form BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh? + self.input_mode = InputMode::Form; + // Call cb + cb(self, input_text); + } + KeyCode::Char(ch) => self.input_txt.push(ch), + KeyCode::Backspace => { + let _ = self.input_txt.pop(); + } + _ => { /* Nothing to do */ } + } + } + } + + /// ### handle_input_event_mode_popup_yesno + /// + /// Input event handler for popup alert + pub(super) fn handle_input_event_mode_popup_yesno( + &mut self, + ev: &InputEvent, + yes_cb: DialogCallback, + no_cb: DialogCallback, + ) { + // If enter, close popup, otherwise move dialog option + if let InputEvent::Key(key) = ev { + match key.code { + KeyCode::Enter => { + // @! Set input mode to Form BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh? + self.input_mode = InputMode::Form; + // Check if user selected yes or not + match self.choice_opt { + DialogYesNoOption::No => no_cb(self), + DialogYesNoOption::Yes => yes_cb(self), + } + // Reset choice option to yes + self.choice_opt = DialogYesNoOption::Yes; + } + KeyCode::Right => self.choice_opt = DialogYesNoOption::No, // Set to NO + KeyCode::Left => self.choice_opt = DialogYesNoOption::Yes, // Set to YES + _ => { /* Nothing to do */ } + } + } + } +} diff --git a/src/ui/activities/auth_activity/layout.rs b/src/ui/activities/auth_activity/layout.rs new file mode 100644 index 0000000..02be048 --- /dev/null +++ b/src/ui/activities/auth_activity/layout.rs @@ -0,0 +1,541 @@ +//! ## AuthActivity +//! +//! `auth_activity` is the module which implements the authentication activity + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +use super::{ + AuthActivity, Context, DialogYesNoOption, FileTransferProtocol, InputField, InputForm, + InputMode, PopupType, +}; + +use crate::bookmarks::Bookmark; +use crate::utils::align_text_center; + +use tui::{ + layout::{Constraint, Corner, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs}, +}; +use unicode_width::UnicodeWidthStr; + +impl AuthActivity { + /// ### draw + /// + /// Draw UI + pub(super) fn draw(&mut self) { + let mut ctx: Context = self.context.take().unwrap(); + let _ = ctx.terminal.draw(|f| { + // Prepare chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Percentage(70), // Auth Form + Constraint::Percentage(30), // Bookmarks + ] + .as_ref(), + ) + .split(f.size()); + // Create explorer chunks + let auth_chunks = Layout::default() + .constraints( + [ + Constraint::Length(5), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ] + .as_ref(), + ) + .direction(Direction::Vertical) + .split(chunks[0]); + // Create bookmark chunks + let bookmark_chunks = Layout::default() + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .direction(Direction::Horizontal) + .split(chunks[1]); + // Draw header + f.render_widget(self.draw_header(), auth_chunks[0]); + // Draw input fields + f.render_widget(self.draw_remote_address(), auth_chunks[1]); + f.render_widget(self.draw_remote_port(), auth_chunks[2]); + f.render_widget(self.draw_protocol_select(), auth_chunks[3]); + f.render_widget(self.draw_protocol_username(), auth_chunks[4]); + f.render_widget(self.draw_protocol_password(), auth_chunks[5]); + // Draw footer + f.render_widget(self.draw_footer(), auth_chunks[6]); + // Set cursor + if let InputForm::AuthCredentials = self.input_form { + match self.selected_field { + InputField::Address => f.set_cursor( + auth_chunks[1].x + self.address.width() as u16 + 1, + auth_chunks[1].y + 1, + ), + InputField::Port => f.set_cursor( + auth_chunks[2].x + self.port.width() as u16 + 1, + auth_chunks[2].y + 1, + ), + InputField::Username => f.set_cursor( + auth_chunks[4].x + self.username.width() as u16 + 1, + auth_chunks[4].y + 1, + ), + InputField::Password => f.set_cursor( + auth_chunks[5].x + self.password_placeholder.width() as u16 + 1, + auth_chunks[5].y + 1, + ), + _ => {} + } + } + // Draw bookmarks + if let Some(tab) = self.draw_bookmarks_tab() { + let mut bookmarks_state: ListState = ListState::default(); + bookmarks_state.select(Some(self.bookmarks_idx)); + f.render_stateful_widget(tab, bookmark_chunks[0], &mut bookmarks_state); + } + if let Some(tab) = self.draw_recents_tab() { + let mut recents_state: ListState = ListState::default(); + recents_state.select(Some(self.recents_idx)); + f.render_stateful_widget(tab, bookmark_chunks[1], &mut recents_state); + } + // Draw popup + if let InputMode::Popup(popup) = &self.input_mode { + // Calculate popup size + let (width, height): (u16, u16) = match popup { + PopupType::Alert(_, _) => (50, 10), + PopupType::Help => (50, 70), + PopupType::Input(_, _) => (40, 10), + PopupType::YesNo(_, _, _) => (30, 10), + }; + let popup_area: Rect = self.draw_popup_area(f.size(), width, height); + f.render_widget(Clear, popup_area); //this clears out the background + match popup { + PopupType::Alert(color, txt) => f.render_widget( + self.draw_popup_alert(*color, txt.clone(), popup_area.width), + popup_area, + ), + PopupType::Help => f.render_widget(self.draw_popup_help(), popup_area), + PopupType::Input(txt, _) => { + f.render_widget(self.draw_popup_input(txt.clone()), popup_area); + // Set cursor + f.set_cursor( + popup_area.x + self.input_txt.width() as u16 + 1, + popup_area.y + 1, + ) + } + PopupType::YesNo(txt, _, _) => { + f.render_widget(self.draw_popup_yesno(txt.clone()), popup_area) + } + } + } + }); + self.context = Some(ctx); + } + + /// ### draw_remote_address + /// + /// Draw remote address block + fn draw_remote_address(&self) -> Paragraph { + Paragraph::new(self.address.as_ref()) + .style(match self.selected_field { + InputField::Address => Style::default().fg(Color::Yellow), + _ => Style::default(), + }) + .block( + Block::default() + .borders(Borders::ALL) + .title("Remote address"), + ) + } + + /// ### draw_remote_port + /// + /// Draw remote port block + fn draw_remote_port(&self) -> Paragraph { + Paragraph::new(self.port.as_ref()) + .style(match self.selected_field { + InputField::Port => Style::default().fg(Color::Cyan), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("Remote port")) + } + + /// ### draw_protocol_select + /// + /// Draw protocol select + fn draw_protocol_select(&self) -> Tabs { + let protocols: Vec = vec![ + Spans::from("SFTP"), + Spans::from("SCP"), + Spans::from("FTP"), + Spans::from("FTPS"), + ]; + let index: usize = match self.protocol { + FileTransferProtocol::Sftp => 0, + FileTransferProtocol::Scp => 1, + FileTransferProtocol::Ftp(ftps) => match ftps { + false => 2, + true => 3, + }, + }; + Tabs::new(protocols) + .block(Block::default().borders(Borders::ALL).title("Protocol")) + .select(index) + .style(match self.selected_field { + InputField::Protocol => Style::default().fg(Color::Green), + _ => Style::default(), + }) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .bg(Color::Green) + .fg(Color::Black), + ) + } + + /// ### draw_protocol_username + /// + /// Draw username block + fn draw_protocol_username(&self) -> Paragraph { + Paragraph::new(self.username.as_ref()) + .style(match self.selected_field { + InputField::Username => Style::default().fg(Color::Magenta), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("Username")) + } + + /// ### draw_protocol_password + /// + /// Draw password block + fn draw_protocol_password(&mut self) -> Paragraph { + // Create password secret + self.password_placeholder = (0..self.password.width()).map(|_| "*").collect::(); + Paragraph::new(self.password_placeholder.as_ref()) + .style(match self.selected_field { + InputField::Password => Style::default().fg(Color::LightBlue), + _ => Style::default(), + }) + .block(Block::default().borders(Borders::ALL).title("Password")) + } + + /// ### draw_header + /// + /// Draw header + fn draw_header(&self) -> Paragraph { + Paragraph::new(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n") + .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)) + } + + /// ### draw_footer + /// + /// Draw authentication page footer + fn draw_footer(&self) -> Paragraph { + // Write header + let (footer, h_style) = ( + vec![ + Span::raw("Press "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to show keybindings"), + ], + Style::default().add_modifier(Modifier::BOLD), + ); + let mut footer_text = Text::from(Spans::from(footer)); + footer_text.patch_style(h_style); + Paragraph::new(footer_text) + } + + /// ### draw_local_explorer + /// + /// Draw local explorer list + pub(super) fn draw_bookmarks_tab(&self) -> Option { + self.bookmarks.as_ref()?; + let hosts: Vec = self + .bookmarks + .as_ref() + .unwrap() + .bookmarks + .iter() + .map(|(key, entry): (&String, &Bookmark)| { + ListItem::new(Span::from(format!( + "{} ({}://{}@{}:{})", + key, + entry.protocol.to_lowercase(), + entry.username, + entry.address, + entry.port + ))) + }) + .collect(); + // Get colors to use; highlight element inverting fg/bg only when tab is active + let (fg, bg): (Color, Color) = match self.input_form { + InputForm::Bookmarks => (Color::Black, Color::LightGreen), + _ => (Color::Reset, Color::Reset), + }; + Some( + List::new(hosts) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(match self.input_form { + InputForm::Bookmarks => Style::default().fg(Color::LightGreen), + _ => Style::default(), + }) + .title("Bookmarks"), + ) + .start_corner(Corner::TopLeft) + .highlight_style(Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)), + ) + } + + /// ### draw_local_explorer + /// + /// Draw local explorer list + pub(super) fn draw_recents_tab(&self) -> Option { + self.bookmarks.as_ref()?; + let hosts: Vec = self + .bookmarks + .as_ref() + .unwrap() + .recents + .values() + .map(|entry: &Bookmark| { + ListItem::new(Span::from(format!( + "{}://{}@{}:{}", + entry.protocol.to_lowercase(), + entry.username, + entry.address, + entry.port + ))) + }) + .collect(); + // Get colors to use; highlight element inverting fg/bg only when tab is active + let (fg, bg): (Color, Color) = match self.input_form { + InputForm::Recents => (Color::Black, Color::LightBlue), + _ => (Color::Reset, Color::Reset), + }; + Some( + List::new(hosts) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(match self.input_form { + InputForm::Recents => Style::default().fg(Color::LightBlue), + _ => Style::default(), + }) + .title("Recent connections"), + ) + .start_corner(Corner::TopLeft) + .highlight_style(Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)), + ) + } + + /// ### draw_popup_area + /// + /// Draw popup area + fn draw_popup_area(&self, area: Rect, width: u16, height: u16) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - height) / 2), + Constraint::Percentage(height), + Constraint::Percentage((100 - height) / 2), + ] + .as_ref(), + ) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - width) / 2), + Constraint::Percentage(width), + Constraint::Percentage((100 - width) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] + } + + /// ### draw_popup_alert + /// + /// Draw alert popup + fn draw_popup_alert(&self, color: Color, text: String, width: u16) -> List { + // Wraps texts + let message_rows = textwrap::wrap(text.as_str(), width as usize); + let mut lines: Vec = Vec::new(); + for msg in message_rows.iter() { + lines.push(ListItem::new(Spans::from(align_text_center(msg, width)))); + } + List::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(color)) + .title("Alert"), + ) + .start_corner(Corner::TopLeft) + .style(Style::default().fg(color)) + } + + /// ### draw_popup_input + /// + /// Draw input popup + pub(super) fn draw_popup_input(&self, text: String) -> Paragraph { + Paragraph::new(self.input_txt.as_ref()) + .style(Style::default().fg(Color::White)) + .block(Block::default().borders(Borders::ALL).title(text)) + } + + /// ### draw_popup_yesno + /// + /// Draw yes/no select popup + pub(super) fn draw_popup_yesno(&self, text: String) -> Tabs { + let choices: Vec = vec![Spans::from("Yes"), Spans::from("No")]; + let index: usize = match self.choice_opt { + DialogYesNoOption::Yes => 0, + DialogYesNoOption::No => 1, + }; + Tabs::new(choices) + .block(Block::default().borders(Borders::ALL).title(text)) + .select(index) + .style(Style::default()) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Yellow), + ) + } + + /// ### draw_footer + /// + /// Draw authentication page footer + pub(super) fn draw_popup_help(&self) -> List { + // Write header + let cmds: Vec = vec![ + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Quit TermSCP"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Switch input form and bookmarks"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Change bookmark tab"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Move up/down in current tab"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Submit"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Delete bookmark"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Delete selected bookmark"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Show help"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Save bookmark"), + ])), + ]; + List::new(cmds) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default()) + .title("Help"), + ) + .start_corner(Corner::TopLeft) + } +} diff --git a/src/ui/activities/auth_activity/mod.rs b/src/ui/activities/auth_activity/mod.rs new file mode 100644 index 0000000..fe66be7 --- /dev/null +++ b/src/ui/activities/auth_activity/mod.rs @@ -0,0 +1,225 @@ +//! ## AuthActivity +//! +//! `auth_activity` is the module which implements the authentication activity + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +// Sub modules +mod bookmarks; +mod callbacks; +mod input; +mod layout; + +// Dependencies +extern crate crossterm; +extern crate tui; +extern crate unicode_width; + +// locals +use super::{Activity, Context}; +use crate::bookmarks::UserHosts; +use crate::filetransfer::FileTransferProtocol; + +// Includes +use crossterm::event::Event as InputEvent; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use tui::style::Color; + +// Types +type DialogCallback = fn(&mut AuthActivity); +type OnInputSubmitCallback = fn(&mut AuthActivity, String); + +/// ### InputField +/// +/// InputField describes the current input field to edit +#[derive(std::cmp::PartialEq)] +enum InputField { + Address, + Port, + Protocol, + Username, + Password, +} + +/// ### DialogYesNoOption +/// +/// Current yes/no dialog option +#[derive(std::cmp::PartialEq, Clone)] +enum DialogYesNoOption { + Yes, + No, +} + +/// ### PopupType +/// +/// PopupType describes the type of the popup displayed +#[derive(Clone)] +enum PopupType { + Alert(Color, String), // Show a message displaying text with the provided color + Help, // Help page + Input(String, OnInputSubmitCallback), // Input description; Callback for submit + YesNo(String, DialogCallback, DialogCallback), // Yes, no callback +} + +/// ### InputMode +/// +/// InputMode describes the current input mode +/// Each input mode handle the input events in a different way +enum InputMode { + Form, + Popup(PopupType), +} + +#[derive(std::cmp::PartialEq)] +/// ### InputForm +/// +/// InputForm describes the selected input form +enum InputForm { + AuthCredentials, + Bookmarks, + Recents, +} + +/// ### AuthActivity +/// +/// AuthActivity is the data holder for the authentication activity +pub struct AuthActivity { + pub address: String, + pub port: String, + pub protocol: FileTransferProtocol, + pub username: String, + pub password: String, + pub submit: bool, // becomes true after user has submitted fields + pub quit: bool, // Becomes true if user has pressed esc + context: Option, + bookmarks: Option, + selected_field: InputField, // Selected field in AuthCredentials Form + input_mode: InputMode, + input_form: InputForm, + password_placeholder: String, + redraw: bool, // Should ui actually be redrawned? + input_txt: String, // Input text + choice_opt: DialogYesNoOption, // Dialog popup selected option + bookmarks_idx: usize, // Index of selected bookmark + recents_idx: usize, // Index of selected recent +} + +impl Default for AuthActivity { + fn default() -> Self { + Self::new() + } +} + +impl AuthActivity { + /// ### new + /// + /// Instantiates a new AuthActivity + pub fn new() -> AuthActivity { + AuthActivity { + address: String::new(), + port: String::from("22"), + protocol: FileTransferProtocol::Sftp, + username: String::new(), + password: String::new(), + submit: false, + quit: false, + context: None, + bookmarks: None, + selected_field: InputField::Address, + input_mode: InputMode::Form, + input_form: InputForm::AuthCredentials, + password_placeholder: String::new(), + redraw: true, // True at startup + input_txt: String::new(), + choice_opt: DialogYesNoOption::Yes, + bookmarks_idx: 0, + recents_idx: 0, + } + } +} + +impl Activity for AuthActivity { + /// ### on_create + /// + /// `on_create` is the function which must be called to initialize the activity. + /// `on_create` must initialize all the data structures used by the activity + /// Context is taken from activity manager and will be released only when activity is destroyed + fn on_create(&mut self, context: Context) { + // Set context + self.context = Some(context); + // Clear terminal + let _ = self.context.as_mut().unwrap().terminal.clear(); + // Put raw mode on enabled + let _ = enable_raw_mode(); + self.input_mode = InputMode::Form; + // Read bookmarks + if self.bookmarks.is_none() { + self.read_bookmarks(); + } + } + + /// ### on_draw + /// + /// `on_draw` is the function which draws the graphical interface. + /// This function must be called at each tick to refresh the interface + fn on_draw(&mut self) { + // Context must be something + if self.context.is_none() { + return; + } + // Read one event + if let Ok(event) = self.context.as_ref().unwrap().input_hnd.read_event() { + if let Some(event) = event { + // Set redraw to true + self.redraw = true; + // Handle event + self.handle_input_event(&event); + } + } + // Redraw if necessary + if self.redraw { + // Draw + self.draw(); + // Set redraw to false + self.redraw = false; + } + } + + /// ### on_destroy + /// + /// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity. + /// This function must be called once before terminating the activity. + /// This function finally releases the context + fn on_destroy(&mut self) -> Option { + // Disable raw mode + let _ = disable_raw_mode(); + self.context.as_ref()?; + // Clear terminal and return + match self.context.take() { + Some(mut ctx) => { + let _ = ctx.terminal.clear(); + Some(ctx) + } + None => None, + } + } +} diff --git a/src/ui/activities/filetransfer_activity/callbacks.rs b/src/ui/activities/filetransfer_activity/callbacks.rs index 0fd3d98..cecfaf2 100644 --- a/src/ui/activities/filetransfer_activity/callbacks.rs +++ b/src/ui/activities/filetransfer_activity/callbacks.rs @@ -327,20 +327,20 @@ impl FileTransferActivity { return; } }; - let files: Vec = self.local.files.clone(); - // Get file at index - if let Some(entry) = files.get(self.local.index) { - // Call send (upload) - self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), Some(input)); + // Get file and clone (due to mutable / immutable stuff...) + if self.local.files.get(self.local.index).is_some() { + let file: FsEntry = self.local.files.get(self.local.index).unwrap().clone(); + // Call upload; pass realfile, keep link name + self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(input)); } } FileExplorerTab::Remote => { - let files: Vec = self.remote.files.clone(); - // Get file at index - if let Some(entry) = files.get(self.remote.index) { - // Call receive (download) + // Get file and clone (due to mutable / immutable stuff...) + if self.remote.files.get(self.remote.index).is_some() { + let file: FsEntry = self.remote.files.get(self.remote.index).unwrap().clone(); + // Call upload; pass realfile, keep link name self.filetransfer_recv( - &entry.get_realfile(), + &file.get_realfile(), self.context.as_ref().unwrap().local.pwd().as_path(), Some(input), ); diff --git a/src/ui/activities/filetransfer_activity/input.rs b/src/ui/activities/filetransfer_activity/input.rs index bccdeee..1d594d5 100644 --- a/src/ui/activities/filetransfer_activity/input.rs +++ b/src/ui/activities/filetransfer_activity/input.rs @@ -219,6 +219,11 @@ impl FileTransferActivity { // Show file info self.input_mode = InputMode::Popup(PopupType::FileInfo); } + 'l' | 'L' => { + // Reload file entries + let pwd: PathBuf = self.context.as_ref().unwrap().local.pwd(); + self.local_scan(pwd.as_path()); + } 'r' | 'R' => { // Rename self.input_mode = InputMode::Popup(PopupType::Input( @@ -258,14 +263,14 @@ impl FileTransferActivity { return; } }; - // Get files - let files: Vec = self.local.files.clone(); // Otherwise self is borrowed both as mutable and immutable... - // Get file at index - if let Some(entry) = files.get(self.local.index) { - let name: String = entry.get_name(); + // Get file and clone (due to mutable / immutable stuff...) + if self.local.files.get(self.local.index).is_some() { + let file: FsEntry = + self.local.files.get(self.local.index).unwrap().clone(); + let name: String = file.get_name(); // Call upload; pass realfile, keep link name self.filetransfer_send( - &entry.get_realfile(), + &file.get_realfile(), wrkdir.as_path(), Some(name), ); @@ -368,10 +373,6 @@ impl FileTransferActivity { } } KeyCode::Char(ch) => match ch { - 'q' | 'Q' => { - // Create quit prompt dialog - self.input_mode = self.create_quit_popup(); - } 'e' | 'E' => { // Get file at index if let Some(entry) = self.remote.files.get(self.remote.index) { @@ -388,6 +389,13 @@ impl FileTransferActivity { )) } } + 'd' | 'D' => { + // Make directory + self.input_mode = InputMode::Popup(PopupType::Input( + String::from("Insert directory name"), + FileTransferActivity::callback_mkdir, + )); + } 'g' | 'G' => { // Goto // Show input popup @@ -396,13 +404,6 @@ impl FileTransferActivity { FileTransferActivity::callback_change_directory, )); } - 'd' | 'D' => { - // Make directory - self.input_mode = InputMode::Popup(PopupType::Input( - String::from("Insert directory name"), - FileTransferActivity::callback_mkdir, - )); - } 'h' | 'H' => { // Show help self.input_mode = InputMode::Popup(PopupType::Help); @@ -411,6 +412,14 @@ impl FileTransferActivity { // Show file info self.input_mode = InputMode::Popup(PopupType::FileInfo); } + 'l' | 'L' => { + // Reload file entries + self.reload_remote_dir(); + } + 'q' | 'Q' => { + // Create quit prompt dialog + self.input_mode = self.create_quit_popup(); + } 'r' | 'R' => { // Rename self.input_mode = InputMode::Popup(PopupType::Input( @@ -444,15 +453,14 @@ impl FileTransferActivity { } } ' ' => { - // Get files - let files: Vec = self.remote.files.clone(); // Otherwise self is borrowed both as mutable and immutable... - // Get file at index - if let Some(entry) = files.get(self.remote.index) { - // Preserve name - let name: String = entry.get_name(); - // Call upload (use entry realfile; pass previous name) + // Get file and clone (due to mutable / immutable stuff...) + if self.remote.files.get(self.remote.index).is_some() { + let file: FsEntry = + self.remote.files.get(self.remote.index).unwrap().clone(); + let name: String = file.get_name(); + // Call upload; pass realfile, keep link name self.filetransfer_recv( - &entry.get_realfile(), + &file.get_realfile(), self.context.as_ref().unwrap().local.pwd().as_path(), Some(name), ); @@ -637,7 +645,7 @@ impl FileTransferActivity { } } - /// ### handle_input_event_mode_explorer_alert + /// ### handle_input_event_mode_popup_progress /// /// Input event handler for popup alert pub(super) fn handle_input_event_mode_popup_progress(&mut self, ev: &InputEvent) { @@ -652,14 +660,14 @@ impl FileTransferActivity { } } - /// ### handle_input_event_mode_explorer_alert + /// ### handle_input_event_mode_popup_wait /// /// Input event handler for popup alert pub(super) fn handle_input_event_mode_popup_wait(&mut self, _ev: &InputEvent) { // There's nothing you can do here I guess... maybe ctrl+c in the future idk } - /// ### handle_input_event_mode_explorer_alert + /// ### handle_input_event_mode_popup_yesno /// /// Input event handler for popup alert pub(super) fn handle_input_event_mode_popup_yesno( diff --git a/src/ui/activities/filetransfer_activity/layout.rs b/src/ui/activities/filetransfer_activity/layout.rs index 52ca901..637750d 100644 --- a/src/ui/activities/filetransfer_activity/layout.rs +++ b/src/ui/activities/filetransfer_activity/layout.rs @@ -28,7 +28,7 @@ use super::{ Context, DialogYesNoOption, FileExplorerTab, FileTransferActivity, FsEntry, InputField, InputMode, LogLevel, LogRecord, PopupType, }; -use crate::utils::time_to_str; +use crate::utils::{align_text_center, time_to_str}; use bytesize::ByteSize; use std::path::{Path, PathBuf}; @@ -56,8 +56,8 @@ impl FileTransferActivity { .margin(1) .constraints( [ - Constraint::Length(20), // Explorer - Constraint::Length(16), // Log + Constraint::Percentage(70), // Explorer + Constraint::Percentage(30), // Log ] .as_ref(), ) @@ -166,13 +166,18 @@ impl FileTransferActivity { .iter() .map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry)))) .collect(); + // Get colors to use; highlight element inverting fg/bg only when tab is active + let (fg, bg): (Color, Color) = match self.tab { + FileExplorerTab::Local => (Color::Black, Color::LightYellow), + _ => (Color::LightYellow, Color::Reset), + }; List::new(files) .block( Block::default() .borders(Borders::ALL) .border_style(match self.input_field { InputField::Explorer => match self.tab { - FileExplorerTab::Local => Style::default().fg(Color::Yellow), + FileExplorerTab::Local => Style::default().fg(Color::LightYellow), _ => Style::default(), }, _ => Style::default(), @@ -189,12 +194,7 @@ impl FileTransferActivity { )), ) .start_corner(Corner::TopLeft) - .highlight_style( - Style::default() - .fg(Color::Black) - .bg(Color::LightYellow) - .add_modifier(Modifier::BOLD), - ) + .highlight_style(Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)) } /// ### draw_remote_explorer @@ -207,6 +207,11 @@ impl FileTransferActivity { .iter() .map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry)))) .collect(); + // Get colors to use; highlight element inverting fg/bg only when tab is active + let (fg, bg): (Color, Color) = match self.tab { + FileExplorerTab::Remote => (Color::Black, Color::LightBlue), + _ => (Color::LightBlue, Color::Reset), + }; List::new(files) .block( Block::default() @@ -230,12 +235,7 @@ impl FileTransferActivity { )), ) .start_corner(Corner::TopLeft) - .highlight_style( - Style::default() - .bg(Color::LightBlue) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ) + .highlight_style(Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD)) } /// ### draw_log_list @@ -334,9 +334,7 @@ impl FileTransferActivity { let message_rows = textwrap::wrap(text.as_str(), width as usize); let mut lines: Vec = Vec::new(); for msg in message_rows.iter() { - lines.push(ListItem::new(Spans::from( - FileTransferActivity::align_text_center(msg, width), - ))); + lines.push(ListItem::new(Spans::from(align_text_center(msg, width)))); } List::new(lines) .block( @@ -357,9 +355,7 @@ impl FileTransferActivity { let message_rows = textwrap::wrap(text.as_str(), width as usize); let mut lines: Vec = Vec::new(); for msg in message_rows.iter() { - lines.push(ListItem::new(Spans::from( - FileTransferActivity::align_text_center(msg, width), - ))); + lines.push(ListItem::new(Spans::from(align_text_center(msg, width)))); } List::new(lines) .block( @@ -415,9 +411,7 @@ impl FileTransferActivity { let message_rows = textwrap::wrap(text.as_str(), width as usize); let mut lines: Vec = Vec::new(); for msg in message_rows.iter() { - lines.push(ListItem::new(Spans::from( - FileTransferActivity::align_text_center(msg, width), - ))); + lines.push(ListItem::new(Spans::from(align_text_center(msg, width)))); } List::new(lines) .block( @@ -624,7 +618,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("disconnect"), + Span::raw("Disconnect"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -654,7 +648,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("change explorer tab"), + Span::raw("Change explorer tab"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -664,7 +658,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("move up/down in list"), + Span::raw("Move up/down in list"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -674,7 +668,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("scroll up/down in list quickly"), + Span::raw("Scroll up/down in list quickly"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -684,7 +678,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("enter directory"), + Span::raw("Enter directory"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -694,7 +688,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("upload/download file"), + Span::raw("Upload/download file"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -704,7 +698,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("delete file"), + Span::raw("Delete file"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -714,7 +708,17 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("make directory"), + Span::raw("Make directory"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Same as "), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -724,7 +728,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("goto path"), + Span::raw("Goto path"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -734,7 +738,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("show help"), + Span::raw("Show help"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -744,7 +748,17 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("show info about the selected file or directory"), + Span::raw("Show info about the selected file or directory"), + ])), + ListItem::new(Spans::from(vec![ + Span::styled( + "", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw("Reload directory content"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -764,7 +778,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("rename file"), + Span::raw("Rename file"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -774,7 +788,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("go to parent directory"), + Span::raw("Go to parent directory"), ])), ListItem::new(Spans::from(vec![ Span::styled( @@ -784,7 +798,7 @@ impl FileTransferActivity { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::raw("abort current file transfer"), + Span::raw("Abort current file transfer"), ])), ]; List::new(cmds) @@ -797,21 +811,6 @@ impl FileTransferActivity { .start_corner(Corner::TopLeft) } - /// align_text_center - /// - /// Align text to center for a given width - fn align_text_center(text: &str, width: u16) -> String { - let indent_size: usize = match (width as usize) >= text.len() { - // NOTE: The check prevents underflow - true => (width as usize - text.len()) / 2, - false => 0, - }; - textwrap::indent( - text, - (0..indent_size).map(|_| " ").collect::().as_str(), - ) - } - /// ### elide_wrkdir_path /// /// Elide working directory path if longer than width + host.len diff --git a/src/ui/activities/filetransfer_activity/session.rs b/src/ui/activities/filetransfer_activity/session.rs index 7f13768..aaf89ef 100644 --- a/src/ui/activities/filetransfer_activity/session.rs +++ b/src/ui/activities/filetransfer_activity/session.rs @@ -451,7 +451,7 @@ impl FileTransferActivity { last_input_event_fetch = Instant::now(); } // Read till you can - let mut buffer: [u8; 8192] = [0; 8192]; + let mut buffer: [u8; 65536] = [0; 65536]; match rhnd.read(&mut buffer) { Ok(bytes_read) => { total_bytes_written += bytes_read; diff --git a/src/ui/input.rs b/src/ui/input.rs index 9e5b5cc..1aed160 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -45,6 +45,7 @@ impl InputHandler { /// ### fetch_events /// /// Check if new events have been received from handler + #[allow(dead_code)] pub(crate) fn fetch_events(&self) -> Result, ()> { let mut inbox: Vec = Vec::new(); loop { diff --git a/src/utils.rs b/src/utils.rs index 24e6c6b..ead6324 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -25,6 +25,7 @@ // Dependencies extern crate chrono; +extern crate textwrap; extern crate whoami; use crate::filetransfer::FileTransferProtocol; @@ -241,6 +242,23 @@ pub fn lstime_to_systime( .unwrap_or(SystemTime::UNIX_EPOCH)) } +/// align_text_center +/// +/// Align text to center for a given width +pub fn align_text_center(text: &str, width: u16) -> String { + let indent_size: usize = match (width as usize) >= text.len() { + // NOTE: The check prevents underflow + true => (width as usize - text.len()) / 2, + false => 0, + }; + textwrap::indent( + text, + (0..indent_size).map(|_| " ").collect::().as_str(), + ) + .trim_end() + .to_string() +} + #[cfg(test)] mod tests { @@ -392,4 +410,12 @@ mod tests { assert!(lstime_to_systime("Feb 31 2018", "%b %d %Y", "%b %d %H:%M").is_err()); assert!(lstime_to_systime("Feb 15 25:32", "%b %d %Y", "%b %d %H:%M").is_err()); } + + #[test] + fn test_utils_align_text_center() { + assert_eq!( + align_text_center("hello world!", 24), + String::from(" hello world!") + ); + } }