From 8e1843f90b1eeedc363412e8536eb5c621689de5 Mon Sep 17 00:00:00 2001 From: veeso Date: Wed, 2 Jun 2021 22:32:52 +0200 Subject: [PATCH 01/53] Working on 0.6.0 --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- dist/pkgs/arch/.SRCINFO | 4 ++-- dist/pkgs/arch/PKGBUILD | 2 +- install.sh | 2 +- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80f0fb..42dcda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog - [Changelog](#changelog) + - [0.6.0](#060) - [0.5.0](#050) - [0.4.2](#042) - [0.4.1](#041) @@ -17,6 +18,12 @@ --- +## 0.6.0 + +Released on FIXME: ?? + +> 🏄 Summer update 2021🌴 + ## 0.5.0 Released on 23/05/2021 diff --git a/Cargo.lock b/Cargo.lock index b351f1f..b1f44f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1313,7 +1313,7 @@ dependencies = [ [[package]] name = "termscp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags", "bytesize", diff --git a/Cargo.toml b/Cargo.toml index 3c6ee2f..ae8fc27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT" name = "termscp" readme = "README.md" repository = "https://github.com/veeso/termscp" -version = "0.5.0" +version = "0.6.0" [package.metadata.rpm] package = "termscp" diff --git a/README.md b/README.md index 8c9725d..d51afeb 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@

Developed by @veeso

-

Current version: 0.5.0 (23/05/2021)

+

Current version: 0.6.0 FIXME: (23/05/2021)

-[![License: MIT](https://img.shields.io/badge/License-MIT-teal.svg)](https://opensource.org/licenses/MIT) [![Stars](https://img.shields.io/github/stars/veeso/termscp.svg)](https://github.com/veeso/termscp) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.5.0-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp) +[![License: MIT](https://img.shields.io/badge/License-MIT-teal.svg)](https://opensource.org/licenses/MIT) [![Stars](https://img.shields.io/github/stars/veeso/termscp.svg)](https://github.com/veeso/termscp) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.6.0-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp) [![Build](https://github.com/veeso/termscp/workflows/Linux/badge.svg)](https://github.com/veeso/termscp/actions) [![Build](https://github.com/veeso/termscp/workflows/MacOS/badge.svg)](https://github.com/veeso/termscp/actions) [![Build](https://github.com/veeso/termscp/workflows/Windows/badge.svg)](https://github.com/veeso/termscp/actions) [![Coverage Status](https://coveralls.io/repos/github/veeso/termscp/badge.svg)](https://coveralls.io/github/veeso/termscp) diff --git a/dist/pkgs/arch/.SRCINFO b/dist/pkgs/arch/.SRCINFO index 6f17789..fbbb60d 100644 --- a/dist/pkgs/arch/.SRCINFO +++ b/dist/pkgs/arch/.SRCINFO @@ -1,13 +1,13 @@ pkgbase = termscp pkgdesc = termscp is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal. - pkgver = 0.5.0 + pkgver = 0.6.0 pkgrel = 1 url = https://github.com/veeso/termscp arch = x86_64 license = MIT provides = termscp options = strip - source = https://github.com/veeso/termscp/releases/download/v0.5.0/termscp-0.5.0-x86_64.tar.gz + source = https://github.com/veeso/termscp/releases/download/v0.6.0/termscp-0.6.0-x86_64.tar.gz sha256sums = 279b4cab7da68c6db0efc054ddf72e36de85910110721b66d5cdc55833c99ccf pkgname = termscp diff --git a/dist/pkgs/arch/PKGBUILD b/dist/pkgs/arch/PKGBUILD index a928561..a48876d 100644 --- a/dist/pkgs/arch/PKGBUILD +++ b/dist/pkgs/arch/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Christian Visintin pkgname=termscp -pkgver=0.5.0 +pkgver=0.6.0 pkgrel=1 pkgdesc="termscp is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal." url="https://github.com/veeso/termscp" diff --git a/install.sh b/install.sh index 62e434a..3144d1b 100755 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ # -f, -y, --force, --yes # Skip the confirmation prompt during installation -TERMSCP_VERSION="0.5.0" +TERMSCP_VERSION="0.6.0" GITHUB_URL="https://github.com/veeso/termscp/releases/download/v${TERMSCP_VERSION}" DEB_URL="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.deb" RPM_URL="${GITHUB_URL}/termscp-${TERMSCP_VERSION}-1.x86_64.rpm" From 3a32f4533406f27b9ead620948bf79255caa16f2 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 6 Jun 2021 14:19:20 +0200 Subject: [PATCH 02/53] tui-realm 0.3.2 --- CHANGELOG.md | 5 +++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/ui/activities/auth/mod.rs | 2 +- src/ui/activities/auth/update.rs | 8 +++++--- src/ui/activities/filetransfer/misc.rs | 1 + src/ui/activities/filetransfer/update.rs | 8 +++++--- src/ui/activities/setup/mod.rs | 2 +- src/ui/activities/setup/update.rs | 6 +++--- 9 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42dcda1..c23d48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ Released on FIXME: ?? > 🏄 Summer update 2021🌴 +- Bugfix: + - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) +- Dependencies: + - Updated `tui-realm` to `0.3.2` + ## 0.5.0 Released on 23/05/2021 diff --git a/Cargo.lock b/Cargo.lock index b1f44f7..83f1860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,9 +1439,9 @@ dependencies = [ [[package]] name = "tuirealm" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad880656efa943543c8048a28e1fa1d0ea6b5c4bf7f53636492ef8a49ec681f" +checksum = "a7c0026d8e0c649dbd9416466777a584d06ac569283683c396f08f8a69832559" dependencies = [ "crossterm", "textwrap", diff --git a/Cargo.toml b/Cargo.toml index ae8fc27..f2ed53f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ tempfile = "3.1.0" textwrap = "0.13.4" thiserror = "^1.0.0" toml = "0.5.8" -tuirealm = { version = "0.3.0", features = [ "with-components" ] } +tuirealm = { version = "0.3.2", features = [ "with-components" ] } whoami = "1.1.1" wildmatch = "2.0.0" diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index 0cd7653..cf400af 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -44,7 +44,7 @@ use crate::utils::git; // Includes use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; -use tuirealm::View; +use tuirealm::{Update, View}; // -- components const COMPONENT_TEXT_H1: &str = "TEXT_H1"; diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index ae9a153..dd8c1f2 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -36,16 +36,16 @@ use super::{ }; use crate::ui::keymap::*; use tuirealm::components::InputPropsBuilder; -use tuirealm::{Msg, Payload, PropsBuilder, Value}; +use tuirealm::{Msg, Payload, PropsBuilder, Update, Value}; // -- update -impl AuthActivity { +impl Update for AuthActivity { /// ### update /// /// Update auth activity model based on msg /// The function exits when returns None - pub(super) fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { + fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg)); // Match msg match ref_msg { @@ -339,7 +339,9 @@ impl AuthActivity { }, } } +} +impl AuthActivity { fn update_input_port(&mut self, port: u16) -> Option<(String, Msg)> { match self.view.get_props(COMPONENT_INPUT_PORT) { None => None, diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index b13e47e..ca14fad 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -28,6 +28,7 @@ use crate::system::sshkey_storage::SshKeyStorage; // Ext use std::env; use std::path::{Path, PathBuf}; +use tuirealm::Update; const LOG_CAPACITY: usize = 256; diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index ecb9292..7f63f2d 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -48,17 +48,17 @@ use tuirealm::{ components::progress_bar::ProgressBarPropsBuilder, props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder}, tui::style::Color, - Msg, Payload, Value, + Msg, Payload, Update, Value, }; -impl FileTransferActivity { +impl Update for FileTransferActivity { // -- update /// ### update /// /// Update auth activity model based on msg /// The function exits when returns None - pub(super) fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { + fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg)); // Match msg match ref_msg { @@ -652,7 +652,9 @@ impl FileTransferActivity { }, } } +} +impl FileTransferActivity { /// ### update_local_filelist /// /// Update local file list diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index 15966fa..1040914 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -40,7 +40,7 @@ extern crate tuirealm; use super::{Activity, Context, ExitReason}; // Ext use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; -use tuirealm::View; +use tuirealm::{Update, View}; // -- components const COMPONENT_TEXT_HELP: &str = "TEXT_HELP"; diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index 053d7b5..ce4b4e2 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -37,14 +37,14 @@ use super::{ use crate::ui::keymap::*; // ext -use tuirealm::{Msg, Payload, Value}; +use tuirealm::{Msg, Payload, Update, Value}; -impl SetupActivity { +impl Update for SetupActivity { /// ### update /// /// Update auth activity model based on msg /// The function exits when returns None - pub(super) fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { + fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg)); // Match msg match ref_msg { From 3c3c680b00be6f1143b8eb16e73e24be61c474b3 Mon Sep 17 00:00:00 2001 From: veeso Date: Mon, 7 Jun 2021 22:09:33 +0200 Subject: [PATCH 03/53] tui-realm 0.4.0 --- CHANGELOG.md | 3 +- Cargo.lock | 86 +++++++++++++++++++++++++++++----------------------- Cargo.toml | 4 +-- 3 files changed, 52 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c23d48c..0d28ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ Released on FIXME: ?? - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: - - Updated `tui-realm` to `0.3.2` + - Updated `textwrap` to `0.14.0` + - Updated `tui-realm` to `0.4.0` ## 0.5.0 diff --git a/Cargo.lock b/Cargo.lock index 83f1860..8b112b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,9 +95,9 @@ checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" [[package]] name = "bumpalo" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" +checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" [[package]] name = "byteorder" @@ -119,9 +119,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.0.67" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" +checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" [[package]] name = "cfg-if" @@ -215,18 +215,18 @@ checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" [[package]] name = "cpufeatures" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "281f563b2c3a0e535ab12d81d3c5859045795256ad269afa7c19542585b68f93" +checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" dependencies = [ "libc", ] [[package]] name = "crc-any" -version = "2.3.9" +version = "2.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d98be01088633be44a2a82b55a96dca49b226d65297428a3c44d33de07528ff" +checksum = "b9950e91c5c444b0729f5f1b0aec76c523e01920ce828e37bccfa27803ff34e1" dependencies = [ "debug-helper", ] @@ -422,9 +422,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if 1.0.0", "libc", @@ -517,9 +517,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e" +checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" [[package]] name = "libssh2-sys" @@ -655,8 +655,8 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.2.0", - "security-framework-sys 2.2.0", + "security-framework 2.3.1", + "security-framework-sys 2.3.0", "tempfile", ] @@ -886,9 +886,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" dependencies = [ "unicode-xid", ] @@ -962,7 +962,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" dependencies = [ - "getrandom 0.2.2", + "getrandom 0.2.3", ] [[package]] @@ -1004,7 +1004,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" dependencies = [ - "getrandom 0.2.2", + "getrandom 0.2.3", "redox_syscall 0.2.8", ] @@ -1135,15 +1135,15 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3670b1d2fdf6084d192bc71ead7aabe6c06aa2ea3fbd9cc3ac111fa5c2b1bd84" +checksum = "23a2ac85147a3a11d77ecf1bc7166ec0b92febfa4461c37944e180f319ece467" dependencies = [ "bitflags", "core-foundation 0.9.1", "core-foundation-sys 0.8.2", "libc", - "security-framework-sys 2.2.0", + "security-framework-sys 2.3.0", ] [[package]] @@ -1158,9 +1158,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3676258fd3cfe2c9a0ec99ce3038798d847ce3e4bb17746373eb9f0f1ac16339" +checksum = "7e4effb91b4b8b6fb7732e670b6cee160278ff8e6bf485c7805d9e319d76e284" dependencies = [ "core-foundation-sys 0.8.2", "libc", @@ -1223,9 +1223,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1d0fef1604ba8f7a073c7e701f213e056707210e9020af4528e0101ce11a6" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" dependencies = [ "libc", ] @@ -1350,28 +1350,29 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.13.4" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835" +checksum = "f59f5365546b8424b0cc48868ae4fbbbc29a538dcc496b53543525201034f0c2" dependencies = [ "smawk", + "unicode-linebreak", "unicode-width", ] [[package]] name = "thiserror" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e" +checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0" +checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" dependencies = [ "proc-macro2", "quote", @@ -1439,9 +1440,9 @@ dependencies = [ [[package]] name = "tuirealm" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c0026d8e0c649dbd9416466777a584d06ac569283683c396f08f8a69832559" +checksum = "684b01c57e5bccf6e48f3ace3433a07e6267fb9c59fedd3074567d3e417b5568" dependencies = [ "crossterm", "textwrap", @@ -1465,10 +1466,19 @@ dependencies = [ ] [[package]] -name = "unicode-normalization" -version = "0.1.17" +name = "unicode-linebreak" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" +checksum = "05a31f45d18a3213b918019f78fe6a73a14ab896807f0aaf5622aa0684749455" +dependencies = [ + "regex", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" dependencies = [ "tinyvec", ] @@ -1539,9 +1549,9 @@ dependencies = [ [[package]] name = "vcpkg" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d" +checksum = "025ce40a007e1907e58d5bc1a594def78e5573bb0b1160bc389634e8f12e4faa" [[package]] name = "version_check" diff --git a/Cargo.toml b/Cargo.toml index f2ed53f..275b633 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,10 +45,10 @@ rpassword = "5.0.1" simplelog = "0.10.0" ssh2 = "0.9.0" tempfile = "3.1.0" -textwrap = "0.13.4" +textwrap = "0.14.0" thiserror = "^1.0.0" toml = "0.5.8" -tuirealm = { version = "0.3.2", features = [ "with-components" ] } +tuirealm = { version = "0.4.0", features = [ "with-components" ] } whoami = "1.1.1" wildmatch = "2.0.0" From c4907c8ce5fc478e5a54820270be6227f5caaef0 Mon Sep 17 00:00:00 2001 From: veeso Date: Mon, 7 Jun 2021 22:28:52 +0200 Subject: [PATCH 04/53] Added open-rs to deps --- CHANGELOG.md | 1 + Cargo.lock | 11 +++++++++++ Cargo.toml | 1 + 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d28ab0..57b10e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Released on FIXME: ?? - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: + - Added `open 1.7.0` - Updated `textwrap` to `0.14.0` - Updated `tui-realm` to `0.4.0` diff --git a/Cargo.lock b/Cargo.lock index 8b112b0..f4f3ea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,6 +757,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "open" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1711eb4b31ce4ad35b0f316d8dfba4fe5c7ad601c448446d84aae7a896627b20" +dependencies = [ + "which", + "winapi", +] + [[package]] name = "openssl" version = "0.10.34" @@ -1329,6 +1339,7 @@ dependencies = [ "lazy_static", "log", "magic-crypt", + "open", "path-slash", "pretty_assertions", "rand 0.8.3", diff --git a/Cargo.toml b/Cargo.toml index 275b633..dc46e83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ hostname = "0.3.1" lazy_static = "1.4.0" log = "0.4.14" magic-crypt = "3.1.7" +open = "1.7.0" rand = "0.8.3" regex = "1.5.4" rpassword = "5.0.1" From a8354ee38fa50302faf5941114213133365b0ba5 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 10 Jun 2021 11:08:17 +0200 Subject: [PATCH 05/53] Open file on --- src/ui/activities/filetransfer/actions/mod.rs | 2 + .../activities/filetransfer/actions/open.rs | 102 ++++++++++++++++++ .../activities/filetransfer/actions/submit.rs | 94 ++++++++++++++++ src/ui/activities/filetransfer/mod.rs | 12 +++ src/ui/activities/filetransfer/session.rs | 53 ++++++--- src/ui/activities/filetransfer/update.rs | 4 +- src/ui/activities/filetransfer/view.rs | 6 +- 7 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 src/ui/activities/filetransfer/actions/open.rs create mode 100644 src/ui/activities/filetransfer/actions/submit.rs diff --git a/src/ui/activities/filetransfer/actions/mod.rs b/src/ui/activities/filetransfer/actions/mod.rs index 2e13003..e8091a4 100644 --- a/src/ui/activities/filetransfer/actions/mod.rs +++ b/src/ui/activities/filetransfer/actions/mod.rs @@ -37,8 +37,10 @@ pub(crate) mod exec; pub(crate) mod find; pub(crate) mod mkdir; pub(crate) mod newfile; +pub(crate) mod open; pub(crate) mod rename; pub(crate) mod save; +pub(crate) mod submit; pub(crate) enum SelectedEntry { One(FsEntry), diff --git a/src/ui/activities/filetransfer/actions/open.rs b/src/ui/activities/filetransfer/actions/open.rs new file mode 100644 index 0000000..b697ef0 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/open.rs @@ -0,0 +1,102 @@ +//! ## FileTransferActivity +//! +//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// deps +extern crate open; +// locals +use super::{FileTransferActivity, FsEntry, LogLevel}; + +impl FileTransferActivity { + /// ### action_open_local + /// + /// Open local file + pub(crate) fn action_open_local(&mut self, entry: FsEntry, open_with: Option) { + let real_entry: FsEntry = entry.get_realfile(); + if let FsEntry::File(file) = real_entry { + // Open file + let result = match open_with { + None => open::that(file.abs_path.as_path()), + Some(with) => open::with(file.abs_path.as_path(), with), + }; + // Log result + match result { + Ok(_) => self.log( + LogLevel::Info, + format!("Opened file `{}`", entry.get_abs_path().display(),), + ), + Err(err) => self.log( + LogLevel::Error, + format!( + "Failed to open filoe `{}`: {}", + entry.get_abs_path().display(), + err + ), + ), + } + } + } + + /// ### action_open_local + /// + /// Open remote file. The file is first downloaded to a temporary directory on localhost + pub(crate) fn action_open_remote(&mut self, entry: FsEntry, open_with: Option) { + let real_entry: FsEntry = entry.get_realfile(); + if let FsEntry::File(file) = real_entry { + // Download file + let tmp = match self.download_file_as_temp(&file) { + Ok(f) => f, + Err(err) => { + self.log( + LogLevel::Error, + format!("Could not open `{}`: {}", file.abs_path.display(), err), + ); + return; + } + }; + // Open file + let result = match open_with { + None => open::that(tmp.as_path()), + Some(with) => open::with(tmp.as_path(), with), + }; + // Log result + match result { + Ok(_) => self.log( + LogLevel::Info, + format!("Opened file `{}`", entry.get_abs_path().display()), + ), + Err(err) => self.log( + LogLevel::Error, + format!( + "Failed to open filoe `{}`: {}", + entry.get_abs_path().display(), + err + ), + ), + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/submit.rs b/src/ui/activities/filetransfer/actions/submit.rs new file mode 100644 index 0000000..a814942 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/submit.rs @@ -0,0 +1,94 @@ +//! ## FileTransferActivity +//! +//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// locals +use super::{FileTransferActivity, FsEntry}; + +enum SubmitAction { + ChangeDir, + OpenFile, +} + +impl FileTransferActivity { + /// ### action_submit_local + /// + /// Decides which action to perform on submit for local explorer + /// Return true whether the directory changed + pub(crate) fn action_submit_local(&mut self, entry: FsEntry) -> bool { + let action: SubmitAction = match &entry { + FsEntry::Directory(_) => SubmitAction::ChangeDir, + FsEntry::File(file) => { + match &file.symlink { + Some(symlink_entry) => { + // If symlink and is directory, point to symlink + match &**symlink_entry { + FsEntry::Directory(_) => SubmitAction::ChangeDir, + _ => SubmitAction::OpenFile, + } + } + None => SubmitAction::OpenFile, + } + } + }; + match action { + SubmitAction::ChangeDir => self.action_enter_local_dir(entry, false), + SubmitAction::OpenFile => { + self.action_open_local(entry, None); + false + } + } + } + + /// ### action_submit_remote + /// + /// Decides which action to perform on submit for remote explorer + /// Return true whether the directory changed + pub(crate) fn action_submit_remote(&mut self, entry: FsEntry) -> bool { + let action: SubmitAction = match &entry { + FsEntry::Directory(_) => SubmitAction::ChangeDir, + FsEntry::File(file) => { + match &file.symlink { + Some(symlink_entry) => { + // If symlink and is directory, point to symlink + match &**symlink_entry { + FsEntry::Directory(_) => SubmitAction::ChangeDir, + _ => SubmitAction::OpenFile, + } + } + None => SubmitAction::OpenFile, + } + } + }; + match action { + SubmitAction::ChangeDir => self.action_enter_remote_dir(entry, false), + SubmitAction::OpenFile => { + self.action_open_remote(entry, None); + false + } + } + } +} diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 5cedb60..6a73d07 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -58,6 +58,7 @@ use chrono::{DateTime, Local}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use std::collections::VecDeque; use std::path::PathBuf; +use tempfile::TempDir; use tuirealm::View; // -- Storage keys @@ -134,6 +135,7 @@ pub struct FileTransferActivity { browser: Browser, // Browser log_records: VecDeque, // Log records transfer: TransferStates, // Transfer states + cache: Option, // Temporary directory where to store stuff } impl FileTransferActivity { @@ -160,6 +162,10 @@ impl FileTransferActivity { browser: Browser::new(config_client.as_ref()), log_records: VecDeque::with_capacity(256), // 256 events is enough I guess transfer: TransferStates::default(), + cache: match TempDir::new() { + Ok(d) => Some(d), + Err(_) => None, + }, } } @@ -279,6 +285,12 @@ impl Activity for FileTransferActivity { /// `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. fn on_destroy(&mut self) -> Option { + // Destroy cache + if let Some(cache) = self.cache.take() { + if let Err(err) = cache.close() { + error!("Failed to delete cache: {}", err); + } + } // Disable raw mode if let Err(err) = disable_raw_mode() { error!("Failed to disable raw mode: {}", err); diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index d9aa06d..74b01e7 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -844,38 +844,32 @@ impl FileTransferActivity { /// Edit file on remote host pub(super) fn edit_remote_file(&mut self, file: &FsFile) -> Result<(), String> { // Create temp file - let tmpfile: tempfile::NamedTempFile = match tempfile::NamedTempFile::new() { - Ok(f) => f, - Err(err) => { - return Err(format!("Could not create temporary file: {}", err)); - } + let tmpfile: PathBuf = match self.download_file_as_temp(file) { + Ok(p) => p, + Err(err) => return Err(err), }; - // Download file - if let Err(err) = self.filetransfer_recv_file(tmpfile.path(), file, file.name.clone()) { - return Err(format!("Could not open file {}: {}", file.name, err)); - } // Get current file modification time - let prev_mtime: SystemTime = match self.host.stat(tmpfile.path()) { + let prev_mtime: SystemTime = match self.host.stat(tmpfile.as_path()) { Ok(e) => e.get_last_change_time(), Err(err) => { return Err(format!( "Could not stat \"{}\": {}", - tmpfile.path().display(), + tmpfile.as_path().display(), err )) } }; // Edit file - if let Err(err) = self.edit_local_file(tmpfile.path()) { + if let Err(err) = self.edit_local_file(tmpfile.as_path()) { return Err(err); } // Get local fs entry - let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.path()) { + let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.as_path()) { Ok(e) => e, Err(err) => { return Err(format!( "Could not stat \"{}\": {}", - tmpfile.path().display(), + tmpfile.as_path().display(), err )) } @@ -891,12 +885,12 @@ impl FileTransferActivity { ), ); // Get local fs entry - let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.path()) { + let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.as_path()) { Ok(e) => e, Err(err) => { return Err(format!( "Could not stat \"{}\": {}", - tmpfile.path().display(), + tmpfile.as_path().display(), err )) } @@ -1035,6 +1029,33 @@ impl FileTransferActivity { } } + /// ### download_file_as_temp + /// + /// Download provided file as a temporary file + pub(super) fn download_file_as_temp(&mut self, file: &FsFile) -> Result { + let tmpfile: PathBuf = match self.cache.as_ref() { + Some(cache) => { + let mut p: PathBuf = cache.path().to_path_buf(); + p.push(file.name.as_str()); + p + } + None => { + return Err(String::from( + "Could not create tempfile: cache not available", + )) + } + }; + // Download file + match self.filetransfer_recv_file(tmpfile.as_path(), file, file.name.clone()) { + Err(err) => Err(format!( + "Could not download {} to temporary file: {}", + file.abs_path.display(), + err + )), + Ok(()) => Ok(tmpfile), + } + } + // -- transfer sizes /// ### get_total_transfer_size_local diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 7f63f2d..4d9cf02 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -87,7 +87,7 @@ impl Update for FileTransferActivity { entry = Some(e.clone()); } if let Some(entry) = entry { - if self.action_enter_local_dir(entry, false) { + if self.action_submit_local(entry) { // Update file list if sync if self.browser.sync_browsing { let _ = self.update_remote_filelist(); @@ -150,7 +150,7 @@ impl Update for FileTransferActivity { entry = Some(e.clone()); } if let Some(entry) = entry { - if self.action_enter_remote_dir(entry, false) { + if self.action_submit_remote(entry) { // Update file list if sync if self.browser.sync_browsing { let _ = self.update_local_filelist(); diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index bab6290..9d654e9 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -934,7 +934,7 @@ impl FileTransferActivity { .with_foreground(Color::Cyan) .build(), ) - .add_col(TextSpan::from(" Enter directory")) + .add_col(TextSpan::from(" Enter directory / Open file")) .add_row() .add_col( TextSpanBuilder::new("") @@ -1030,7 +1030,9 @@ impl FileTransferActivity { .with_foreground(Color::Cyan) .build(), ) - .add_col(TextSpan::from(" Open text file")) + .add_col(TextSpan::from( + " Open text file with preferred editor", + )) .add_row() .add_col( TextSpanBuilder::new("") From 4e50038b4167af03b8fd2978274c7fe0d1cc473f Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 10 Jun 2021 12:14:09 +0200 Subject: [PATCH 06/53] Open file with --- .../activities/filetransfer/actions/find.rs | 45 +++++++++++++++++++ .../activities/filetransfer/actions/open.rs | 36 +++++++++++++-- .../activities/filetransfer/actions/submit.rs | 4 +- src/ui/activities/filetransfer/mod.rs | 1 + src/ui/activities/filetransfer/update.rs | 32 ++++++++++--- src/ui/activities/filetransfer/view.rs | 25 +++++++++++ src/ui/keymap.rs | 2 +- 7 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/ui/activities/filetransfer/actions/find.rs b/src/ui/activities/filetransfer/actions/find.rs index 7e0a97d..6e8ead8 100644 --- a/src/ui/activities/filetransfer/actions/find.rs +++ b/src/ui/activities/filetransfer/actions/find.rs @@ -140,4 +140,49 @@ impl FileTransferActivity { } } } + + pub(crate) fn action_find_open(&mut self) { + match self.get_found_selected_entries() { + SelectedEntry::One(entry) => { + // Open file + self.open_found_file(&entry, None); + } + SelectedEntry::Many(entries) => { + // Iter files + for entry in entries.iter() { + // Open file + self.open_found_file(entry, None); + } + } + SelectedEntry::None => {} + } + } + + pub(crate) fn action_find_open_with(&mut self, with: &str) { + match self.get_found_selected_entries() { + SelectedEntry::One(entry) => { + // Open file + self.open_found_file(&entry, Some(with)); + } + SelectedEntry::Many(entries) => { + // Iter files + for entry in entries.iter() { + // Open file + self.open_found_file(entry, Some(with)); + } + } + SelectedEntry::None => {} + } + } + + fn open_found_file(&mut self, entry: &FsEntry, with: Option<&str>) { + match self.browser.tab() { + FileExplorerTab::FindLocal | FileExplorerTab::Local => { + self.action_open_local(entry, with); + } + FileExplorerTab::FindRemote | FileExplorerTab::Remote => { + self.action_open_remote(entry, with); + } + } + } } diff --git a/src/ui/activities/filetransfer/actions/open.rs b/src/ui/activities/filetransfer/actions/open.rs index b697ef0..00b692a 100644 --- a/src/ui/activities/filetransfer/actions/open.rs +++ b/src/ui/activities/filetransfer/actions/open.rs @@ -28,13 +28,13 @@ // deps extern crate open; // locals -use super::{FileTransferActivity, FsEntry, LogLevel}; +use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; impl FileTransferActivity { /// ### action_open_local /// /// Open local file - pub(crate) fn action_open_local(&mut self, entry: FsEntry, open_with: Option) { + pub(crate) fn action_open_local(&mut self, entry: &FsEntry, open_with: Option<&str>) { let real_entry: FsEntry = entry.get_realfile(); if let FsEntry::File(file) = real_entry { // Open file @@ -63,7 +63,7 @@ impl FileTransferActivity { /// ### action_open_local /// /// Open remote file. The file is first downloaded to a temporary directory on localhost - pub(crate) fn action_open_remote(&mut self, entry: FsEntry, open_with: Option) { + pub(crate) fn action_open_remote(&mut self, entry: &FsEntry, open_with: Option<&str>) { let real_entry: FsEntry = entry.get_realfile(); if let FsEntry::File(file) = real_entry { // Download file @@ -99,4 +99,34 @@ impl FileTransferActivity { } } } + + /// ### action_local_open_with + /// + /// Open selected file with provided application + pub(crate) fn action_local_open_with(&mut self, with: &str) { + let entries: Vec = match self.get_local_selected_entries() { + SelectedEntry::One(entry) => vec![entry], + SelectedEntry::Many(entries) => entries, + SelectedEntry::None => vec![], + }; + // Open all entries + for entry in entries.iter() { + self.action_open_local(entry, Some(with)); + } + } + + /// ### action_remote_open_with + /// + /// Open selected file with provided application + pub(crate) fn action_remote_open_with(&mut self, with: &str) { + let entries: Vec = match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => vec![entry], + SelectedEntry::Many(entries) => entries, + SelectedEntry::None => vec![], + }; + // Open all entries + for entry in entries.iter() { + self.action_open_remote(entry, Some(with)); + } + } } diff --git a/src/ui/activities/filetransfer/actions/submit.rs b/src/ui/activities/filetransfer/actions/submit.rs index a814942..712eb16 100644 --- a/src/ui/activities/filetransfer/actions/submit.rs +++ b/src/ui/activities/filetransfer/actions/submit.rs @@ -57,7 +57,7 @@ impl FileTransferActivity { match action { SubmitAction::ChangeDir => self.action_enter_local_dir(entry, false), SubmitAction::OpenFile => { - self.action_open_local(entry, None); + self.action_open_local(&entry, None); false } } @@ -86,7 +86,7 @@ impl FileTransferActivity { match action { SubmitAction::ChangeDir => self.action_enter_remote_dir(entry, false), SubmitAction::OpenFile => { - self.action_open_remote(entry, None); + self.action_open_remote(&entry, None); false } } diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 6a73d07..61d4bb2 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -83,6 +83,7 @@ const COMPONENT_INPUT_FIND: &str = "INPUT_FIND"; const COMPONENT_INPUT_GOTO: &str = "INPUT_GOTO"; const COMPONENT_INPUT_MKDIR: &str = "INPUT_MKDIR"; const COMPONENT_INPUT_NEWFILE: &str = "INPUT_NEWFILE"; +const COMPONENT_INPUT_OPEN_WITH: &str = "INPUT_OPEN_WITH"; const COMPONENT_INPUT_RENAME: &str = "INPUT_RENAME"; const COMPONENT_INPUT_SAVEAS: &str = "INPUT_SAVEAS"; const COMPONENT_RADIO_DELETE: &str = "RADIO_DELETE"; diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 4d9cf02..decc4fc 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -32,11 +32,11 @@ use super::{ actions::SelectedEntry, browser::FileExplorerTab, FileTransferActivity, LogLevel, COMPONENT_EXPLORER_FIND, COMPONENT_EXPLORER_LOCAL, COMPONENT_EXPLORER_REMOTE, COMPONENT_INPUT_COPY, COMPONENT_INPUT_EXEC, COMPONENT_INPUT_FIND, COMPONENT_INPUT_GOTO, - COMPONENT_INPUT_MKDIR, COMPONENT_INPUT_NEWFILE, COMPONENT_INPUT_RENAME, COMPONENT_INPUT_SAVEAS, - COMPONENT_LIST_FILEINFO, COMPONENT_LOG_BOX, COMPONENT_PROGRESS_BAR_FULL, - COMPONENT_PROGRESS_BAR_PARTIAL, COMPONENT_RADIO_DELETE, COMPONENT_RADIO_DISCONNECT, - COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SORTING, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_FATAL, - COMPONENT_TEXT_HELP, + COMPONENT_INPUT_MKDIR, COMPONENT_INPUT_NEWFILE, COMPONENT_INPUT_OPEN_WITH, + COMPONENT_INPUT_RENAME, COMPONENT_INPUT_SAVEAS, COMPONENT_LIST_FILEINFO, COMPONENT_LOG_BOX, + COMPONENT_PROGRESS_BAR_FULL, COMPONENT_PROGRESS_BAR_PARTIAL, COMPONENT_RADIO_DELETE, + COMPONENT_RADIO_DISCONNECT, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SORTING, + COMPONENT_TEXT_ERROR, COMPONENT_TEXT_FATAL, COMPONENT_TEXT_HELP, }; use crate::fs::explorer::FileSorting; use crate::fs::FsEntry; @@ -266,6 +266,12 @@ impl Update for FileTransferActivity { self.mount_saveas(); None } + (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_W) + | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_W) + | (COMPONENT_EXPLORER_FIND, &MSG_KEY_CHAR_W) => { + self.mount_openwith(); + None + } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_X) | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_X) => { // Mount exec @@ -484,6 +490,22 @@ impl Update for FileTransferActivity { _ => None, } } + // -- open with + (COMPONENT_INPUT_OPEN_WITH, &MSG_KEY_ESC) => { + self.umount_openwith(); + None + } + (COMPONENT_INPUT_OPEN_WITH, Msg::OnSubmit(Payload::One(Value::Str(input)))) => { + match self.browser.tab() { + FileExplorerTab::Local => self.action_local_open_with(input), + FileExplorerTab::Remote => self.action_remote_open_with(input), + FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { + self.action_find_open_with(input) + } + } + self.umount_openwith(); + None + } // -- rename (COMPONENT_INPUT_RENAME, &MSG_KEY_ESC) => { self.umount_rename(); diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 9d654e9..e08d179 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -215,6 +215,14 @@ impl FileTransferActivity { self.view.render(super::COMPONENT_INPUT_NEWFILE, f, popup); } } + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_OPEN_WITH) { + if props.visible { + let popup = draw_area_in(f.size(), 40, 10); + f.render_widget(Clear, popup); + // make popup + self.view.render(super::COMPONENT_INPUT_OPEN_WITH, f, popup); + } + } if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_RENAME) { if props.visible { let popup = draw_area_in(f.size(), 40, 10); @@ -593,6 +601,23 @@ impl FileTransferActivity { self.view.umount(super::COMPONENT_INPUT_NEWFILE); } + pub(super) fn mount_openwith(&mut self) { + self.view.mount( + super::COMPONENT_INPUT_OPEN_WITH, + Box::new(Input::new( + InputPropsBuilder::default() + .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_label(String::from("Open file with...")) + .build(), + )), + ); + self.view.active(super::COMPONENT_INPUT_OPEN_WITH); + } + + pub(super) fn umount_openwith(&mut self) { + self.view.umount(super::COMPONENT_INPUT_OPEN_WITH); + } + pub(super) fn mount_rename(&mut self) { self.view.mount( super::COMPONENT_INPUT_RENAME, diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 0540013..8a6fee4 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -170,11 +170,11 @@ pub const MSG_KEY_CHAR_V: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::NONE, }); +*/ pub const MSG_KEY_CHAR_W: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::NONE, }); -*/ pub const MSG_KEY_CHAR_X: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Char('x'), modifiers: KeyModifiers::NONE, From cd3fc15bcbb5c6c5368261ba96c1b3a870c667ea Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 10 Jun 2021 12:16:48 +0200 Subject: [PATCH 07/53] Fmt symlink with len 48 in found dialog --- src/ui/activities/filetransfer/lib/browser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/activities/filetransfer/lib/browser.rs b/src/ui/activities/filetransfer/lib/browser.rs index 5fe8147..2726082 100644 --- a/src/ui/activities/filetransfer/lib/browser.rs +++ b/src/ui/activities/filetransfer/lib/browser.rs @@ -171,7 +171,7 @@ impl Browser { .with_group_dirs(Some(GroupDirs::First)) .with_hidden_files(true) .with_stack_size(0) - .with_formatter(Some("{NAME} {SYMLINK}")) + .with_formatter(Some("{NAME:32} {SYMLINK}")) .build() } } From 541a9a55b5c6984b191eea29853ec30725c7defd Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 10 Jun 2021 20:55:12 +0200 Subject: [PATCH 08/53] Handle shift enter (file list) --- src/ui/components/file_list.rs | 27 ++++++++++++++++++++++----- src/ui/keymap.rs | 4 ++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/ui/components/file_list.rs b/src/ui/components/file_list.rs index ec21799..c0ca43a 100644 --- a/src/ui/components/file_list.rs +++ b/src/ui/components/file_list.rs @@ -393,10 +393,10 @@ impl Component for FileList { self.states.toggle_file(self.states.list_index()); Msg::None } - KeyCode::Enter => { - // Report event - Msg::OnSubmit(self.get_state()) - } + KeyCode::Enter => match key.modifiers.intersects(KeyModifiers::SHIFT) { + false => Msg::OnSubmit(self.get_state()), + true => Msg::OnKey(key), + }, _ => { // Return key event to activity Msg::OnKey(key) @@ -449,7 +449,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - use tuirealm::event::KeyEvent; + use tuirealm::event::{KeyEvent, KeyModifiers}; #[test] fn test_ui_components_file_list_states() { @@ -616,6 +616,14 @@ mod tests { component.on(Event::Key(KeyEvent::from(KeyCode::Enter))), Msg::OnSubmit(Payload::One(Value::Usize(0))) ); + // Enter shift + assert_eq!( + component.on(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::SHIFT + ))), + Msg::OnKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)) + ); // On key assert_eq!( component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))), @@ -626,6 +634,15 @@ mod tests { component.on(Event::Key(KeyEvent::from(KeyCode::Char('a')))), Msg::OnKey(KeyEvent::from(KeyCode::Char('a'))) ); + // Ctrl + a + assert_eq!( + component.on(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL + ))), + Msg::None + ); + assert_eq!(component.states.selected.len(), component.states.list_len()); } #[test] diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 8a6fee4..8ea32e7 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -34,6 +34,10 @@ pub const MSG_KEY_ENTER: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, }); +pub const MSG_KEY_SHIFT_ENTER: Msg = Msg::OnKey(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::SHIFT, +}); pub const MSG_KEY_ESC: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, From d981b77ed759608b7d2ba8df58aac946b0c04aa9 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 10 Jun 2021 22:36:29 +0200 Subject: [PATCH 09/53] Working on open --- .../activities/filetransfer/actions/find.rs | 4 +- .../activities/filetransfer/actions/open.rs | 120 ++++++++++++------ .../activities/filetransfer/actions/submit.rs | 20 +-- src/ui/activities/filetransfer/mod.rs | 16 +++ src/ui/activities/filetransfer/update.rs | 12 ++ 5 files changed, 115 insertions(+), 57 deletions(-) diff --git a/src/ui/activities/filetransfer/actions/find.rs b/src/ui/activities/filetransfer/actions/find.rs index 6e8ead8..d229d8e 100644 --- a/src/ui/activities/filetransfer/actions/find.rs +++ b/src/ui/activities/filetransfer/actions/find.rs @@ -178,10 +178,10 @@ impl FileTransferActivity { fn open_found_file(&mut self, entry: &FsEntry, with: Option<&str>) { match self.browser.tab() { FileExplorerTab::FindLocal | FileExplorerTab::Local => { - self.action_open_local(entry, with); + self.action_open_local_file(entry, with); } FileExplorerTab::FindRemote | FileExplorerTab::Remote => { - self.action_open_remote(entry, with); + self.action_open_remote_file(entry, with); } } } diff --git a/src/ui/activities/filetransfer/actions/open.rs b/src/ui/activities/filetransfer/actions/open.rs index 00b692a..c98796a 100644 --- a/src/ui/activities/filetransfer/actions/open.rs +++ b/src/ui/activities/filetransfer/actions/open.rs @@ -29,54 +29,90 @@ extern crate open; // locals use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; +// ext +use std::path::PathBuf; impl FileTransferActivity { /// ### action_open_local /// /// Open local file - pub(crate) fn action_open_local(&mut self, entry: &FsEntry, open_with: Option<&str>) { - let real_entry: FsEntry = entry.get_realfile(); - if let FsEntry::File(file) = real_entry { - // Open file - let result = match open_with { - None => open::that(file.abs_path.as_path()), - Some(with) => open::with(file.abs_path.as_path(), with), - }; - // Log result - match result { - Ok(_) => self.log( - LogLevel::Info, - format!("Opened file `{}`", entry.get_abs_path().display(),), + pub(crate) fn action_open_local(&mut self) { + let entries: Vec = match self.get_local_selected_entries() { + SelectedEntry::One(entry) => vec![entry], + SelectedEntry::Many(entries) => entries, + SelectedEntry::None => vec![], + }; + entries + .iter() + .for_each(|x| self.action_open_local_file(x, None)); + } + + /// ### action_open_remote + /// + /// Open local file + pub(crate) fn action_open_remote(&mut self) { + let entries: Vec = match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => vec![entry], + SelectedEntry::Many(entries) => entries, + SelectedEntry::None => vec![], + }; + entries + .iter() + .for_each(|x| self.action_open_remote_file(x, None)); + } + + /// ### action_open_local_file + /// + /// Perform open lopcal file + pub(crate) fn action_open_local_file(&mut self, entry: &FsEntry, open_with: Option<&str>) { + let entry: FsEntry = entry.get_realfile(); + // Open file + let result = match open_with { + None => open::that(entry.get_abs_path().as_path()), + Some(with) => open::with(entry.get_abs_path().as_path(), with), + }; + // Log result + match result { + Ok(_) => self.log( + LogLevel::Info, + format!("Opened file `{}`", entry.get_abs_path().display(),), + ), + Err(err) => self.log( + LogLevel::Error, + format!( + "Failed to open filoe `{}`: {}", + entry.get_abs_path().display(), + err ), - Err(err) => self.log( - LogLevel::Error, - format!( - "Failed to open filoe `{}`: {}", - entry.get_abs_path().display(), - err - ), - ), - } + ), } } /// ### action_open_local /// /// Open remote file. The file is first downloaded to a temporary directory on localhost - pub(crate) fn action_open_remote(&mut self, entry: &FsEntry, open_with: Option<&str>) { - let real_entry: FsEntry = entry.get_realfile(); - if let FsEntry::File(file) = real_entry { - // Download file - let tmp = match self.download_file_as_temp(&file) { - Ok(f) => f, - Err(err) => { - self.log( - LogLevel::Error, - format!("Could not open `{}`: {}", file.abs_path.display(), err), - ); - return; - } - }; + pub(crate) fn action_open_remote_file(&mut self, entry: &FsEntry, open_with: Option<&str>) { + let entry: FsEntry = entry.get_realfile(); + // Download file + let tmpfile: String = match self.get_cache_tmp_name(entry.get_name()) { + None => { + self.log(LogLevel::Error, String::from("Could not create tempdir")); + return; + } + Some(p) => p, + }; + let cache: PathBuf = match self.cache.as_ref() { + None => { + self.log(LogLevel::Error, String::from("Could not create tempdir")); + return; + } + Some(p) => p.path().to_path_buf(), + }; + self.filetransfer_recv(entry, cache.as_path(), Some(tmpfile.clone())); + // Make file and open if file exists + let mut tmp: PathBuf = cache; + tmp.push(tmpfile.as_str()); + if tmp.exists() { // Open file let result = match open_with { None => open::that(tmp.as_path()), @@ -110,9 +146,9 @@ impl FileTransferActivity { SelectedEntry::None => vec![], }; // Open all entries - for entry in entries.iter() { - self.action_open_local(entry, Some(with)); - } + entries + .iter() + .for_each(|x| self.action_open_local_file(x, Some(with))); } /// ### action_remote_open_with @@ -125,8 +161,8 @@ impl FileTransferActivity { SelectedEntry::None => vec![], }; // Open all entries - for entry in entries.iter() { - self.action_open_remote(entry, Some(with)); - } + entries + .iter() + .for_each(|x| self.action_open_remote_file(x, Some(with))); } } diff --git a/src/ui/activities/filetransfer/actions/submit.rs b/src/ui/activities/filetransfer/actions/submit.rs index 712eb16..ea034a7 100644 --- a/src/ui/activities/filetransfer/actions/submit.rs +++ b/src/ui/activities/filetransfer/actions/submit.rs @@ -30,7 +30,7 @@ use super::{FileTransferActivity, FsEntry}; enum SubmitAction { ChangeDir, - OpenFile, + None, } impl FileTransferActivity { @@ -47,19 +47,16 @@ impl FileTransferActivity { // If symlink and is directory, point to symlink match &**symlink_entry { FsEntry::Directory(_) => SubmitAction::ChangeDir, - _ => SubmitAction::OpenFile, + _ => SubmitAction::None, } } - None => SubmitAction::OpenFile, + None => SubmitAction::None, } } }; match action { SubmitAction::ChangeDir => self.action_enter_local_dir(entry, false), - SubmitAction::OpenFile => { - self.action_open_local(&entry, None); - false - } + SubmitAction::None => false, } } @@ -76,19 +73,16 @@ impl FileTransferActivity { // If symlink and is directory, point to symlink match &**symlink_entry { FsEntry::Directory(_) => SubmitAction::ChangeDir, - _ => SubmitAction::OpenFile, + _ => SubmitAction::None, } } - None => SubmitAction::OpenFile, + None => SubmitAction::None, } } }; match action { SubmitAction::ChangeDir => self.action_enter_remote_dir(entry, false), - SubmitAction::OpenFile => { - self.action_open_remote(&entry, None); - false - } + SubmitAction::None => false, } } } diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 61d4bb2..00437eb 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -193,6 +193,22 @@ impl FileTransferActivity { pub(crate) fn found_mut(&mut self) -> Option<&mut FileExplorer> { self.browser.found_mut() } + + /// ### get_cache_tmp_name + /// + /// Get file name for a file in cache + pub(crate) fn get_cache_tmp_name(&self, name: &str) -> Option { + self.cache.as_ref().map(|_| { + format!( + "{}-{}", + name, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + ) + }) + } } /** diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index decc4fc..4ab9a4d 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -211,6 +211,18 @@ impl Update for FileTransferActivity { self.update_remote_filelist() } // -- common explorer keys + (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_SHIFT_ENTER) + | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_SHIFT_ENTER) + | (COMPONENT_EXPLORER_FIND, &MSG_KEY_SHIFT_ENTER) => { + match self.browser.tab() { + FileExplorerTab::Local => self.action_open_local(), + FileExplorerTab::Remote => self.action_open_remote(), + FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { + self.action_find_open() + } + } + None + } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_B) | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_B) => { // Show sorting file From 90afe204b1c1a324f607673c49326ba82bb37fb1 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 11 Jun 2021 09:52:50 +0200 Subject: [PATCH 10/53] Open files with ; fixed cache file names --- .../activities/filetransfer/actions/open.rs | 28 ++++++++----------- src/ui/activities/filetransfer/mod.rs | 10 +++++-- src/ui/activities/filetransfer/update.rs | 26 +++++++++-------- src/ui/activities/filetransfer/view.rs | 22 ++++++++++++++- src/ui/components/file_list.rs | 13 +-------- src/ui/keymap.rs | 8 +----- 6 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/ui/activities/filetransfer/actions/open.rs b/src/ui/activities/filetransfer/actions/open.rs index c98796a..9a72f24 100644 --- a/src/ui/activities/filetransfer/actions/open.rs +++ b/src/ui/activities/filetransfer/actions/open.rs @@ -94,13 +94,14 @@ impl FileTransferActivity { pub(crate) fn action_open_remote_file(&mut self, entry: &FsEntry, open_with: Option<&str>) { let entry: FsEntry = entry.get_realfile(); // Download file - let tmpfile: String = match self.get_cache_tmp_name(entry.get_name()) { - None => { - self.log(LogLevel::Error, String::from("Could not create tempdir")); - return; - } - Some(p) => p, - }; + let tmpfile: String = + match self.get_cache_tmp_name(entry.get_name(), entry.get_ftype().as_deref()) { + None => { + self.log(LogLevel::Error, String::from("Could not create tempdir")); + return; + } + Some(p) => p, + }; let cache: PathBuf = match self.cache.as_ref() { None => { self.log(LogLevel::Error, String::from("Could not create tempdir")); @@ -108,7 +109,7 @@ impl FileTransferActivity { } Some(p) => p.path().to_path_buf(), }; - self.filetransfer_recv(entry, cache.as_path(), Some(tmpfile.clone())); + self.filetransfer_recv(&entry, cache.as_path(), Some(tmpfile.clone())); // Make file and open if file exists let mut tmp: PathBuf = cache; tmp.push(tmpfile.as_str()); @@ -120,17 +121,10 @@ impl FileTransferActivity { }; // Log result match result { - Ok(_) => self.log( - LogLevel::Info, - format!("Opened file `{}`", entry.get_abs_path().display()), - ), + Ok(_) => self.log(LogLevel::Info, format!("Opened file `{}`", tmp.display())), Err(err) => self.log( LogLevel::Error, - format!( - "Failed to open filoe `{}`: {}", - entry.get_abs_path().display(), - err - ), + format!("Failed to open filoe `{}`: {}", tmp.display(), err), ), } } diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 00437eb..5bfa149 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -197,16 +197,20 @@ impl FileTransferActivity { /// ### get_cache_tmp_name /// /// Get file name for a file in cache - pub(crate) fn get_cache_tmp_name(&self, name: &str) -> Option { + pub(crate) fn get_cache_tmp_name(&self, name: &str, file_type: Option<&str>) -> Option { self.cache.as_ref().map(|_| { - format!( + let base: String = format!( "{}-{}", name, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() - ) + ); + match file_type { + None => base, + Some(file_type) => format!("{}.{}", base, file_type), + } }) } } diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 4ab9a4d..0463805 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -211,18 +211,6 @@ impl Update for FileTransferActivity { self.update_remote_filelist() } // -- common explorer keys - (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_SHIFT_ENTER) - | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_SHIFT_ENTER) - | (COMPONENT_EXPLORER_FIND, &MSG_KEY_SHIFT_ENTER) => { - match self.browser.tab() { - FileExplorerTab::Local => self.action_open_local(), - FileExplorerTab::Remote => self.action_open_remote(), - FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { - self.action_find_open() - } - } - None - } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_B) | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_B) => { // Show sorting file @@ -278,9 +266,23 @@ impl Update for FileTransferActivity { self.mount_saveas(); None } + (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_V) + | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_V) + | (COMPONENT_EXPLORER_FIND, &MSG_KEY_CHAR_V) => { + // View + match self.browser.tab() { + FileExplorerTab::Local => self.action_open_local(), + FileExplorerTab::Remote => self.action_open_remote(), + FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { + self.action_find_open() + } + } + None + } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_W) | (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_W) | (COMPONENT_EXPLORER_FIND, &MSG_KEY_CHAR_W) => { + // Open with self.mount_openwith(); None } diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index e08d179..11b5c83 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -959,7 +959,7 @@ impl FileTransferActivity { .with_foreground(Color::Cyan) .build(), ) - .add_col(TextSpan::from(" Enter directory / Open file")) + .add_col(TextSpan::from(" Enter directory")) .add_row() .add_col( TextSpanBuilder::new("") @@ -1091,6 +1091,26 @@ impl FileTransferActivity { ) .add_col(TextSpan::from(" Go to parent directory")) .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from( + " Open file with default application for file type", + )) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from( + " Open file with specified application", + )) + .add_row() .add_col( TextSpanBuilder::new("") .bold() diff --git a/src/ui/components/file_list.rs b/src/ui/components/file_list.rs index c0ca43a..1c512a9 100644 --- a/src/ui/components/file_list.rs +++ b/src/ui/components/file_list.rs @@ -393,10 +393,7 @@ impl Component for FileList { self.states.toggle_file(self.states.list_index()); Msg::None } - KeyCode::Enter => match key.modifiers.intersects(KeyModifiers::SHIFT) { - false => Msg::OnSubmit(self.get_state()), - true => Msg::OnKey(key), - }, + KeyCode::Enter => Msg::OnSubmit(self.get_state()), _ => { // Return key event to activity Msg::OnKey(key) @@ -616,14 +613,6 @@ mod tests { component.on(Event::Key(KeyEvent::from(KeyCode::Enter))), Msg::OnSubmit(Payload::One(Value::Usize(0))) ); - // Enter shift - assert_eq!( - component.on(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::SHIFT - ))), - Msg::OnKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)) - ); // On key assert_eq!( component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))), diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 8ea32e7..99bc124 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -34,10 +34,6 @@ pub const MSG_KEY_ENTER: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, }); -pub const MSG_KEY_SHIFT_ENTER: Msg = Msg::OnKey(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::SHIFT, -}); pub const MSG_KEY_ESC: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, @@ -128,7 +124,7 @@ pub const MSG_KEY_CHAR_L: Msg = Msg::OnKey(KeyEvent { modifiers: KeyModifiers::NONE, }); /* -pub const MSG_KEY_CHAR_M: Msg = Msg::OnKey(KeyEvent { +pub const MSG_KEY_CHAR_M: Msg = Msg::OnKey(KeyEvent { NOTE: used for mark code: KeyCode::Char('m'), modifiers: KeyModifiers::NONE, }); @@ -169,12 +165,10 @@ pub const MSG_KEY_CHAR_U: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Char('u'), modifiers: KeyModifiers::NONE, }); -/* pub const MSG_KEY_CHAR_V: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::NONE, }); -*/ pub const MSG_KEY_CHAR_W: Msg = Msg::OnKey(KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::NONE, From bc50328006196496fd3ebfbbc0eeb522fa119727 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 11 Jun 2021 13:01:28 +0200 Subject: [PATCH 11/53] open-rs docs --- README.md | 15 +++++++++++++++ docs/man.md | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index d51afeb..a0dda99 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,20 @@ while if you're a Windows user, you can install termscp with [Chocolatey](https: For more information or other platforms, please visit [veeso.github.io](https://veeso.github.io/termscp/#get-started) to view all installation methods. +### Soft Requirements ✔️ + +These requirements are not forcely required to run termscp, but to enjoy all of its features + +- **Linux** users + - To **open** files via `V` (at least one of these) + - *xdg-open* + - *gio* + - *gnome-open* + - *kde-open* +- **WSL** users + - To **open** files via `V` (at least one of these) + - [wslu](https://github.com/wslutilities/wslu) + --- ## Buy me a coffee ☕ @@ -134,6 +148,7 @@ termscp is powered by these aweseome projects: - [crossterm](https://github.com/crossterm-rs/crossterm) - [edit](https://github.com/milkey-mouse/edit) - [keyring-rs](https://github.com/hwchen/keyring-rs) +- [open-rs](https://github.com/Byron/open-rs) - [rpassword](https://github.com/conradkleinespel/rpassword) - [rust-ftp](https://github.com/mattnenterprise/rust-ftp) - [ssh2-rs](https://github.com/alexcrichton/ssh2-rs) diff --git a/docs/man.md b/docs/man.md index aa49c00..b1a9bf1 100644 --- a/docs/man.md +++ b/docs/man.md @@ -105,6 +105,8 @@ In order to change panel you need to type `` to move the remote explorer p | `` | Rename file | Rename | | `` | Save file as... | Save | | `` | Go to parent directory | Upper | +| `` | Open file with default program for filetype | View | +| `` | Open file with provided program | With | | `` | Execute a command | eXecute | | `` | Toggle synchronized browsing | sYnc | | `` | Delete file | | @@ -130,6 +132,20 @@ This means that whenever you'll change the working directory on one panel, the s *Warning*: at the moment, whenever you try to access an unexisting directory, you won't be prompted to create it. This might change in a future update. +### Open and Open With 🚪 + +Open and open with commands are powered by [open-rs](https://docs.rs/crate/open/1.7.0). +When opening files with View command (``), the system default application for the file type will be used. To do so, the default operting system service will be used, so be sure to have at least one of these installed on your system: + +- **Windows** users: you don't have to worry about it, since the crate will use the `start` command. +- **MacOS** users: you don't have to worry either, since the crate will use `open`, which is already installed on your system. +- **Linux** users: one of these should be installed + - *xdg-open* + - *gio* + - *gnome-open* + - *kde-open* +- **WSL** users: *wslview* is required, you must install [wslu](https://github.com/wslutilities/wslu). + --- ## Bookmarks ⭐ From fe494c52b18cfb9e360f3de2e90ff3aaea6906e1 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 11 Jun 2021 14:46:20 +0200 Subject: [PATCH 12/53] Clear screen once opened file, to prevent crap on stderr --- .../activities/filetransfer/actions/open.rs | 61 ++++++++----------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/src/ui/activities/filetransfer/actions/open.rs b/src/ui/activities/filetransfer/actions/open.rs index 9a72f24..ecd8735 100644 --- a/src/ui/activities/filetransfer/actions/open.rs +++ b/src/ui/activities/filetransfer/actions/open.rs @@ -30,7 +30,7 @@ extern crate open; // locals use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; // ext -use std::path::PathBuf; +use std::path::{Path, PathBuf}; impl FileTransferActivity { /// ### action_open_local @@ -66,26 +66,7 @@ impl FileTransferActivity { /// Perform open lopcal file pub(crate) fn action_open_local_file(&mut self, entry: &FsEntry, open_with: Option<&str>) { let entry: FsEntry = entry.get_realfile(); - // Open file - let result = match open_with { - None => open::that(entry.get_abs_path().as_path()), - Some(with) => open::with(entry.get_abs_path().as_path(), with), - }; - // Log result - match result { - Ok(_) => self.log( - LogLevel::Info, - format!("Opened file `{}`", entry.get_abs_path().display(),), - ), - Err(err) => self.log( - LogLevel::Error, - format!( - "Failed to open filoe `{}`: {}", - entry.get_abs_path().display(), - err - ), - ), - } + self.open_path_with(entry.get_abs_path().as_path(), open_with); } /// ### action_open_local @@ -114,19 +95,7 @@ impl FileTransferActivity { let mut tmp: PathBuf = cache; tmp.push(tmpfile.as_str()); if tmp.exists() { - // Open file - let result = match open_with { - None => open::that(tmp.as_path()), - Some(with) => open::with(tmp.as_path(), with), - }; - // Log result - match result { - Ok(_) => self.log(LogLevel::Info, format!("Opened file `{}`", tmp.display())), - Err(err) => self.log( - LogLevel::Error, - format!("Failed to open filoe `{}`: {}", tmp.display(), err), - ), - } + self.open_path_with(tmp.as_path(), open_with); } } @@ -159,4 +128,28 @@ impl FileTransferActivity { .iter() .for_each(|x| self.action_open_remote_file(x, Some(with))); } + + /// ### open_path_with + /// + /// Common function which opens a path with default or specified program. + fn open_path_with(&mut self, p: &Path, with: Option<&str>) { + // Open file + let result = match with { + None => open::that(p), + Some(with) => open::with(p, with), + }; + // Log result + match result { + Ok(_) => self.log(LogLevel::Info, format!("Opened file `{}`", p.display())), + Err(err) => self.log( + LogLevel::Error, + format!("Failed to open filoe `{}`: {}", p.display(), err), + ), + } + // NOTE: clear screen in order to prevent crap on stderr + if let Some(ctx) = self.context.as_mut() { + // Clear screen + ctx.clear_screen(); + } + } } From d07b1c86be9ddf16b69bf34de63066fdde658c2b Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 11 Jun 2021 14:48:38 +0200 Subject: [PATCH 13/53] tui-realm 0.4.1 --- CHANGELOG.md | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d28ab0..c1e6b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ Released on FIXME: ?? - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: - Updated `textwrap` to `0.14.0` - - Updated `tui-realm` to `0.4.0` + - Updated `tui-realm` to `0.4.1` ## 0.5.0 diff --git a/Cargo.lock b/Cargo.lock index 8b112b0..027358e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1440,9 +1440,9 @@ dependencies = [ [[package]] name = "tuirealm" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684b01c57e5bccf6e48f3ace3433a07e6267fb9c59fedd3074567d3e417b5568" +checksum = "6bee2a1c050878fac02ba3a6c2e93aa92a1de56849d5deec00d4ab4bc7928c0a" dependencies = [ "crossterm", "textwrap", diff --git a/Cargo.toml b/Cargo.toml index 275b633..9ca28de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ tempfile = "3.1.0" textwrap = "0.14.0" thiserror = "^1.0.0" toml = "0.5.8" -tuirealm = { version = "0.4.0", features = [ "with-components" ] } +tuirealm = { version = "0.4.1", features = [ "with-components" ] } whoami = "1.1.1" wildmatch = "2.0.0" From 99d4177e89cb1da71c76ea8035881031de20b233 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 11 Jun 2021 15:20:49 +0200 Subject: [PATCH 14/53] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b10e5..e70594e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ Released on FIXME: ?? > 🏄 Summer update 2021🌴 +- **Open any file** in explorer: + - Open file with default program for file type with `` + - Open file with a specific program with `` - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: From b73c3228e4693a92078be508fbc5b814f105e664 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 12 Jun 2021 09:26:33 +0200 Subject: [PATCH 15/53] manual --- docs/man.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/man.md b/docs/man.md index b1a9bf1..4e2a7a9 100644 --- a/docs/man.md +++ b/docs/man.md @@ -146,6 +146,9 @@ When opening files with View command (``), the system default application for - *kde-open* - **WSL** users: *wslview* is required, you must install [wslu](https://github.com/wslutilities/wslu). +> Q: Can I edit remote files using the view command? +> A: No, at least not directly from the "remote panel". You have to download it to a local directory first, that's due to the fact that when you open a remote file, the file is downloaded into a temporary directory, but there's no way to create a watcher for the file to check when the program you used to open it was closed, so termscp is not able to know when you're done editing the file. + --- ## Bookmarks ⭐ From 00bee04c2cf32c814c603533c3a9e4e7f3b027b7 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 19 Jun 2021 15:03:28 +0200 Subject: [PATCH 16/53] open-rs fixes --- src/ui/activities/filetransfer/actions/mod.rs | 2 + src/ui/activities/filetransfer/mod.rs | 8 +- src/ui/activities/filetransfer/session.rs | 97 ++++++++++--------- src/ui/activities/filetransfer/update.rs | 6 +- 4 files changed, 55 insertions(+), 58 deletions(-) diff --git a/src/ui/activities/filetransfer/actions/mod.rs b/src/ui/activities/filetransfer/actions/mod.rs index e8091a4..6df793b 100644 --- a/src/ui/activities/filetransfer/actions/mod.rs +++ b/src/ui/activities/filetransfer/actions/mod.rs @@ -42,12 +42,14 @@ pub(crate) mod rename; pub(crate) mod save; pub(crate) mod submit; +#[derive(Debug)] pub(crate) enum SelectedEntry { One(FsEntry), Many(Vec), None, } +#[derive(Debug)] enum SelectedEntryIndex { One(usize), Many(Vec), diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 5bfa149..9b21a84 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -57,7 +57,6 @@ use lib::transfer::TransferStates; use chrono::{DateTime, Local}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use std::collections::VecDeque; -use std::path::PathBuf; use tempfile::TempDir; use tuirealm::View; @@ -236,11 +235,8 @@ impl Activity for FileTransferActivity { if let Err(err) = enable_raw_mode() { error!("Failed to enter raw mode: {}", err); } - // Set working directory - let pwd: PathBuf = self.host.pwd(); - // Get files at current wd - self.local_scan(pwd.as_path()); - self.local_mut().wrkdir = pwd; + // Get files at current pwd + self.reload_local_dir(); debug!("Read working directory"); // Configure text editor self.setup_text_editor(); diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index 74b01e7..f5fb653 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -135,19 +135,59 @@ impl FileTransferActivity { /// ### reload_remote_dir /// - /// Reload remote directory entries + /// Reload remote directory entries and update browser pub(super) fn reload_remote_dir(&mut self) { // Get current entries - if let Ok(pwd) = self.client.pwd() { - self.remote_scan(pwd.as_path()); + if let Ok(wrkdir) = self.client.pwd() { + self.remote_scan(wrkdir.as_path()); // Set wrkdir - self.remote_mut().wrkdir = pwd; + self.remote_mut().wrkdir = wrkdir; } } + /// ### reload_local_dir + /// + /// Reload local directory entries and update browser pub(super) fn reload_local_dir(&mut self) { - let wrkdir: PathBuf = self.local().wrkdir.clone(); + let wrkdir: PathBuf = self.host.pwd(); self.local_scan(wrkdir.as_path()); + self.local_mut().wrkdir = wrkdir; + } + + /// ### local_scan + /// + /// Scan current local directory + fn local_scan(&mut self, path: &Path) { + match self.host.scan_dir(path) { + Ok(files) => { + // Set files and sort (sorting is implicit) + self.local_mut().set_files(files); + } + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Could not scan current directory: {}", err), + ); + } + } + } + + /// ### remote_scan + /// + /// Scan current remote directory + fn remote_scan(&mut self, path: &Path) { + match self.client.list_dir(path) { + Ok(files) => { + // Set files and sort (sorting is implicit) + self.remote_mut().set_files(files); + } + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Could not scan current directory: {}", err), + ); + } + } } /// ### filetransfer_send @@ -559,7 +599,7 @@ impl FileTransferActivity { } } // Reload directory on local - self.local_scan(local_path); + self.reload_local_dir(); // if aborted; show alert if self.transfer.aborted() { // Log abort @@ -688,42 +728,6 @@ impl FileTransferActivity { Ok(()) } - /// ### local_scan - /// - /// Scan current local directory - pub(super) fn local_scan(&mut self, path: &Path) { - match self.host.scan_dir(path) { - Ok(files) => { - // Set files and sort (sorting is implicit) - self.local_mut().set_files(files); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!("Could not scan current directory: {}", err), - ); - } - } - } - - /// ### remote_scan - /// - /// Scan current remote directory - pub(super) fn remote_scan(&mut self, path: &Path) { - match self.client.list_dir(path) { - Ok(files) => { - // Set files and sort (sorting is implicit) - self.remote_mut().set_files(files); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!("Could not scan current directory: {}", err), - ); - } - } - } - /// ### local_changedir /// /// Change directory for local @@ -738,9 +742,7 @@ impl FileTransferActivity { format!("Changed directory on local: {}", path.display()), ); // Reload files - self.local_scan(path); - // Set wrkdir - self.local_mut().wrkdir = PathBuf::from(path); + self.reload_local_dir(); // Push prev_dir to stack if push { self.local_mut().pushd(prev_dir.as_path()) @@ -767,9 +769,7 @@ impl FileTransferActivity { format!("Changed directory on remote: {}", path.display()), ); // Update files - self.remote_scan(path); - // Set wrkdir - self.remote_mut().wrkdir = PathBuf::from(path); + self.reload_remote_dir(); // Push prev_dir to stack if push { self.remote_mut().pushd(prev_dir.as_path()) @@ -809,6 +809,7 @@ impl FileTransferActivity { return Err(format!("Could not read file: {}", err)); } } + debug!("Ok, file {} is textual; opening file...", path.display()); // Put input mode back to normal if let Err(err) = disable_raw_mode() { error!("Failed to disable raw mode: {}", err); diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 0463805..6d9f42d 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -118,8 +118,7 @@ impl Update for FileTransferActivity { } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_L) => { // Reload directory - let pwd: PathBuf = self.local().wrkdir.clone(); - self.local_scan(pwd.as_path()); + self.reload_local_dir(); // Reload file list component self.update_local_filelist() } @@ -191,8 +190,7 @@ impl Update for FileTransferActivity { } (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_L) => { // Reload directory - let pwd: PathBuf = self.remote().wrkdir.clone(); - self.remote_scan(pwd.as_path()); + self.reload_remote_dir(); // Reload file list component self.update_remote_filelist() } From 15d13af7d531c5f7c14243bb1d3f5f0bfb27fa26 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 11:00:31 +0200 Subject: [PATCH 17/53] wip --- Cargo.toml | 9 ++++++--- src/system/keys/keyringstorage.rs | 7 ++++--- src/system/keys/mod.rs | 28 +++++++++++++--------------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e5334aa..83fba59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ dirs = "3.0.1" edit = "0.1.3" getopts = "0.2.21" hostname = "0.3.1" +keyring = { version = "0.10.1", optional = true } lazy_static = "1.4.0" log = "0.4.14" magic-crypt = "3.1.7" @@ -70,14 +71,16 @@ version = "2.1.0" [features] githubActions = [] +with-containers = [] +with-keyring = [ "keyring" ] [target."cfg(any(target_os = \"unix\", target_os = \"macos\", target_os = \"linux\"))"] [target."cfg(any(target_os = \"unix\", target_os = \"macos\", target_os = \"linux\"))".dependencies] users = "0.11.0" -[target."cfg(any(target_os = \"windows\", target_os = \"macos\"))"] -[target."cfg(any(target_os = \"windows\", target_os = \"macos\"))".dependencies] -keyring = "0.10.1" +[target."cfg(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\"))"] +[target."cfg(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\"))".features] +default = [ "keyring" ] [target."cfg(target_os = \"windows\")"] [target."cfg(target_os = \"windows\")".dependencies] diff --git a/src/system/keys/keyringstorage.rs b/src/system/keys/keyringstorage.rs index 5fae681..8cdc2fa 100644 --- a/src/system/keys/keyringstorage.rs +++ b/src/system/keys/keyringstorage.rs @@ -39,7 +39,6 @@ pub struct KeyringStorage { username: String, } -#[cfg(not(tarpaulin_include))] impl KeyringStorage { /// ### new /// @@ -51,7 +50,6 @@ impl KeyringStorage { } } -#[cfg(not(tarpaulin_include))] impl KeyStorage for KeyringStorage { /// ### get_key /// @@ -68,7 +66,10 @@ impl KeyStorage for KeyringStorage { KeyringError::WindowsVaultError => Err(KeyStorageError::NoSuchKey), #[cfg(target_os = "macos")] KeyringError::MacOsKeychainError(_) => Err(KeyStorageError::NoSuchKey), - _ => panic!("{}", e), + #[cfg(target_os = "linux")] + KeyringError::SecretServiceError(SsError) => Err(KeyStorageError::ProviderError), + KeyringError::Parse(_) => Err(KeyStorageError::BadSytax), + _ => Err(KeyStorageError::ProviderError), }, } } diff --git a/src/system/keys/mod.rs b/src/system/keys/mod.rs index 4933a8e..f89418c 100644 --- a/src/system/keys/mod.rs +++ b/src/system/keys/mod.rs @@ -29,28 +29,22 @@ pub mod filestorage; #[cfg(any(target_os = "windows", target_os = "macos"))] pub mod keyringstorage; +// ext +use thiserror::Error; /// ## KeyStorageError /// /// defines the error type for the `KeyStorage` -#[derive(PartialEq, std::fmt::Debug)] +#[derive(Debug, Error, PartialEq)] pub enum KeyStorageError { - //BadKey, + #[error("Key has a bad syntax")] + BadSytax, + #[error("Provider service error")] ProviderError, + #[error("No such key")] NoSuchKey, } -impl std::fmt::Display for KeyStorageError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let err: String = String::from(match &self { - //KeyStorageError::BadKey => "Bad key syntax", - KeyStorageError::ProviderError => "Provider service error", - KeyStorageError::NoSuchKey => "No such key", - }); - write!(f, "{}", err) - } -} - /// ## KeyStorage /// /// this traits provides the methods to communicate and interact with the key storage. @@ -83,11 +77,15 @@ mod tests { #[test] fn test_system_keys_mod_errors() { assert_eq!( - format!("{}", KeyStorageError::ProviderError), + KeyStorageError::BadSytax.to_string(), + String::from("Key has a bad syntax") + ); + assert_eq!( + KeyStorageError::ProviderError.to_string(), String::from("Provider service error") ); assert_eq!( - format!("{}", KeyStorageError::NoSuchKey), + KeyStorageError::NoSuchKey.to_string(), String::from("No such key") ); } From 19610479bdba510f210e1347b762167f111dc376 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 15:04:16 +0200 Subject: [PATCH 18/53] keyring support for linux --- .github/workflows/coverage.yml | 2 +- .github/workflows/linux.yml | 2 +- Cargo.toml | 5 +---- src/system/bookmarks_client.rs | 10 +++++----- src/system/keys/keyringstorage.rs | 8 +++++++- src/system/keys/mod.rs | 2 +- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f491d62..b9779c9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,7 +21,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --all-features --no-fail-fast + args: --no-default-features --features githubActions --features with-containers --no-fail-fast env: CARGO_INCREMENTAL: "0" RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests" diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 374007b..79a9775 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -19,7 +19,7 @@ jobs: - uses: actions-rs/cargo@v1 with: command: test - args: --all-features --no-fail-fast + args: --no-default-features --features githubActions --features with-containers --no-fail-fast - name: Format run: cargo fmt --all -- --check - name: Clippy diff --git a/Cargo.toml b/Cargo.toml index 83fba59..8f75ac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ features = ["json"] version = "2.1.0" [features] +default = [ "with-keyring" ] githubActions = [] with-containers = [] with-keyring = [ "keyring" ] @@ -78,10 +79,6 @@ with-keyring = [ "keyring" ] [target."cfg(any(target_os = \"unix\", target_os = \"macos\", target_os = \"linux\"))".dependencies] users = "0.11.0" -[target."cfg(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\"))"] -[target."cfg(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\"))".features] -default = [ "keyring" ] - [target."cfg(target_os = \"windows\")"] [target."cfg(target_os = \"windows\")".dependencies] path-slash = "0.1.4" diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index fdff0c9..0d6fb93 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -28,7 +28,7 @@ // Deps extern crate whoami; // Crate -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(feature = "with-keyring")] use super::keys::keyringstorage::KeyringStorage; use super::keys::{filestorage::FileStorage, KeyStorage, KeyStorageError}; // Local @@ -69,8 +69,8 @@ impl BookmarksClient { // Create default hosts let default_hosts: UserHosts = Default::default(); debug!("Setting up bookmarks client..."); - // Make a key storage (windows / macos) - #[cfg(any(target_os = "windows", target_os = "macos"))] + // Make a key storage (with-keyring) + #[cfg(feature = "with-keyring")] let (key_storage, service_id): (Box, &str) = { debug!("Setting up KeyStorage"); let username: String = whoami::username(); @@ -91,8 +91,8 @@ impl BookmarksClient { } } }; - // Make a key storage (linux / unix) - #[cfg(any(target_os = "linux", target_os = "unix"))] + // Make a key storage (wno-keyring) + #[cfg(not(feature = "with-keyring"))] let (key_storage, service_id): (Box, &str) = { #[cfg(not(test))] let app_name: &str = "bookmarks"; diff --git a/src/system/keys/keyringstorage.rs b/src/system/keys/keyringstorage.rs index 8cdc2fa..ca05945 100644 --- a/src/system/keys/keyringstorage.rs +++ b/src/system/keys/keyringstorage.rs @@ -67,7 +67,7 @@ impl KeyStorage for KeyringStorage { #[cfg(target_os = "macos")] KeyringError::MacOsKeychainError(_) => Err(KeyStorageError::NoSuchKey), #[cfg(target_os = "linux")] - KeyringError::SecretServiceError(SsError) => Err(KeyStorageError::ProviderError), + KeyringError::SecretServiceError(_) => Err(KeyStorageError::ProviderError), KeyringError::Parse(_) => Err(KeyStorageError::BadSytax), _ => Err(KeyStorageError::ProviderError), }, @@ -94,7 +94,13 @@ impl KeyStorage for KeyringStorage { // Check what kind of error is returned match storage.get_password() { Ok(_) => true, + #[cfg(not(target_os = "linux"))] Err(err) => !matches!(err, KeyringError::NoBackendFound), + #[cfg(target_os = "linux")] + Err(err) => !matches!( + err, + KeyringError::NoBackendFound | KeyringError::SecretServiceError(_) + ), } } } diff --git a/src/system/keys/mod.rs b/src/system/keys/mod.rs index f89418c..0ca1e92 100644 --- a/src/system/keys/mod.rs +++ b/src/system/keys/mod.rs @@ -27,7 +27,7 @@ */ // Storages pub mod filestorage; -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(feature = "with-keyring")] pub mod keyringstorage; // ext use thiserror::Error; From 5986130dfab48b9c90094dd713d0680d5c3c2d96 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 15:08:35 +0200 Subject: [PATCH 19/53] Unused variant if keyring is not enabled --- src/system/keys/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/system/keys/mod.rs b/src/system/keys/mod.rs index 0ca1e92..b7d13d0 100644 --- a/src/system/keys/mod.rs +++ b/src/system/keys/mod.rs @@ -37,6 +37,7 @@ use thiserror::Error; /// defines the error type for the `KeyStorage` #[derive(Debug, Error, PartialEq)] pub enum KeyStorageError { + #[cfg(feature = "with-keyring")] #[error("Key has a bad syntax")] BadSytax, #[error("Provider service error")] From 320fd5c2dde6e3c81972f50210c9393dcb175c00 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 15:13:20 +0200 Subject: [PATCH 20/53] Updated dockerfiles for dbus --- dist/build/x86_64_archlinux/Dockerfile | 1 + dist/build/x86_64_centos7/Dockerfile | 1 + dist/build/x86_64_debian8/Dockerfile | 1 + dist/build/x86_64_debian9/Dockerfile | 1 + 4 files changed, 4 insertions(+) diff --git a/dist/build/x86_64_archlinux/Dockerfile b/dist/build/x86_64_archlinux/Dockerfile index ded6c9b..bda246d 100644 --- a/dist/build/x86_64_archlinux/Dockerfile +++ b/dist/build/x86_64_archlinux/Dockerfile @@ -7,6 +7,7 @@ RUN pacman -Syu --noconfirm \ gcc \ openssl \ pkg-config \ + dbus \ sudo # Install rust RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \ diff --git a/dist/build/x86_64_centos7/Dockerfile b/dist/build/x86_64_centos7/Dockerfile index 570f3ec..b7716dd 100644 --- a/dist/build/x86_64_centos7/Dockerfile +++ b/dist/build/x86_64_centos7/Dockerfile @@ -7,6 +7,7 @@ RUN yum -y install \ gcc \ openssl \ pkgconfig \ + libdbus-devel \ openssl-devel # Install rust RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \ diff --git a/dist/build/x86_64_debian8/Dockerfile b/dist/build/x86_64_debian8/Dockerfile index 26fa74c..e3971e2 100644 --- a/dist/build/x86_64_debian8/Dockerfile +++ b/dist/build/x86_64_debian8/Dockerfile @@ -8,6 +8,7 @@ RUN apt update && apt install -y \ pkg-config \ libssl-dev \ libssh2-1-dev \ + libdbus-1-dev \ curl # Install rust diff --git a/dist/build/x86_64_debian9/Dockerfile b/dist/build/x86_64_debian9/Dockerfile index 01e0d9a..e78c72c 100644 --- a/dist/build/x86_64_debian9/Dockerfile +++ b/dist/build/x86_64_debian9/Dockerfile @@ -8,6 +8,7 @@ RUN apt update && apt install -y \ pkg-config \ libssl-dev \ libssh2-1-dev \ + libdbus-1-dev \ curl # Install rust From 407ca567f146df99be9e0fa59608cbcc947f2d61 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 16:55:17 +0200 Subject: [PATCH 21/53] keyring-rs-linux docs --- CHANGELOG.md | 6 ++++++ README.md | 9 ++++++++- docs/man.md | 35 +++++++++++++++++++++++++++++------ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3839c5e..961f084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ Released on FIXME: ?? - **Open any file** in explorer: - Open file with default program for file type with `` - Open file with a specific program with `` +- **Keyring support for Linux** + - From now on keyring will be available for Linux only + - Read the manual to find out if your system supports the keyring and how you can enable it + - libdbus is now a dependency + - added `with-keyring` feature + - **❗ BREAKING CHANGE ❗**: if you start using keyring on Linux, all the saved password will be lost - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: diff --git a/README.md b/README.md index a0dda99..3fe81a7 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,23 @@ while if you're a Windows user, you can install termscp with [Chocolatey](https: For more information or other platforms, please visit [veeso.github.io](https://veeso.github.io/termscp/#get-started) to view all installation methods. +### Requirements ❗ + +- **Linux/BSD** users: + - libssh + - libdbus-1 + ### Soft Requirements ✔️ These requirements are not forcely required to run termscp, but to enjoy all of its features -- **Linux** users +- **Linux/BSD** users: - To **open** files via `V` (at least one of these) - *xdg-open* - *gio* - *gnome-open* - *kde-open* + - A keyring manager: read more in the [User manual](docs/man.md#linux-keyring) - **WSL** users - To **open** files via `V` (at least one of these) - [wslu](https://github.com/wslutilities/wslu) diff --git a/docs/man.md b/docs/man.md index 4e2a7a9..33ee42f 100644 --- a/docs/man.md +++ b/docs/man.md @@ -187,13 +187,35 @@ whenever you want to use the previously saved connection, just press `` to ### Are my passwords Safe 😈 -Well, kinda. -As said before, bookmarks are saved in your configuration directory along with passwords. Passwords are obviously not plain text, they are encrypted with **AES-128**. Does this make them safe? Well, depends on your operating system: +Well, Yep 😉. +As said before, bookmarks are saved in your configuration directory along with passwords. Passwords are obviously not plain text, they are encrypted with **AES-128**. Does this make them safe? Absolutely! (except for BSD and WSL users 😢) -On Windows and MacOS the passwords are stored, if possible (but should be), in respectively the Windows Vault and the Keychain. This is actually super-safe and is directly managed by your operating system. +On **Windows**, **Linux** and **MacOS** the passwords are stored, if possible (but should be), respectively in the *Windows Vault*, in the *system keyring* and into the *Keychain*. This is actually super-safe and is directly managed by your operating system. -On Linux and BSD, on the other hand, the key used to encrypt your passwords is stored on your drive (at $HOME/.config/termscp). It is then, still possible to retrieve the key to decrypt passwords. Luckily, the location of the key guarantees your key can't be read by users different from yours, but yeah, I still wouldn't save the password for a server exposed on the internet 😉. -Actually [keyring-rs](https://github.com/hwchen/keyring-rs), supports Linux, but for different reasons I preferred not to make it available for this configuration. If you want to read more about my decision read [this issue](https://github.com/veeso/termscp/issues/2), while if you think this might have been implemented differently feel free to open an issue with your proposal. +❗ Please, notice that if you're a Linux user, you should really read the [chapter below 👀](#linux-keyring), because the keyring might not be enabled or supported on your system! + +On *BSD* and *WSL*, on the other hand, the key used to encrypt your passwords is stored on your drive (at $HOME/.config/termscp). It is then, still possible to retrieve the key to decrypt passwords. Luckily, the location of the key guarantees your key can't be read by users different from yours, but yeah, I still wouldn't save the password for a server exposed on the internet 😉. + +#### Linux Keyring + +We all love Linux thanks to the freedom it gives to the users. You can basically do anything you want as a Linux user, but this has also some cons, such as the fact that often there is no standard applications across different distributions. And this involves keyring too. +This means that on Linux there might be no keyring installed on your system. Unfortunately the library we use to work with the key storage requires a service which exposes `org.freedesktop.secrets` on D-BUS and the worst fact is that there only two services exposing it. + +- ❗ If you use GNOME as desktop environment (e.g. ubuntu users), you should already be fine, since keyring is already provided by `gnome-keyring` and everything should already be working. +- ❗ For other desktop environment users there is a nice program you can use to get a keyring which is [KeepassXC](https://keepassxc.org/), which I use on my Manjaro installation (with KDE) and works fine. The only problem is that you have to setup it to be used along with termscp (but it's quite simple). To get started with KeepassXC read more [here](#keepassxc-setup-for-termscp). +- ❗ What about you don't want to install any of these services? Well, there's no problem! **termscp will keep working as usual**, but it will save the key in a file, as it usually does for BSD and WSL. + +##### KeepassXC setup for termscp + +Follow these steps in order to setup keepassXC for termscp: + +1. Install KeepassXC +2. Go to "tools" > "settings" in toolbar +3. Select "Secret service integration" and toggle "Enable KeepassXC freedesktop.org secret service integration" +4. Create a database, if you don't have one yet: from toolbar "Database" > "New database" +5. From toolbar: "Database" > "Database settings" +6. Select "Secret service integration" and toggle "Expose entries under this group" +7. Select the group in the list where you want the termscp secret to be kept. Remember that this group might be used by any other application to store secrets via DBUS. --- @@ -217,7 +239,8 @@ These parameters can be changed: - **Show Hidden Files**: select whether hidden files shall be displayed by default. You will be able to decide whether to show or not hidden files at runtime pressing `A` anyway. - **Check for updates**: if set to `yes`, termscp will fetch the Github API to check if there is a new version of termscp available. - **Group Dirs**: select whether directories should be groupped or not in file explorers. If `Display first` is selected, directories will be sorted using the configured method but displayed before files, viceversa if `Display last` is selected. -- **File formatter syntax**: syntax to display file info for each file in the explorer. See [File explorer format](#file-explorer-format) +- **Remote File formatter syntax**: syntax to display file info for each file in the remote explorer. See [File explorer format](#file-explorer-format) +- **Local File formatter syntax**: syntax to display file info for each file in the local explorer. See [File explorer format](#file-explorer-format) ### SSH Key Storage 🔐 From f8300fa587a09e122054c2f4dac62d7857a064d9 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 17:00:35 +0200 Subject: [PATCH 22/53] fixed tests --- src/system/keys/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/system/keys/mod.rs b/src/system/keys/mod.rs index b7d13d0..66ac480 100644 --- a/src/system/keys/mod.rs +++ b/src/system/keys/mod.rs @@ -77,6 +77,7 @@ mod tests { #[test] fn test_system_keys_mod_errors() { + #[cfg(feature = "with-keyring")] assert_eq!( KeyStorageError::BadSytax.to_string(), String::from("Key has a bad syntax") From 931a66498f47aedb0de57eaa8530c1084f2860e1 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 09:51:13 +0200 Subject: [PATCH 23/53] filemode manifest freebsd --- dist/pkgs/freebsd/manifest | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 dist/pkgs/freebsd/manifest diff --git a/dist/pkgs/freebsd/manifest b/dist/pkgs/freebsd/manifest old mode 100755 new mode 100644 From 1ee5e368f63f75d23710fafd2996d5322370528b Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 11:02:07 +0200 Subject: [PATCH 24/53] Install cargo dependencies when building with cargo (rust too); build without default features on bsd --- CHANGELOG.md | 2 + install.sh | 113 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89bc842..6e06188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ Released on FIXME: ?? - libdbus is now a dependency - added `with-keyring` feature - **❗ BREAKING CHANGE ❗**: if you start using keyring on Linux, all the saved password will be lost +- **Installation script**: + - From now on, in case cargo is used to install termscp, all the cargo dependencies will be installed - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: diff --git a/install.sh b/install.sh index 7e09cff..d180823 100755 --- a/install.sh +++ b/install.sh @@ -81,6 +81,17 @@ download() { return $rc } +test_writeable() { + local path + path="${1:-}/test.txt" + if touch "${path}" 2>/dev/null; then + rm "${path}" + return 0 + else + return 1 + fi +} + elevate_priv() { if ! has sudo; then error 'Could not find the command "sudo", needed to install termscp on your system.' @@ -95,15 +106,16 @@ elevate_priv() { fi } -test_writeable() { - local path - path="${1:-}/test.txt" - if touch "${path}" 2>/dev/null; then - rm "${path}" - return 0 - else - return 1 - fi +elevate_priv_ex() { + check_dir="$1" + if test_writeable "$check_dir"; then + sudo="" + else + warn "Root permissions are required to install dependecies" + elevate_priv + sudo="sudo" + fi + echo $sudo } # Currently supporting: @@ -275,11 +287,92 @@ install_on_macos() { fi } +# -- cargo installation + +install_bsd_cargo_deps() { + set -e + confirm "${YELLOW}libssh, gcc${NO_COLOR} are required to install ${GREEN}termscp${NO_COLOR}; would you like to proceed?" + sudo="$(elevate_priv_ex /usr/local/bin)" + $sudo pkg install -y curl wget libssh gcc + info "Dependencies installed successfully" +} + +install_linux_cargo_deps() { + local debian_deps="gcc pkg-config libssl-dev libssh2-1-dev libdbus-1-dev" + local rpm_deps="gcc openssl pkgconfig libdbus-devel openssl-devel" + local arch_deps="gcc openssl pkg-config dbus" + local deps_cmd="" + # Get pkg manager + if has apt; then + deps_cmd="apt install -y $debian_deps" + elif has apt-get; then + deps_cmd="apt-get install -y $debian_deps" + elif has yum; then + deps_cmd="yum -y install $rpm_deps" + elif has dnf; then + deps_cmd="dnf -y install $rpm_deps" + elif has pacman; then + deps_cmd="pacman -S --noconfirm $arch_deps" + else + error "Could not find any suitable package manager for your linux distro 🙄" + error "Supported package manager are: 'apt', 'apt-get', 'yum', 'dnf', 'pacman'" + exit 1 + fi + set -e + confirm "${YELLOW}libssh, gcc, openssl, pkg-config, libdbus${NO_COLOR} are required to install ${GREEN}termscp${NO_COLOR}. The following command will be used to install the dependencies: '${BOLD}${YELLOW}${deps_cmd}${NO_COLOR}'. Would you like to proceed?" + sudo="$(elevate_priv_ex /usr/local/bin)" + $sudo $deps_cmd + info "Dependencies installed successfully" +} + +install_cargo() { + if has cargo; then + return 0 + fi + cargo_env="$HOME/.cargo/env" + # Check if cargo is already installed (actually), but not loaded + if [ -f $cargo_env ]; then + . $cargo_env + fi + # Check again cargo + if has cargo; then + return 0 + else + confirm "${YELLOW}rust${NO_COLOR} is required to build termscp with cargo; would you like to install it now?" + set -e + rustup=$(get_tmpfile "sh") + info "Downloading rustup.sh…" + download "${rustup}" "https://sh.rustup.rs" + chmod +x $rustup + $rustup -y + info "Rust installed with success" + . $cargo_env + fi + +} + try_with_cargo() { err="$1" + # Install cargo + install_cargo if has cargo; then info "Installing ${GREEN}termscp${NO_COLOR} via Cargo…" - cargo install termscp + case $PLATFORM in + "freebsd") + install_bsd_cargo_deps + cargo install --no-default-features termscp + ;; + + "linux") + install_linux_cargo_deps + cargo install termscp + ;; + + *) + cargo install termscp + ;; + + esac else error "$err" error "Alternatively you can opt for installing Cargo " From 8b01c718196e92f701c1f41a92d12de701caeba7 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 12:06:49 +0200 Subject: [PATCH 25/53] Coverage --- src/system/bookmarks_client.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index b9db7d8..66153f6 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -704,6 +704,22 @@ mod tests { ); } + #[test] + fn test_system_bookmarks_decrypt_str() { + let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + // Initialize a new bookmarks client + let mut client: BookmarksClient = + BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); + client.key = "MYSUPERSECRETKEY".to_string(); + let input: &str = "Hello world!"; + assert_eq!( + client.decrypt_str("z4Z6LpcpYqBW4+bkIok+5A==").ok().unwrap(), + "Hello world!" + ); + assert!(client.decrypt_str("bidoof").is_err()); + } + /// ### get_paths /// /// Get paths for configuration and key for bookmarks From d976a347052ee6cf1e1abb4c76a74157d55f3657 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 12:08:05 +0200 Subject: [PATCH 26/53] --lib tests --- .github/workflows/coverage.yml | 2 +- .github/workflows/linux.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b4e895f..396d62e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -22,7 +22,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --no-default-features --features github-actions --features with-containers --no-fail-fast + args: --lib --no-default-features --features github-actions --features with-containers --no-fail-fast env: CARGO_INCREMENTAL: "0" RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests" diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index d500ad0..69f4b06 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -22,7 +22,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --no-default-features --features github-actions --features with-containers --no-fail-fast + args: --lib --no-default-features --features github-actions --features with-containers --no-fail-fast - name: Format run: cargo fmt --all -- --check - name: Clippy From 7ed49126a4be7922dce272ce5d48147347d3761e Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 12:25:24 +0200 Subject: [PATCH 27/53] security policy --- .github/ISSUE_TEMPLATE/security.md | 23 +++++++++++++++++++++++ SECURITY.md | 11 +++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/security.md create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/security.md b/.github/ISSUE_TEMPLATE/security.md new file mode 100644 index 0000000..6c00db8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security.md @@ -0,0 +1,23 @@ +--- +name: Security report +about: Create a report of a security vulnerability +title: "[SECURITY] - ISSUE_TITLE" +labels: security +assignees: veeso + +--- + +## Description + +Severity: + +- [ ] **critical** +- [ ] high +- [ ] medium +- [ ] low + +A clear and concise description of the security vulnerability. + +## Additional information + +Add any other context about the problem here. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9dcbbbd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +Only latst version of termscp has the latest security updates. +Because of that, **you should always consider updating termscp to the latest version**. + +## Reporting a Vulnerability + +If you have any security vulnerability or concern to report, please open an issue using the `Security report` template. +w \ No newline at end of file From 97a62def113073ce1322e3fbdb9f7927634d5a3e Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 12:32:11 +0200 Subject: [PATCH 28/53] Brought all extern crate to top level --- src/config/mod.rs | 3 --- src/filetransfer/ftp_transfer.rs | 7 ------ src/filetransfer/mod.rs | 2 -- src/filetransfer/scp_transfer.rs | 6 ----- src/filetransfer/sftp_transfer.rs | 3 --- src/fs/explorer/formatter.rs | 5 ---- src/fs/explorer/mod.rs | 2 -- src/host/mod.rs | 2 -- src/lib.rs | 24 +++++++++++++++++++ src/system/bookmarks_client.rs | 2 -- src/system/config_client.rs | 2 -- src/system/environment.rs | 3 --- src/system/keys/keyringstorage.rs | 4 ---- src/ui/activities/auth/bookmarks.rs | 3 --- src/ui/activities/auth/mod.rs | 4 ---- .../activities/filetransfer/actions/copy.rs | 1 - .../activities/filetransfer/actions/open.rs | 2 -- src/ui/activities/filetransfer/mod.rs | 6 ----- src/ui/activities/filetransfer/session.rs | 6 ----- src/ui/activities/filetransfer/update.rs | 2 -- src/ui/activities/filetransfer/view.rs | 5 ---- src/ui/activities/setup/mod.rs | 4 ---- src/ui/components/msgbox.rs | 3 --- src/ui/context.rs | 4 ---- src/ui/input.rs | 2 -- src/utils/crypto.rs | 3 --- src/utils/fmt.rs | 3 --- src/utils/git.rs | 2 -- src/utils/parser.rs | 5 ---- src/utils/random.rs | 2 -- 30 files changed, 24 insertions(+), 98 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 06bcb44..7220843 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -28,9 +28,6 @@ // Modules pub mod serializer; -// Deps -extern crate edit; - // Locals use crate::filetransfer::FileTransferProtocol; diff --git a/src/filetransfer/ftp_transfer.rs b/src/filetransfer/ftp_transfer.rs index 9cb6199..20088c7 100644 --- a/src/filetransfer/ftp_transfer.rs +++ b/src/filetransfer/ftp_transfer.rs @@ -25,13 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Dependencies -extern crate chrono; -extern crate ftp4; -#[cfg(os_target = "windows")] -extern crate path_slash; -extern crate regex; - use super::{FileTransfer, FileTransferError, FileTransferErrorType}; use crate::fs::{FsDirectory, FsEntry, FsFile}; use crate::utils::fmt::{fmt_time, shadow_password}; diff --git a/src/filetransfer/mod.rs b/src/filetransfer/mod.rs index 0158e10..a58a191 100644 --- a/src/filetransfer/mod.rs +++ b/src/filetransfer/mod.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// dependencies -extern crate wildmatch; // locals use crate::fs::{FsEntry, FsFile}; // ext diff --git a/src/filetransfer/scp_transfer.rs b/src/filetransfer/scp_transfer.rs index 34907a0..dbeeca1 100644 --- a/src/filetransfer/scp_transfer.rs +++ b/src/filetransfer/scp_transfer.rs @@ -25,12 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Dependencies -#[cfg(os_target = "windows")] -extern crate path_slash; -extern crate regex; -extern crate ssh2; - // Locals use super::{FileTransfer, FileTransferError, FileTransferErrorType}; use crate::fs::{FsDirectory, FsEntry, FsFile}; diff --git a/src/filetransfer/sftp_transfer.rs b/src/filetransfer/sftp_transfer.rs index 5f353f0..54dcf51 100644 --- a/src/filetransfer/sftp_transfer.rs +++ b/src/filetransfer/sftp_transfer.rs @@ -25,9 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Dependencies -extern crate ssh2; - // Locals use super::{FileTransfer, FileTransferError, FileTransferErrorType}; use crate::fs::{FsDirectory, FsEntry, FsFile}; diff --git a/src/fs/explorer/formatter.rs b/src/fs/explorer/formatter.rs index 96bdbba..d3448fb 100644 --- a/src/fs/explorer/formatter.rs +++ b/src/fs/explorer/formatter.rs @@ -25,11 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate bytesize; -extern crate regex; -#[cfg(target_family = "unix")] -extern crate users; // Locals use super::FsEntry; use crate::utils::fmt::{fmt_path_elide, fmt_pex, fmt_time}; diff --git a/src/fs/explorer/mod.rs b/src/fs/explorer/mod.rs index 320867b..3d33421 100644 --- a/src/fs/explorer/mod.rs +++ b/src/fs/explorer/mod.rs @@ -28,8 +28,6 @@ // Mods pub(crate) mod builder; mod formatter; -// Deps -extern crate bitflags; // Locals use super::FsEntry; use formatter::Formatter; diff --git a/src/host/mod.rs b/src/host/mod.rs index f832f6e..f9913de 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// dependencies -extern crate wildmatch; // ext use std::fs::{self, File, Metadata, OpenOptions}; use std::path::{Path, PathBuf}; diff --git a/src/lib.rs b/src/lib.rs index 3847ecd..ba2e3d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,12 +32,36 @@ #[macro_use] extern crate bitflags; +extern crate bytesize; +extern crate chrono; +extern crate content_inspector; +extern crate crossterm; +extern crate dirs; +extern crate edit; +extern crate ftp4; +extern crate hostname; +#[cfg(feature = "with-keyring")] +extern crate keyring; #[macro_use] extern crate lazy_static; #[macro_use] extern crate log; #[macro_use] extern crate magic_crypt; +extern crate open; +#[cfg(target_os = "windows")] +extern crate path_slash; +extern crate rand; +extern crate regex; +extern crate ssh2; +extern crate tempfile; +extern crate textwrap; +extern crate tuirealm; +extern crate ureq; +#[cfg(target_family = "unix")] +extern crate users; +extern crate whoami; +extern crate wildmatch; pub mod activity_manager; pub mod bookmarks; diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index f17680f..e9afd48 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate whoami; // Crate #[cfg(any(target_os = "windows", target_os = "macos"))] use super::keys::keyringstorage::KeyringStorage; diff --git a/src/system/config_client.rs b/src/system/config_client.rs index 9caaa0b..babc379 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate rand; // Locals use crate::config::serializer::ConfigSerializer; use crate::config::{SerializerError, SerializerErrorKind, UserConfig}; diff --git a/src/system/environment.rs b/src/system/environment.rs index c17cc99..ce1db6e 100644 --- a/src/system/environment.rs +++ b/src/system/environment.rs @@ -25,9 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate dirs; - // Ext use std::path::{Path, PathBuf}; diff --git a/src/system/keys/keyringstorage.rs b/src/system/keys/keyringstorage.rs index 5fae681..349357f 100644 --- a/src/system/keys/keyringstorage.rs +++ b/src/system/keys/keyringstorage.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate keyring; // Local use super::{KeyStorage, KeyStorageError}; // Ext @@ -100,8 +98,6 @@ impl KeyStorage for KeyringStorage { #[cfg(test)] mod tests { - - extern crate whoami; use super::*; use pretty_assertions::assert_eq; diff --git a/src/ui/activities/auth/bookmarks.rs b/src/ui/activities/auth/bookmarks.rs index 7bf4534..44c4499 100644 --- a/src/ui/activities/auth/bookmarks.rs +++ b/src/ui/activities/auth/bookmarks.rs @@ -25,9 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Dependencies -extern crate dirs; - // Locals use super::{AuthActivity, FileTransferProtocol}; use crate::system::bookmarks_client::BookmarksClient; diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index 76be0e0..a88ca79 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -31,10 +31,6 @@ mod misc; mod update; mod view; -// Dependencies -extern crate crossterm; -extern crate tuirealm; - // locals use super::{Activity, Context, ExitReason}; use crate::filetransfer::FileTransferProtocol; diff --git a/src/ui/activities/filetransfer/actions/copy.rs b/src/ui/activities/filetransfer/actions/copy.rs index 27e43a6..1d28fe1 100644 --- a/src/ui/activities/filetransfer/actions/copy.rs +++ b/src/ui/activities/filetransfer/actions/copy.rs @@ -25,7 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -extern crate tempfile; // locals use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry, TransferPayload}; use crate::filetransfer::FileTransferErrorType; diff --git a/src/ui/activities/filetransfer/actions/open.rs b/src/ui/activities/filetransfer/actions/open.rs index d2536cc..c3de73a 100644 --- a/src/ui/activities/filetransfer/actions/open.rs +++ b/src/ui/activities/filetransfer/actions/open.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// deps -extern crate open; // locals use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry, TransferPayload}; // ext diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index da53142..2c8be22 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -33,12 +33,6 @@ pub(self) mod session; pub(self) mod update; pub(self) mod view; -// Dependencies -extern crate chrono; -extern crate crossterm; -extern crate textwrap; -extern crate tuirealm; - // locals use super::{Activity, Context, ExitReason}; use crate::filetransfer::ftp_transfer::FtpFileTransfer; diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index 8a55b83..6607a06 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -25,12 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate bytesize; -extern crate content_inspector; -extern crate crossterm; -extern crate tempfile; - // Locals use super::{FileTransferActivity, LogLevel}; use crate::filetransfer::FileTransferError; diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 272163d..55834ee 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// deps -extern crate bytesize; // locals use super::{ actions::SelectedEntry, browser::FileExplorerTab, FileTransferActivity, LogLevel, diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index ffc78a4..534173a 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -25,11 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate bytesize; -extern crate hostname; -#[cfg(target_family = "unix")] -extern crate users; // locals use super::{browser::FileExplorerTab, Context, FileTransferActivity}; use crate::fs::explorer::FileSorting; diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index 1040914..dc99cc8 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -32,10 +32,6 @@ mod config; mod update; mod view; -// Deps -extern crate crossterm; -extern crate tuirealm; - // Locals use super::{Activity, Context, ExitReason}; // Ext diff --git a/src/ui/components/msgbox.rs b/src/ui/components/msgbox.rs index 8b21152..aae1120 100644 --- a/src/ui/components/msgbox.rs +++ b/src/ui/components/msgbox.rs @@ -25,9 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// deps -extern crate textwrap; -extern crate tuirealm; // locals use crate::utils::fmt::align_text_center; // ext diff --git a/src/ui/context.rs b/src/ui/context.rs index d9affa6..e790eb7 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -25,10 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Dependencies -extern crate crossterm; -extern crate tuirealm; - // Locals use super::input::InputHandler; use super::store::Store; diff --git a/src/ui/input.rs b/src/ui/input.rs index 13026d6..083d488 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -extern crate crossterm; - use crossterm::event::{poll, read, Event}; use std::time::Duration; diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index a7a54e6..b90ffa2 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -25,9 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate magic_crypt; - // Ext use magic_crypt::MagicCryptTrait; diff --git a/src/utils/fmt.rs b/src/utils/fmt.rs index 1957ab1..a7921e3 100644 --- a/src/utils/fmt.rs +++ b/src/utils/fmt.rs @@ -25,9 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -extern crate chrono; -extern crate textwrap; - use chrono::prelude::*; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; diff --git a/src/utils/git.rs b/src/utils/git.rs index cab9070..c888b6e 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate ureq; // Locals use super::parser::parse_semver; // Others diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 9ac603d..86c0286 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -25,11 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Dependencies -extern crate chrono; -extern crate regex; -extern crate whoami; - // Locals use crate::filetransfer::FileTransferProtocol; #[cfg(not(test))] // NOTE: don't use configuration during tests diff --git a/src/utils/random.rs b/src/utils/random.rs index 0cc2ad5..8afa1b0 100644 --- a/src/utils/random.rs +++ b/src/utils/random.rs @@ -25,8 +25,6 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Deps -extern crate rand; // Ext use rand::{distributions::Alphanumeric, thread_rng, Rng}; From 398f518b2fe81effa154754726c4cbf5d67dedb6 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 14:39:09 +0200 Subject: [PATCH 29/53] test_utils_git_check_for_updates not when github and mac and bsd --- src/utils/git.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/utils/git.rs b/src/utils/git.rs index c888b6e..6aae409 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -78,7 +78,15 @@ mod tests { use super::*; #[test] - #[cfg(not(all(target_os = "macos", feature = "github-actions")))] + #[cfg(not(all( + any( + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "netbsd" + ), + feature = "github-actions" + )))] fn test_utils_git_check_for_updates() { assert!(check_for_updates("100.0.0").ok().unwrap().is_none()); assert!(check_for_updates("0.0.1").ok().unwrap().is_some()); From 3cbd2ed0132f01125990c5fcf5484d081eb25643 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 14:40:23 +0200 Subject: [PATCH 30/53] Removed archlinux build stuff --- .github/workflows/aur-pub.yml | 21 ---------------- dist/build/deploy.sh | 15 ------------ dist/build/x86_64_archlinux/Dockerfile | 33 -------------------------- dist/pkgs/arch/.SRCINFO | 14 ----------- dist/pkgs/arch/PKGBUILD | 16 ------------- 5 files changed, 99 deletions(-) delete mode 100644 .github/workflows/aur-pub.yml delete mode 100644 dist/build/x86_64_archlinux/Dockerfile delete mode 100644 dist/pkgs/arch/.SRCINFO delete mode 100644 dist/pkgs/arch/PKGBUILD diff --git a/.github/workflows/aur-pub.yml b/.github/workflows/aur-pub.yml deleted file mode 100644 index d7727eb..0000000 --- a/.github/workflows/aur-pub.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: aur-pub -on: - push: - tags: - - "*" -jobs: - aur-publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Publish AUR package - uses: KSXGitHub/github-actions-deploy-aur@v2.2.4 - with: - pkgname: termscp - pkgbuild: ./dist/pkgs/arch/PKGBUILD - commit_username: ${{ secrets.AUR_USERNAME }} - commit_email: ${{ secrets.AUR_EMAIL }} - ssh_private_key: ${{ secrets.AUR_KEY }} - commit_message: Update AUR package - ssh_keyscan_types: rsa,dsa,ecdsa,ed25519 diff --git a/dist/build/deploy.sh b/dist/build/deploy.sh index 10b520e..89f836d 100755 --- a/dist/build/deploy.sh +++ b/dist/build/deploy.sh @@ -28,20 +28,5 @@ cd - mkdir -p ${PKGS_DIR}/rpm/ CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_centos7 termscp-${VERSION}-x86_64_centos7) docker cp ${CONTAINER_NAME}:/usr/src/termscp/target/release/rpmbuild/RPMS/x86_64/termscp-${VERSION}-1.el7.x86_64.rpm ${PKGS_DIR}/rpm/termscp-${VERSION}-1.x86_64.rpm -# Build x86_64_archlinux - -##################### TEMP REMOVED ################################### -# cd x86_64_archlinux/ -# docker build --tag termscp-${VERSION}-x86_64_archlinux . -# # Create container and get AUR pkg -# cd - -# mkdir -p ${PKGS_DIR}/arch/ -# CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_archlinux termscp-${VERSION}-x86_64_archlinux) -# docker cp ${CONTAINER_NAME}:/usr/src/termscp/termscp-${VERSION}-x86_64.tar.gz ${PKGS_DIR}/arch/ -# docker cp ${CONTAINER_NAME}:/usr/src/termscp/PKGBUILD ${PKGS_DIR}/arch/ -# docker cp ${CONTAINER_NAME}:/usr/src/termscp/.SRCINFO ${PKGS_DIR}/arch/ -# # Replace termscp-bin with termscp in PKGBUILD -# sed -i 's/termscp-bin/termscp/g' ${PKGS_DIR}/arch/PKGBUILD -##################### TEMP REMOVED ################################### exit $? diff --git a/dist/build/x86_64_archlinux/Dockerfile b/dist/build/x86_64_archlinux/Dockerfile deleted file mode 100644 index f9a1cc8..0000000 --- a/dist/build/x86_64_archlinux/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM archlinux:latest as builder - -WORKDIR /usr/src/ -# Install dependencies -RUN pacman -Syu --noconfirm \ - git \ - gcc \ - openssl \ - pkg-config \ - sudo -# Install rust -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \ - chmod +x /tmp/rust.sh && \ - /tmp/rust.sh -y -# Create build user -RUN useradd build -m && \ - passwd -d build && \ - mkdir -p termscp && \ - chown -R build.build termscp/ -# Clone repository -RUN git clone https://github.com/veeso/termscp.git -# Set workdir to termscp -WORKDIR /usr/src/termscp/ -# Install cargo arxch -RUN source $HOME/.cargo/env && cargo install cargo-aur -# Build for x86_64 -RUN source $HOME/.cargo/env && cargo build --release -# Build pkgs -RUN source $HOME/.cargo/env && cargo aur -# Create SRCINFO -RUN chown -R build.build ../termscp/ && sudo -u build bash -c 'makepkg --printsrcinfo > .SRCINFO' - -CMD ["sh"] diff --git a/dist/pkgs/arch/.SRCINFO b/dist/pkgs/arch/.SRCINFO deleted file mode 100644 index fbbb60d..0000000 --- a/dist/pkgs/arch/.SRCINFO +++ /dev/null @@ -1,14 +0,0 @@ -pkgbase = termscp - pkgdesc = termscp is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal. - pkgver = 0.6.0 - pkgrel = 1 - url = https://github.com/veeso/termscp - arch = x86_64 - license = MIT - provides = termscp - options = strip - source = https://github.com/veeso/termscp/releases/download/v0.6.0/termscp-0.6.0-x86_64.tar.gz - sha256sums = 279b4cab7da68c6db0efc054ddf72e36de85910110721b66d5cdc55833c99ccf - -pkgname = termscp - diff --git a/dist/pkgs/arch/PKGBUILD b/dist/pkgs/arch/PKGBUILD deleted file mode 100644 index ce950fb..0000000 --- a/dist/pkgs/arch/PKGBUILD +++ /dev/null @@ -1,16 +0,0 @@ -# Maintainer: Christian Visintin -pkgname=termscp -pkgver=0.6.0 -pkgrel=1 -pkgdesc="termscp is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal." -url="https://github.com/veeso/termscp" -license=("MIT") -arch=("x86_64") -provides=("termscp") -options=("strip") -source=("https://github.com/veeso/termscp/releases/download/v$pkgver/termscp-$pkgver-x86_64.tar.gz") -sha256sums=("f66a1d1602dc8ea336ba4a42bfbe818edc9c20722e1761b471b76109c272094c") - -package() { - install -Dm755 termscp -t "$pkgdir/usr/bin/" -} From c7414ab070982c1feecb9c78fd2e458caae6d088 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 18:00:03 +0200 Subject: [PATCH 31/53] Release notes in-app --- CHANGELOG.md | 7 ++- Cargo.lock | 6 ++- Cargo.toml | 2 +- src/ui/activities/auth/mod.rs | 26 +++++++---- src/ui/activities/auth/update.rs | 42 +++++++++++------ src/ui/activities/auth/view.rs | 57 +++++++++++++++++++----- src/ui/activities/filetransfer/update.rs | 17 +++++++ src/ui/activities/setup/update.rs | 5 +++ src/utils/git.rs | 29 ++++++------ 9 files changed, 140 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5e69b..0fb2d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,17 +23,20 @@ Released on FIXME: ?? -> 🏄 Summer update 2021🌴 +> 🍹 Summer update 2021 🍨 - **Open any file** in explorer: - Open file with default program for file type with `` - Open file with a specific program with `` +- **In-app release notes** + - Possibility to see the release note of the new available release whenever a new version is available + - Just press `` when a new version is available from the auth activity to read the release notes - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: - Added `open 1.7.0` - Updated `textwrap` to `0.14.0` - - Updated `tui-realm` to `0.4.1` + - Updated `tui-realm` to `0.4.3` ## 0.5.1 diff --git a/Cargo.lock b/Cargo.lock index c4d2717..a0a85cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "aes" version = "0.6.0" @@ -1451,9 +1453,9 @@ dependencies = [ [[package]] name = "tuirealm" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9897335542e4a4a87ad391419c35e54b4088661e671ba53e578fbbb1154740c2" +checksum = "0fcbd06f2aa6a2424aaa245c10e8767fe3f0fee234ac8c144cb15eaf2ee37ce9" dependencies = [ "crossterm", "textwrap", diff --git a/Cargo.toml b/Cargo.toml index 7ffc340..989b13e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ tempfile = "3.1.0" textwrap = "0.14.0" thiserror = "^1.0.0" toml = "0.5.8" -tuirealm = { version = "0.4.2", features = [ "with-components" ] } +tuirealm = { version = "0.4.3", features = [ "with-components" ] } ureq = { version = "2.1.0", features = [ "json" ] } whoami = "1.1.1" wildmatch = "2.0.0" diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index a88ca79..7b4c255 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -47,6 +47,7 @@ use tuirealm::{Update, View}; const COMPONENT_TEXT_H1: &str = "TEXT_H1"; const COMPONENT_TEXT_H2: &str = "TEXT_H2"; const COMPONENT_TEXT_NEW_VERSION: &str = "TEXT_NEW_VERSION"; +const COMPONENT_TEXT_NEW_VERSION_NOTES: &str = "TEXTAREA_NEW_VERSION"; const COMPONENT_TEXT_FOOTER: &str = "TEXT_FOOTER"; const COMPONENT_TEXT_HELP: &str = "TEXT_HELP"; const COMPONENT_TEXT_ERROR: &str = "TEXT_ERROR"; @@ -66,6 +67,7 @@ const COMPONENT_RECENTS_LIST: &str = "RECENTS_LIST"; // Store keys const STORE_KEY_LATEST_VERSION: &str = "AUTH_LATEST_VERSION"; +const STORE_KEY_RELEASE_NOTES: &str = "AUTH_RELEASE_NOTES"; /// ### AuthActivity /// @@ -111,16 +113,13 @@ impl AuthActivity { let ctx: &Context = self.context.as_ref().unwrap(); if !ctx.store.isset(STORE_KEY_LATEST_VERSION) { debug!("Version is not set in storage"); - let mut new_version: Option = match ctx.config_client.as_ref() { + let mut github_tag: Option = match ctx.config_client.as_ref() { Some(client) => { if client.get_check_for_updates() { debug!("Check for updates is enabled"); // Send request - match git::check_for_updates(env!("CARGO_PKG_VERSION")) { - Ok(version) => { - info!("Latest version is: {:?}", version); - version - } + match git::check_for_updates("0.5.0") { + Ok(github_tag) => github_tag, Err(err) => { // Report error error!("Failed to get latest version: {}", err); @@ -140,9 +139,18 @@ impl AuthActivity { }; let ctx: &mut Context = self.context.as_mut().unwrap(); // Set version into the store (or just a flag) - match new_version.take() { - Some(new_version) => ctx.store.set_string(STORE_KEY_LATEST_VERSION, new_version), // If Some, set String - None => ctx.store.set(STORE_KEY_LATEST_VERSION), // If None, just set flag + match github_tag.take() { + Some(git::GithubTag { tag_name, body }) => { + // If some store version and release notes + info!("Latest version is: {}", tag_name); + ctx.store.set_string(STORE_KEY_LATEST_VERSION, tag_name); + ctx.store.set_string(STORE_KEY_RELEASE_NOTES, body); + } + None => { + info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION")); + // Just set flag as check + ctx.store.set(STORE_KEY_LATEST_VERSION); + } } } } diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index 23971bb..7173850 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -32,7 +32,7 @@ use super::{ COMPONENT_INPUT_PORT, COMPONENT_INPUT_USERNAME, COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, COMPONENT_RADIO_BOOKMARK_DEL_RECENT, COMPONENT_RADIO_BOOKMARK_SAVE_PWD, COMPONENT_RADIO_PROTOCOL, COMPONENT_RADIO_QUIT, COMPONENT_RECENTS_LIST, COMPONENT_TEXT_ERROR, - COMPONENT_TEXT_HELP, COMPONENT_TEXT_SIZE_ERR, + COMPONENT_TEXT_HELP, COMPONENT_TEXT_NEW_VERSION_NOTES, COMPONENT_TEXT_SIZE_ERR, }; use crate::ui::keymap::*; use tuirealm::components::InputPropsBuilder; @@ -116,18 +116,6 @@ impl Update for AuthActivity { } } } - // bookmarks - (COMPONENT_BOOKMARKS_LIST, &MSG_KEY_TAB) - | (COMPONENT_RECENTS_LIST, &MSG_KEY_TAB) => { - // Give focus to address - self.view.active(COMPONENT_INPUT_ADDR); - None - } - // Any , go to bookmarks - (_, &MSG_KEY_TAB) => { - self.view.active(COMPONENT_BOOKMARKS_LIST); - None - } // Bookmarks commands // / (COMPONENT_BOOKMARKS_LIST, &MSG_KEY_RIGHT) => { @@ -219,10 +207,12 @@ impl Update for AuthActivity { self.umount_recent_del_dialog(); None } + (COMPONENT_RADIO_BOOKMARK_DEL_RECENT, _) => None, (COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, &MSG_KEY_ESC) => { self.umount_bookmark_del_dialog(); None } + (COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, _) => None, // Error message (COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) => { // Umount text error @@ -230,17 +220,31 @@ impl Update for AuthActivity { None } (COMPONENT_TEXT_ERROR, _) => None, + (COMPONENT_TEXT_NEW_VERSION_NOTES, &MSG_KEY_ESC) + | (COMPONENT_TEXT_NEW_VERSION_NOTES, &MSG_KEY_ENTER) => { + // Umount release notes + self.umount_release_notes(); + None + } + (COMPONENT_TEXT_NEW_VERSION_NOTES, _) => None, // Help (_, &MSG_KEY_CTRL_H) => { // Show help self.mount_help(); None } + // Release notes + (_, &MSG_KEY_CTRL_R) => { + // Show release notes + self.mount_release_notes(); + None + } (COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) | (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) => { // Hide text help self.umount_help(); None } + (COMPONENT_TEXT_HELP, _) => None, // Enter setup (_, &MSG_KEY_CTRL_C) => { self.exit_reason = Some(super::ExitReason::EnterSetup); @@ -308,6 +312,18 @@ impl Update for AuthActivity { } // -- text size error; block everything (COMPONENT_TEXT_SIZE_ERR, _) => None, + // bookmarks + (COMPONENT_BOOKMARKS_LIST, &MSG_KEY_TAB) + | (COMPONENT_RECENTS_LIST, &MSG_KEY_TAB) => { + // Give focus to address + self.view.active(COMPONENT_INPUT_ADDR); + None + } + // Any , go to bookmarks + (_, &MSG_KEY_TAB) => { + self.view.active(COMPONENT_BOOKMARKS_LIST); + None + } // On submit on any unhandled (connect) (_, Msg::OnSubmit(_)) | (_, &MSG_KEY_ENTER) => { // Match key for all other components diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index eb55543..9e64b19 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -39,6 +39,7 @@ use tuirealm::components::{ radio::{Radio, RadioPropsBuilder}, scrolltable::{ScrollTablePropsBuilder, Scrolltable}, span::{Span, SpanPropsBuilder}, + textarea::{Textarea, TextareaPropsBuilder}, }; use tuirealm::tui::{ layout::{Constraint, Direction, Layout}, @@ -185,17 +186,15 @@ impl AuthActivity { self.view.mount( super::COMPONENT_TEXT_NEW_VERSION, Box::new(Span::new( - SpanPropsBuilder::default() + SpanPropsBuilder::default() .with_foreground(Color::Yellow) - .with_spans( - vec![ - TextSpan::from("termscp "), - TextSpanBuilder::new(version).underlined().bold().build(), - TextSpan::from(" is now available! Download it from ") - ] - ) - .build() - )) + .with_spans(vec![ + TextSpan::from("termscp "), + TextSpanBuilder::new(version).underlined().bold().build(), + TextSpan::from(" is now available! View release notes with "), + ]) + .build(), + )), ); } // Bookmarks @@ -346,6 +345,15 @@ impl AuthActivity { .render(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT, f, popup); } } + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_NEW_VERSION_NOTES) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 90, 90); + f.render_widget(Clear, popup); + self.view + .render(super::COMPONENT_TEXT_NEW_VERSION_NOTES, f, popup); + } + } if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) { if props.visible { // make popup @@ -753,6 +761,35 @@ impl AuthActivity { self.view.umount(super::COMPONENT_TEXT_HELP); } + /// ### mount_release_notes + /// + /// mount release notes text area + pub(super) fn mount_release_notes(&mut self) { + if let Some(ctx) = self.context.as_ref() { + if let Some(release_notes) = ctx.store.get_string(super::STORE_KEY_RELEASE_NOTES) { + // make spans + let spans: Vec = release_notes.lines().map(TextSpan::from).collect(); + self.view.mount( + super::COMPONENT_TEXT_NEW_VERSION_NOTES, + Box::new(Textarea::new( + TextareaPropsBuilder::default() + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) + .with_texts(Some(String::from("Release notes")), spans) + .build(), + )), + ); + self.view.active(super::COMPONENT_TEXT_NEW_VERSION_NOTES); + } + } + } + + /// ### umount_release_notes + /// + /// Umount release notes text area + pub(super) fn umount_release_notes(&mut self) { + self.view.umount(super::COMPONENT_TEXT_NEW_VERSION_NOTES); + } + /// ### get_input /// /// Collect input values from view diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 55834ee..0443bd5 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -379,6 +379,7 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_INPUT_COPY, _) => None, // -- exec popup (COMPONENT_INPUT_EXEC, &MSG_KEY_ESC) => { self.umount_exec(); @@ -399,6 +400,7 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_INPUT_EXEC, _) => None, // -- find popup (COMPONENT_INPUT_FIND, &MSG_KEY_ESC) => { self.umount_find_input(); @@ -466,6 +468,7 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_INPUT_GOTO, _) => None, // -- make directory (COMPONENT_INPUT_MKDIR, &MSG_KEY_ESC) => { self.umount_mkdir(); @@ -485,6 +488,7 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_INPUT_MKDIR, _) => None, // -- new file (COMPONENT_INPUT_NEWFILE, &MSG_KEY_ESC) => { self.umount_newfile(); @@ -504,6 +508,7 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_INPUT_NEWFILE, _) => None, // -- open with (COMPONENT_INPUT_OPEN_WITH, &MSG_KEY_ESC) => { self.umount_openwith(); @@ -520,6 +525,7 @@ impl Update for FileTransferActivity { self.umount_openwith(); None } + (COMPONENT_INPUT_OPEN_WITH, _) => None, // -- rename (COMPONENT_INPUT_RENAME, &MSG_KEY_ESC) => { self.umount_rename(); @@ -539,6 +545,7 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_INPUT_RENAME, _) => None, // -- save as (COMPONENT_INPUT_SAVEAS, &MSG_KEY_ESC) => { self.umount_saveas(); @@ -563,12 +570,14 @@ impl Update for FileTransferActivity { FileExplorerTab::FindRemote => self.update_local_filelist(), } } + (COMPONENT_INPUT_SAVEAS, _) => None, // -- fileinfo (COMPONENT_LIST_FILEINFO, &MSG_KEY_ENTER) | (COMPONENT_LIST_FILEINFO, &MSG_KEY_ESC) => { self.umount_file_info(); None } + (COMPONENT_LIST_FILEINFO, _) => None, // -- delete (COMPONENT_RADIO_DELETE, &MSG_KEY_ESC) | (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => { @@ -612,6 +621,7 @@ impl Update for FileTransferActivity { FileExplorerTab::FindRemote => self.update_remote_filelist(), } } + (COMPONENT_RADIO_DELETE, _) => None, // -- disconnect (COMPONENT_RADIO_DISCONNECT, &MSG_KEY_ESC) | (COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => { @@ -623,6 +633,7 @@ impl Update for FileTransferActivity { self.umount_disconnect(); None } + (COMPONENT_RADIO_DISCONNECT, _) => None, // -- quit (COMPONENT_RADIO_QUIT, &MSG_KEY_ESC) | (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => { @@ -634,6 +645,7 @@ impl Update for FileTransferActivity { self.umount_quit(); None } + (COMPONENT_RADIO_QUIT, _) => None, // -- sorting (COMPONENT_RADIO_SORTING, &MSG_KEY_ESC) | (COMPONENT_RADIO_SORTING, Msg::OnSubmit(_)) => { @@ -666,27 +678,32 @@ impl Update for FileTransferActivity { _ => None, } } + (COMPONENT_RADIO_SORTING, _) => None, // -- error (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) => { self.umount_error(); None } + (COMPONENT_TEXT_ERROR, _) => None, // -- fatal (COMPONENT_TEXT_FATAL, &MSG_KEY_ESC) | (COMPONENT_TEXT_FATAL, &MSG_KEY_ENTER) => { self.exit_reason = Some(super::ExitReason::Disconnect); None } + (COMPONENT_TEXT_FATAL, _) => None, // -- help (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) | (COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) => { self.umount_help(); None } + (COMPONENT_TEXT_HELP, _) => None, // -- progress bar (COMPONENT_PROGRESS_BAR_PARTIAL, &MSG_KEY_CTRL_C) => { // Set transfer aborted to True self.transfer.abort(); None } + (COMPONENT_PROGRESS_BAR_PARTIAL, _) => None, // -- fallback (_, _) => None, // Nothing to do }, diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index ce4b4e2..7531413 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -114,6 +114,7 @@ impl Update for SetupActivity { self.umount_error(); None } + (COMPONENT_TEXT_ERROR, _) => None, // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes @@ -135,12 +136,14 @@ impl Update for SetupActivity { self.umount_quit(); None } + (COMPONENT_RADIO_QUIT, _) => None, // Close help (COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) | (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) => { // Umount help self.umount_help(); None } + (COMPONENT_TEXT_HELP, _) => None, // Delete key (COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Delete key @@ -156,6 +159,7 @@ impl Update for SetupActivity { self.umount_del_ssh_key(); None } + (COMPONENT_RADIO_DEL_SSH_KEY, _) => None, // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config @@ -170,6 +174,7 @@ impl Update for SetupActivity { self.umount_save_popup(); None } + (COMPONENT_RADIO_SAVE, _) => None, // Edit SSH Key // Change view (COMPONENT_LIST_SSH_KEYS, &MSG_KEY_TAB) => { diff --git a/src/utils/git.rs b/src/utils/git.rs index 6aae409..7ed9bea 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -30,38 +30,39 @@ use super::parser::parse_semver; // Others use serde::Deserialize; -#[derive(Deserialize)] -struct TagInfo { - tag_name: String, +#[derive(Debug, Deserialize)] +/// ## GithubTag +/// +/// Info related to a github tag +pub struct GithubTag { + pub tag_name: String, + pub body: String, } /// ### check_for_updates /// /// Check if there is a new version available for termscp. /// This is performed through the Github API -/// In case of success returns Ok(Option), where the Option is Some(new_version); otherwise if no version is available, return None +/// In case of success returns Ok(Option), where the Option is Some(new_version); otherwise if no version is available, return None /// In case of error returns Error with the error description -pub fn check_for_updates(current_version: &str) -> Result, String> { +pub fn check_for_updates(current_version: &str) -> Result, String> { // Send request - let github_version: Result = + let github_tag: Result = match ureq::get("https://api.github.com/repos/veeso/termscp/releases/latest").call() { - Ok(response) => match response.into_json::() { - Ok(tag_info) => Ok(tag_info.tag_name), - Err(err) => Err(err.to_string()), - }, + Ok(response) => response.into_json::().map_err(|x| x.to_string()), Err(err) => Err(err.to_string()), }; // Check version - match github_version { + match github_tag { Err(err) => Err(err), - Ok(version) => { + Ok(tag) => { // Parse version - match parse_semver(version.as_str()) { + match parse_semver(tag.tag_name.as_str()) { Some(new_version) => { // Check if version is different if new_version.as_str() > current_version { - Ok(Some(new_version)) // New version is available + Ok(Some(tag)) // New version is available } else { Ok(None) // No new version } From 8a36c459d554afd9cb08bf82422db3c02817dd14 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 18:06:16 +0200 Subject: [PATCH 32/53] Restored version from env --- src/ui/activities/auth/mod.rs | 2 +- src/ui/activities/auth/view.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index 7b4c255..a899541 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -118,7 +118,7 @@ impl AuthActivity { if client.get_check_for_updates() { debug!("Check for updates is enabled"); // Send request - match git::check_for_updates("0.5.0") { + match git::check_for_updates(env!("CARGO_PKG_VERSION")) { Ok(github_tag) => github_tag, Err(err) => { // Report error diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index 9e64b19..fe7a03d 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -191,7 +191,7 @@ impl AuthActivity { .with_spans(vec![ TextSpan::from("termscp "), TextSpanBuilder::new(version).underlined().bold().build(), - TextSpan::from(" is now available! View release notes with "), + TextSpan::from(" is NOW available! Get it from ; view release notes with "), ]) .build(), )), From 0cb84fc531a7cd8d6f7c3d10c59448fa4a58132a Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 26 Jun 2021 18:26:21 +0200 Subject: [PATCH 33/53] I've no idea of what user input buffer was meant to be --- src/ui/activities/setup/mod.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index dc99cc8..f27ce9d 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -79,11 +79,6 @@ pub struct SetupActivity { impl Default for SetupActivity { fn default() -> Self { - // Initialize user input - let mut user_input_buffer: Vec = Vec::with_capacity(16); - for _ in 0..16 { - user_input_buffer.push(String::new()); - } SetupActivity { exit_reason: None, context: None, From 14abed44a5c57ad0593c54843062de1e1663c974 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 20 Jun 2021 11:00:31 +0200 Subject: [PATCH 34/53] Keyring-rs support for Linux --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ddc33..80eeac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,11 @@ Released on FIXME: ?? - libdbus is now a dependency - added `with-keyring` feature - **❗ BREAKING CHANGE ❗**: if you start using keyring on Linux, all the saved password will be lost -- **Installation script**: - - From now on, in case cargo is used to install termscp, all the cargo dependencies will be installed - **In-app release notes** - Possibility to see the release note of the new available release whenever a new version is available - Just press `` when a new version is available from the auth activity to read the release notes +- **Installation script**: + - From now on, in case cargo is used to install termscp, all the cargo dependencies will be installed - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: From a105a42519510e4efb1bc0027f83f22ab98b8f61 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 3 Jul 2021 15:56:26 +0200 Subject: [PATCH 35/53] Dependencies --- CHANGELOG.md | 3 +- Cargo.lock | 96 ++++++++++++++++++++++++++-------------------------- Cargo.toml | 4 +-- 3 files changed, 52 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80eeac2..fef97a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,8 @@ Released on FIXME: ?? - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: - Added `open 1.7.0` - - Updated `textwrap` to `0.14.0` + - Updated `rand` to `0.8.4` + - Updated `textwrap` to `0.14.2` - Updated `tui-realm` to `0.4.3` ## 0.5.1 diff --git a/Cargo.lock b/Cargo.lock index a0a85cb..ff3a8d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,18 +217,18 @@ checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" [[package]] name = "cpufeatures" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" +checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" dependencies = [ "libc", ] [[package]] name = "crc-any" -version = "2.3.11" +version = "2.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9950e91c5c444b0729f5f1b0aec76c523e01920ce828e37bccfa27803ff34e1" +checksum = "79269446ee9793fb06fb297c61dd65e53e880fd10bdb222d544db1a98a2f083b" dependencies = [ "debug-helper", ] @@ -289,9 +289,9 @@ dependencies = [ [[package]] name = "debug-helper" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4460596867846f73bddca51f7403b6a29f5315125be10a1640259b4db5b9494c" +checksum = "76fbd10dce159c002b9c688ae8ab7cd531151e185e0ad360f4bfea3b0eede3a8" [[package]] name = "des" @@ -519,9 +519,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" +checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6" [[package]] name = "libssh2-sys" @@ -624,9 +624,9 @@ checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" [[package]] name = "mio" -version = "0.7.11" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf80d3e903b34e0bd7282b218398aec54e082c840d9baf8339e0080a0c542956" +checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" dependencies = [ "libc", "log", @@ -749,9 +749,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" [[package]] name = "opaque-debug" @@ -771,9 +771,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.34" +version = "0.10.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7830286ad6a3973c0f1d9b73738f69c76b739301d0229c4b96501695cbe4c8" +checksum = "549430950c79ae24e6d02e0b7404534ecf311d94cc9f861e9e4020187d13d885" dependencies = [ "bitflags", "cfg-if 1.0.0", @@ -791,9 +791,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" [[package]] name = "openssl-sys" -version = "0.9.63" +version = "0.9.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b0d6fb7d80f877617dfcb014e605e2b5ab2fb0afdf27935219bb6bd984cb98" +checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d" dependencies = [ "autocfg", "cc", @@ -855,7 +855,7 @@ dependencies = [ "cfg-if 1.0.0", "instant", "libc", - "redox_syscall 0.2.8", + "redox_syscall 0.2.9", "smallvec", "winapi", ] @@ -929,14 +929,14 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" dependencies = [ "libc", - "rand_chacha 0.3.0", - "rand_core 0.6.2", - "rand_hc 0.3.0", + "rand_chacha 0.3.1", + "rand_core 0.6.3", + "rand_hc 0.3.1", ] [[package]] @@ -951,12 +951,12 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.2", + "rand_core 0.6.3", ] [[package]] @@ -970,9 +970,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ "getrandom 0.2.3", ] @@ -988,11 +988,11 @@ dependencies = [ [[package]] name = "rand_hc" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" dependencies = [ - "rand_core 0.6.2", + "rand_core 0.6.3", ] [[package]] @@ -1003,9 +1003,9 @@ checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" [[package]] name = "redox_syscall" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc" +checksum = "5ab49abadf3f9e1c4bc499e8845e152ad87d2ad2d30371841171169e9d75feee" dependencies = [ "bitflags", ] @@ -1017,7 +1017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" dependencies = [ "getrandom 0.2.3", - "redox_syscall 0.2.8", + "redox_syscall 0.2.9", ] [[package]] @@ -1291,9 +1291,9 @@ checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2" [[package]] name = "syn" -version = "1.0.72" +version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" +checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7" dependencies = [ "proc-macro2", "quote", @@ -1308,8 +1308,8 @@ checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ "cfg-if 1.0.0", "libc", - "rand 0.8.3", - "redox_syscall 0.2.8", + "rand 0.8.4", + "redox_syscall 0.2.9", "remove_dir_all", "winapi", ] @@ -1344,7 +1344,7 @@ dependencies = [ "open", "path-slash", "pretty_assertions", - "rand 0.8.3", + "rand 0.8.4", "regex", "rpassword", "serde", @@ -1363,9 +1363,9 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59f5365546b8424b0cc48868ae4fbbbc29a538dcc496b53543525201034f0c2" +checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80" dependencies = [ "smawk", "unicode-linebreak", @@ -1374,18 +1374,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" +checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" +checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" dependencies = [ "proc-macro2", "quote", @@ -1498,9 +1498,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" [[package]] name = "unicode-width" @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "vcpkg" -version = "0.2.13" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025ce40a007e1907e58d5bc1a594def78e5573bb0b1160bc389634e8f12e4faa" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" diff --git a/Cargo.toml b/Cargo.toml index 20db1b0..619f494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,14 +42,14 @@ lazy_static = "1.4.0" log = "0.4.14" magic-crypt = "3.1.7" open = "1.7.0" -rand = "0.8.3" +rand = "0.8.4" regex = "1.5.4" rpassword = "5.0.1" serde = { version = "^1.0.0", features = [ "derive" ] } simplelog = "0.10.0" ssh2 = "0.9.0" tempfile = "3.1.0" -textwrap = "0.14.0" +textwrap = "0.14.2" thiserror = "^1.0.0" toml = "0.5.8" tuirealm = { version = "0.4.3", features = [ "with-components" ] } From 0a7e29d92f258b4f96cd20ce1135c20517b8296d Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 4 Jul 2021 11:50:32 +0200 Subject: [PATCH 36/53] Theme provider and '-t' and '-c' CLI options --- .github/actions-rs/grcov.yml | 19 +- CHANGELOG.md | 6 + README.md | 1 + assets/images/themes.gif | Bin 0 -> 335784 bytes docs/man.md | 94 ++ src/activity_manager.rs | 34 +- src/bookmarks/serializer.rs | 228 ----- src/{bookmarks/mod.rs => config/bookmarks.rs} | 89 +- src/config/mod.rs | 241 +----- src/config/params.rs | 155 ++++ src/config/serialization.rs | 574 +++++++++++++ src/config/serializer.rs | 281 ------ src/config/themes.rs | 260 ++++++ src/lib.rs | 2 +- src/main.rs | 28 +- src/support.rs | 68 ++ src/system/bookmarks_client.rs | 19 +- src/system/config_client.rs | 14 +- src/system/environment.rs | 19 + src/system/mod.rs | 3 +- src/system/theme_provider.rs | 246 ++++++ src/ui/activities/auth/mod.rs | 8 + src/ui/activities/auth/view.rs | 91 +- src/ui/activities/filetransfer/mod.rs | 22 +- src/ui/activities/filetransfer/view.rs | 201 +++-- src/ui/activities/setup/actions.rs | 108 ++- src/ui/activities/setup/config.rs | 18 + src/ui/activities/setup/mod.rs | 60 +- src/ui/activities/setup/update.rs | 445 +++++++++- src/ui/activities/setup/view.rs | 808 ------------------ src/ui/activities/setup/view/mod.rs | 265 ++++++ src/ui/activities/setup/view/setup.rs | 414 +++++++++ src/ui/activities/setup/view/ssh_keys.rs | 296 +++++++ src/ui/activities/setup/view/theme.rs | 656 ++++++++++++++ src/ui/components/color_picker.rs | 300 +++++++ src/ui/components/file_list.rs | 43 +- src/ui/components/logbox.rs | 13 + src/ui/components/mod.rs | 1 + src/ui/context.rs | 32 +- src/utils/fmt.rs | 426 +++++++++ src/utils/parser.rs | 487 +++++++++++ src/utils/test_helpers.rs | 13 + themes/default.toml | 25 + themes/earth-wind-fire.toml | 25 + themes/horizon.toml | 25 + themes/mono-bright.toml | 25 + themes/mono-dark.toml | 25 + themes/sugarplum.toml | 25 + themes/ubuntu.toml | 25 + themes/veeso.toml | 25 + 50 files changed, 5453 insertions(+), 1835 deletions(-) create mode 100644 assets/images/themes.gif delete mode 100644 src/bookmarks/serializer.rs rename src/{bookmarks/mod.rs => config/bookmarks.rs} (65%) create mode 100644 src/config/params.rs create mode 100644 src/config/serialization.rs delete mode 100644 src/config/serializer.rs create mode 100644 src/config/themes.rs create mode 100644 src/support.rs create mode 100644 src/system/theme_provider.rs delete mode 100644 src/ui/activities/setup/view.rs create mode 100644 src/ui/activities/setup/view/mod.rs create mode 100644 src/ui/activities/setup/view/setup.rs create mode 100644 src/ui/activities/setup/view/ssh_keys.rs create mode 100644 src/ui/activities/setup/view/theme.rs create mode 100644 src/ui/components/color_picker.rs create mode 100644 themes/default.toml create mode 100644 themes/earth-wind-fire.toml create mode 100644 themes/horizon.toml create mode 100644 themes/mono-bright.toml create mode 100644 themes/mono-dark.toml create mode 100644 themes/sugarplum.toml create mode 100644 themes/ubuntu.toml create mode 100644 themes/veeso.toml diff --git a/.github/actions-rs/grcov.yml b/.github/actions-rs/grcov.yml index 86c7afd..cf68823 100644 --- a/.github/actions-rs/grcov.yml +++ b/.github/actions-rs/grcov.yml @@ -3,12 +3,13 @@ ignore-not-existing: true llvm: true output-type: lcov ignore: - - "/*" - - "C:/*" - - "../*" - - src/main.rs - - src/lib.rs - - src/activity_manager.rs - - "src/ui/activities/*" - - src/ui/context.rs - - src/ui/input.rs + - "/*" + - "C:/*" + - "../*" + - src/main.rs + - src/lib.rs + - src/activity_manager.rs + - src/support.rs + - "src/ui/activities/*" + - src/ui/context.rs + - src/ui/input.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fef97a1..45972b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ Released on FIXME: ?? - **Open any file** in explorer: - Open file with default program for file type with `` - Open file with a specific program with `` +- **Themes**: + - You can now set colors for 25 elements in the application + - Colors can be any RGB, also supports **CSS colors** syntax + - Configure theme from settings or import from CLI using the `-t ` argument + - You can find several themes in the `themes/` directory - **Keyring support for Linux** - From now on keyring will be available for Linux only - Read the manual to find out if your system supports the keyring and how you can enable it @@ -39,6 +44,7 @@ Released on FIXME: ?? - Just press `` when a new version is available from the auth activity to read the release notes - **Installation script**: - From now on, in case cargo is used to install termscp, all the cargo dependencies will be installed +- **Start termscp from configuration**: Start termscp with `-c` or `--config` to start termscp from configuration page - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Dependencies: diff --git a/README.md b/README.md index bd6cda0..83b373c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Termscp is a feature rich terminal file transfer and explorer, with support for - 💁 SFTP/SCP authentication through SSH keys and username/password - 🐧 Compatible with Windows, Linux, BSD and MacOS - ✏ Customizable + - Themes - Custom file explorer format - Customizable text editor - Customizable file sorting diff --git a/assets/images/themes.gif b/assets/images/themes.gif new file mode 100644 index 0000000000000000000000000000000000000000..c8cf91242102f096df30971f5b8b4e5a4b8fa32a GIT binary patch literal 335784 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¬wWNOguoQ0OSb-xr7FNT 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�#Yh^g5suI$8r|K7jx>HkhB2QniwB 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�nlz^(iK+m|H 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 z zgXG5NYvZOm7~qPJ7VGZQ)J6$N4zAQxx;2XhaFd2RL+nD=Z!A#eUYMu3Y2d4U0i1z~^>6?*yA4g0fz zNODLIh z_=kWPh%3Z*D}**Nu^`pQZnYF=vBZ5A@n6va0|qrX-i1cxfrYAPenxX6T!lW9s26f3 zg*@?o@gft$6NK{@JX&afKL{LEmVaMUW&TA})PWn_7KLD@5`J@wRj7*xh$eEz79_G2 zDxrO!Q*{o3N=%0W5wcfhm@9JkCSiCG%s5{U(QR^8jm|iS5P^+;)r^h^5!x7zM2Sp=O08HlLRcW3Q~rcH_g%C%Q<_3$rl@*lQCUj_fI;PpP*Q*f zsVP`tX;;G)KS7LXG(;i-hIBZ9pZAT{$c;X7flb1Z6H$)cSbqZnlFxXJA{l4km=PpN zjVY;)VB(T68Iupkj`oL$7Kdo|$a!fYXLFVj0I(GvXB)(UdqWmCQlxw_C|HlTM`yDb zqKJ>%h67=dH#Qe-G8ka0w{&~-T}zjbO~+n-czfDJXVp|n?;>PqB9#M?7Xw29eP9TE zKmo4Abe3o>TTx2EF$v+AQlf)xLf09CA}0xvmwRat4b*>x*%2m45QiCuX;GLDvlV*j zf{)n~P$iR?nVHQ+ljgUPi{TQ5{#lhH88Uu&R@`)vcjOkUpmKOtivT2+g(G3GDQ#Dn zmZ$X-Z21#z372viGPgsKcKK3?$q|DB7yWl_bP<@-n1YoF5si6=%~^uWc}N<9n34&Z z3^AO07@g|4m74jT;0aAV1%ro3eK#pq^p!V-)H-sx3jD|+>d{AQnF&p1IH{;U4s?4d z7be@5AVEN%)ggT{2YP8a7gQNC(t~E~gO%(fg5C)xb_i##$vtKRLdO;&$BBf$XcRMX z7)Gf6K9|+1L z(ziA}1`{9JMkJG-t=X9vYN3l!p{?{BQ3;ktDMM})qB=LC!C|6KT7^)$Fh}B|vlpY< zG^61YQ&+L1LHeFNnvs1<5J+mH3o)XeQKWcwq<`uliW;ex`lOPIe9C8~qB^SNf~85g zV_w;3dDB%s=oLdZ5oc2-VAnc9QDVZD84biHlp!^p$ZTapIApeEa&VL#;pTci3A~40>KtPq*QzXHg$)LDg{ZR z@uC`79vl>0lj{B}#KKg0iI(!JFIzFC@!E4|*K>3d1mzSJXY{Z4k|%LBuaYCM8ufC+ z;;wZGt^S&O5Ew8NtFE=t6=_DQ9NV$qVyZ7utN0RM45T5Mu~ys_A#DU)wmMcP`4ZL| ztc8)SGEqZJ<*na(v!F;e;`*!Q>RvShWv2NPiLd8?XcGu()@%!dJBl>n>Sauovq|z!9|%3$UGmv>0oHq3W@2`?ggAvNdU*c@vP> zmk_39cd1nhL*zuw$bJ?JLoIYRn#3~^vI;j83#CvB?zuys)kenoY^rHcP*J!(H;PvV z0NpkTQ~rt;#VZn8PYq2(D97NQMX%Q64aZMUE znF+x#l1NV-*pRXC3B+5xpFlvKCzc1ByZ}{B%At0pMX<%BylgSN#!I}&D~UltKgU$P z*NeUFflf9$DB4>!-K!Vhd%WRmOwD7x*NeQ~`@P6pAYl=_aC^V_3m|e^AWKKNhUz7h z;ZZ6fL!N6pYh+G?m`>@Zs6LXqr>m-n)w;?vLxkmdvkSZFskw@QyIC|jOQV-a1Dpu) zyLu74@%z5(tGr1|D9vj`b4N1|CcT6bQzvY^*=ro!+rH^Lzc4kvM^e5yTo5%}ygZD1 z{?v=U^4l3h489c!cx{`%P#nd9M~KO%gbU<<&SJW!yF@ON37-lcv48^xrV^zPSqX9n zQ1mof>lHGi7!)u_%o4$K6E{`#MpaXCM|j2024-BxIL;###FDQ4s(MNHYy(lq&*rSA zn`~avGD);Ns@F2bxI8M=$J98mWc;2CLy45EhJXV&@M^YlH^Nz4cX!}3G=nf(=e61P z$s$7+-a83cccZS{5~0k>y?Z3FJjyv)-?#h}7X zX{-`!{7&U$Lm-G2Sp&$WAjsjxp8(K#3xUWDlErcyrB#8rdZZIRgU*_$xt47H5K2=` z(Nk_jizu9&%uOfEzr2^gmpmH<9;loNsvNYqjAz0O%jhf1_&h@gUA6^1%Hf*Oy!?v| zEwL0`%=ui=fB3}A+|eE#68kG-5wg&xfz2CnmI#5;AFYm3y3#KF(j*bmB4N@_!HP1P za|fZ)Ful_v#L+zc(?I<<%=dt-LDLxlrwW18L2ZMWmaa?v)KD$cAz{>pXwwa8qRoTV zP`%aT;?rFH)ypi^%tbk0ToP1m5iy$(X${uMhsJ}()^PpPV*L?kJ*JFYNiR!{c^%h$ z?I>Q|*ML2$bG=-T)Wl{zwW*_ZoT1o(U2Df{#*H1>Q9Rg+iP-H}&S?I`*wCTblHJ+) z@z8&w1Q#vV>ei5hK5}z>8g-VBLG3Z`-=cL{clx@gavFC{%A)-zajNUAct{Rz+ z5sRZz>)gqR;WISh>EAK$O6y?iiSM0p*5Vg)3!aP&WPVAhn%A2kd z#_q&`Ug~%5+KglzSe+_ep4|94wH z@E=avmzhK{MejXt5)jYq4BU&WDfGv5^dPYnbO`mD3hy9M?d*J`R8JI$iW>GlDNdg# z896vWibm5&^x5w7W`62w9`yLx^jmKdQoj&!&l>b@^|<%`X$lzk^=lUH?~4Q zLGUzN*vuj z8N6fhQ#TwSMdFk>wCGBu8hd^MP%!>z#imFfGU}E|BUY^&t6H5%p;Wg%4=G@sM3pGm zuV61ebsN`UTatacrKM=7o1uw83=QS0@lIBwQRN;+oLKQ<#*H06h8(%1&TcA08qNq; zs?fD@xo#!wn65#wWX+NeG`>xX&+k5s+)Vi186E$$2p+~aK4x+@O8 zJCS%^o)}*JL--{T zF2$%BV||;kVr%qos`olO3^oC4Gi@UNk|K~X{pK5K!Hux8&L9eltI(ncE0PaF4?hGk zL=i_M(I)m%>M%d|GLmqn0R92|PqYRfGO)O^va9Gq9RJaAq8>@=&LV^ktVlT0jO6G? z3!wu_r|EcPvLGct3Nkt;GpdrLDWME(%PmWkEJZNKB(qF2&qPzP6I0qyE&MDJQ@s{D zLQ=yO_nQ$X-eS>c&yMDtsm-|tQZUb&07XjBlN=)LF+@M2U`Px%>Zvk};!HDBO*iGV zQ%=`B2~&y`rO!~C{LBbd8Q(OB)K3WIF3NwhTr$J3BsGcFCV$EZ*CtD0Dbg4pLM|3V zh`dNxWIfV#qGFH5sM(a1&4^l06%+MZZMWsNTW=2om88l}r0m9mC>s~Jt_p(f6CB3s zrW95Ux~$%kF2f-c{{Lc`1O@p*LSYjB#$AO-bkE&sU3(u|m|mS4HfT+B&Hd-#h(lx7 zr*|usH`$?h%?LI|G!oe&7+~|M4-`hrci(;=s`QRkjGfiLOnfuwWdBw%gwcN{=_ZJR zfo;1h2YNprxD>I%P(Ii%nxs5-1SB&`_1EI!q{Vg1&3`5fSAD(@FNat>L zPyFw$fEN1xAEJFaI?85y0+u3#z&5CGl%z$dc#Q0d#WxsUTuFN6mR<#Ci@eWOR)g$P zI{d@U7yQnR$iE1F$!B7Ge*5poKYtP_AIT6y3iqEOC^jqqA!HH(>=}X#w+cph4Oyv! zm`4DBoJ<%3Hn9jo>$Z0n1Xiy${;L=P{pT|=l`tyZNF8_l2SEP~Fn|Uu$Osjvy9RQP zBFHh7K^W4Fw_Rk1J%pJ+8i){q%!q;(6o~aI=A@w@q!d7d)zEBah_R@zApTiMEEuRo zf>7XhF)Sbl1IW80UQmhu*x&(Q7_<)>!hi_m9Y=FN+|Dle1!<)f5M(qv+3+ zr$l8c*HfpDq_KmN1mpjH7eI;ufG=)L4hSiL8NMuVWPEzy1UH$Ydi~IZ05E3j{xQdY z@XLU(DG@}cBL5MoSo9DD2t;WnJyMiG7G!%RrO~WPNw%zclqe~rmqhIx7 zCxQieiZXKwfU2a3FVHMVq&()6IDW5r*YjsZBGM`%zR8c;6J!ssq&uAu1Zd{+W=j)_ zR4-aeU<4V=jgZ8jtL7}LG-47%0#}enaR#Xcv5R%u>e9EiN++WW+c<&R8@IxAk=R)# zG@F{4dgiPlK@{stfjF0jCiP>B{ob&4q!Cs!Os5wGZD>V1T7->~B(T{jX5{Kw00fn% zQTb>=Iusa@Diw&ADIHT^3jn(SYq!0H*iY`tTj+$u-1Hr&>Phzk^f^aw1Ciu0k zYh6g7E8EWo7AmA9YG;gEG3!lGCBC`{j5X}Ok^1LN=Ik8|5mr3A4R&2XvnOTwQmJ#f zW<_hwA&zsVW4g+B!|_b)LjR-_AToqcl&vt60~^kXjKw!fcBz)p=%2C_4zrByhR?oQ zsw^Y6R8l5MB-IN_gkG#L0mgHl^?a=X%Q0xBeDY9DoZ1j0_@*T0<@l3 zhlli29@2|!SC%J_TK5y{0J%`MVi|A{dt}qn`63op8oVYOJ)O+{xg*UAX%9;iY*p`288b)9RB_8MU$D;!J(6~+8HDx$4?5Pw|g!KBGDWY(OyXXGl^98nF{C5Y2B)5Gw|Fy03 zg6|qv`X4)F)nMrEk(xvKBD(7PO6V)>7~-5GfnO@zd(Q2jH{S7&Z^^UyyU{!S3+W_| z)#);4cgz2jQk97c4iLhpT`~+SO4A#nqpbR;x4vHNF0s2w`IDp*lP^C>9hFU@^^EYI z@LwTv*hZhgoST}uXnbf8E#)7D=%py9hrwV(zIwI+NAvwc4!9b@Ojs! zAO-*W##$^6{mzjM>Bs!y=M0>+hE0tO{`iaAIpp=ffBt(!@}PmRtiYM@FuF&JGNmge zTREY(iIdmzJ*eZjj{1p_fuP>03{Fy;689U_MZKK7f-x!YelYDvfH2yW*f5 zpqYx+P>TVYzu%cKj2MdnI>8wP62p6l8Voj+xIvnuzs6&{{v*O7+`s?Rxz?(@qX>@# z^Oww%D1bw>3S_y;$gXIGKqqd~GyQQOu6OxDq{ye%kG!@+9liC|bPQ$4n3Y#Kvh)UT&i4(iK zgDXO$jL}2H!mBgK3cH11#g=H6EeR5H)WL~Z#k=YVMr^uqWC{BN#8i|=j9AC!s)&1R zMLiorTJ*<%3@}?nu>!=5FGM#Sn2u{xEhC{qdKrnz@DHfDj9pYF)KI+yLr5`fJwlVX zcJm3MyT**Hjf#(o@{cCkM@{S__i;+Aq{O6b zB#kJIN!UmG>xrO*I)G%$wmdfefy6(|KuQ91j}MEjj7&7sBAZd#Ezep>{0oXzP$tZ9 zMslPiu?t#bwZJIHbUZAxY$gAowaavg zdfXt+M7wXSOwmjhM#4-y>qobw&DuOAxE#jLk+wCwyw;i(1UWQ^qK$ORKZ}?q-vEH0 zTm}3Hf=oCg-VnKS+o__kj=mhKqKdtbe7DY^&XH`8!I*@T%m^MF&jg7tgJ}@hC7-w`egIJx3NDJDKPgbLws_2Q^*ioQ(P;%OcS;Nl{ z4bhWmmN=w4WK13|GmHC(5T*gDDz%6{s2!gf3M!Sqn`2EfWk)DIi8CD)F-;$p^P}yQzNq`G0%JRjh`Gf#py%E@uME9)xuJU(pkTZ z35-jHtXF-&q5j~VE#Xu_lrPI-Dz&Jyy&9zu{e)KC~egwb2Sc)B|&~H&UO{rp)Hel zMKss?EXuo!p$N~T&2^Bl4Q*s5#TUS%$)0fxutg)!!`~3c#6{+&y6ZZC(Clr{`e) zU@QSPxqS@-e&BG`VEl#PWwTS`72y$<4CKwa-bl8MGT-n;J9jY%w{x@*h8e|Fo7zJ_ znL)j#Qeo^%TC!C)g(!t=fg%_HNqqSUW3e`k(TaFkKb@@z|Jc>9BTKY_-ynzuB59jU zu&P|60QE$xo6Hxg0ZSdS*}qyS99Uru;TtVJ4k7{^%tB-ATPl*3ow-uuePLr_i3J>J z#8RkVpjzYS3b($fP-LZJBCT8mLEJ2svN)!hY@OLA`x~8*JHtbWR4h>v*5pkl59CcK zB03kB9mcdc->DlaM@HC@bQhE)1wj+cc(94H!N%9`XTablds2-?vjzqoIMRz~X^>{N(uiVHhUuENY5x=H$XIFot7y1V&2j#|n-=Ob>FA+0YECxcd(KP} z{g9JZYPk8GG+n2B9tncA2&+D70K?(2%;c>0YLJd-x7caneYAz_=l-kKy{}el2r25d zcI!M%YLZ@Rs`e1F%@v;UXQuWp7-kN>e(Q_s-9PT>zb0&X4r{O&YqF+Vz3^)UY3#y= zY;1Yz$fj%)jcd6!(B>TxT!swT(Co@?*R}@j(eA&)whGU_I3FhM)lLu5X6@JZIn##i z*#<4wrtRC-Gug)N-9{zb=I!4eE!_t0;g%fVChp@-(VRx^DCnE zrta%*9O=gH?FJL;=I-zA6zvA@@n&o1Chzm^7V$>!^&SuJX7Be75%q@e`ECsNrtkYc z5BbLL{dNoc=I{TG4E+Z10jD4HCh!9{4*^H;1-A?XXYdDy{tN|&@Cnx_%%<=QXAB9) z@C_GU4)<{H&~RnrNvR$Y8Ivmg0_{8KgM_YY2mkOFpNSAZ4-;Q6SA+3Q_U{|l@r{si zJ-e<=sT6w)a@aOs9PDlv-|-|zi5@49A-_ZxSMqhu@hGSA(rVYRB5@J<){5A2(#9Sr zZ*3&6@-gQu<@S^=M`W%hbE2K{HQ#Y2zlk2>lr>w4pWGQ+S;wEo31oybKF1$9&y;DV zj8BML?IQG>)AJ*@a5rc4rZn?l@fy628iYu6oA7g*qeI2W^OiuG{6Lvs?j%FE@r~$o zjj$ZLX`4wf@{JI5oj7#hoaDR{b(JXfSXpLSAM{cFkB(VC4qjh8F>mxnhjW`)=rQr4 z1x<-6dXAr-xt91#D^W|xDRvTpq6+ewLzX9ju=cE6tYgoVHV^g>4{>E53TCe?ns|1W zjCQ$b_H6xNL{=LLMwV*-A8ucicQ1~wiKj9mwm8%FdS_jMODUAW8G65VFdr#=2c=IK z_&3b#`SN5T-_YfPei#8e03!J#o&+J%G<=pbILgVg>@;Ib^P^Obss|g5=7h`+^&>XMad6( zi2zX>Z7dzaeSUs@v^M-W<^7;uh~MX`;BPwQhy2!`2+9Xxy2tz^zxzPZ>oFPnvH_Xy z%6^pC-i!GDl34jdzSdSf3)_th#)pt}{d)HI#!g~;%a{J?*Ky6?{`ixN@aKBDcZ>Qc z4T=E>3XHl<`e*Q95JR8389M0D5dJ}j2QziMW3W#|iT@xr)aX%T!i60@2I&?H#z%wQ zx@n}aGG$7P1vPT~cxUCzgEBqVgqboVL!Sn3X4F~qVNjtCchXGCRANu1Qm0a_YV|7C zj{vr2?dtU_*sx;9k}YfYEZVec*RpNv_HEUbEPc*}D;7t$Pj>f8T-6Q7-Mm5<0_Gd5 zqR_(@(^6avwOb#)SMlnV{C40-hnNj+*4SAiWy^25iY{&XH0so*InzdY^b-{-A zNm#Pt#E4bj_Sl#wWZ;<(en#4{IPs)dqiXc)_~T27E@w+c&RMxbqB)ZfzuuiHbDKCl zW9MGg`oGTT(Z6nQm9+c#{_^M3uW$c8{?oDP=Koi_zg5wN^@PR?2r27~Iv8r(s% z2AYXOg&vydqAe}{RGCi{F(Q<(zU8%RH;R4)(Mr77!-tGpY&N$L!fq=Y@K%s zs^?0R8q{i_u;zL!qRBezUtW$vDyfZ@nv^N2p?X^Hwxc?fA+R_mo9aOn{7MzHs|Z!F ziYu15Rhwu!{4m53OH87g&>7UQM&!QAq^)vAR%^%I;5reyyhb@NvE2+CtjcaOl<#-{ zz##7`h5+o)%!k!{^FJYr{A{$-R(oxE8x_rNlFc3M5W-U~?U%GZVRX_#Fyt0i(@V{| zint$Tz1-3lZQZp;Uk8k`RA!eg=)|Hi+&0{C%RM*UY*p;hOK&@L>b#5~it4_98$yw* z5?M`a;4zS~bLVmSIMAZlr3M*+!yz#(29=VO)u6x+Hv8;0WDiOM5 z@IR!7etN)y6Ta%kb!s8NZ!H}KG>1(cC!;y?VvI~#Ony` zA)yKHS3m9{^pKW>DN27x310BK4^GOQu2+#@Vw*)UmdC6Rk%WGn&m30j8I zKeT-0C?mOwNybuO*$XBuBU1`qZbg~01m;#G2})S%1qXZ7(N^R%N{jS?G+PlRDPK9r zLLTyfwp`>t8rjNI22+}w{KqA?lF3bWaw}FNW-sB1OjYI+mHhl>DIFQmYwG0_{>bFz z$L_$)gfjDD%zP+BBP!8}#?g)zy(mUAx*9w>L@u&CC&O?FOI<#+n!jXcG>19O0~*ty z3x(%Hof%7THgli==wUqpU=dW_lzj}_=|OvG&1*VTn>zhRH)pv=SIW|A8yO`EM)nVd z%95cJg(XvAX}fd|EU%B)mVu?XouG2E%qO3Jzx+%aoMeOWV1*8 zsyrhb)Fi>6msGWQr48Y-K{{G`_t)SQnY&{t!XEl zT2u0{wd{p$c2`T=tnzgx$c?3aAL>xR^0&VTG%SDvEZ_krRyNV0rg#|!-jQanq`q9P zXNlWa{7$Zbw!unuD=iHvk)*9u!+PZ_eZyOdX;L6b z93JmRD2alO+_$XxjcANrjMu&f`JqM5=8Nl_WLW;#zfOKKl*>xsDO0)17DcQ|Sob-J zKr1H@*-n-plL?+ZhC0O)EBZ=ho-FIgPAgf3=T61tqsW)h>Te(H$q(! znI(}kcV%*9W|T<|EoeWPWQcD3$8-#`ijlcQFOi;K8G+{Iql6hLectn`2z`(VeP$(X zW)__9H|Oot`5;|J-EdT=6oGXPCbW(piWFw$UGuuvzIF?id!}WheOf4g-d~_Y-RP_s z+9%ESNUJsNki*FDCfG9wwbvL#2R$0n(V4VCD(&qR$=b4tyF>9ZeYRSIMMw~VkaP}n zJQ6zVCh*3}t)U3&?&Lcxr%tQ(iQS&nn|J?|BJFZF8e_1mx%&xy`sO@dBZI;&{$VjRghs z?umVMx@I`q)2?=22b-f8ztz}}?XP4*J%v@-c}noEb)PWt(N;G|-<|b$ZvQXnMk9K- zjt+2wX^8AG^!L%eytc(i{^whzO4uJi6+~S=^JBltzgJ8v$A2F4HMI5V*RJ~2v%d9f zX*N5Yh@utWKGK=Kod8YcDkDoB1++K0Hw|mS_UKr>@ z1nQ-?^FlSZ`n-qviwr`MkEKgcCb?Si{$T}+rZx0$qeq91oW|s%>CT-HHG!z8~0%!&FP=B zoe`pSgN}fl;F%f)j?1}_->aNo<26qH;mo!j!~h0hyB!UB^h6D|Agci2p``@KrBDp+ zAn4(s)I}W(>L6JBAn_QX0xlsFHlgb=prhp(Mr<8Y8Q^tn$D+X?hfD~fFpiZFgx#4? z`oY8){sb2V%wZ_snt#W}{iiOb|n3L)OvCF0=W1PY=E zrAAPMzM-Z>pa)Z`TtIrxYPOkQL?EphlmPnN2{|Wla8GMKr0uZ}q-jrY@%nt;N58QB=fn7TDoeo=XVTeQuroJvL<=@N?sfq3XKqSB4h``AG=uSg?1bS8cRngC^%A$!2G6h z7+dZHPon8(xc!PVQVEinP$;4fbA}tDOdx%l=w8-bcLV{Thz!8w=gRGiiuy#k>8Ps| z+f<$CkFHycR>_Sf4>$#4xmD+nNk3ns;N#2q=`zA>tt$A z&S{6?h3N!+Tf=IYv9UX=!`usY$DCaRwrLa1J)vEf9aKE|O! zNt0|KqYA~e!lLro+v^Bio6d_usHcrEE2mj1P5`0m+#aL=Ye2>Vui|Ra%xd?Ajhg}r znBCg@$cvFCsxFd@x~{9Q-d;nl1fKThuJUWUD(b%3Ubo6cz|!i#@~W?DPPf%6!FFiF z-s-~+E5>H5#;QtaYFUOnDo9LGpWQ($JjWgQ#jV^-^;JruR9h_IKvX3Px}uM~L<%$d z1;~#6r(#ycH+}>YsoNT%tim2z?p>dM2wZop>=c!Ph}Nh_WSdVw?YkPv(u(N(0HmMn zsL2Ej&Pqi+0&IkktjP*3*ggi;w(J_cY|JLh%=$;oE-csX?0j}@V>;-P?vKxO#J_e- z%rxl6{w?4xT(KSr+488@cq>fc0Mc3@%O*?H2IZa=_Oai(&TI~Yq>>jMZsw?hB z;MImq!1XTb)b8He3U3B3^EPj14z6W*AB9kW-SzB8`0Z&(FIfEIM{qCCnThuH2lf6Y zscA5!^PVsIURlQqC|rcES9mW-@M8MTFa6f9{c05SN{0L1M_9?vZ*+y?rN;hd#aVjY zTv{ObPGo)bFT=(zUxc02-me5tFvR39Ukor-6!1rAFa>w82Y)aKi--m91qRc{&!!3! zL8(;~%x1Pl30FmgB9C`2ivpL(3(Lm|R}GSy#>s|c2>&n;Q^*M0#SKqI3_s@#2eA?_ zF%viO>#eV96mfhM%K`0VK5gnGAFY#3$7+Fvok+4G^5=yM>927 zvo*ibG+(neZ!X$4vwiflM<6soFEm5ru|d~|-Z)G|m&M^MZ)<>@Gh*~3 zYimPyG{v~HnP_z4lAuSIv`H)RL(fM=N%TaE1y1d=d+6QT@MjxO{!JmDG)}8VO3Q~# zOQR3xG*Ab1^FGiX9YjmVbR4I|N;?EC{X<0Umn=zyJ!1%ws0Sh%>>jy}8MOb4{ z4)Q5nptbfHcH5A(YvA?ea<1^wwOo9{6;B0YkC42^%cV?$(RNW?OLm!sOucpXWP5Go z`OKq1_DXm*0E@QkM)qj;HEibxQ6G>;$@N7iwMTF@Q#Z9!LsW0`(r#A;40WqkbA^FK zv{@4zCn?2nCtX&S^;Fy93yKFaN`-Bs#dV)WgJgGpmmqZGvrow9kn8Dg>r^brVDMnB=~e}IUv-T8 zna#O%j@yxyze#4bIdxFYyO0-lf{3;Zm&1Hkf%ho!o<$M$5xQ6oI_ez z3dB#t#IMi%NJG2I=e%n?#fVmAw_fX|cRba{#b0AZ%~K~|^!%buy;%S~n+uSegT>7c z#n|K9(6=H;L_Kh5W#$)N@+jN2z0mWKRsf zwpYF4r^&~Y2C1`r`QkRJr@WI#d#b}Y(^E=bs62beyv$4c^-4Kb0%MR{#rb^1>HGSt z|LL{>VNS7~R#*{geW=)$napu&ylV?w#KY<1nI`n2Y zqa2O4ta*{+r*1fi9t_eg7RRYoqh`r5)&?Dh*D)kvJM%gtT?iy%(D*f4CF}C zR9S<_ zl{0rub|+nilw;YJS?%Q=8#M&x)Tem zEX0LCVJ9H;|^jg6YVJ2ExWNSoXAb@;xsE#hcf&mhzNUYXilT>GzcZ5 zc04H4O+kI}pA0wrr^A7K8`09~c$AGHl4xy6%bDJ4h@@62>Vv8mVHmDPnRaDJ*zZeZoTDRmapZniw`N|2zPs9Q9_4OiT8$t~C108c=$zjHNXGo#UxTCg;{ntSa{ zm{3BE5fkrdPoOtTnS_y8VsX~6Obn41*zK?*juA(zOxVbUCDaZ8r%)VC+5#yuL}M*A zHl)t&Iwon7jt!x}wtBI>*I1Y|YAL?%EB0oRe?3Sg z4|)Els$)azm(YM28rbYu4*qa9xy7EXVF?u;8=;iqQz_Sff-ZF;!yGS2T8fTB*Ye9T z&s_7(Irm9ji9R<-ZJaf3ThxuQ?U-hpNiW%LY*WTpqQ)ySn(5k)o*h&}BCL^ONOPT< zq{#mnoox*p68+`V|H*pmRTlC(TT#1Y$f+7fUECnZ zc`aXbQiTJx?}DBu__aZAVxOVd?pP>B2`i$%Xt{5HrRZPxGGr^=Ew3T8>xcjsNW9KX zkb)Jo;Qj?MsJYO=Cq*d=385xI8W@nI8$$AkN+L#`Rm1{OLt0Oc1TwyT5er}~oY;D_ zBdqB-C1MfLp?D~kxGtf9WJT1NSVnV^mk|vjTv?*zaHv8S%EV<8(FjxAWj+R;gg%e@ z$4I6qo&a6RWJ%MAuu74|_k>3_PK)9fSJ*-heyD1s@mdK1u$+a&r4JCPTxY!a51Rc0 zgb^8G6%Fzlm4L=*p(=|PN7B86Kt)1Ok=j3GI4q46D~tufh!+9yLxgk@iQ(BH7Z>NJ zPgH3l%c2_;)#oXtWRRA%wB;>vnaef(aUvVEk^36ykgrgLD-#(NB;)81RkE@nXyk}9 z{)IWL^?~Ogzk8kfis=wVG9;J|$!7A5La2hkfGW(SqXetD#r=izeG2(v<@Ct7ZrF#H zu#||}T!(`DwM8PXq{-w8LalDtZ#a`k-kacLEtvSTBc_{{QCC@|!4*+Bl;ip{0MBohMiJN--$Oawzwel}z`HA)L$CYKg$&X%=O8LT&n>%wkSDMN!iXGgxd&gGhFp0HKQ zTIqw4)lPJrBuq+##h z!&;#WJqK+7ScQH7un+>>5o`=Gs7_H5LOJ;rii@(bfCElXA+ zhA$G{&1;{_B}028x|0MTUqZE2=;Bx)9FB{AsEo-Qd5s{ct}{h~(6KuhjlTl4Y{*sAi`C4`1dqbS!T%hkp=pfk*&G3GcW%IVWWW!e8L6Z21NM7lcxBTTX{}XN7 z30XCZkL~C+?tj0?+;3rtKEr8FYsz+!_l`NOGDK&h{+*bqDQ+tRkv4IHEGVf2w(9TM zN@Jx09`}|)IJjS=RUuPgI9+j#2zLnAW0xIhUl0q+%m5K4e$U1jOheT zbk2_|!pnu)jQG0Z0jY1xAg?i+Z!I#=A^OTAn#&-B<*UMuBFryl9MJ5Tr|n?w#wKv> zQgEFx;x+6eLSP4A`fKstXM7~<|IBHC7U#?~i;#3p042izQi%2du6uY8CvcEQlF$eN zFead|2Y2uY5%3DJFbgqn^XOv%Jt9031T>cZ1^7Oq#l|npc%;oPO|=Rt_h963VhIix zPCTYUfuc|L;Dh!E#P%``2SVs%nk*tfufe`(_QHzbTrZpakha9Eu9VM=;v!pwEE2f` zsmAh7>)h4tp&ZKLWibfeM2V-@Zl*kue#S(Sj6^040JIqbUMM5JyDN413BMIU*9X z@fbmi7<(#PNa6uS?IWNOx!4W{&2dOXFrCiP(=fspzsM8&02DEd*%D(nN<|pG(IqCz z6tAT$CJG3HaW{rBB-DoF^f4!zuz3E410e}=9|!Mdh%q6Tu_7(732<55)LE6L8wD(+Jhv> zFbFaMup(kAumpv6#3a57DopZk5=lZ-aw9;oB}a)?Jm@7GPC-OwAz;8MfRWP%s(K;^ z2h$EC6>~8evoe}dBFwUH)G`{St}Br8b*d7(T;lLJf-}9veNNBH+9ED+ZWoVg*}If|F!u*LZ|pjwvoXOlJjF9z1kI$p@C!Y!%Or^(NlAs) zGv#(`*HmSO;Da7Ltmjy74v9u>ykj?RQYR-OF+qdgULyzqv4`L&31OmDoKpC*i08_p zKnd#zBT0^+kBo-Wb`V9406;@u=0K4t2{X}>_zGY`L_iF5FMIBZ0xCT{>ow%aDS|8_ z1QLai={<#zaYRLK=2MdbMC))-FEJ*FJ~Siv^SdT5qw=PlZnGGJMKi}UN~Ls48|*we z54l)LAZ4@L2tz{2{)a-tQJIvqAv6?s6qK7xRCI=;*#IU8gwqAFlp(Zq9KlHiBalFA z%QVrkHODDUQ4}~i0tX;#Nr#Pmnr%#v?Y{2fya+O#R_Qr*BLbJHftb`u3(G_Xs!D{7 znUD=p?Ge^6b<=FCQeQ1mJXM|w)t*q5Q;CpDUG-IAbtW{@O0#4(3?YG1%I3<9;9p%eh-UjENo^d^2pkVsjSj#^JAH39_~;S)X~6Q-so`*K;gRNq))5}e`>$;Vz~ zM<&4VL5W6*zRbF~6-vt@cIHN`8b_-L#XP`LJmR%Tzm;A0D-%aXB@d!lR^?5GQAm^( zEHxs5Y&2K?#Uc_(CM{-aN5R!tel=K!6_Z+`F4c>mpmY>>&MQH~DWO$bsdb_pi05KK zT1{43aphAh;sax~WnFek9}_N8r(+n_oD$R1Zl+ryJl5WY? zZ27ivTlHlncXDHOR<(g~vl2BuZln|p)GlHH2T)H!1P@2z2S}Dog$C+F zxkLU@^iw}eOsQa3SELF2aNyP|1`t7aJ*Leg$SI9LGLLu^Cvk*#jW0O-V|0UWnI3dF zQuNEDcO%G^>VgA!eeCumM5`vH3;D!#Lrutl#n3nxFz$eaIsQf@ra3^8b5-o_eMT4bNo1j>Vf&SQu zh1iM-n2Spoj2C!}$v8?IxH2lRC&)D?C?LERQjOCXClDAY;&>*m!YaUyOQi8902q1y zNi{9QgtV%SiC8jjwu})uk^k(BDWi^M;*Kid}f-ZZThBhI;YXZrM2QUY&cxZvM=(tGJHCoIisjI1F2&fTX#aK zHN&G%Gl_>VNCDTt@D!l|u#tGT+Xy*k@ybtAuetjW5p&Dx`|`mEJ@t=YP*&n2zh zIYE)u^GFu9owcC`>`c^ zvMF1bBfGLOJF_)AfiHWrJ^QmkJ3Ko(v`M?PP5TN*`?OViwOQN#+ETl6(0{Y;a(IvMiLb!9=GVWj=i@UOG8(fH6BAPq7p*y-Yd%1NQpDu_c zyb-&ln=)=~g11|*soPAn8`6xzsK5KX(VMQr`=+y|bTJ4>Kl5tSdnklOo97$6cRPdN z`&sDuzWw{Z+uEg#@RfaHz%wILF37-df@Nz06o1p*$$vb+^V=qv+{s<@F{Hd`b#N!pk6=th%nhQ>%N)Y{Z`bS-TnbS;9YyoXSmpHV%&Qo+|dQ$r9CEw z>6I8>CW4kDCVtcmekaiDVU_SdSH3bK7E%0 zzT1fc>z6|5Lt;2H!|4C|BMe>dJwlQv!tkLU>2cy_!=mGTg79ZT>(}Bq{yysa{<)jv zm1eH)tGetH|1l8XArPPO?YlHEKT%Gf{_|CT^*JY>k?J{DH`B*Yhq0wbUf=Yc#6*}x zm}36{aYHwFL!pR-IK*)hN+k&7JL?7lRR#*`hW}6o4L?x5Bdl~%;KcSn3kzlI_0KNW@Pp{{@lQ(C{a%QmuAo3p%vu(WFb8K8-rH>eZ}UyM7Hjw(QxoYlDT$ z6n8^IydzHH&6`5QI|nlv`Wp)}Ll_o+1COYj;PYre z0hbp;(^Z$Cg-$ue$Xo+y1YS3U;e^6LAu&~AHzzi-Xz+l0JTh^ZmI^||MUM|yZuh=Vqi;)-#Z*wB#w6nUhPN;3J~pI8+d z=s~AG%4@H_{t9fc!VXJpvBn;Y>}!)cv@AnP?fTDt?J*aNs()p~P>Fpt^sHD)fmu)= zm=z=~rR%9W(nTYVG*x-*nOiMGaOU?9b`)%BRl1G+1H+CCl}iw{sut8OSq$EEEviR} zbQn_wYh=iI-u^)p??Xy`f-ghjmSWY$H$~JQ4ll+CFu&&(bWnaT3a1mNMZPOzeM=tt zPesBJSK+|~JQ!r3m&t%%-A9PYU8PJ1%B=dDZY((tM}FTr4D%#^;A`s=jM z0&B!`VHabq@j)NrByKlnOGUAw$w0AVQUEPKNJs4kY;56 z<1ti;+sB~Ic;?)8;bUKw_Sza%K6mDtZ_at=o_`K{=%W8xH_CTA^taV(!{D2<|Izfx zo%I?W*0-vb7>nzxx9)V34WZPYQoF~woJDy_bvRDx^4PNO%kf?`v|G*W@aqf(5j)?T zL6y36{s)mq9KBveWVuG;RY*+YK#us(YXK*aaovHIP!!3z+e0=9J_C zxyD`EF=cuD3Ll3=_w_eVjEL7?}^WR>T{p`?58!i8N=@FFIo|aMTQy@$D;g1 zgtcTOW1uonk)`CIPJ5#_#}(0nQWTl;GiEQv87&4$)Swwd;8qUlONHvOq7H>tC$mVn ztmx#2O`)mxu(d@ZZbYLzI#|ST%Ah*}YM>TjNyKK7P#6&BjRF-ZPaTp_g{=NGe+?Zc z{`f|-k#01k7X4{?viej%bu^M6ZCFMpT2gX$1x`U?3R|bD5tRCKu5_)dSpa&-sPYA_ zAkCpmdkM&xVr8bDfgeMPdQqevM5%zi%3IwEB)HmjvXrf?WiN}_%o?hjR~#o{29_=q z{Dm=Cg8_nc0<@gI>MKgY=>}CaT1McjuQ&awXb&P=rrgnIaw@C(LK|DM6-=;Nkt@GW zo06E)mZ3!2Az&Bl6$-EqD$-S}SEEP7+mg1LwUyylhuX!zRH!E#T94YWyV`%5D7Xzm zZfaSX-s@NrtJ=-&4V{ZE;Nq5SyOnG>eVbkO`n9=VK|zklX5heCCH|h8ZLovqidW7a zS3=3vUP}qAA&WwHtCvIvgGXkqo@P{~EcFnoV(NML3qjy7E` zAqj(Jz$r|^P#L9F%v>tKq6CvfW-=f{1=P0biY6(E%ozpO2SfB>KCt|jsaHJ`*Z!pxX`0U} zMihih6tJ;A7IKgit&wFndlJv?T0<814{ghf+cBz|y~uj$zKOd{-QKjd6R1|pNSn?U zRKMgD12W*$p>%ZZ>9`UhA~=q;mGaX?!Gx_4|NRu0!}Dq`x)POyU4cNE$?a9 zyV}^k_F^Q?Zm2<$AmBE*;1F&f{LC)J`T?+CEIw|GU-{kR{NqLpF(RedJX$#4V7-eC z^q@QH*fg2<^&0DCcjtS~?n$*)AkGx1J4EX40l8LmE|Q+-Z|6ABy3oH4_OOe6?4~wu z42AX=UiysB1JtrFJC61q%4>3j>^Pa$PS}DZ^lTCS<88^C6 zTxkbO<8PuAD8;ekNv}Az9`C)tK}1D_CrQRqbz)XE=z?ncVB(F~w(BlDq(Ixxim1hs zq)_wamzVqL+b(%)$6a=Y6QSMJi+98BJyd=#=D|~avBIgm^B9Y=s&Aid#>?G4g>QN+ zwsc+MTpzzT5!a!~4*u|ihUl)#J>?s__}Yh2^AU+@?==E?6p3CVuos*SrCEI$ZNJsn z(mwWSE`9+hfCET?1t=Qi$1v2P6f`9z&4Em-fFNZhR3#Qc9atlS3rHw=VuDSF zgS?@GST%!0h=BmubZat(9r%GE$ZaE7D{g35Y^5MY;(1|Mgi>aBBzPQSNG3iAWGuLN zvG56rsED5s5w(Yii=fY3w9DILjge$3zkES>qtZcK>^ElGkvI! zPElogGZzRk7|Mf>ETWFaD34-5K&=j?_nv4rh)R zg^md+Jvfk&nUR09vlj<}E#K0UcGrHsNR*cdi~-V-;N%eXm@hebQJ)ZzrNECnNe}_a z6jn(h75OyvB$BSvlj8!Ed+~ckNtR`4mS;IxN7-O7poPEYD?=F@FGUuYF>*ISmuMLp zY)K_6xdAnyIOp`UIM*2^LaORiYu91vY=H!g;RwV8>{IxXGLO z)S9I+n_dxrBvPBbX`IK2oXIIHzp0#7;VENj8o}8W5d)7nL7mNMS&XQX*vXwQ#GIoc zom_F9P%)m}X`bhap6S^ZnMh{psVILjlkO>>^GTm~#h$VOpL|)L`^lgE>7PT^p4;@F z14^I;YM^v;p9iX-3(BAknik*bpb;ve6MCQ!N}(5up&5#v7OJ5g>Y*R1nj8wEBTAwr zdX^$;qA9APD>{JwD9WNQ3ZpSPY%VIJHEN?bs#!FOqdUr@J?fhY>Z3s_q(kaYI!dHR zilj+8H%6+YP3oji>MKkNrBh0!RSFwYI&8KUo5eD4A9G$-nptr;5U;H5I9simAp)qdM_(p^;#iX&MX}k%s!q{yv8o!c>S6I_HF7!|;7S_k3LBitIqC`))QS+sYOV3g ztlDa?0Z5~M2^uC98vI%sOH!9%0VG(Mt?-o^v4#@}8#?})8k_ffsAU}AQa%t{Co1?% zw8#`Zgb{ZlCThwR6gwAP*+~e(8>5vbAZxK-;joqT2_!2X-LO(C8x|-VlNh8D6I)X;>k#xyzzspa zmn*!0$)wHuyy%4*(hGmZ33;{~8iwJ$(r9N35d=|uW+`KrVC)rKtRdK9##HDhf~&&4*uV`# z!xo$tH;fZ>Ts<&+7I5-GL~MaIti7@U!p5SzY200ed{Ix_#)u*)^=UXYlMh=#yEkdc2ULcdCD4L%3A!&v+Nb3 zELJpV%UTi3y*n9z&sbs4Z3xgAo4uVj(1kKtUuT**B63Gd9# zg%%ss>{yvG8^+wv)V0tLjnZk!$b#b|i)W5*WD?o2P=(Xd6=#l|$`i%0QeU|()n#+} z*%dG_TA$DdDDXGLIFXyDlyLI4Ss_=yN*9*%l^?{@6$h1q0WF$w6{d+3Tumi&@eu=~ z)NjHaP=c(`0@Vmn7tg9zINcL7Z903gb7u{;vGdj^Ydw-d5|#cTt!hjr`%DlR3$?l? zp6_-kO!26R8_+Ukw1{IAZB@90y%h!R*hK-^S2iz)*3OFA-B| z4NIjh*9t8W6u}?EXxoGX(*vmzQ-#zqB@;A3gNwQocMTNNokh)UG36|ig(caat=@HY zl%g6ch*u`)p&q>D-rfV>(QPLM;wcYe9p&0;j?6c)Ac8OghCHws( zS-0ZA*WzR4DzJhivqISR>^_>CE1itl0i!F@=o7NzAMkO%WPvOHQVMq<9CC4IMGNFz ztUOKr8)`wyxJyyKljv3mO0wFK1DS9U13|`@)LW;`#zh2wnLvx#VH{c)U z;WTnZ6__Ka#Uln89+6cLH*O?3E;zKpClp@bhHF6#e%RiWC5Ya^7vUvko#^!BF6VJ3 z=qM(Z&Sxw)cza|X{V`T;h~$>;=r9f{9A&{vPJ|d8=C2M#?LA+VnJyPu9VpC^L7%>e4$X zZ$YW`>Gr~xS|jB08}uH}uMaQr^;+;1Z}qGh+T-3g5QJpGGwWNgM)RYR0HpQmb4NW% z+;1eV`6EE}{twrj6%1B1H6%OJ{#SDT$8TV9(x&9*C}BBqv^Js)_vS1@bid56)j?x# zyXl_zL(e}bvpuI%(5zDwJt0_3#P6hh9xDPT_yZn>5E8>4@n3=Y&iQVo{}l-y>5(`Q?MC0P(~E_Dn#}k zOZjbQ_|Pj~bAIk4C;a?UL0s=>C=5arlxhxgJe>&YZbz!$Km2_>%s1a2&FV-uU`9197_27^gr1`1`waR;rLhZU$<@)3Q{nHa8(e4|L%1A6!BlFZV)YQ zpP@SzCsvGzQRBvy zExV1RX)r0rlcj_%<@hwERf|!lX8K3r7&khx)W zTV>F1;)&JE77V+PEbV_-c_v)$u&zkrRdai@vd z{PIYm{DQ6_f~tgJC>r@Q48EZF!e|vbMI*8}GA&{>x=JGwsE^Mmcr;R)Fyb^KPpJbH zv`I@6H7`X)D;3g`UOkngDN(fbR$Oz{byr?TWO24M^^b zi@Xy_C1YW7{yy0t`$|AKk2Fir;tECR&_BD(liDS3tI|p=J2JO2ffy0atR&OT>d)ei zLo?56ujObWgdQ|Z&n@?rvETIG?M&8KKkfBlh$EJGVu~xa_+pGF{uQK+HEk8g9h0r` z;~+!6H(6=_3DaXBVeE`ZtI$Lt6NDH^557_V>El@`gAI@^w&06pWs`MdiD;0=o0X=% z_$1Y@ZVeL9E}RY8Iny4ajtB;z4G|jYJ+%h(u`)tzv=3cCCR_CS(1-$k4b6JB3?pVKqWXJ6k>Xe!m8|L+7rnwuNH{wiNdJ*atO@-DjsBP-r?j~-P zc0=0sn)NO{Xqn7@%51xAoOkBtQ5RWJ=5aS%z*v@UD((Eb&mE-cL$kc3^*?w2efZ;- ze}4Mww>5O#Gv80%h~Uc+U!xdgf0p_Z`HqDHtPQY#sY?l>EP@d@A;d3}KpN3vVmGiL z4m51C%YLwSE|g_0PgrvaOh!kO?4Xc3v$>paFsLNzrL2QIQ=OlLWUQ1S5PeQF;6H+} zxP|n=5PhHkd{&q!!NmoFj*3qUD>JE`@sEJV;Ty@CQmRQfach2&VplMdMbt#jA~62o zBD7rfMZ;lHi$}ts{oMFQIL1*|_#2PUK;l8|(c~#Q^irAx1DX^z@QD8cV1y(Xp9(H0 zTdm@Z?=14eDE$yF6c8d2jo8NIv4oG#nP4JIg2x)l=!4a>V*WY_lW8R@fQme2M36>C zicn{bTKW^74oN{&icplDLSw_4C`VuhQ<%dfW-*PK8aiUjjz4T;)BfkCg6zyaJ{)9M z{PMpWrf!cPW9HKsm=Yx+Pg!uHSt;mL#8q_bQWNRoXA~BMOeE%qD8r#HPjVxdz-c0* zw9Bee;?Iu22S_p_U{Kh}C8xnto(z0f5A9h>8IrAN7wJaM5M<84T@fSgg#M6%?qmqD z4XKoUDU3myRv>0JNl78yp-1xsQjqk)FO*B3OJxL8R6%qjGF@DH#HdHAWCf=k)zVNU zdQ7AyRjI{HrgN4V&qt=No@YB3J{@V0eS!v{Ald3h!Z{vtF7BL@NT(uS#7NoDtewrG zs70}g(TCosoMSC(K@=KQlx;JK2;FNz=1MBE8p$RWk!D2xYSO@x>WdJfX=NPxkHan` zv2pc`Kx~SGp<1@6EM016J^NYEhE}wvX{sXam_afX5g{?EQrz?sDJ7-Wh}b-;VHHKQ z#KcyztMv>la|SSr+9XS={UAw$5Q~z62Ddx(3E*fMLfal^xo7R^{z72LkqLbwJ%Wj$ zZ#2je6d*TCu#A>*k!!ej38c9YZD=i1M>FYW#9cgeZ+^%azOH;iU~;2Ly*@aRm9&aUFhbL%NGDDJmI*^-VuFWz4r!tY zlI(PCKdT_lRHudNKNsiKorYUS0Oqv)*fdVkp2|{k&17DS4?>eL1gL@yv|%SKKHYw< zj?aZ`er}uGI>r+$FN_c{epshm!$>UL+G9$8Lf`vVQna4US;yVlYr7`ZTyU-H3==vw zQKMPD_pR@Klg<#8z7oS_@^F4z@!tlX8`mhl6oBjW&rcnDraA3txN3Oqe|{BC!b_18553fS z68P5-S9X4G+#)7ldCDFBaF)wl@r!4C;~oDQnxFO`Fu2_(Fk4-T!~zG{Tim^+R{6`* z6h6fEQlCvS7r{|}wV|ISsrt~iEFM>!I6!zTGQrXyG>0_PnI@r?#IBpIIVY%R9qmWG z-$8TaIy71S;NDGj@#S92$phlQejl3=tEc(|Bt4u@g#+U6aQh4FesoNaf_KNJ6d`q- z&~T~!VZ~;^c;|SM(+|~u-OlzwH))=G6#jqt!?#bE1h<$k07QuSL$i!3xr-w}Ba6Bc zqrd+56hv2!MeTrt)S5d0!Gwc~5*)I)P!ej>k|Y!g z&EvdM@Vq>!LcY*Fg9w7g>OTqOzMXij8Vt1?9KIv$h~)E_15^|X9G1S~Ksu~LJG{eT z@<2SqkPIvlmnjWMqKQDHG?igOhiT0m=iKB6!}Ba7{rp0#6{d37`VFDt3*xQ zL{98P4b($V6b%P55l9>jwW5hr{tQJ`T)a}5zEylhSR}1Rtg%P@rp`#klEB4S+(lmO zMPK|yUIay8be6gzky2D2Bx9ds97bkDswFhLW{k#W6h>pTMaST-l)y%5+(vHfMsNJa zJe)-uYeqv@g*nuaW6Y`EW0iFTM|b?3Pgn&ld`Eg*L~0z7bljWqQ;K%1M}PcBfDA~1 zRE>Ka$PS^I@-s+5JAXHbV!PNRRwTS(H3S1WA%C zNs~OulhjC*TuGK}Ntg6Sm3&E=oJpFj$x4h#o6Jd_+)18%G@R^7pbSc(972mYN~e5EsEo?~6cNW+luE0-O03LE$fHV;)Jm`XO0d+*rVLB6EK9TO$+0|3 zwOmWKG|9AVOSp_nxm?J%oJ+gBOT6U9y39+y>`T8?#l6gxuL7qW{7b_;OvLP!z)VcW zY)r>=w8gX);F*_+d`!!{Ow3G)$jnU6>`c$BpUpfGI%_#-{7ln4P0S2U)Lc#0Y|REQs+8PoFr?YpP7?Y|l@mPWOyY`JB(` z?8t<1{EYse;snA6OsYH3w9C=X+;oiY^o#%vL)O@j#)1!zV$hv%P+@7%pehIm zU7#tXPYr!M=iE>a{m|Cz&=Aqa%2`IwnGjgZHNQB=W2&^iB2X3`A8V{E1jLY-2(Sz) zkQ==XCDBn7i-;ce4IB+p5G_*16Hy~gQYB?fB>fPhONl4_oKmqU3IHY1O3(py8VHHe zLre`U#Sj-`u`o3>oq@*17}G>sQZ-$)CS6lEebcsV(++92l%Uhkp&9;bh6Mg=B0ZB$5&RH=Ma4WY!2uvE^Os!zbtM(fkk_*04r zvcjy8!Tu!GQDwtYl~h#?NBgYGRee=h6-)Ts2=v1+hu}XLc)lPs2%53HwpqUlA+V;< z#T~<)C~U$~2}9ZowOuud+H2Op7((j6RfIFg2=%etqX?AZ4^hp&Wc7(cYGcb7_qiXdRJeyJS zqlwW|SnS-0g^g8+eIrVZScOnz@Te`Jaymg<6&0D_hTZV*4GE1Ba<+CgSu08^?t2LW8?1;ijiN>vymiZXr zT3RZTGs-2J!1WD}3a>sxFooETf4DFntSG1(+I-oOKD#fXnOvEz+$}Sq%@waDQ_!Z^ z8IQUhu~C>z62D5hFR&$LoTkE}; zy}e%T-Cmf)*gcUa{VEFQ-4En(iMq(D&`8yeP~Woxl0X#*= zSzxji4I_%)(5+sO2w`V&UhYldT*+P)ZebUOMTkTX%_NV>2wCvBQQ-JdKjBn~_>)yq zB>%7vYzbljIjijJh!^Z(?f8oB7>kMk-BBDKr)3Z!?vL_t5HC4m<#Spk?u_YxCPj*1 z&hpeHt5@JHAbd$w$9R_{4x~*ff9!z%SbaWlhkj+@3O+?NaG&P^w@MgN{n$tPA-%$?Xp(wXOu2FTl zGR+ilZe`9pAC7?LarTySreVVXU2G9!qX`Vit*u)|sWof0v;Uq-Lr91jFc^yPa3ZfxW zt!la;gCOZ!@}!bZX;*?$mi7@$9oADt6Mo+1O-i0xX083zU;f&9ATEX%r)g# z$YQf%9P2Oq5@Kbmt~#{1GtV46sKKLa$+Bz1#%m+dYrEoWxgO!Qt{miQl(uym-OZto zF1*)FrHaG+w9a>qJJV%gU_5eJLZ+ETLNM)V?Xs0_(4yXxXl9+lI8RhLH8T z5#(Lkk8uga{Vp+7K*1GmS~XENM6Tn)QP%Y{cV08%@|fL5-IcKkE0f(U%VxSbFIwue z=(gN1lWrW7TxmWR*Dbj6tY0fByP*aNGMg@=)-n5){%NEhRWpRbUGDDcF7M@4I2>IJ zL~GxX`)@@FaH73!+oo*EqJu`Al!aVwD(TauMKXnSpZfCBmpW;hVe($~wJ4lU^gFx%)4V^*aqC@tZU8sYCI!Wm%KG1Ga#O^cuIN**SQ% zU>#evQM~M*K<|Bkdi)~ z^Yb&5i8K@nK`*x3fwdS~+>_LitTWJcRxOJ zc%OHAXN_n_*ro_|#YlC7gr!nogo)#=dLMX0^mc+j_+8IlevQg1{k{D|c!;+{gO7NM zU+{^qc#N0ri_dtD*XoV$c#wzaj}Lj0Kjx7yd6d87lTUe;PvMnsd6>7}mydax$J?2& zd7OvXo6mWkSJj>Gd7x+1pAUMXSJVEXFM6ae(xXp$rq|G=cVHCus;F;zs-MfJuX?P1 z&a0|Q-b5SMe#pm9{FMZUvdUvMd4$zSytqJ7c8{np@o&e8otrBG>e z3DQq}nO}Y4KmH|+$t@Yc+wgtSIQQc=?qd-V6y-LA6CCHKQFrGM-rpSO-%+WL6u~fj z!!vN#a2qIb(IZBN40CiB)-K%G%IS zw>}KN)eQtm)oNsfu%X<949VJct9LKozJC7#4lH=E&ZZF!6HcsnG2_OLA485T zc{1h7kr6+{+>kBRZdPvw=@v_3=+PA6-sJ4?Q!EM#QLlu}Q*}t!3T*?G{gJ{dv3KWM z4laB+@#4mh@73H;{yFmI&Ywe%E`2)n>eihzUzmL+_nBq~cJH1^Vc1VNO2I7kxgmvv zZ6k(dBr(227-6OMK>-^_Cake0p`cJyS*;RFLWW?K2@DL`ht(>E;P%fX-2@>}5WRh} zAVI)|$K7}f@#i0a4WTdzRx_>A6?h%O^r1z(Aq0bo-IS6Si2aE+B8XC<&umeqlydNF%yy`!bGJ*C`7uM zXo>bFnrDpuwrEILu~ca1BnS0Y%7m>c6hwlCptp*K|6nB$3b7zct7{Z=!{tAKb|WaC z5&qLCl%9$f>Oe4H#QMxM%$3En@%L`qkjT7r@;p!oUpDk6xERwS8p0o?~L8C+|m*P-wui}s?lJUNZ?8qy=o2hFP z`Z?>k12wuXu6LT~TBF~cDUioBA1kh;VcGZ(r7kMuaYYBwh+E1wGA)qJk`AceKvC%` zP}lzde0`g{1096z$q@}G?VV*%o9(`@fdTA;!edg7AH1A6DX1om5E9J=5JG_C4?O!GxlDA7*u5Sdc zUdiNjFykQ`(!QF==K{1^m8zy=Z%YykkKUL*5q@y#cLtx-9AMO}v>e}|OxnokYb?hW zCZ`wIyP7wNdslVm-yWO}$gg@D;j4d|d;`2Ny}q=w<%D%pPGb7yA*-x({ygoKz~4Tf zw&?nGyP5OoaVf*7XS<6T#+Cw2u@l7q)cFNRuPbs%VhstJfbItB4~cv`I7LLV6i+x`lml*i9!Bs&_g60)*Y6<_Yge{kAXevA4^R^(r>00rX0UyqsKp1^>-lXwntfRPa$F0?PgR?nquYNAYA5kuw6v;@4` z3xW&v6mfx*-gfQA0uBcKC0so5Q#zXiN40}1d{eZK?pAUf3K<$3=gxx_if!(oA>=6T z)Y?o&_d&%GlMSA<)^ChGPD6OSohX(JD)cferHRv0iL`?NOAsSiZ2S8=;FPcqh#MWM z@cNYc+1(h_osp~xaLLO+j)#%I2ooT|I?(M6$QKlF$3rI7 zrX+pzDwiyX*`Wjce}Y3`x?hY2+_xV}7Zl$pIuIIxL#QOgwyN=fW~PaRsoDt;vx*-v zs&|G$_bh<8N6pF(8i+pZM(k-V^!<@!NCNuCHGi46X{mu>Tf)T3i+WGCTxmmjDzeDb z`XghLTaM zd+pZ?9DZA7UXcesmELMcz9!RuU|{RZ9xZ9DsH7r5)0@;BdV%SH3r0oa5B4;W%zu>< z@CB8Pif5~9>DxEyL4Tg&9_Q{}P_`sS8C~reZ!lo4@^Z;a|DE8ECL|8C0HGs`6P_PW znd*qYTkdZh_6YW0k5t7tI&G3~3aay&J1cAov0^~3G$WBpx+u2sS{TweYNHj;^tmJ5 zQt}C*Uq(BUK=Wd-xh7K~o`|sEv`T`kS$AnsH0=tkEz4~OO?RU&Lnv39J+Q+7yts%D zUJ8Hw4!Y4p=wc+wMlo5P;?&5hKYX}rb3xLqctn`Xxd`DYsW1Kn8f9j6-q*N7$sQf$ zE)k)uq4XD?$*1BkqpaJcRR*AVRuOd+ke#RW*YN>kpiK)g? z(S;27ZWBg=A{!R$itTb9J;Jj_vMM+rvhp(4KWv@mGn=g9$vLz@bUX1$%dpAGMVdz1 zHU+RnVTK%-(JQ(uyegDwGj^HtL~PIMszu&z1z1aw7$pP}p3XSATEqLbubm@2i%#qn z6ZU*UZ&i4{jVi0cU+l<=TBI9jE)xzFJ9e<}_v#+IY3da_347W0?a<}6jXeCscU5Hd zr#V5#PqDKQYEk&uEUq4U@$)F^g7wkP9|JPt7fbe{@T+ilBb-!c?^i{4d#mooLdCDt zAKUj-T<Q0Z)_++P> zaoF2F3-QZ&n};14iF>Tqom#5j@D5cZel7{%TsNKmI4!1)1@`BFp4_dW*9n-|V-b1IVVM0|Kr&iS;5yln1U)z ztymaB8E9&o_#<|hX*Kz+r-|IzGuccGaEknBuDU!L-48Y+=0(CpJ;OO0eTEl(w4Lw6 zH8<{MY2-vV7`4?{#&DQI)^EJl!Q~sQWLRsH{mfxTg4=*GF#vlpoLE}M*ZFdl_#E=U zGBk|1cVjM2o=wrI&G*gu%Inl*semLUfJEy$cdAVn;AZtVM!NeH+p%@W0 zgdP45T`Ryrwv3G~x)DGJh0ZaLK}PdAW6~B?&z2tRcYP{SM_NPu6Fk)=hQrY?c;>SF+wLNmHee&7lfmdA19qZeLDV8B0=tIAv zCGwFpt;<753lZ~ycI658YsE5Lk$3-imBFj|!BJ$lDrD5GqauEf?x4`>~hiIFCRP;$#8~-yAU`Z13h; zGDqm{F%Cs%cguDH?7HkqN!?jueOW@kM9oYnH)D}iC6AhKs3G>qKA^{V^+2hw$&v+u z9Zo8I;j*zIir{a-j>&^la!U-zJIvVw3Cgjpof^wh%|yN0|Svpo{d$`rZk_4^;P<|fN-Lp1G%he zd8x}mY7|v!xe1XE^8G2kQL< z3`C68u=oPfLx=C^;lFg`rCyx54L}KX?*jl`uZdIYlNiyXX#r>$Xm|xt%vEuPuBi@_ zUaT?FC5na++4{rsw^ZdlhwW1k1w~>b!)4j9{mTACMQ#oCgQ6mU6E5-?-%X0{-7Iv+ z(GfAhlf$^FCO3t3`uV;HYn|zq6(9n?=YX4`1O=W7 z9_PY~eL`nSTq$ww2EFpE@0 zQR9>&V}KAi?2fcB_QWOh-dXtIq!31Ux-M_NPIr3I0~ukrsbq50>?rJVXNdmnl4#~? zng=RMj`dAB(G~tU$C}Qj?Ar6x#bA8~c&w{2sdCd=rCy^O+M+^nK;u}X>Zl%JHok}EbROK}LJbeXH_FAM=W}MV%b=g(< z%^Oi!SUL^Mmi zn-1#Hld+$$Cr2~SdB-Jl72GbXi0^2ZqP`=`SHqYJQIO;guTmh2AH`STS`|AZj^?&i zo|mE^aWtzAK_>ZZ$Q|>3ABHC#HN{#=!5cQtwIPjMQ;t=cy);HTP(7dRqEJ@zF|Qhv z{K6ZC6*TM0v7+^%hV5(iKB&$RY5~vx;^acBJp{G|uI_MmWc{QV>FafMwIGZOS9mXvYh8x0V zK?>cJR$(~i$*KInD#m+7Rr^Ma3>Er;RyIi@MLjTt6iE(J5z${0I9y~z26Ca7nqyOR z8Hst5On7Y#NXiop2hxhz843u}g~|`Lk_dFNnVx=Mb{I;J!v~KDNWu8d zo-C*Ks?4@P>jj7qdBrT>pnLq>7xI=prr2DdO*Y1_#kfxH38qiA^MyQMHJSuV8l79- z=!Gvr8X*H#aoP|aCHRr*TE_aH<_(%mAj}OZ3vzlsXolu|^Lm~py$!^j{M^9+Gil4q zJ}-0B71Kj6i-P=#ov=ytZA5{vp}Xs6I((M&H&)BLrkLKs7_JoY{MI^+CJ^uE);Gq& zHx5aa8eN69w%&Fslorw6_8BXtw%-vlZX8Z2?dxtFEwUYZZ=58uou+S`39_9xZ(MG( zTrO{1m$O`9Pj211vfK!6U(R1Vr@wW#Eq3R<^{B{xDRb*NV!rK6<7F*iEbQa$(e%XC z$0xLDL&?V%Dli=D^Qz#sKg&nabVoSJ(XZEslLu#&@KW*N$P6B7C( zVl@S=cu}gp*eos#qgeMwc>tMNo+zFF79kSPiYKzSA!74KT-~ggw<}Lsvts=-S*$zx z_Vv@b91Pm)^OucQ2^(th`5+mg_}w2qI(I1*nI^SrY!@$87V>Ry3muPRp|3FXtGzV3 zn=R`%VFC_=>1nirRze(yzM0c^9QLnT-k++`co-5{Az(19Fl}A3%B7?B@0=9nHF;zO zoo7P)3T!h|ZsRMdoYmZRrFH!jv9?{m9&6$WTPAE9Mln7L-%n-Mk~diBznp)@ z%nUm9N3<(o$1o%%r>Q~(K1N&Qcm4G1NcHO`2zXett+iwc+KCLhQCbs?ep40CWpFP& z4m-CAdX)g$NmwO<8{KqE1Mce!K2 zFo{~{d8=joWbj^;pA0!+kUu`Z9xsj~g zze?Jlk@+=)k6lhNN+uMa>>4YL)>^M_JR>MZ?%KhPG*ka+E_x|;dW*a*<+b***v|)1 zNA8B=&Zbw{6;tDoDa^oia&^*W<8#4DWd9fh{^*bHvQ6l%X0KW zll_*XZ#yc!x;Ee=66vDmc(VoW6@t<3?MX#jpuooUO3vLiGK>+{vF-?7KMXvD8GP-S zs_(q;--L{Ht!z_al3ZX;2yHZE8&{$|Kho(-2qwVN2IF+LPF!?}n;#}-caiTDblepieg)pxtC^K=`Y5xoo{x(0QJXO)NNO4Bmg7|puUG8+0ur+^s z0kBYSQ~^DoWO0(`#b}Mvs<=3^RW`RfjgUrqwvOpKy^qIA@Pb{9P!|KTa0+$Jk zO+JrQfdO}y`}0i_?|CL4ZtreyAE~<$kSP+nk+4Paq zXg0~N4)XP?4&2Hjp0eTe<~TrD(5}()b8%(vb0zCaFb>Ab#j8vP`++HbXVR4Xe2UVc znHc%GQDo_1^^-3$arl^0lxql!$u4jz%9w;WSs*p42_qaG5BRXJTwf?Ws*p9N6TkA! znnFjPeh(lR6Wn$hR}KA&MMpq~t$fZ)qvohx|2~2su_1_~b^=o3R;N6P2VuR^JW*#W zos2tSVPqvs(;k%x^_&7~h-p0#=OV^I)v(6Gh+&!( zo~Yb-S$4gSBbEv95D0j0GlFGStK94BZ+e>JgPc@95n{P_Op76IG@omnQhQz+Ax@|E zEqw6%yqPaH>%Kq;T~raPUO`9=xh(DlKTCKD*+OAdWU<#cG`p%H*#zye=u(XjWu(B{i}?!3}Lg%@!0^;39#l z5&z|hC?0MGDE(pQuuYMsW&f)s9Gf+Q(LjK^S7zTQVyW&_&1H~K%Q zFc{E-X_D8m>rr!_g?(lrDC$@P?kUp8b!`E`5>R?WgvFKFH->|6W8aSQ$4X`UXWHhE2HJ!Py zyDQufq3+#DkbRz+C$ep%;lEPnbJe#iez&NRw?CbO2-=g(L)HwTBFsZqgGzyxG~bEq zN@9iY$%2iwgxBMwh-UZXh5V=^y$K6xL1iFrzv9Gf!XohYh=Sgdc3S6^En@>z^_j6w z#)@ZgFE3dA)zWlE?M#Ujh*ct3Shs*GB}-23K>NdiTG3NbxmNg&PLZ)*1qQU-VD>=Y z{)1jsAgJ>Fm4o4svA$T`Y$eQI4?;ZIs^8Rk{f-Yg&2-(^plze(qtEQ2Is1EqP8g`V z_0rK2-Ndi}d#*ZCk8KRE&2T`x(Kt5z$OgR3+lutD_QP!D7(<)Uc;L<3v?7R|l8NyP zuetg%J?8+;HskruRPK-A$4>STlEpcqrk0`+XRl?G)g|?M;a|sYkv~aRh=^N;MpfKW zmQ8nwcACdiINS?F%nt7xTjyt8oa@@mPFow>Rza@zy&~q9E{*NGdZ#wiZRWSEjUA^c zu9ll37C#RfI&WuBO)lFk9@87T9zkaYPed)Bm^5^w>bvO>v|FN*H+;rTb)>ZtF8Ve)pYv{0o&Ss*Hqr`_Ubvd9ryon1`xQmZQby^;{Q`H_@`FleLNW{ClyR+A>3@7ZMq&V{dQj zgGrqnEZRb+`e8SXGIq``LFB$mD7VdG2hQ#}8QvT6x2@j`T)exMxli8SwiD31`mLHs z?eyPvb`-e=J}w*Ip#10_*>Vk`4p2u@_|X%baWu(8u`#Oc+XtpfjWnRxoaOWF7oswL zciXy2OFjKX&io>igLsR{pk+X-(>+bWf0Hxqj@w$?BZItYM_|6iO0n}Y;`SSSB8A@s z5dH}eeA`n%^@U%3buAX4T-A6d+)*U%!od|GjrPkbJ|R<5%hJ8wJYf=~l0ktKfU6R(_=$p>txbolna zTRM+F^@MFP=GYG#bWWuW1Qhaj-O2l`Tt=e2-oq60n?hZ>Dp~b7&5pebDsi~_573U33i-if|GcwD%j zUwwJg5M+Jg6V$(o`WXF10tQN3ePEUkx>slqya`(Pb-L>IupRX5emjcs`<`Z7G_NNI?x<;Nz zxV1@Z0zO3pRNPwC@sM*=kPWI^j3Hc=^Lhizqgp$dUWb1xWlsgV^W%&To>q2$v+Z=KgIWNzFJ({L7z7TBIHE zq#4`~*+8-(4YHE+hw@3XSzPkEnqQ5HW(~q_A{B6g>hvYzr4}`10`*UBq(>kPSd#|z0ts`9h98fXp!NxI60Ot(Ewwv< zUYkxWfeylh%!yBLq)9Jufh;mb{~V7&rWQpZiQ(l0gN8e*ggPTZBBMzX&;p-{T8qh6 z8{ct?2?D>pC4$+1ia7|MB~+W`T@p+56iYll7^)3UO9E$1fphR#3$$5Fl2|LISU=%I z>a-zENszWFNEbd^uQuC265GfWSZj=JTAO`7iG6vBeHEW$Q=4NqiQ{mJ;}oCsQk(NO z2{M_)`H0W;M28C%%7r=2g-gIqpuK|5Grq zNd7*U*rb>^q}aIRI6w+Kd@2GWS|U;g5(*|VDi(4YFeNR7k`6*e&-R}}&A|3|VW(#J zC!t|rr(tBLW#XV?;-F{dWCU|DvvPykdDuDmxOt!QJrxoZ5)l&)z#C})7RHG zFfcGQG&C|YGB!3gF)=YUH8nFcGdDN4u&}VSw0!pLnU$55wY9a4jm`7t&tJTFVQXt^ zXJ=<`Z|~sX;OOY+ zuU`52`T6_%2LuGXehnYX8yFN6^ybZ*;NakpkdV;O(6F$u@bK`rZ{NOq_bwtLA~G^E zDk>^EIyxpMCN?%UE-o%UK0YBKAu%yADJcmGg(fE_r=+B$rlzK)rM-Xu{=

FMbi z85xloS6&010 zl^;KTtg5Q|^yyP|b#+ZmO>J#mU0r>BeFJex$=voBS?^A!x7ciTWnB3zLTCe)EnlxXTTl2LD%@)cY(1gea2k}qx;PCzC@81zbK_!F-4k9WV2o3!=h?v+U zSU6)H)PD5^ekKqEZmG>ZbnufCU_vc%>NJ;HeMDszP}HcjStMu$I8JE z;e5)$Ex^Sq$ipwpFDNP`A|WO&B_$0f6HcWf9LIlw_M)dcye}jb^*um^78un`UVc;?d^{rKkn|}Oy2+eV?8`P{6XhG!SgqWFa*TX z-ynVtMI&G}9jwmleTPS{oT*ro|0SA)*Wue>O~F7s0FjRbt+sFo$^su!I8<9Sl7@-+ zgB-oCcr-)M4E`9D%Jrp_1+qa%a>Mmy(g=Y=&5ZP^MjPBVhqHcGS9W>1?jL*~@u@Tm+Pk=+RBh&(>Voy~bA4^D z+00P>ewVSBd$BR_p4ai_$228^F|U{tm0D}P?1yYU7RAc$`osBZ#6sAv2#-`{a{tfTGrbgefinp&f?{qAya zy24_-v%|kv)VDoHBgZn}?)vgmSG+~nzL?c0LFPJp62&{OnwC z0X^m9`NKhcLcDMd35W>5O(ZNPA_|w0q=cljv<$o_$io}Lzq-MHs~h~kuB6}PVE=bL zI6ORrH-zKk;}dvGIED9wv-5L!RroEbtE;Pj>Z$+L9{yC4{lx!PNk9yW_rH}yBNIiT zgufvl$M~m`RNgXcb!IC4RuYQ!aNkf}$#@RF*9icwB&`hTcT{F@B@GlRXTcx*v@@l8 zbq->FphLKl61}li8l6U~%ty=3GJx#MO>QF6u_{d*Us}8`5A=L5EWUQUL3CyKAyB>E z{TdfCmf>5oIgmiFlC9cOyFCI|(#qGCy4~?yndsk2+Mh1fsW2aHZGbDudL&z|t?_ud z+2e3!w5{oMwL27(TD`sbd~*;Au^4NID`}!YB}cvEuS#0_tCD`+o^6igXmr6(qW*Dt zxH{g|_4tT@!m{=TKxDTTjK);D7J~J3Z!Hv0o@G6ZP~UDnoa9C6`dbR`z4doAZv~$r zl1BJyAz~-n(a9nIfNn$)H0MOQI-kCZK@!HN>Uq zj1rPy-P46o&HK=?WEuT^4MA){rHR-pz+(4%Ge0oB7}CUs6|yaGt^0E)gn)p6jDiXx zCa0ohW?c!9YTYkUI_lNdG1>w#U`z<`U z@BU@G|L#uc|7Y9%S;^qLrQfy_LHN^lQo&EKsbov5X{3X(AH~8--RR`r5fR{j^-ZLg zi^r3Sq7cHjmWMuJc6s;#-z2>Sn!qs85NKf_wP!ZZX?*NG?PzqY-Jd^q(REd@e}N$K EZ$4!O`2YX_ literal 0 HcmV?d00001 diff --git a/docs/man.md b/docs/man.md index 33ee42f..d521ab9 100644 --- a/docs/man.md +++ b/docs/man.md @@ -1,5 +1,30 @@ # User manual 🎓 +- [User manual 🎓](#user-manual-) + - [Usage ❓](#usage-) + - [Address argument 🌎](#address-argument-) + - [How Password can be provided 🔐](#how-password-can-be-provided-) + - [File explorer 📂](#file-explorer-) + - [Keybindings ⌨](#keybindings-) + - [Work on multiple files 🥷](#work-on-multiple-files-) + - [Synchronized browsing ⏲️](#synchronized-browsing-️) + - [Open and Open With 🚪](#open-and-open-with-) + - [Bookmarks ⭐](#bookmarks-) + - [Are my passwords Safe 😈](#are-my-passwords-safe-) + - [Linux Keyring](#linux-keyring) + - [KeepassXC setup for termscp](#keepassxc-setup-for-termscp) + - [Configuration ⚙️](#configuration-️) + - [SSH Key Storage 🔐](#ssh-key-storage-) + - [File Explorer Format](#file-explorer-format) + - [Themes 🎨](#themes-) + - [Styles 💈](#styles-) + - [Authentication page](#authentication-page) + - [Transfer page](#transfer-page) + - [Misc](#misc) + - [Text Editor ✏](#text-editor-) + - [How do I configure the text editor 🦥](#how-do-i-configure-the-text-editor-) + - [Logging 🩺](#logging-) + ## Usage ❓ termscp can be started with the following options: @@ -7,7 +32,9 @@ termscp can be started with the following options: `termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]` - `-P, --password ` if address is provided, password will be this argument +- `-c, --config` Open termscp starting from the configuration page - `-q, --quiet` Disable logging +- `-t, --theme ` Import specified theme - `-v, --version` Print version info - `-h, --help` Print help page @@ -281,6 +308,73 @@ If left empty, the default formatter syntax will be used: `{NAME:24} {PEX} {USER --- +## Themes 🎨 + +Termscp provides you with an awesome feature: the possibility to set the colors for several components in the application. +If you want to customize termscp there are two available ways to do so: + +- From the **configuration menu** +- Importing a **theme file** + +In order to create your own customization from termscp, all you have to do so is to enter the configuration from the auth activity, pressing `` and then `` twice. You should have now moved to the `themes` panel. + +Here you can move with `` and `` to change the style you want to change, as shown in the gif below: + +![Themes](../assets/images/themes.gif) + +termscp supports both the traditional explicit hex (`#rrggbb`) and rgb `rgb(r, g, b)` syntax to provide colors, but also **[css colors](https://www.w3schools.com/cssref/css_colors.asp)** (such as `crimson`) are accepted 😉. There is also a special keywork which is `Default`. Default means that the color used will be the default foreground or background color based on the situation (foreground for texts and lines, background for well, guess what). + +As said before, you can also import theme files. You can take inspiration from or directly use one of the themes provided along with termscp, located in the `themes/` directory of this repository and import them running termscp as `termscp -t `. If everything was fine, it should tell you the theme has successfully been imported. + +### Styles 💈 + +You can find in the table below, the description for each style field. +Please, notice that **styles won't apply to configuration page**, in order to make it always accessible in case you mess everything up + +#### Authentication page + +| Key | Description | +|----------------|------------------------------------------| +| auth_address | Color of the input field for IP address | +| auth_bookmarks | Color of the bookmarks panel | +| auth_password | Color of the input field for password | +| auth_port | Color of the input field for port number | +| auth_protocol | Color of the radio group for protocol | +| auth_recents | Color of the recents panel | +| auth_username | Color of the input field for username | + +#### Transfer page + +| Key | Description | +|--------------------------------------|---------------------------------------------------------------------------| +| transfer_local_explorer_background | Background color of localhost explorer | +| transfer_local_explorer_foreground | Foreground coloor of localhost explorer | +| transfer_local_explorer_highlighted | Border and highlighted color for localhost explorer | +| transfer_remote_explorer_background | Background color of remote explorer | +| transfer_remote_explorer_foreground | Foreground coloor of remote explorer | +| transfer_remote_explorer_highlighted | Border and highlighted color for remote explorer | +| transfer_log_background | Background color for log panel | +| transfer_log_window | Window color for log panel | +| transfer_progress_bar | Progress bar color | +| transfer_status_hidden | Color for status bar "hidden" label | +| transfer_status_sorting | Color for status bar "sorting" label; applies also to file sorting dialog | +| transfer_status_sync_browsing | Color for status bar "sync browsing" label | + +#### Misc + +These styles applie to different part of the application. + +| Key | Description | +|-------------------|---------------------------------------------| +| misc_error_dialog | Color for error messages | +| misc_input_dialog | Color for input dialogs (such as copy file) | +| misc_keys | Color of text for key strokes | +| misc_quit_dialog | Color for quit dialogs | +| misc_save_dialog | Color for save dialogs | +| misc_warn_dialog | Color for warn dialogs | + +--- + ## Text Editor ✏ termscp has, as you might have noticed, many features, one of these is the possibility to view and edit text file. It doesn't matter if the file is located on the local host or on the remote host, termscp provides the possibility to open a file in your favourite text editor. diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 98585c1..19dfb65 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -30,6 +30,7 @@ use crate::filetransfer::FileTransferProtocol; use crate::host::{HostError, Localhost}; use crate::system::config_client::ConfigClient; use crate::system::environment; +use crate::system::theme_provider::ThemeProvider; use crate::ui::activities::{ auth::AuthActivity, filetransfer::FileTransferActivity, setup::SetupActivity, Activity, ExitReason, @@ -74,7 +75,8 @@ impl ActivityManager { (None, Some(err)) } }; - let ctx: Context = Context::new(config_client, error); + let theme_provider: ThemeProvider = Self::init_theme_provider(); + let ctx: Context = Context::new(config_client, theme_provider, error); Ok(ActivityManager { context: Some(ctx), local_dir: local_dir.to_path_buf(), @@ -306,7 +308,7 @@ impl ActivityManager { } } None => Err(String::from( - "Your system doesn't support configuration paths", + "Your system doesn't provide a configuration directory", )), } } @@ -316,4 +318,32 @@ impl ActivityManager { )), } } + + fn init_theme_provider() -> ThemeProvider { + match environment::init_config_dir() { + Ok(config_dir) => { + match config_dir { + Some(config_dir) => { + // Get config client paths + let theme_path: PathBuf = environment::get_theme_path(config_dir.as_path()); + match ThemeProvider::new(theme_path.as_path()) { + Ok(provider) => provider, + Err(err) => { + error!("Could not initialize theme provider with file '{}': {}; using theme provider in degraded mode", theme_path.display(), err); + ThemeProvider::degraded() + } + } + } + None => { + error!("This system doesn't provide a configuration directory; using theme provider in degraded mode"); + ThemeProvider::degraded() + } + } + } + Err(err) => { + error!("Could not initialize configuration directory: {}; using theme provider in degraded mode", err); + ThemeProvider::degraded() + } + } + } } diff --git a/src/bookmarks/serializer.rs b/src/bookmarks/serializer.rs deleted file mode 100644 index 6d7d597..0000000 --- a/src/bookmarks/serializer.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! ## Serializer -//! -//! `serializer` is the module which provides the serializer/deserializer for bookmarks - -/** - * MIT License - * - * termscp - Copyright (c) 2021 Christian Visintin - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -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(), - )) - } - }; - trace!("Serialized new bookmarks data: {}", data); - // 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(), - )); - } - trace!("Read bookmarks from file: {}", data); - // Deserialize - match toml::de::from_str(data.as_str()) { - Ok(bookmarks) => { - debug!("Read bookmarks from file {:?}", bookmarks); - Ok(bookmarks) - } - Err(err) => Err(SerializerError::new_ex( - SerializerErrorKind::SyntaxError, - err.to_string(), - )), - } - } -} - -// Tests - -#[cfg(test)] -mod tests { - - use super::super::Bookmark; - use super::*; - - use pretty_assertions::assert_eq; - 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")); - assert_eq!(host.password, None); - // 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")); - assert_eq!(*host.password.as_ref().unwrap(), String::from("mypassword")); - 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")); - assert_eq!(*host.password.as_ref().unwrap(), String::from("mysecret")); - 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")); - assert_eq!(host.password, None); - } - - #[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"), - password: None, - }, - ); - bookmarks.insert( - String::from("msi-estrem"), - Bookmark { - address: String::from("192.168.1.30"), - port: 4022, - protocol: String::from("SFTP"), - username: String::from("cvisintin"), - password: Some(String::from("password")), - }, - ); - 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"), - password: Some(String::from("aaa")), - }, - ); - 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", password = "mypassword" } - msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP", username = "cvisintin", password = "mysecret" } - 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/bookmarks/mod.rs b/src/config/bookmarks.rs similarity index 65% rename from src/bookmarks/mod.rs rename to src/config/bookmarks.rs index 58a29cf..0bb5265 100644 --- a/src/bookmarks/mod.rs +++ b/src/config/bookmarks.rs @@ -25,11 +25,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -pub mod serializer; - use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use thiserror::Error; #[derive(Deserialize, Serialize, std::fmt::Debug)] /// ## UserHosts @@ -53,66 +50,15 @@ pub struct Bookmark { pub password: Option, // Password is optional; base64, aes-128 encrypted password } -// 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(Error, Debug)] -pub enum SerializerErrorKind { - #[error("IO error")] - IoError, - #[error("Serialization error")] - SerializationError, - #[error("Syntax error")] - SyntaxError, -} - impl Default for UserHosts { fn default() -> Self { - UserHosts { + Self { 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 { - match &self.msg { - Some(msg) => write!(f, "{} ({})", self.kind, msg), - None => write!(f, "{}", self.kind), - } - } -} - // Tests #[cfg(test)] @@ -121,6 +67,13 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_bookmarks_default() { + let bookmarks: UserHosts = UserHosts::default(); + assert_eq!(bookmarks.bookmarks.len(), 0); + assert_eq!(bookmarks.recents.len(), 0); + } + #[test] fn test_bookmarks_bookmark_new() { let bookmark: Bookmark = Bookmark { @@ -168,30 +121,4 @@ mod tests { String::from("password") ); } - - #[test] - fn test_bookmarks_bookmark_errors() { - let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError); - assert!(error.msg.is_none()); - assert_eq!(format!("{}", error), String::from("Syntax error")); - let error: SerializerError = - SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax")); - assert!(error.msg.is_some()); - assert_eq!( - format!("{}", error), - String::from("Syntax error (bad syntax)") - ); - // Fmt - assert_eq!( - format!("{}", SerializerError::new(SerializerErrorKind::IoError)), - String::from("IO error") - ); - assert_eq!( - format!( - "{}", - SerializerError::new(SerializerErrorKind::SerializationError) - ), - String::from("Serialization error") - ); - } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 7220843..137992b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,6 @@ //! ## Config //! -//! `config` is the module which provides access to termscp configuration +//! `config` is the module which provides access to all the termscp configurations /** * MIT License @@ -25,237 +25,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -// Modules -pub mod serializer; +// export +pub use params::*; -// Locals -use crate::filetransfer::FileTransferProtocol; - -// Ext -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use thiserror::Error; - -#[derive(Deserialize, Serialize, std::fmt::Debug)] -/// ## UserConfig -/// -/// UserConfig contains all the configurations for the user, -/// supported by termscp -pub struct UserConfig { - pub user_interface: UserInterfaceConfig, - pub remote: RemoteConfig, -} - -#[derive(Deserialize, Serialize, std::fmt::Debug)] -/// ## UserInterfaceConfig -/// -/// UserInterfaceConfig provides all the keys to configure the user interface -pub struct UserInterfaceConfig { - pub text_editor: PathBuf, - pub default_protocol: String, - pub show_hidden_files: bool, - pub check_for_updates: Option, // @! Since 0.3.3 - pub group_dirs: Option, - pub file_fmt: Option, // Refers to local host (for backward compatibility) - pub remote_file_fmt: Option, // @! Since 0.5.0 -} - -#[derive(Deserialize, Serialize, std::fmt::Debug)] -/// ## RemoteConfig -/// -/// Contains configuratio related to remote hosts -pub struct RemoteConfig { - pub ssh_keys: HashMap, // Association between host name and path to private key -} - -impl Default for UserConfig { - fn default() -> Self { - UserConfig { - user_interface: UserInterfaceConfig::default(), - remote: RemoteConfig::default(), - } - } -} - -impl Default for UserInterfaceConfig { - fn default() -> Self { - UserInterfaceConfig { - text_editor: match edit::get_editor() { - Ok(p) => p, - Err(_) => PathBuf::from("nano"), // Default to nano - }, - default_protocol: FileTransferProtocol::Sftp.to_string(), - show_hidden_files: false, - check_for_updates: Some(true), - group_dirs: None, - file_fmt: None, - remote_file_fmt: None, - } - } -} - -impl Default for RemoteConfig { - fn default() -> Self { - RemoteConfig { - ssh_keys: HashMap::new(), - } - } -} - -// 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(Error, Debug)] -pub enum SerializerErrorKind { - #[error("IO error")] - IoError, - #[error("Serialization error")] - SerializationError, - #[error("Syntax error")] - SyntaxError, -} - -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 { - match &self.msg { - Some(msg) => write!(f, "{} ({})", self.kind, msg), - None => write!(f, "{}", self.kind), - } - } -} - -// Tests - -#[cfg(test)] -mod tests { - - use super::*; - use pretty_assertions::assert_eq; - use std::env; - - #[test] - fn test_config_mod_new() { - let mut keys: HashMap = HashMap::with_capacity(1); - keys.insert( - String::from("192.168.1.31"), - PathBuf::from("/tmp/private.key"), - ); - let remote: RemoteConfig = RemoteConfig { ssh_keys: keys }; - let ui: UserInterfaceConfig = UserInterfaceConfig { - default_protocol: String::from("SFTP"), - text_editor: PathBuf::from("nano"), - show_hidden_files: true, - check_for_updates: Some(true), - group_dirs: Some(String::from("first")), - file_fmt: Some(String::from("{NAME}")), - remote_file_fmt: Some(String::from("{USER}")), - }; - assert_eq!(ui.default_protocol, String::from("SFTP")); - assert_eq!(ui.text_editor, PathBuf::from("nano")); - assert_eq!(ui.show_hidden_files, true); - assert_eq!(ui.check_for_updates, Some(true)); - assert_eq!(ui.group_dirs, Some(String::from("first"))); - assert_eq!(ui.file_fmt, Some(String::from("{NAME}"))); - let cfg: UserConfig = UserConfig { - user_interface: ui, - remote: remote, - }; - assert_eq!( - *cfg.remote - .ssh_keys - .get(&String::from("192.168.1.31")) - .unwrap(), - PathBuf::from("/tmp/private.key") - ); - assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP")); - assert_eq!(cfg.user_interface.text_editor, PathBuf::from("nano")); - assert_eq!(cfg.user_interface.show_hidden_files, true); - assert_eq!(cfg.user_interface.check_for_updates, Some(true)); - assert_eq!(cfg.user_interface.group_dirs, Some(String::from("first"))); - assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME}"))); - assert_eq!( - cfg.user_interface.remote_file_fmt, - Some(String::from("{USER}")) - ); - } - - #[test] - fn test_config_mod_new_default() { - // Force vim editor - env::set_var(String::from("EDITOR"), String::from("vim")); - // Get default - let cfg: UserConfig = UserConfig::default(); - assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP")); - // Text editor - #[cfg(target_os = "windows")] - assert_eq!( - PathBuf::from(cfg.user_interface.text_editor.file_name().unwrap()), // NOTE: since edit 0.1.3 real path is used - PathBuf::from("vim.EXE") - ); - #[cfg(target_family = "unix")] - assert_eq!( - PathBuf::from(cfg.user_interface.text_editor.file_name().unwrap()), // NOTE: since edit 0.1.3 real path is used - PathBuf::from("vim") - ); - assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true); - assert_eq!(cfg.remote.ssh_keys.len(), 0); - assert!(cfg.user_interface.file_fmt.is_none()); - assert!(cfg.user_interface.remote_file_fmt.is_none()); - } - - #[test] - fn test_config_mod_errors() { - let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError); - assert!(error.msg.is_none()); - assert_eq!(format!("{}", error), String::from("Syntax error")); - let error: SerializerError = - SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax")); - assert!(error.msg.is_some()); - assert_eq!( - format!("{}", error), - String::from("Syntax error (bad syntax)") - ); - // Fmt - assert_eq!( - format!("{}", SerializerError::new(SerializerErrorKind::IoError)), - String::from("IO error") - ); - assert_eq!( - format!( - "{}", - SerializerError::new(SerializerErrorKind::SerializationError) - ), - String::from("Serialization error") - ); - } -} +pub mod bookmarks; +pub mod params; +pub mod serialization; +pub mod themes; diff --git a/src/config/params.rs b/src/config/params.rs new file mode 100644 index 0000000..09dcd04 --- /dev/null +++ b/src/config/params.rs @@ -0,0 +1,155 @@ +//! ## Config +//! +//! `config` is the module which provides access to termscp configuration + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// Locals +use crate::filetransfer::FileTransferProtocol; + +// Ext +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Deserialize, Serialize, std::fmt::Debug)] +/// ## UserConfig +/// +/// UserConfig contains all the configurations for the user, +/// supported by termscp +pub struct UserConfig { + pub user_interface: UserInterfaceConfig, + pub remote: RemoteConfig, +} + +#[derive(Deserialize, Serialize, std::fmt::Debug)] +/// ## UserInterfaceConfig +/// +/// UserInterfaceConfig provides all the keys to configure the user interface +pub struct UserInterfaceConfig { + pub text_editor: PathBuf, + pub default_protocol: String, + pub show_hidden_files: bool, + pub check_for_updates: Option, // @! Since 0.3.3 + pub group_dirs: Option, + pub file_fmt: Option, // Refers to local host (for backward compatibility) + pub remote_file_fmt: Option, // @! Since 0.5.0 +} + +#[derive(Deserialize, Serialize, std::fmt::Debug)] +/// ## RemoteConfig +/// +/// Contains configuratio related to remote hosts +pub struct RemoteConfig { + pub ssh_keys: HashMap, // Association between host name and path to private key +} + +impl Default for UserConfig { + fn default() -> Self { + UserConfig { + user_interface: UserInterfaceConfig::default(), + remote: RemoteConfig::default(), + } + } +} + +impl Default for UserInterfaceConfig { + fn default() -> Self { + UserInterfaceConfig { + text_editor: match edit::get_editor() { + Ok(p) => p, + Err(_) => PathBuf::from("nano"), // Default to nano + }, + default_protocol: FileTransferProtocol::Sftp.to_string(), + show_hidden_files: false, + check_for_updates: Some(true), + group_dirs: None, + file_fmt: None, + remote_file_fmt: None, + } + } +} + +impl Default for RemoteConfig { + fn default() -> Self { + RemoteConfig { + ssh_keys: HashMap::new(), + } + } +} + +// Tests + +#[cfg(test)] +mod tests { + + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_config_mod_new() { + let mut keys: HashMap = HashMap::with_capacity(1); + keys.insert( + String::from("192.168.1.31"), + PathBuf::from("/tmp/private.key"), + ); + let remote: RemoteConfig = RemoteConfig { ssh_keys: keys }; + let ui: UserInterfaceConfig = UserInterfaceConfig { + default_protocol: String::from("SFTP"), + text_editor: PathBuf::from("nano"), + show_hidden_files: true, + check_for_updates: Some(true), + group_dirs: Some(String::from("first")), + file_fmt: Some(String::from("{NAME}")), + remote_file_fmt: Some(String::from("{USER}")), + }; + assert_eq!(ui.default_protocol, String::from("SFTP")); + assert_eq!(ui.text_editor, PathBuf::from("nano")); + assert_eq!(ui.show_hidden_files, true); + assert_eq!(ui.check_for_updates, Some(true)); + assert_eq!(ui.group_dirs, Some(String::from("first"))); + assert_eq!(ui.file_fmt, Some(String::from("{NAME}"))); + let cfg: UserConfig = UserConfig { + user_interface: ui, + remote: remote, + }; + assert_eq!( + *cfg.remote + .ssh_keys + .get(&String::from("192.168.1.31")) + .unwrap(), + PathBuf::from("/tmp/private.key") + ); + assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP")); + assert_eq!(cfg.user_interface.text_editor, PathBuf::from("nano")); + assert_eq!(cfg.user_interface.show_hidden_files, true); + assert_eq!(cfg.user_interface.check_for_updates, Some(true)); + assert_eq!(cfg.user_interface.group_dirs, Some(String::from("first"))); + assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME}"))); + assert_eq!( + cfg.user_interface.remote_file_fmt, + Some(String::from("{USER}")) + ); + } +} diff --git a/src/config/serialization.rs b/src/config/serialization.rs new file mode 100644 index 0000000..0a4b661 --- /dev/null +++ b/src/config/serialization.rs @@ -0,0 +1,574 @@ +//! ## Serialization +//! +//! `serialization` provides serialization and deserialization for configurations + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +use serde::{de::DeserializeOwned, Serialize}; +use std::io::{Read, Write}; +use thiserror::Error; + +/// ## 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(Error, Debug)] +pub enum SerializerErrorKind { + #[error("Operation failed")] + GenericError, + #[error("IO error")] + IoError, + #[error("Serialization error")] + SerializationError, + #[error("Syntax error")] + SyntaxError, +} + +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 { + match &self.msg { + Some(msg) => write!(f, "{} ({})", self.kind, msg), + None => write!(f, "{}", self.kind), + } + } +} + +/// ### serialize +/// +/// Serialize `UserHosts` into TOML and write content to writable +pub fn serialize(serializable: &S, mut writable: Box) -> Result<(), SerializerError> +where + S: Serialize + Sized, +{ + // Serialize content + let data: String = match toml::ser::to_string(serializable) { + Ok(dt) => dt, + Err(err) => { + return Err(SerializerError::new_ex( + SerializerErrorKind::SerializationError, + err.to_string(), + )) + } + }; + trace!("Serialized new bookmarks data: {}", data); + // 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(mut readable: Box) -> Result +where + S: DeserializeOwned + Sized + std::fmt::Debug, +{ + // 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(), + )); + } + trace!("Read bookmarks from file: {}", data); + // Deserialize + match toml::de::from_str(data.as_str()) { + Ok(deserialized) => { + debug!("Read bookmarks from file {:?}", deserialized); + Ok(deserialized) + } + Err(err) => Err(SerializerError::new_ex( + SerializerErrorKind::SyntaxError, + err.to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use std::io::{Seek, SeekFrom}; + use std::path::PathBuf; + use tuirealm::tui::style::Color; + + use crate::config::bookmarks::{Bookmark, UserHosts}; + use crate::config::params::UserConfig; + use crate::config::themes::Theme; + use crate::utils::test_helpers::create_file_ioers; + + #[test] + fn test_config_serialization_errors() { + let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError); + assert!(error.msg.is_none()); + assert_eq!(format!("{}", error), String::from("Syntax error")); + let error: SerializerError = + SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax")); + assert!(error.msg.is_some()); + assert_eq!( + format!("{}", error), + String::from("Syntax error (bad syntax)") + ); + // Fmt + assert_eq!( + format!( + "{}", + SerializerError::new(SerializerErrorKind::GenericError) + ), + String::from("Operation failed") + ); + assert_eq!( + format!("{}", SerializerError::new(SerializerErrorKind::IoError)), + String::from("IO error") + ); + assert_eq!( + format!( + "{}", + SerializerError::new(SerializerErrorKind::SerializationError) + ), + String::from("Serialization error") + ); + } + + // -- Serialization of params + + #[test] + fn test_config_serialization_params_deserialize_ok() { + let toml_file: tempfile::NamedTempFile = create_good_toml_bookmarks_params(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + let cfg = deserialize(Box::new(toml_file)); + assert!(cfg.is_ok()); + let cfg: UserConfig = cfg.ok().unwrap(); + // Verify configuration + // Verify ui + assert_eq!(cfg.user_interface.default_protocol, String::from("SCP")); + assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim")); + assert_eq!(cfg.user_interface.show_hidden_files, true); + assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true); + assert_eq!(cfg.user_interface.group_dirs, Some(String::from("last"))); + assert_eq!( + cfg.user_interface.file_fmt, + Some(String::from("{NAME} {PEX}")) + ); + assert_eq!( + cfg.user_interface.remote_file_fmt, + Some(String::from("{NAME} {USER}")), + ); + // Verify keys + assert_eq!( + *cfg.remote + .ssh_keys + .get(&String::from("192.168.1.31")) + .unwrap(), + PathBuf::from("/home/omar/.ssh/raspberry.key") + ); + assert_eq!( + *cfg.remote + .ssh_keys + .get(&String::from("192.168.1.32")) + .unwrap(), + PathBuf::from("/home/omar/.ssh/beaglebone.key") + ); + assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none()); + } + + #[test] + fn test_config_serialization_params_deserialize_ok_no_opts() { + let toml_file: tempfile::NamedTempFile = create_good_toml_bookmarks_params_no_opts(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + let cfg = deserialize(Box::new(toml_file)); + assert!(cfg.is_ok()); + let cfg: UserConfig = cfg.ok().unwrap(); + // Verify configuration + // Verify ui + assert_eq!(cfg.user_interface.default_protocol, String::from("SCP")); + assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim")); + assert_eq!(cfg.user_interface.show_hidden_files, true); + assert_eq!(cfg.user_interface.group_dirs, None); + assert!(cfg.user_interface.check_for_updates.is_none()); + assert!(cfg.user_interface.file_fmt.is_none()); + assert!(cfg.user_interface.remote_file_fmt.is_none()); + // Verify keys + assert_eq!( + *cfg.remote + .ssh_keys + .get(&String::from("192.168.1.31")) + .unwrap(), + PathBuf::from("/home/omar/.ssh/raspberry.key") + ); + assert_eq!( + *cfg.remote + .ssh_keys + .get(&String::from("192.168.1.32")) + .unwrap(), + PathBuf::from("/home/omar/.ssh/beaglebone.key") + ); + assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none()); + } + + #[test] + fn test_config_serialization_params_deserialize_nok() { + let toml_file: tempfile::NamedTempFile = create_bad_toml_bookmarks_params(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + assert!(deserialize::(Box::new(toml_file)).is_err()); + } + + #[test] + fn test_config_serialization_params_serialize() { + let mut cfg: UserConfig = UserConfig::default(); + let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap(); + // Insert key + cfg.remote.ssh_keys.insert( + String::from("192.168.1.31"), + PathBuf::from("/home/omar/.ssh/id_rsa"), + ); + // Serialize + let writer: Box = Box::new(std::fs::File::create(toml_file.path()).unwrap()); + assert!(serialize(&cfg, writer).is_ok()); + // Reload configuration and check if it's ok + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + assert!(deserialize::(Box::new(toml_file)).is_ok()); + } + + #[test] + fn test_config_serialization_params_fail_write() { + let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap(); + let writer: Box = Box::new(std::fs::File::open(toml_file.path()).unwrap()); + // Try to write unexisting file + let cfg: UserConfig = UserConfig::default(); + assert!(serialize(&cfg, writer).is_err()); + } + + #[test] + fn test_config_serialization_params_fail_read() { + let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap(); + let reader: Box = Box::new(std::fs::File::open(toml_file.path()).unwrap()); + // Try to write unexisting file + assert!(deserialize::(reader).is_err()); + } + + fn create_good_toml_bookmarks_params() -> tempfile::NamedTempFile { + // Write + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + [user_interface] + default_protocol = "SCP" + text_editor = "vim" + show_hidden_files = true + check_for_updates = true + group_dirs = "last" + file_fmt = "{NAME} {PEX}" + remote_file_fmt = "{NAME} {USER}" + + [remote.ssh_keys] + "192.168.1.31" = "/home/omar/.ssh/raspberry.key" + "192.168.1.32" = "/home/omar/.ssh/beaglebone.key" + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } + + fn create_good_toml_bookmarks_params_no_opts() -> tempfile::NamedTempFile { + // Write + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + [user_interface] + default_protocol = "SCP" + text_editor = "vim" + show_hidden_files = true + + [remote.ssh_keys] + "192.168.1.31" = "/home/omar/.ssh/raspberry.key" + "192.168.1.32" = "/home/omar/.ssh/beaglebone.key" + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } + + fn create_bad_toml_bookmarks_params() -> tempfile::NamedTempFile { + // Write + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + [user_interface] + default_protocol = "SFTP" + + [remote.ssh_keys] + "192.168.1.31" = "/home/omar/.ssh/raspberry.key" + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } + + // -- bookmarks + + #[test] + fn test_config_serializer_bookmarks_serializer_deserialize_ok() { + let toml_file: tempfile::NamedTempFile = create_good_toml_bookmarks(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + let hosts = 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")); + assert_eq!(host.password, None); + // 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")); + assert_eq!(*host.password.as_ref().unwrap(), String::from("mypassword")); + 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")); + assert_eq!(*host.password.as_ref().unwrap(), String::from("mysecret")); + 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")); + assert_eq!(host.password, None); + } + + #[test] + fn test_config_serializer_bookmarks_serializer_deserialize_nok() { + let toml_file: tempfile::NamedTempFile = create_bad_toml_bookmarks(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + // Parse + assert!(deserialize::(Box::new(toml_file)).is_err()); + } + + #[test] + fn test_config_serializer_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"), + password: None, + }, + ); + bookmarks.insert( + String::from("msi-estrem"), + Bookmark { + address: String::from("192.168.1.30"), + port: 4022, + protocol: String::from("SFTP"), + username: String::from("cvisintin"), + password: Some(String::from("password")), + }, + ); + 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"), + password: Some(String::from("aaa")), + }, + ); + let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + // Serialize + let hosts: UserHosts = UserHosts { bookmarks, recents }; + assert!(serialize(&hosts, Box::new(tmpfile)).is_ok()); + } + + #[test] + fn test_config_serialization_theme_serialize() { + let mut theme: Theme = Theme::default(); + theme.auth_address = Color::Rgb(240, 240, 240); + let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let (reader, writer) = create_file_ioers(tmpfile.path()); + assert!(serialize(&theme, Box::new(writer)).is_ok()); + // Try to deserialize + let deserialized_theme: Theme = deserialize(Box::new(reader)).ok().unwrap(); + assert_eq!(theme, deserialized_theme); + } + + #[test] + fn test_config_serialization_theme_deserialize() { + let toml_file = create_good_toml_theme(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + assert!(deserialize::(Box::new(toml_file)).is_ok()); + let toml_file = create_bad_toml_theme(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); + assert!(deserialize::(Box::new(toml_file)).is_err()); + } + + fn create_good_toml_bookmarks() -> 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", password = "mypassword" } + msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP", username = "cvisintin", password = "mysecret" } + 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_bookmarks() -> 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 + } + + fn create_good_toml_theme() -> tempfile::NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r##"auth_address = "Yellow" + auth_bookmarks = "LightGreen" + auth_password = "LightBlue" + auth_port = "LightCyan" + auth_protocol = "LightGreen" + auth_recents = "LightBlue" + auth_username = "LightMagenta" + misc_error_dialog = "Red" + misc_input_dialog = "240,240,240" + misc_keys = "Cyan" + misc_quit_dialog = "Yellow" + misc_save_dialog = "Cyan" + misc_warn_dialog = "LightRed" + transfer_local_explorer_background = "rgb(240, 240, 240)" + transfer_local_explorer_foreground = "rgb(60, 60, 60)" + transfer_local_explorer_highlighted = "Yellow" + transfer_log_background = "255, 255, 255" + transfer_log_window = "LightGreen" + transfer_progress_bar = "Green" + transfer_remote_explorer_background = "#f0f0f0" + transfer_remote_explorer_foreground = "rgb(40, 40, 40)" + transfer_remote_explorer_highlighted = "LightBlue" + transfer_status_hidden = "LightBlue" + transfer_status_sorting = "LightYellow" + transfer_status_sync_browsing = "LightGreen" + "##; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } + + fn create_bad_toml_theme() -> tempfile::NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + auth_address = "Yellow" + auth_bookmarks = "LightGreen" + auth_password = "LightBlue" + auth_port = "LightCyan" + auth_protocol = "LightGreen" + auth_recents = "LightBlue" + auth_username = "LightMagenta" + misc_error_dialog = "Red" + misc_input_dialog = "240,240,240" + misc_keys = "Cyan" + misc_quit_dialog = "Yellow" + misc_warn_dialog = "LightRed" + transfer_local_explorer_text = "rgb(240, 240, 240)" + transfer_local_explorer_window = "Yellow" + transfer_log_text = "255, 255, 255" + transfer_log_window = "LightGreen" + transfer_progress_bar = "Green" + transfer_remote_explorer_text = "verdazzurro" + transfer_remote_explorer_window = "LightBlue" + transfer_status_hidden = "LightBlue" + transfer_status_sorting = "LightYellow" + transfer_status_sync_browsing = "LightGreen" + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } +} diff --git a/src/config/serializer.rs b/src/config/serializer.rs deleted file mode 100644 index 5ec89f4..0000000 --- a/src/config/serializer.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! ## Serializer -//! -//! `serializer` is the module which provides the serializer/deserializer for configuration - -/** - * MIT License - * - * termscp - Copyright (c) 2021 Christian Visintin - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -use super::{SerializerError, SerializerErrorKind, UserConfig}; - -use std::io::{Read, Write}; - -pub struct ConfigSerializer; - -impl ConfigSerializer { - /// ### serialize - /// - /// Serialize `UserConfig` into TOML and write content to writable - pub fn serialize( - &self, - mut writable: Box, - cfg: &UserConfig, - ) -> Result<(), SerializerError> { - // Serialize content - let data: String = match toml::ser::to_string(cfg) { - Ok(dt) => dt, - Err(err) => { - return Err(SerializerError::new_ex( - SerializerErrorKind::SerializationError, - err.to_string(), - )) - } - }; - trace!("Serialized new configuration data: {}", data); - // 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(), - )); - } - trace!("Read configuration from file: {}", data); - // Deserialize - match toml::de::from_str(data.as_str()) { - Ok(config) => { - debug!("Read config from file {:?}", config); - Ok(config) - } - Err(err) => Err(SerializerError::new_ex( - SerializerErrorKind::SyntaxError, - err.to_string(), - )), - } - } -} - -// Tests - -#[cfg(test)] -mod tests { - - use super::*; - - use pretty_assertions::assert_eq; - use std::io::{Seek, SeekFrom}; - use std::path::PathBuf; - - #[test] - fn test_config_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: ConfigSerializer = ConfigSerializer {}; - let cfg = deserializer.deserialize(Box::new(toml_file)); - assert!(cfg.is_ok()); - let cfg: UserConfig = cfg.ok().unwrap(); - // Verify configuration - // Verify ui - assert_eq!(cfg.user_interface.default_protocol, String::from("SCP")); - assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim")); - assert_eq!(cfg.user_interface.show_hidden_files, true); - assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true); - assert_eq!(cfg.user_interface.group_dirs, Some(String::from("last"))); - assert_eq!( - cfg.user_interface.file_fmt, - Some(String::from("{NAME} {PEX}")) - ); - assert_eq!( - cfg.user_interface.remote_file_fmt, - Some(String::from("{NAME} {USER}")), - ); - // Verify keys - assert_eq!( - *cfg.remote - .ssh_keys - .get(&String::from("192.168.1.31")) - .unwrap(), - PathBuf::from("/home/omar/.ssh/raspberry.key") - ); - assert_eq!( - *cfg.remote - .ssh_keys - .get(&String::from("192.168.1.32")) - .unwrap(), - PathBuf::from("/home/omar/.ssh/beaglebone.key") - ); - assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none()); - } - - #[test] - fn test_config_serializer_deserialize_ok_no_opts() { - let toml_file: tempfile::NamedTempFile = create_good_toml_no_opts(); - toml_file.as_file().sync_all().unwrap(); - toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); - // Parse - let deserializer: ConfigSerializer = ConfigSerializer {}; - let cfg = deserializer.deserialize(Box::new(toml_file)); - assert!(cfg.is_ok()); - let cfg: UserConfig = cfg.ok().unwrap(); - // Verify configuration - // Verify ui - assert_eq!(cfg.user_interface.default_protocol, String::from("SCP")); - assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim")); - assert_eq!(cfg.user_interface.show_hidden_files, true); - assert_eq!(cfg.user_interface.group_dirs, None); - assert!(cfg.user_interface.check_for_updates.is_none()); - assert!(cfg.user_interface.file_fmt.is_none()); - assert!(cfg.user_interface.remote_file_fmt.is_none()); - // Verify keys - assert_eq!( - *cfg.remote - .ssh_keys - .get(&String::from("192.168.1.31")) - .unwrap(), - PathBuf::from("/home/omar/.ssh/raspberry.key") - ); - assert_eq!( - *cfg.remote - .ssh_keys - .get(&String::from("192.168.1.32")) - .unwrap(), - PathBuf::from("/home/omar/.ssh/beaglebone.key") - ); - assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none()); - } - - #[test] - fn test_config_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: ConfigSerializer = ConfigSerializer {}; - assert!(deserializer.deserialize(Box::new(toml_file)).is_err()); - } - - #[test] - fn test_config_serializer_serialize() { - let mut cfg: UserConfig = UserConfig::default(); - let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap(); - // Insert key - cfg.remote.ssh_keys.insert( - String::from("192.168.1.31"), - PathBuf::from("/home/omar/.ssh/id_rsa"), - ); - // Serialize - let serializer: ConfigSerializer = ConfigSerializer {}; - let writer: Box = Box::new(std::fs::File::create(toml_file.path()).unwrap()); - assert!(serializer.serialize(writer, &cfg).is_ok()); - // Reload configuration and check if it's ok - toml_file.as_file().sync_all().unwrap(); - toml_file.as_file().seek(SeekFrom::Start(0)).unwrap(); - assert!(serializer.deserialize(Box::new(toml_file)).is_ok()); - } - - #[test] - fn test_config_serializer_fail_write() { - let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap(); - let writer: Box = Box::new(std::fs::File::open(toml_file.path()).unwrap()); - // Try to write unexisting file - let serializer: ConfigSerializer = ConfigSerializer {}; - let cfg: UserConfig = UserConfig::default(); - assert!(serializer.serialize(writer, &cfg).is_err()); - } - - #[test] - fn test_config_serializer_fail_read() { - let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap(); - let reader: Box = Box::new(std::fs::File::open(toml_file.path()).unwrap()); - // Try to write unexisting file - let serializer: ConfigSerializer = ConfigSerializer {}; - assert!(serializer.deserialize(reader).is_err()); - } - - fn create_good_toml() -> tempfile::NamedTempFile { - // Write - let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); - let file_content: &str = r#" - [user_interface] - default_protocol = "SCP" - text_editor = "vim" - show_hidden_files = true - check_for_updates = true - group_dirs = "last" - file_fmt = "{NAME} {PEX}" - remote_file_fmt = "{NAME} {USER}" - - [remote.ssh_keys] - "192.168.1.31" = "/home/omar/.ssh/raspberry.key" - "192.168.1.32" = "/home/omar/.ssh/beaglebone.key" - "#; - tmpfile.write_all(file_content.as_bytes()).unwrap(); - tmpfile - } - - fn create_good_toml_no_opts() -> tempfile::NamedTempFile { - // Write - let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); - let file_content: &str = r#" - [user_interface] - default_protocol = "SCP" - text_editor = "vim" - show_hidden_files = true - - [remote.ssh_keys] - "192.168.1.31" = "/home/omar/.ssh/raspberry.key" - "192.168.1.32" = "/home/omar/.ssh/beaglebone.key" - "#; - tmpfile.write_all(file_content.as_bytes()).unwrap(); - tmpfile - } - - fn create_bad_toml() -> tempfile::NamedTempFile { - // Write - let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); - let file_content: &str = r#" - [user_interface] - default_protocol = "SFTP" - - [remote.ssh_keys] - "192.168.1.31" = "/home/omar/.ssh/raspberry.key" - "#; - tmpfile.write_all(file_content.as_bytes()).unwrap(); - tmpfile - } -} diff --git a/src/config/themes.rs b/src/config/themes.rs new file mode 100644 index 0000000..90bf00e --- /dev/null +++ b/src/config/themes.rs @@ -0,0 +1,260 @@ +//! ## Themes +//! +//! `themes` is the module which provides the themes configurations and the serializers + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// locals +use crate::utils::fmt::fmt_color; +use crate::utils::parser::parse_color; +// ext +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use tuirealm::tui::style::Color; + +/// ### Theme +/// +/// Theme contains all the colors lookup table for termscp +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Theme { + // -- auth + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_address: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_bookmarks: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_password: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_port: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_protocol: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_recents: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub auth_username: Color, + // -- misc + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub misc_error_dialog: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub misc_input_dialog: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub misc_keys: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub misc_quit_dialog: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub misc_save_dialog: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub misc_warn_dialog: Color, + // -- transfer + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_local_explorer_background: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_local_explorer_foreground: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_local_explorer_highlighted: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_log_background: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_log_window: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_progress_bar: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_remote_explorer_background: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_remote_explorer_foreground: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_remote_explorer_highlighted: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_status_hidden: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_status_sorting: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_status_sync_browsing: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self { + auth_address: Color::Yellow, + auth_bookmarks: Color::LightGreen, + auth_password: Color::LightBlue, + auth_port: Color::LightCyan, + auth_protocol: Color::LightGreen, + auth_recents: Color::LightBlue, + auth_username: Color::LightMagenta, + misc_error_dialog: Color::Red, + misc_input_dialog: Color::Reset, + misc_keys: Color::Cyan, + misc_quit_dialog: Color::Yellow, + misc_save_dialog: Color::LightCyan, + misc_warn_dialog: Color::LightRed, + transfer_local_explorer_background: Color::Reset, + transfer_local_explorer_foreground: Color::Reset, + transfer_local_explorer_highlighted: Color::Yellow, + transfer_log_background: Color::Reset, + transfer_log_window: Color::LightGreen, + transfer_progress_bar: Color::Green, + transfer_remote_explorer_background: Color::Reset, + transfer_remote_explorer_foreground: Color::Reset, + transfer_remote_explorer_highlighted: Color::LightBlue, + transfer_status_hidden: Color::LightBlue, + transfer_status_sorting: Color::LightYellow, + transfer_status_sync_browsing: Color::LightGreen, + } + } +} + +// -- deserializer + +fn deserialize_color<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: &str = Deserialize::deserialize(deserializer)?; + // Parse color + match parse_color(s) { + None => Err(DeError::custom("Invalid color")), + Some(color) => Ok(color), + } +} + +fn serialize_color(color: &Color, serializer: S) -> Result +where + S: Serializer, +{ + // Convert color to string + let s: String = fmt_color(color); + serializer.serialize_str(s.as_str()) +} + +#[cfg(test)] +mod test { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn test_config_themes_default() { + let theme: Theme = Theme::default(); + assert_eq!(theme.auth_address, Color::Yellow); + assert_eq!(theme.auth_bookmarks, Color::LightGreen); + assert_eq!(theme.auth_password, Color::LightBlue); + assert_eq!(theme.auth_port, Color::LightCyan); + assert_eq!(theme.auth_protocol, Color::LightGreen); + assert_eq!(theme.auth_recents, Color::LightBlue); + assert_eq!(theme.auth_username, Color::LightMagenta); + assert_eq!(theme.misc_error_dialog, Color::Red); + assert_eq!(theme.misc_input_dialog, Color::Reset); + assert_eq!(theme.misc_keys, Color::Cyan); + assert_eq!(theme.misc_quit_dialog, Color::Yellow); + assert_eq!(theme.misc_save_dialog, Color::LightCyan); + assert_eq!(theme.misc_warn_dialog, Color::LightRed); + assert_eq!(theme.transfer_local_explorer_background, Color::Reset); + assert_eq!(theme.transfer_local_explorer_foreground, Color::Reset); + assert_eq!(theme.transfer_local_explorer_highlighted, Color::Yellow); + assert_eq!(theme.transfer_log_background, Color::Reset); + assert_eq!(theme.transfer_log_window, Color::LightGreen); + assert_eq!(theme.transfer_progress_bar, Color::Green); + assert_eq!(theme.transfer_remote_explorer_background, Color::Reset); + assert_eq!(theme.transfer_remote_explorer_foreground, Color::Reset); + assert_eq!(theme.transfer_remote_explorer_highlighted, Color::LightBlue); + assert_eq!(theme.transfer_status_hidden, Color::LightBlue); + assert_eq!(theme.transfer_status_sorting, Color::LightYellow); + assert_eq!(theme.transfer_status_sync_browsing, Color::LightGreen); + } +} diff --git a/src/lib.rs b/src/lib.rs index ba2e3d9..b6e3d0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,11 +64,11 @@ extern crate whoami; extern crate wildmatch; pub mod activity_manager; -pub mod bookmarks; pub mod config; pub mod filetransfer; pub mod fs; pub mod host; +pub mod support; pub mod system; pub mod ui; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 83e44d9..384640d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,16 +40,16 @@ extern crate rpassword; // External libs use getopts::Options; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; // Include mod activity_manager; -mod bookmarks; mod config; mod filetransfer; mod fs; mod host; +mod support; mod system; mod ui; mod utils; @@ -83,11 +83,14 @@ fn main() { let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol let mut ticks: Duration = Duration::from_millis(10); let mut log_enabled: bool = true; + let mut start_activity: NextActivity = NextActivity::Authentication; //Process options let mut opts = Options::new(); + opts.optflag("c", "config", "Open termscp configuration"); + opts.optflag("q", "quiet", "Disable logging"); + opts.optopt("t", "theme", "Import specified theme", ""); opts.optopt("P", "password", "Provide password from CLI", ""); opts.optopt("T", "ticks", "Set UI ticks; default 10ms", ""); - opts.optflag("q", "quiet", "Disable logging"); opts.optflag("v", "version", ""); opts.optflag("h", "help", "Print this menu"); let matches = match opts.parse(&args[1..]) { @@ -110,6 +113,10 @@ fn main() { ); std::process::exit(255); } + // Setup activity? + if matches.opt_present("c") { + start_activity = NextActivity::SetupActivity; + } // Logging if matches.opt_present("q") { log_enabled = false; @@ -129,6 +136,20 @@ fn main() { } } } + // @! extra modes + if let Some(theme) = matches.opt_str("t") { + match support::import_theme(Path::new(theme.as_str())) { + Ok(_) => { + println!("Theme has been successfully imported!"); + std::process::exit(0) + } + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + } + } + // @! Ordinary mode // Check free args let extra_args: Vec = matches.free; // Remote argument @@ -172,7 +193,6 @@ fn main() { } info!("termscp {} started!", TERMSCP_VERSION); // Initialize client if necessary - let mut start_activity: NextActivity = NextActivity::Authentication; if address.is_some() { debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", address, port, protocol, username, utils::fmt::shadow_password(password.as_deref().unwrap_or(""))); if password.is_none() { diff --git a/src/support.rs b/src/support.rs new file mode 100644 index 0000000..150fb42 --- /dev/null +++ b/src/support.rs @@ -0,0 +1,68 @@ +//! ## Support +//! +//! this module exposes some extra run modes for termscp, meant to be used for "support", such as installing themes + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// mod +use crate::system::{environment, theme_provider::ThemeProvider}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// ### import_theme +/// +/// Import theme at provided path into termscp +pub fn import_theme(p: &Path) -> Result<(), String> { + if !p.exists() { + return Err(String::from( + "Could not import theme: No such file or directory", + )); + } + // Validate theme file + ThemeProvider::new(p).map_err(|e| format!("Invalid theme error: {}", e))?; + // get config dir + let cfg_dir: PathBuf = get_config_dir()?; + // Get theme directory + let theme_file: PathBuf = environment::get_theme_path(cfg_dir.as_path()); + // Copy theme to theme_dir + fs::copy(p, theme_file.as_path()) + .map(|_| ()) + .map_err(|e| format!("Could not import theme: {}", e)) +} + +/// ### get_config_dir +/// +/// Get configuration directory +fn get_config_dir() -> Result { + match environment::init_config_dir() { + Ok(Some(config_dir)) => Ok(config_dir), + Ok(None) => Err(String::from( + "Your system doesn't provide a configuration directory", + )), + Err(err) => Err(format!( + "Could not initialize configuration directory: {}", + err + )), + } +} diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index c12cd67..ed40f65 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -30,8 +30,10 @@ use super::keys::keyringstorage::KeyringStorage; use super::keys::{filestorage::FileStorage, KeyStorage, KeyStorageError}; // Local -use crate::bookmarks::serializer::BookmarkSerializer; -use crate::bookmarks::{Bookmark, SerializerError, SerializerErrorKind, UserHosts}; +use crate::config::{ + bookmarks::{Bookmark, UserHosts}, + serialization::{deserialize, serialize, SerializerError, SerializerErrorKind}, +}; use crate::filetransfer::FileTransferProtocol; use crate::utils::crypto; use crate::utils::fmt::fmt_time; @@ -65,7 +67,7 @@ impl BookmarksClient { recents_size: usize, ) -> Result { // Create default hosts - let default_hosts: UserHosts = Default::default(); + let default_hosts: UserHosts = UserHosts::default(); debug!("Setting up bookmarks client..."); // Make a key storage (with-keyring) #[cfg(feature = "with-keyring")] @@ -322,10 +324,7 @@ impl BookmarksClient { .truncate(true) .open(self.bookmarks_file.as_path()) { - Ok(writer) => { - let serializer: BookmarkSerializer = BookmarkSerializer {}; - serializer.serialize(Box::new(writer), &self.hosts) - } + Ok(writer) => serialize(&self.hosts, Box::new(writer)), Err(err) => { error!("Failed to write bookmarks: {}", err); Err(SerializerError::new_ex( @@ -348,8 +347,7 @@ impl BookmarksClient { { Ok(reader) => { // Deserialize - let deserializer: BookmarkSerializer = BookmarkSerializer {}; - match deserializer.deserialize(Box::new(reader)) { + match deserialize(Box::new(reader)) { Ok(hosts) => { self.hosts = hosts; Ok(()) @@ -448,7 +446,7 @@ mod tests { target_os = "linux", target_os = "freebsd", target_os = "netbsd", - target_os = "netbsd" + target_os = "openbsd" ))] fn test_system_bookmarks_new_err() { assert!(BookmarksClient::new( @@ -710,7 +708,6 @@ mod tests { let mut client: BookmarksClient = BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); client.key = "MYSUPERSECRETKEY".to_string(); - let input: &str = "Hello world!"; assert_eq!( client.decrypt_str("z4Z6LpcpYqBW4+bkIok+5A==").ok().unwrap(), "Hello world!" diff --git a/src/system/config_client.rs b/src/system/config_client.rs index babc379..fd0a481 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -26,8 +26,10 @@ * SOFTWARE. */ // Locals -use crate::config::serializer::ConfigSerializer; -use crate::config::{SerializerError, SerializerErrorKind, UserConfig}; +use crate::config::{ + params::UserConfig, + serialization::{deserialize, serialize, SerializerError, SerializerErrorKind}, +}; use crate::filetransfer::FileTransferProtocol; use crate::fs::explorer::GroupDirs; // Ext @@ -323,10 +325,7 @@ impl ConfigClient { .truncate(true) .open(self.config_path.as_path()) { - Ok(writer) => { - let serializer: ConfigSerializer = ConfigSerializer {}; - serializer.serialize(Box::new(writer), &self.config) - } + Ok(writer) => serialize(&self.config, Box::new(writer)), Err(err) => { error!("Failed to write configuration file: {}", err); Err(SerializerError::new_ex( @@ -348,8 +347,7 @@ impl ConfigClient { { Ok(reader) => { // Deserialize - let deserializer: ConfigSerializer = ConfigSerializer {}; - match deserializer.deserialize(Box::new(reader)) { + match deserialize(Box::new(reader)) { Ok(config) => { self.config = config; Ok(()) diff --git a/src/system/environment.rs b/src/system/environment.rs index ce1db6e..ea943d0 100644 --- a/src/system/environment.rs +++ b/src/system/environment.rs @@ -93,6 +93,17 @@ pub fn get_log_paths(config_dir: &Path) -> PathBuf { log_file } +/// ### get_theme_path +/// +/// Get paths for theme provider +/// Returns: path of theme.toml +pub fn get_theme_path(config_dir: &Path) -> PathBuf { + // Prepare paths + let mut theme_file: PathBuf = PathBuf::from(config_dir); + theme_file.push("theme.toml"); + theme_file +} + #[cfg(test)] mod tests { @@ -157,4 +168,12 @@ mod tests { PathBuf::from("/home/omar/.config/termscp/termscp.log"), ); } + + #[test] + fn test_system_environment_get_theme_path() { + assert_eq!( + get_theme_path(&Path::new("/home/omar/.config/termscp/")), + PathBuf::from("/home/omar/.config/termscp/theme.toml"), + ); + } } diff --git a/src/system/mod.rs b/src/system/mod.rs index 7ff9694..c702b60 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -29,6 +29,7 @@ pub mod bookmarks_client; pub mod config_client; pub mod environment; -pub(crate) mod keys; +pub(self) mod keys; pub mod logging; pub mod sshkey_storage; +pub mod theme_provider; diff --git a/src/system/theme_provider.rs b/src/system/theme_provider.rs new file mode 100644 index 0000000..d878eb4 --- /dev/null +++ b/src/system/theme_provider.rs @@ -0,0 +1,246 @@ +//! ## ThemeProvider +//! +//! `theme_provider` is the module which provides an API between the theme configuration and the system + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// Locals +use crate::config::{ + serialization::{deserialize, serialize, SerializerError, SerializerErrorKind}, + themes::Theme, +}; +// Ext +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +use std::string::ToString; + +/// ## ThemeProvider +/// +/// ThemeProvider provides a high level API to communicate with the termscp theme +pub struct ThemeProvider { + theme: Theme, // Theme loaded + theme_path: PathBuf, // Theme TOML Path + degraded: bool, // Fallback mode; won't work with file system +} + +impl ThemeProvider { + /// ### new + /// + /// Instantiates a new `ThemeProvider` + pub fn new(theme_path: &Path) -> Result { + let default_theme: Theme = Theme::default(); + info!( + "Setting up theme provider with thene path {} ", + theme_path.display(), + ); + // Create provider + let mut provider: ThemeProvider = ThemeProvider { + theme: default_theme, + theme_path: theme_path.to_path_buf(), + degraded: false, + }; + // If Config file doesn't exist, create it + if !theme_path.exists() { + if let Err(err) = provider.save() { + error!("Couldn't write theme file: {}", err); + return Err(err); + } + debug!("Theme file didn't exist; created file"); + } else { + // otherwise Load configuration from file + if let Err(err) = provider.load() { + error!("Couldn't read thene file: {}", err); + return Err(err); + } + debug!("Read theme file"); + } + Ok(provider) + } + + /// ### degraded + /// + /// Create a new theme provider which won't work with file system. + /// This is done in order to prevent a lot of `unwrap_or` on Ui + pub fn degraded() -> Self { + Self { + theme: Theme::default(), + theme_path: PathBuf::default(), + degraded: true, + } + } + + // -- getters + + /// ### theme + /// + /// Returns theme as reference + pub fn theme(&self) -> &Theme { + &self.theme + } + + /// ### theme_mut + /// + /// Returns a mutable reference to the theme + pub fn theme_mut(&mut self) -> &mut Theme { + &mut self.theme + } + + // -- io + + /// ### load + /// + /// Load theme from file + pub fn load(&mut self) -> Result<(), SerializerError> { + if self.degraded { + warn!("Configuration won't be loaded, since degraded; reloading default..."); + self.theme = Theme::default(); + return Err(SerializerError::new_ex( + SerializerErrorKind::GenericError, + String::from("Can't access theme file"), + )); + } + // Open theme file for read + debug!("Loading theme from file..."); + match OpenOptions::new() + .read(true) + .open(self.theme_path.as_path()) + { + Ok(reader) => { + // Deserialize + match deserialize(Box::new(reader)) { + Ok(theme) => { + self.theme = theme; + Ok(()) + } + Err(err) => Err(err), + } + } + Err(err) => { + error!("Failed to read theme: {}", err); + Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )) + } + } + } + + /// ### save + /// + /// Save theme to file + pub fn save(&self) -> Result<(), SerializerError> { + if self.degraded { + warn!("Configuration won't be saved, since in degraded mode"); + return Err(SerializerError::new_ex( + SerializerErrorKind::GenericError, + String::from("Can't access theme file"), + )); + } + // Open file + debug!("Writing theme"); + match OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(self.theme_path.as_path()) + { + Ok(writer) => serialize(self.theme(), Box::new(writer)), + Err(err) => { + error!("Failed to write theme: {}", err); + Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )) + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use tuirealm::tui::style::Color; + + #[test] + fn test_system_theme_provider_new() { + let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap(); + let theme_path: PathBuf = get_theme_path(tmp_dir.path()); + // Initialize a new bookmarks client + let mut provider: ThemeProvider = ThemeProvider::new(theme_path.as_path()).unwrap(); + // Verify client + assert_eq!(provider.theme().auth_address, Color::Yellow); + assert_eq!(provider.theme_path, theme_path); + assert_eq!(provider.degraded, false); + // Mutation + provider.theme_mut().auth_address = Color::Green; + assert_eq!(provider.theme().auth_address, Color::Green); + } + + #[test] + fn test_system_theme_provider_load_and_save() { + let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap(); + let theme_path: PathBuf = get_theme_path(tmp_dir.path()); + // Initialize a new bookmarks client + let mut provider: ThemeProvider = ThemeProvider::new(theme_path.as_path()).unwrap(); + // Write + provider.theme_mut().auth_address = Color::Green; + assert!(provider.save().is_ok()); + provider.theme_mut().auth_address = Color::Blue; + // Reload + assert!(provider.load().is_ok()); + // Unchanged + assert_eq!(provider.theme().auth_address, Color::Green); + // Instantiate a new provider + let provider: ThemeProvider = ThemeProvider::new(theme_path.as_path()).unwrap(); + assert_eq!(provider.theme().auth_address, Color::Green); // Unchanged + } + + #[test] + fn test_system_theme_provider_degraded() { + let mut provider: ThemeProvider = ThemeProvider::degraded(); + assert_eq!(provider.theme().auth_address, Color::Yellow); + assert_eq!(provider.degraded, true); + provider.theme_mut().auth_address = Color::Green; + assert!(provider.load().is_err()); + assert_eq!(provider.theme().auth_address, Color::Yellow); + assert!(provider.save().is_err()); + } + + #[test] + fn test_system_theme_provider_err() { + assert!(ThemeProvider::new(Path::new("/tmp/oifoif/omar")).is_err()); + } + + /// ### get_theme_path + /// + /// Get paths for theme file + fn get_theme_path(dir: &Path) -> PathBuf { + let mut p: PathBuf = PathBuf::from(dir); + p.push("theme.toml"); + p + } +} diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index a899541..0a53272 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -33,6 +33,7 @@ mod view; // locals use super::{Activity, Context, ExitReason}; +use crate::config::themes::Theme; use crate::filetransfer::FileTransferProtocol; use crate::system::bookmarks_client::BookmarksClient; use crate::ui::context::FileTransferParams; @@ -154,6 +155,13 @@ impl AuthActivity { } } } + + /// ### theme + /// + /// Returns a reference to theme + fn theme(&self) -> &Theme { + self.context.as_ref().unwrap().theme_provider.theme() + } } impl Activity for AuthActivity { diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index fe7a03d..b256915 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -56,6 +56,14 @@ impl AuthActivity { /// /// Initialize view, mounting all startup components inside the view pub(super) fn init(&mut self) { + let key_color = self.theme().misc_keys; + let addr_color = self.theme().auth_address; + let protocol_color = self.theme().auth_protocol; + let port_color = self.theme().auth_port; + let username_color = self.theme().auth_username; + let password_color = self.theme().auth_password; + let bookmarks_color = self.theme().auth_bookmarks; + let recents_color = self.theme().auth_recents; // Headers self.view.mount( super::COMPONENT_TEXT_H1, @@ -86,14 +94,14 @@ impl AuthActivity { TextSpanBuilder::new("Press ").bold().build(), TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), TextSpanBuilder::new(" to show keybindings; ") .bold() .build(), TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), TextSpanBuilder::new(" to enter setup").bold().build(), ]) @@ -111,9 +119,9 @@ impl AuthActivity { super::COMPONENT_RADIO_PROTOCOL, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::LightGreen) + .with_color(protocol_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) + .with_borders(Borders::ALL, BorderType::Rounded, protocol_color) .with_options( Some(String::from("Protocol")), vec![ @@ -132,8 +140,8 @@ impl AuthActivity { super::COMPONENT_INPUT_ADDR, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::Yellow) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) + .with_foreground(addr_color) + .with_borders(Borders::ALL, BorderType::Rounded, addr_color) .with_label(String::from("Remote address")) .build(), )), @@ -143,8 +151,8 @@ impl AuthActivity { super::COMPONENT_INPUT_PORT, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::LightCyan) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan) + .with_foreground(port_color) + .with_borders(Borders::ALL, BorderType::Rounded, port_color) .with_label(String::from("Port number")) .with_input(InputType::Number) .with_input_len(5) @@ -157,8 +165,8 @@ impl AuthActivity { super::COMPONENT_INPUT_USERNAME, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::LightMagenta) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta) + .with_foreground(username_color) + .with_borders(Borders::ALL, BorderType::Rounded, username_color) .with_label(String::from("Username")) .build(), )), @@ -168,8 +176,8 @@ impl AuthActivity { super::COMPONENT_INPUT_PASSWORD, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::LightBlue) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue) + .with_foreground(password_color) + .with_borders(Borders::ALL, BorderType::Rounded, password_color) .with_label(String::from("Password")) .with_input(InputType::Password) .build(), @@ -202,26 +210,27 @@ impl AuthActivity { super::COMPONENT_BOOKMARKS_LIST, Box::new(BookmarkList::new( BookmarkListPropsBuilder::default() - .with_background(Color::LightGreen) + .with_background(bookmarks_color) .with_foreground(Color::Black) - .with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen) + .with_borders(Borders::ALL, BorderType::Plain, bookmarks_color) .with_bookmarks(Some(String::from("Bookmarks")), vec![]) .build(), )), ); - let _ = self.view_bookmarks(); // Recents self.view.mount( super::COMPONENT_RECENTS_LIST, Box::new(BookmarkList::new( BookmarkListPropsBuilder::default() - .with_background(Color::LightBlue) + .with_background(recents_color) .with_foreground(Color::Black) - .with_borders(Borders::ALL, BorderType::Plain, Color::LightBlue) + .with_borders(Borders::ALL, BorderType::Plain, recents_color) .with_bookmarks(Some(String::from("Recent connections")), vec![]) .build(), )), ); + // Load bookmarks + let _ = self.view_bookmarks(); let _ = self.view_recent_connections(); // Active protocol self.view.active(super::COMPONENT_RADIO_PROTOCOL); @@ -475,12 +484,13 @@ impl AuthActivity { /// Mount error box pub(super) fn mount_error(&mut self, text: &str) { // Mount + let err_color = self.theme().misc_error_dialog; self.view.mount( super::COMPONENT_TEXT_ERROR, Box::new(MsgBox::new( MsgBoxPropsBuilder::default() - .with_foreground(Color::Red) - .with_borders(Borders::ALL, BorderType::Thick, Color::Red) + .with_foreground(err_color) + .with_borders(Borders::ALL, BorderType::Thick, err_color) .bold() .with_texts(None, vec![TextSpan::from(text)]) .build(), @@ -502,12 +512,13 @@ impl AuthActivity { /// Mount size error pub(super) fn mount_size_err(&mut self) { // Mount + let err_color = self.theme().misc_error_dialog; self.view.mount( super::COMPONENT_TEXT_SIZE_ERR, Box::new(MsgBox::new( MsgBoxPropsBuilder::default() - .with_foreground(Color::Red) - .with_borders(Borders::ALL, BorderType::Thick, Color::Red) + .with_foreground(err_color) + .with_borders(Borders::ALL, BorderType::Thick, err_color) .bold() .with_texts( None, @@ -534,12 +545,13 @@ impl AuthActivity { /// Mount quit popup pub(super) fn mount_quit(&mut self) { // Protocol + let quit_color = self.theme().misc_quit_dialog; self.view.mount( super::COMPONENT_RADIO_QUIT, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Yellow) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow) + .with_color(quit_color) + .with_borders(Borders::ALL, BorderType::Rounded, quit_color) .with_inverted_color(Color::Black) .with_options( Some(String::from("Quit termscp?")), @@ -562,13 +574,14 @@ impl AuthActivity { /// /// Mount bookmark delete dialog pub(super) fn mount_bookmark_del_dialog(&mut self) { + let warn_color = self.theme().misc_warn_dialog; self.view.mount( super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Yellow) + .with_color(warn_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow) + .with_borders(Borders::ALL, BorderType::Rounded, warn_color) .with_options( Some(String::from("Delete bookmark?")), vec![String::from("Yes"), String::from("No")], @@ -594,13 +607,14 @@ impl AuthActivity { /// /// Mount recent delete dialog pub(super) fn mount_recent_del_dialog(&mut self) { + let warn_color = self.theme().misc_warn_dialog; self.view.mount( super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Yellow) + .with_color(warn_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow) + .with_borders(Borders::ALL, BorderType::Rounded, warn_color) .with_options( Some(String::from("Delete bookmark?")), vec![String::from("Yes"), String::from("No")], @@ -624,11 +638,13 @@ impl AuthActivity { /// /// Mount bookmark save dialog pub(super) fn mount_bookmark_save_dialog(&mut self) { + let save_color = self.theme().misc_save_dialog; + let warn_color = self.theme().misc_warn_dialog; self.view.mount( super::COMPONENT_INPUT_BOOKMARK_NAME, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::LightCyan) + .with_foreground(save_color) .with_label(String::from("Save bookmark as...")) .with_borders( Borders::TOP | Borders::RIGHT | Borders::LEFT, @@ -642,7 +658,7 @@ impl AuthActivity { super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Red) + .with_color(warn_color) .with_borders( Borders::BOTTOM | Borders::RIGHT | Borders::LEFT, BorderType::Rounded, @@ -671,6 +687,7 @@ impl AuthActivity { /// /// Mount help pub(super) fn mount_help(&mut self) { + let key_color = self.theme().misc_keys; self.view.mount( super::COMPONENT_TEXT_HELP, Box::new(Scrolltable::new( @@ -685,7 +702,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Quit termscp")) @@ -693,7 +710,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Switch from form and bookmarks")) @@ -701,7 +718,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Switch bookmark tab")) @@ -709,7 +726,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Move up/down in current tab")) @@ -717,7 +734,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Connect/Load bookmark")) @@ -725,7 +742,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Delete selected bookmark")) @@ -733,7 +750,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Enter setup")) @@ -741,7 +758,7 @@ impl AuthActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Save bookmark")) diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 2c8be22..ca4bbc5 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -35,6 +35,7 @@ pub(self) mod view; // locals use super::{Activity, Context, ExitReason}; +use crate::config::themes::Theme; use crate::filetransfer::ftp_transfer::FtpFileTransfer; use crate::filetransfer::scp_transfer::ScpFileTransfer; use crate::filetransfer::sftp_transfer::SftpFileTransfer; @@ -165,34 +166,34 @@ impl FileTransferActivity { } } - pub(crate) fn local(&self) -> &FileExplorer { + fn local(&self) -> &FileExplorer { self.browser.local() } - pub(crate) fn local_mut(&mut self) -> &mut FileExplorer { + fn local_mut(&mut self) -> &mut FileExplorer { self.browser.local_mut() } - pub(crate) fn remote(&self) -> &FileExplorer { + fn remote(&self) -> &FileExplorer { self.browser.remote() } - pub(crate) fn remote_mut(&mut self) -> &mut FileExplorer { + fn remote_mut(&mut self) -> &mut FileExplorer { self.browser.remote_mut() } - pub(crate) fn found(&self) -> Option<&FileExplorer> { + fn found(&self) -> Option<&FileExplorer> { self.browser.found() } - pub(crate) fn found_mut(&mut self) -> Option<&mut FileExplorer> { + fn found_mut(&mut self) -> Option<&mut FileExplorer> { self.browser.found_mut() } /// ### get_cache_tmp_name /// /// Get file name for a file in cache - pub(crate) fn get_cache_tmp_name(&self, name: &str, file_type: Option<&str>) -> Option { + fn get_cache_tmp_name(&self, name: &str, file_type: Option<&str>) -> Option { self.cache.as_ref().map(|_| { let base: String = format!( "{}-{}", @@ -208,6 +209,13 @@ impl FileTransferActivity { } }) } + + /// ### theme + /// + /// Get a reference to `Theme` + fn theme(&self) -> &Theme { + self.context.as_ref().unwrap().theme_provider.theme() + } } /** diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 534173a..0f26cac 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -65,13 +65,22 @@ impl FileTransferActivity { /// Initialize file transfer activity's view pub(super) fn init(&mut self) { // Mount local file explorer + let local_explorer_background = self.theme().transfer_local_explorer_background; + let local_explorer_foreground = self.theme().transfer_local_explorer_foreground; + let local_explorer_highlighted = self.theme().transfer_local_explorer_highlighted; + let remote_explorer_background = self.theme().transfer_remote_explorer_background; + let remote_explorer_foreground = self.theme().transfer_remote_explorer_foreground; + let remote_explorer_highlighted = self.theme().transfer_remote_explorer_highlighted; + let log_panel = self.theme().transfer_log_window; + let log_background = self.theme().transfer_log_background; self.view.mount( super::COMPONENT_EXPLORER_LOCAL, Box::new(FileList::new( FileListPropsBuilder::default() - .with_background(Color::Yellow) - .with_foreground(Color::Yellow) - .with_borders(Borders::ALL, BorderType::Plain, Color::Yellow) + .with_highlight_color(local_explorer_highlighted) + .with_background(local_explorer_background) + .with_foreground(local_explorer_foreground) + .with_borders(Borders::ALL, BorderType::Plain, local_explorer_highlighted) .build(), )), ); @@ -80,9 +89,10 @@ impl FileTransferActivity { super::COMPONENT_EXPLORER_REMOTE, Box::new(FileList::new( FileListPropsBuilder::default() - .with_background(Color::LightBlue) - .with_foreground(Color::LightBlue) - .with_borders(Borders::ALL, BorderType::Plain, Color::LightBlue) + .with_highlight_color(remote_explorer_highlighted) + .with_background(remote_explorer_background) + .with_foreground(remote_explorer_foreground) + .with_borders(Borders::ALL, BorderType::Plain, remote_explorer_highlighted) .build(), )), ); @@ -91,7 +101,8 @@ impl FileTransferActivity { super::COMPONENT_LOG_BOX, Box::new(LogBox::new( LogboxPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen) + .with_background(log_background) + .with_borders(Borders::ALL, BorderType::Plain, log_panel) .build(), )), ); @@ -369,12 +380,13 @@ impl FileTransferActivity { /// Mount error box pub(super) fn mount_error(&mut self, text: &str) { // Mount + let error_color = self.theme().misc_error_dialog; self.view.mount( super::COMPONENT_TEXT_ERROR, Box::new(MsgBox::new( MsgBoxPropsBuilder::default() - .with_foreground(Color::Red) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Red) + .with_foreground(error_color) + .with_borders(Borders::ALL, BorderType::Rounded, error_color) .bold() .with_texts(None, vec![TextSpan::from(text)]) .build(), @@ -393,12 +405,13 @@ impl FileTransferActivity { pub(super) fn mount_fatal(&mut self, text: &str) { // Mount + let error_color = self.theme().misc_error_dialog; self.view.mount( super::COMPONENT_TEXT_FATAL, Box::new(MsgBox::new( MsgBoxPropsBuilder::default() - .with_foreground(Color::Red) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Red) + .with_foreground(error_color) + .with_borders(Borders::ALL, BorderType::Rounded, error_color) .bold() .with_texts(None, vec![TextSpan::from(text)]) .build(), @@ -434,13 +447,14 @@ impl FileTransferActivity { /// Mount quit popup pub(super) fn mount_quit(&mut self) { // Protocol + let quit_color = self.theme().misc_quit_dialog; self.view.mount( super::COMPONENT_RADIO_QUIT, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Yellow) + .with_color(quit_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow) + .with_borders(Borders::ALL, BorderType::Rounded, quit_color) .with_options( Some(String::from("Are you sure you want to quit?")), vec![String::from("Yes"), String::from("No")], @@ -463,13 +477,14 @@ impl FileTransferActivity { /// Mount disconnect popup pub(super) fn mount_disconnect(&mut self) { // Protocol + let quit_color = self.theme().misc_quit_dialog; self.view.mount( super::COMPONENT_RADIO_DISCONNECT, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Yellow) + .with_color(quit_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow) + .with_borders(Borders::ALL, BorderType::Rounded, quit_color) .with_options( Some(String::from("Are you sure you want to disconnect?")), vec![String::from("Yes"), String::from("No")], @@ -488,11 +503,13 @@ impl FileTransferActivity { } pub(super) fn mount_copy(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_COPY, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Copy file(s) to...")) .build(), )), @@ -505,11 +522,13 @@ impl FileTransferActivity { } pub(super) fn mount_exec(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_EXEC, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Plain, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Execute command")) .build(), )), @@ -523,9 +542,17 @@ impl FileTransferActivity { pub(super) fn mount_find(&mut self, search: &str) { // Get color - let color: Color = match self.browser.tab() { - FileExplorerTab::Local | FileExplorerTab::FindLocal => Color::Yellow, - FileExplorerTab::Remote | FileExplorerTab::FindRemote => Color::LightBlue, + let (bg, fg, hg): (Color, Color, Color) = match self.browser.tab() { + FileExplorerTab::Local | FileExplorerTab::FindLocal => ( + self.theme().transfer_local_explorer_background, + self.theme().transfer_local_explorer_foreground, + self.theme().transfer_local_explorer_highlighted, + ), + FileExplorerTab::Remote | FileExplorerTab::FindRemote => ( + self.theme().transfer_remote_explorer_background, + self.theme().transfer_remote_explorer_foreground, + self.theme().transfer_remote_explorer_highlighted, + ), }; // Mount component self.view.mount( @@ -533,9 +560,10 @@ impl FileTransferActivity { Box::new(FileList::new( FileListPropsBuilder::default() .with_files(Some(format!("Search results for \"{}\"", search)), vec![]) - .with_borders(Borders::ALL, BorderType::Plain, color) - .with_background(color) - .with_foreground(color) + .with_borders(Borders::ALL, BorderType::Plain, hg) + .with_highlight_color(hg) + .with_background(bg) + .with_foreground(fg) .build(), )), ); @@ -548,11 +576,13 @@ impl FileTransferActivity { } pub(super) fn mount_find_input(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_FIND, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Search files by name")) .build(), )), @@ -567,11 +597,13 @@ impl FileTransferActivity { } pub(super) fn mount_goto(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_GOTO, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Change working directory")) .build(), )), @@ -584,11 +616,13 @@ impl FileTransferActivity { } pub(super) fn mount_mkdir(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_MKDIR, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Insert directory name")) .build(), )), @@ -601,11 +635,13 @@ impl FileTransferActivity { } pub(super) fn mount_newfile(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_NEWFILE, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("New file name")) .build(), )), @@ -618,11 +654,13 @@ impl FileTransferActivity { } pub(super) fn mount_openwith(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_OPEN_WITH, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Open file with...")) .build(), )), @@ -635,11 +673,13 @@ impl FileTransferActivity { } pub(super) fn mount_rename(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_RENAME, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Move file(s) to...")) .build(), )), @@ -652,11 +692,13 @@ impl FileTransferActivity { } pub(super) fn mount_saveas(&mut self) { + let input_color = self.theme().misc_input_dialog; self.view.mount( super::COMPONENT_INPUT_SAVEAS, Box::new(Input::new( InputPropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_borders(Borders::ALL, BorderType::Rounded, input_color) + .with_foreground(input_color) .with_label(String::from("Save as...")) .build(), )), @@ -669,11 +711,12 @@ impl FileTransferActivity { } pub(super) fn mount_progress_bar(&mut self, root_name: String) { + let prog_color = self.theme().transfer_progress_bar; self.view.mount( super::COMPONENT_PROGRESS_BAR_FULL, Box::new(ProgressBar::new( ProgressBarPropsBuilder::default() - .with_progbar_color(Color::Green) + .with_progbar_color(prog_color) .with_background(Color::Black) .with_borders( Borders::TOP | Borders::RIGHT | Borders::LEFT, @@ -688,7 +731,7 @@ impl FileTransferActivity { super::COMPONENT_PROGRESS_BAR_PARTIAL, Box::new(ProgressBar::new( ProgressBarPropsBuilder::default() - .with_progbar_color(Color::Green) + .with_progbar_color(prog_color) .with_background(Color::Black) .with_borders( Borders::BOTTOM | Borders::RIGHT | Borders::LEFT, @@ -708,6 +751,7 @@ impl FileTransferActivity { } pub(super) fn mount_file_sorting(&mut self) { + let sorting_color = self.theme().transfer_status_sorting; let sorting: FileSorting = match self.browser.tab() { FileExplorerTab::Local => self.local().get_file_sorting(), FileExplorerTab::Remote => self.remote().get_file_sorting(), @@ -723,9 +767,9 @@ impl FileTransferActivity { super::COMPONENT_RADIO_SORTING, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::LightMagenta) + .with_color(sorting_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta) + .with_borders(Borders::ALL, BorderType::Rounded, sorting_color) .with_options( Some(String::from("Sort files by")), vec![ @@ -747,13 +791,14 @@ impl FileTransferActivity { } pub(super) fn mount_radio_delete(&mut self) { + let warn_color = self.theme().misc_warn_dialog; self.view.mount( super::COMPONENT_RADIO_DELETE, Box::new(Radio::new( RadioPropsBuilder::default() - .with_color(Color::Red) + .with_color(warn_color) .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Plain, Color::Red) + .with_borders(Borders::ALL, BorderType::Plain, warn_color) .with_options( Some(String::from("Delete file")), vec![String::from("Yes"), String::from("No")], @@ -881,21 +926,23 @@ impl FileTransferActivity { } pub(super) fn refresh_local_status_bar(&mut self) { + let sorting_color = self.theme().transfer_status_sorting; + let hidden_color = self.theme().transfer_status_hidden; let local_bar_spans: Vec = vec![ TextSpanBuilder::new("File sorting: ") - .with_foreground(Color::LightYellow) + .with_foreground(sorting_color) .build(), TextSpanBuilder::new(Self::get_file_sorting_str(self.local().get_file_sorting())) - .with_foreground(Color::LightYellow) + .with_foreground(sorting_color) .reversed() .build(), TextSpanBuilder::new(" Hidden files: ") - .with_foreground(Color::LightBlue) + .with_foreground(hidden_color) .build(), TextSpanBuilder::new(Self::get_hidden_files_str( self.local().hidden_files_visible(), )) - .with_foreground(Color::LightBlue) + .with_foreground(hidden_color) .reversed() .build(), ]; @@ -910,31 +957,34 @@ impl FileTransferActivity { } pub(super) fn refresh_remote_status_bar(&mut self) { + let sorting_color = self.theme().transfer_status_sorting; + let hidden_color = self.theme().transfer_status_hidden; + let sync_color = self.theme().transfer_status_sync_browsing; let remote_bar_spans: Vec = vec![ TextSpanBuilder::new("File sorting: ") - .with_foreground(Color::LightYellow) + .with_foreground(sorting_color) .build(), TextSpanBuilder::new(Self::get_file_sorting_str(self.remote().get_file_sorting())) - .with_foreground(Color::LightYellow) + .with_foreground(sorting_color) .reversed() .build(), TextSpanBuilder::new(" Hidden files: ") - .with_foreground(Color::LightBlue) + .with_foreground(hidden_color) .build(), TextSpanBuilder::new(Self::get_hidden_files_str( self.remote().hidden_files_visible(), )) - .with_foreground(Color::LightBlue) + .with_foreground(hidden_color) .reversed() .build(), TextSpanBuilder::new(" Sync Browsing: ") - .with_foreground(Color::LightGreen) + .with_foreground(sync_color) .build(), TextSpanBuilder::new(match self.browser.sync_browsing { true => "ON ", false => "OFF", }) - .with_foreground(Color::LightGreen) + .with_foreground(sync_color) .reversed() .build(), ]; @@ -952,6 +1002,7 @@ impl FileTransferActivity { /// /// Mount help pub(super) fn mount_help(&mut self) { + let key_color = self.theme().misc_keys; self.view.mount( super::COMPONENT_TEXT_HELP, Box::new(Scrolltable::new( @@ -966,7 +1017,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Disconnect")) @@ -974,7 +1025,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from( @@ -984,7 +1035,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Go to previous directory")) @@ -992,7 +1043,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Change explorer tab")) @@ -1000,7 +1051,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Move up/down in list")) @@ -1008,7 +1059,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Enter directory")) @@ -1016,7 +1067,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Upload/Download file")) @@ -1024,7 +1075,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Toggle hidden files")) @@ -1032,7 +1083,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Change file sorting mode")) @@ -1040,7 +1091,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Copy")) @@ -1048,7 +1099,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Make directory")) @@ -1056,7 +1107,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Go to path")) @@ -1064,7 +1115,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Show help")) @@ -1072,7 +1123,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Show info about selected file")) @@ -1080,7 +1131,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Reload directory content")) @@ -1088,7 +1139,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Select file")) @@ -1096,7 +1147,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Create new file")) @@ -1104,7 +1155,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from( @@ -1114,7 +1165,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Quit termscp")) @@ -1122,7 +1173,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Rename file")) @@ -1130,7 +1181,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Save file as")) @@ -1138,7 +1189,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Go to parent directory")) @@ -1146,7 +1197,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from( @@ -1156,7 +1207,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from( @@ -1166,7 +1217,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Execute shell command")) @@ -1174,7 +1225,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Toggle synchronized browsing")) @@ -1182,7 +1233,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Delete selected file")) @@ -1190,7 +1241,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Select all files")) @@ -1198,7 +1249,7 @@ impl FileTransferActivity { .add_col( TextSpanBuilder::new("") .bold() - .with_foreground(Color::Cyan) + .with_foreground(key_color) .build(), ) .add_col(TextSpan::from(" Interrupt file transfer")) diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index 4ca3aeb..26e34df 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -29,18 +29,24 @@ // Locals use super::SetupActivity; // Ext +use crate::config::themes::Theme; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use std::env; +use tuirealm::tui::style::Color; use tuirealm::{Payload, Value}; impl SetupActivity { /// ### action_save_config /// /// Save configuration - pub(super) fn action_save_config(&mut self) -> Result<(), String> { + pub(super) fn action_save_all(&mut self) -> Result<(), String> { // Collect input values self.collect_input_values(); - self.save_config() + self.save_config()?; + // save theme + self.collect_styles() + .map_err(|e| format!("'{}' has an invalid color", e))?; + self.save_theme() } /// ### action_reset_config @@ -56,6 +62,19 @@ impl SetupActivity { } } + /// ### action_reset_theme + /// + /// Reset configuration input fields + pub(super) fn action_reset_theme(&mut self) -> Result<(), String> { + match self.reset_theme_changes() { + Err(err) => Err(err), + Ok(_) => { + self.load_styles(); + Ok(()) + } + } + } + /// ### action_delete_ssh_key /// /// delete of a ssh key @@ -159,4 +178,89 @@ impl SetupActivity { } } } + + /// ### set_color + /// + /// Given a component and a color, save the color into the theme + pub(super) fn action_save_color(&mut self, component: &str, color: Color) { + let theme: &mut Theme = self.theme_mut(); + match component { + super::COMPONENT_COLOR_AUTH_ADDR => { + theme.auth_address = color; + } + super::COMPONENT_COLOR_AUTH_BOOKMARKS => { + theme.auth_bookmarks = color; + } + super::COMPONENT_COLOR_AUTH_PASSWORD => { + theme.auth_password = color; + } + super::COMPONENT_COLOR_AUTH_PORT => { + theme.auth_port = color; + } + super::COMPONENT_COLOR_AUTH_PROTOCOL => { + theme.auth_protocol = color; + } + super::COMPONENT_COLOR_AUTH_RECENTS => { + theme.auth_recents = color; + } + super::COMPONENT_COLOR_AUTH_USERNAME => { + theme.auth_username = color; + } + super::COMPONENT_COLOR_MISC_ERROR => { + theme.misc_error_dialog = color; + } + super::COMPONENT_COLOR_MISC_INPUT => { + theme.misc_input_dialog = color; + } + super::COMPONENT_COLOR_MISC_KEYS => { + theme.misc_keys = color; + } + super::COMPONENT_COLOR_MISC_QUIT => { + theme.misc_quit_dialog = color; + } + super::COMPONENT_COLOR_MISC_SAVE => { + theme.misc_save_dialog = color; + } + super::COMPONENT_COLOR_MISC_WARN => { + theme.misc_warn_dialog = color; + } + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG => { + theme.transfer_local_explorer_background = color; + } + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG => { + theme.transfer_local_explorer_foreground = color; + } + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG => { + theme.transfer_local_explorer_highlighted = color; + } + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG => { + theme.transfer_remote_explorer_background = color; + } + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG => { + theme.transfer_remote_explorer_foreground = color; + } + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG => { + theme.transfer_remote_explorer_highlighted = color; + } + super::COMPONENT_COLOR_TRANSFER_LOG_BG => { + theme.transfer_log_background = color; + } + super::COMPONENT_COLOR_TRANSFER_LOG_WIN => { + theme.transfer_log_window = color; + } + super::COMPONENT_COLOR_TRANSFER_PROG_BAR => { + theme.transfer_progress_bar = color; + } + super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN => { + theme.transfer_status_hidden = color; + } + super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING => { + theme.transfer_status_sorting = color; + } + super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC => { + theme.transfer_status_sync_browsing = color; + } + _ => {} + } + } } diff --git a/src/ui/activities/setup/config.rs b/src/ui/activities/setup/config.rs index 2373134..6e65b36 100644 --- a/src/ui/activities/setup/config.rs +++ b/src/ui/activities/setup/config.rs @@ -60,6 +60,24 @@ impl SetupActivity { } } + /// ### save_theme + /// + /// Save theme to file + pub(super) fn save_theme(&mut self) -> Result<(), String> { + self.theme_provider() + .save() + .map_err(|e| format!("Could not save theme: {}", e)) + } + + /// ### reset_theme_changes + /// + /// Reset changes committed to theme + pub(super) fn reset_theme_changes(&mut self) -> Result<(), String> { + self.theme_provider() + .load() + .map_err(|e| format!("Could not restore theme: {}", e)) + } + /// ### delete_ssh_key /// /// Delete ssh key from config cli diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index f27ce9d..21dc372 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -34,16 +34,21 @@ mod view; // Locals use super::{Activity, Context, ExitReason}; +use crate::config::themes::Theme; +use crate::system::theme_provider::ThemeProvider; // Ext use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use tuirealm::{Update, View}; // -- components +// -- common const COMPONENT_TEXT_HELP: &str = "TEXT_HELP"; const COMPONENT_TEXT_FOOTER: &str = "TEXT_FOOTER"; const COMPONENT_TEXT_ERROR: &str = "TEXT_ERROR"; const COMPONENT_RADIO_QUIT: &str = "RADIO_QUIT"; const COMPONENT_RADIO_SAVE: &str = "RADIO_SAVE"; +const COMPONENT_RADIO_TAB: &str = "RADIO_TAB"; +// -- config const COMPONENT_INPUT_TEXT_EDITOR: &str = "INPUT_TEXT_EDITOR"; const COMPONENT_RADIO_DEFAULT_PROTOCOL: &str = "RADIO_DEFAULT_PROTOCOL"; const COMPONENT_RADIO_HIDDEN_FILES: &str = "RADIO_HIDDEN_FILES"; @@ -51,11 +56,47 @@ const COMPONENT_RADIO_UPDATES: &str = "RADIO_CHECK_UPDATES"; const COMPONENT_RADIO_GROUP_DIRS: &str = "RADIO_GROUP_DIRS"; const COMPONENT_INPUT_LOCAL_FILE_FMT: &str = "INPUT_LOCAL_FILE_FMT"; const COMPONENT_INPUT_REMOTE_FILE_FMT: &str = "INPUT_REMOTE_FILE_FMT"; -const COMPONENT_RADIO_TAB: &str = "RADIO_TAB"; +// -- ssh keys const COMPONENT_LIST_SSH_KEYS: &str = "LIST_SSH_KEYS"; const COMPONENT_INPUT_SSH_HOST: &str = "INPUT_SSH_HOST"; const COMPONENT_INPUT_SSH_USERNAME: &str = "INPUT_SSH_USERNAME"; const COMPONENT_RADIO_DEL_SSH_KEY: &str = "RADIO_DEL_SSH_KEY"; +// -- theme +const COMPONENT_COLOR_AUTH_TITLE: &str = "COMPONENT_COLOR_AUTH_TITLE"; +const COMPONENT_COLOR_MISC_TITLE: &str = "COMPONENT_COLOR_MISC_TITLE"; +const COMPONENT_COLOR_TRANSFER_TITLE: &str = "COMPONENT_COLOR_TRANSFER_TITLE"; +const COMPONENT_COLOR_TRANSFER_TITLE_2: &str = "COMPONENT_COLOR_TRANSFER_TITLE_2"; +const COMPONENT_COLOR_AUTH_ADDR: &str = "COMPONENT_COLOR_AUTH_ADDR"; +const COMPONENT_COLOR_AUTH_BOOKMARKS: &str = "COMPONENT_COLOR_AUTH_BOOKMARKS"; +const COMPONENT_COLOR_AUTH_PASSWORD: &str = "COMPONENT_COLOR_AUTH_PASSWORD"; +const COMPONENT_COLOR_AUTH_PORT: &str = "COMPONENT_COLOR_AUTH_PORT"; +const COMPONENT_COLOR_AUTH_PROTOCOL: &str = "COMPONENT_COLOR_AUTH_PROTOCOL"; +const COMPONENT_COLOR_AUTH_RECENTS: &str = "COMPONENT_COLOR_AUTH_RECENTS"; +const COMPONENT_COLOR_AUTH_USERNAME: &str = "COMPONENT_COLOR_AUTH_USERNAME"; +const COMPONENT_COLOR_MISC_ERROR: &str = "COMPONENT_COLOR_MISC_ERROR"; +const COMPONENT_COLOR_MISC_INPUT: &str = "COMPONENT_COLOR_MISC_INPUT"; +const COMPONENT_COLOR_MISC_KEYS: &str = "COMPONENT_COLOR_MISC_KEYS"; +const COMPONENT_COLOR_MISC_QUIT: &str = "COMPONENT_COLOR_MISC_QUIT"; +const COMPONENT_COLOR_MISC_SAVE: &str = "COMPONENT_COLOR_MISC_SAVE"; +const COMPONENT_COLOR_MISC_WARN: &str = "COMPONENT_COLOR_MISC_WARN"; +const COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG: &str = + "COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG"; +const COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG: &str = + "COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG"; +const COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG: &str = + "COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG"; +const COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG: &str = + "COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG"; +const COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG: &str = + "COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG"; +const COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG: &str = + "COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG"; +const COMPONENT_COLOR_TRANSFER_PROG_BAR: &str = "COMPONENT_COLOR_TRANSFER_PROG_BAR"; +const COMPONENT_COLOR_TRANSFER_LOG_BG: &str = "COMPONENT_COLOR_TRANSFER_LOG_BG"; +const COMPONENT_COLOR_TRANSFER_LOG_WIN: &str = "COMPONENT_COLOR_TRANSFER_LOG_WIN"; +const COMPONENT_COLOR_TRANSFER_STATUS_SORTING: &str = "COMPONENT_COLOR_TRANSFER_STATUS_SORTING"; +const COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN: &str = "COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN"; +const COMPONENT_COLOR_TRANSFER_STATUS_SYNC: &str = "COMPONENT_COLOR_TRANSFER_STATUS_SYNC"; /// ### ViewLayout /// @@ -64,6 +105,7 @@ const COMPONENT_RADIO_DEL_SSH_KEY: &str = "RADIO_DEL_SSH_KEY"; enum ViewLayout { SetupForm, SshKeys, + Theme, } /// ## SetupActivity @@ -89,6 +131,20 @@ impl Default for SetupActivity { } } +impl SetupActivity { + fn theme(&self) -> &Theme { + self.context.as_ref().unwrap().theme_provider.theme() + } + + fn theme_mut(&mut self) -> &mut Theme { + self.context.as_mut().unwrap().theme_provider.theme_mut() + } + + fn theme_provider(&mut self) -> &mut ThemeProvider { + &mut self.context.as_mut().unwrap().theme_provider + } +} + impl Activity for SetupActivity { /// ### on_create /// @@ -105,7 +161,7 @@ impl Activity for SetupActivity { error!("Failed to enter raw mode: {}", err); } // Init view - self.init_setup(); + self.init(ViewLayout::SetupForm); // Verify error state from context if let Some(err) = self.context.as_mut().unwrap().get_error() { self.mount_error(err.as_str()); diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index 7531413..032399f 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -28,13 +28,25 @@ */ // locals use super::{ - SetupActivity, COMPONENT_INPUT_LOCAL_FILE_FMT, COMPONENT_INPUT_REMOTE_FILE_FMT, - COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR, - COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY, - COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES, COMPONENT_RADIO_QUIT, - COMPONENT_RADIO_SAVE, COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP, + SetupActivity, ViewLayout, COMPONENT_COLOR_AUTH_ADDR, COMPONENT_COLOR_AUTH_BOOKMARKS, + COMPONENT_COLOR_AUTH_PASSWORD, COMPONENT_COLOR_AUTH_PORT, COMPONENT_COLOR_AUTH_PROTOCOL, + COMPONENT_COLOR_AUTH_RECENTS, COMPONENT_COLOR_AUTH_USERNAME, COMPONENT_COLOR_MISC_ERROR, + COMPONENT_COLOR_MISC_INPUT, COMPONENT_COLOR_MISC_KEYS, COMPONENT_COLOR_MISC_QUIT, + COMPONENT_COLOR_MISC_SAVE, COMPONENT_COLOR_MISC_WARN, + COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG, COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG, + COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, + COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, + COMPONENT_COLOR_TRANSFER_LOG_BG, COMPONENT_COLOR_TRANSFER_LOG_WIN, + COMPONENT_COLOR_TRANSFER_PROG_BAR, COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, + COMPONENT_COLOR_TRANSFER_STATUS_SORTING, COMPONENT_COLOR_TRANSFER_STATUS_SYNC, + COMPONENT_INPUT_LOCAL_FILE_FMT, COMPONENT_INPUT_REMOTE_FILE_FMT, COMPONENT_INPUT_SSH_HOST, + COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR, COMPONENT_LIST_SSH_KEYS, + COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY, COMPONENT_RADIO_GROUP_DIRS, + COMPONENT_RADIO_HIDDEN_FILES, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE, + COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP, }; use crate::ui::keymap::*; +use crate::utils::parser::parse_color; // ext use tuirealm::{Msg, Payload, Update, Value}; @@ -45,6 +57,16 @@ impl Update for SetupActivity { /// Update auth activity model based on msg /// The function exits when returns None fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { + match self.layout { + ViewLayout::SetupForm => self.update_setup(msg), + ViewLayout::SshKeys => self.update_ssh_keys(msg), + ViewLayout::Theme => self.update_theme(msg), + } + } +} + +impl SetupActivity { + fn update_setup(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg)); // Match msg match ref_msg { @@ -118,7 +140,100 @@ impl Update for SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_config() { + if let Err(err) = self.action_save_all() { + self.mount_error(err.as_str()); + } + // Exit + self.exit_reason = Some(super::ExitReason::Quit); + None + } + (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => { + // Quit + self.exit_reason = Some(super::ExitReason::Quit); + self.umount_quit(); + None + } + (COMPONENT_RADIO_QUIT, Msg::OnSubmit(_)) => { + // Umount popup + self.umount_quit(); + None + } + (COMPONENT_RADIO_QUIT, _) => None, + // Close help + (COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) | (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) => { + // Umount help + self.umount_help(); + None + } + (COMPONENT_TEXT_HELP, _) => None, + // Save popup + (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { + // Save config + if let Err(err) = self.action_save_all() { + self.mount_error(err.as_str()); + } + self.umount_save_popup(); + None + } + (COMPONENT_RADIO_SAVE, Msg::OnSubmit(_)) => { + // Umount radio save + self.umount_save_popup(); + None + } + (COMPONENT_RADIO_SAVE, _) => None, + // Show help + (_, &MSG_KEY_CTRL_H) => { + // Show help + self.mount_help(); + None + } + (_, &MSG_KEY_TAB) => { + // Change view + self.init(ViewLayout::SshKeys); + None + } + // Revert changes + (_, &MSG_KEY_CTRL_R) => { + // Revert changes + if let Err(err) = self.action_reset_config() { + self.mount_error(err.as_str()); + } + None + } + // Save + (_, &MSG_KEY_CTRL_S) => { + // Show save + self.mount_save_popup(); + None + } + // + (_, &MSG_KEY_ESC) => { + // Mount quit prompt + self.mount_quit(); + None + } + (_, _) => None, // Nothing to do + }, + } + } + + fn update_ssh_keys(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { + let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg)); + // Match msg + match ref_msg { + None => None, + Some(msg) => match msg { + // Error or + (COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) => { + // Umount text error + self.umount_error(); + None + } + (COMPONENT_TEXT_ERROR, _) => None, + // Exit + (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { + // Save changes + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } // Exit @@ -163,7 +278,7 @@ impl Update for SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_config() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } self.umount_save_popup(); @@ -176,12 +291,6 @@ impl Update for SetupActivity { } (COMPONENT_RADIO_SAVE, _) => None, // Edit SSH Key - // Change view - (COMPONENT_LIST_SSH_KEYS, &MSG_KEY_TAB) => { - // Change view - self.init_setup(); - None - } // Show help (_, &MSG_KEY_CTRL_H) => { // Show help @@ -247,7 +356,7 @@ impl Update for SetupActivity { } (_, &MSG_KEY_TAB) => { // Change view - self.init_ssh_keys(); + self.init(ViewLayout::Theme); None } // Revert changes @@ -274,4 +383,312 @@ impl Update for SetupActivity { }, } } + + fn update_theme(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> { + let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg)); + // Match msg + match ref_msg { + None => None, + Some(msg) => match msg { + // Input fields + (COMPONENT_COLOR_AUTH_PROTOCOL, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_ADDR); + None + } + (COMPONENT_COLOR_AUTH_ADDR, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_PORT); + None + } + (COMPONENT_COLOR_AUTH_PORT, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_USERNAME); + None + } + (COMPONENT_COLOR_AUTH_USERNAME, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_PASSWORD); + None + } + (COMPONENT_COLOR_AUTH_PASSWORD, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_BOOKMARKS); + None + } + (COMPONENT_COLOR_AUTH_BOOKMARKS, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_RECENTS); + None + } + (COMPONENT_COLOR_AUTH_RECENTS, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_MISC_ERROR); + None + } + (COMPONENT_COLOR_MISC_ERROR, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_MISC_INPUT); + None + } + (COMPONENT_COLOR_MISC_INPUT, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_MISC_KEYS); + None + } + (COMPONENT_COLOR_MISC_KEYS, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_MISC_QUIT); + None + } + (COMPONENT_COLOR_MISC_QUIT, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_MISC_SAVE); + None + } + (COMPONENT_COLOR_MISC_SAVE, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_MISC_WARN); + None + } + (COMPONENT_COLOR_MISC_WARN, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, &MSG_KEY_DOWN) => { + self.view + .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, &MSG_KEY_DOWN) => { + self.view + .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, &MSG_KEY_DOWN) => { + self.view + .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR); + None + } + (COMPONENT_COLOR_TRANSFER_PROG_BAR, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_LOG_BG); + None + } + (COMPONENT_COLOR_TRANSFER_LOG_BG, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_LOG_WIN); + None + } + (COMPONENT_COLOR_TRANSFER_LOG_WIN, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_STATUS_SORTING); + None + } + (COMPONENT_COLOR_TRANSFER_STATUS_SORTING, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN); + None + } + (COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_STATUS_SYNC); + None + } + (COMPONENT_COLOR_TRANSFER_STATUS_SYNC, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_AUTH_PROTOCOL); + None + } + (COMPONENT_COLOR_AUTH_PROTOCOL, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_STATUS_SYNC); + None + } + (COMPONENT_COLOR_AUTH_ADDR, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_PROTOCOL); + None + } + (COMPONENT_COLOR_AUTH_PORT, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_ADDR); + None + } + (COMPONENT_COLOR_AUTH_USERNAME, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_PORT); + None + } + (COMPONENT_COLOR_AUTH_PASSWORD, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_USERNAME); + None + } + (COMPONENT_COLOR_AUTH_BOOKMARKS, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_PASSWORD); + None + } + (COMPONENT_COLOR_AUTH_RECENTS, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_BOOKMARKS); + None + } + (COMPONENT_COLOR_MISC_ERROR, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_AUTH_RECENTS); + None + } + (COMPONENT_COLOR_MISC_INPUT, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_MISC_ERROR); + None + } + (COMPONENT_COLOR_MISC_KEYS, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_MISC_INPUT); + None + } + (COMPONENT_COLOR_MISC_QUIT, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_MISC_KEYS); + None + } + (COMPONENT_COLOR_MISC_SAVE, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_MISC_QUIT); + None + } + (COMPONENT_COLOR_MISC_WARN, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_MISC_SAVE); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_MISC_WARN); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, &MSG_KEY_UP) => { + self.view + .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG); + None + } + (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, &MSG_KEY_UP) => { + self.view + .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG); + None + } + (COMPONENT_COLOR_TRANSFER_PROG_BAR, &MSG_KEY_UP) => { + self.view + .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG); + None + } + (COMPONENT_COLOR_TRANSFER_LOG_BG, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR); + None + } + (COMPONENT_COLOR_TRANSFER_LOG_WIN, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_LOG_BG); + None + } + (COMPONENT_COLOR_TRANSFER_STATUS_SORTING, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_LOG_WIN); + None + } + (COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_STATUS_SORTING); + None + } + (COMPONENT_COLOR_TRANSFER_STATUS_SYNC, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN); + None + } + // On color change + (component, Msg::OnChange(Payload::One(Value::Str(color)))) => { + if let Some(color) = parse_color(color) { + self.action_save_color(component, color); + } + None + } + // Error or + (COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) => { + // Umount text error + self.umount_error(); + None + } + (COMPONENT_TEXT_ERROR, _) => None, + // Exit + (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { + // Save changes + if let Err(err) = self.action_save_all() { + self.mount_error(err.as_str()); + } + // Exit + self.exit_reason = Some(super::ExitReason::Quit); + None + } + (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => { + // Quit + self.exit_reason = Some(super::ExitReason::Quit); + self.umount_quit(); + None + } + (COMPONENT_RADIO_QUIT, Msg::OnSubmit(_)) => { + // Umount popup + self.umount_quit(); + None + } + (COMPONENT_RADIO_QUIT, _) => None, + // Close help + (COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) | (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) => { + // Umount help + self.umount_help(); + None + } + (COMPONENT_TEXT_HELP, _) => None, + // Save popup + (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { + // Save config + if let Err(err) = self.action_save_all() { + self.mount_error(err.as_str()); + } + self.umount_save_popup(); + None + } + (COMPONENT_RADIO_SAVE, Msg::OnSubmit(_)) => { + // Umount radio save + self.umount_save_popup(); + None + } + (COMPONENT_RADIO_SAVE, _) => None, + // Edit SSH Key + // Show help + (_, &MSG_KEY_CTRL_H) => { + // Show help + self.mount_help(); + None + } + (_, &MSG_KEY_TAB) => { + // Change view + self.init(ViewLayout::SetupForm); + None + } + // Revert changes + (_, &MSG_KEY_CTRL_R) => { + // Revert changes + if let Err(err) = self.action_reset_theme() { + self.mount_error(err.as_str()); + } + None + } + // Save + (_, &MSG_KEY_CTRL_S) => { + // Show save + self.mount_save_popup(); + None + } + // + (_, &MSG_KEY_ESC) => { + // Mount quit prompt + self.mount_quit(); + None + } + (_, _) => None, // Nothing to do + }, + } + } } diff --git a/src/ui/activities/setup/view.rs b/src/ui/activities/setup/view.rs deleted file mode 100644 index 36342e3..0000000 --- a/src/ui/activities/setup/view.rs +++ /dev/null @@ -1,808 +0,0 @@ -//! ## SetupActivity -//! -//! `setup_activity` is the module which implements the Setup activity, which is the activity to -//! work on termscp configuration - -/** - * MIT License - * - * termscp - Copyright (c) 2021 Christian Visintin - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -// Locals -use super::{Context, SetupActivity, ViewLayout}; -use crate::filetransfer::FileTransferProtocol; -use crate::fs::explorer::GroupDirs; -use crate::ui::components::{ - bookmark_list::{BookmarkList, BookmarkListPropsBuilder}, - msgbox::{MsgBox, MsgBoxPropsBuilder}, -}; -use crate::utils::ui::draw_area_in; -// Ext -use std::path::PathBuf; -use tuirealm::components::{ - input::{Input, InputPropsBuilder}, - radio::{Radio, RadioPropsBuilder}, - scrolltable::{ScrollTablePropsBuilder, Scrolltable}, - span::{Span, SpanPropsBuilder}, -}; -use tuirealm::tui::{ - layout::{Constraint, Direction, Layout}, - style::Color, - widgets::{BorderType, Borders, Clear}, -}; -use tuirealm::{ - props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder}, - Payload, Value, View, -}; - -impl SetupActivity { - // -- view - - /// ### init_setup - /// - /// Initialize setup view - pub(super) fn init_setup(&mut self) { - // Init view - self.view = View::init(); - // Common stuff - // Radio tab - self.view.mount( - super::COMPONENT_RADIO_TAB, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightYellow) - .with_inverted_color(Color::Black) - .with_borders(Borders::BOTTOM, BorderType::Thick, Color::White) - .with_options( - None, - vec![String::from("User Interface"), String::from("SSH Keys")], - ) - .with_value(0) - .build(), - )), - ); - // Footer - self.view.mount( - super::COMPONENT_TEXT_FOOTER, - Box::new(Span::new( - SpanPropsBuilder::default() - .with_spans(vec![ - TextSpanBuilder::new("Press ").bold().build(), - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - TextSpanBuilder::new(" to show keybindings").bold().build(), - ]) - .build(), - )), - ); - // Input fields - self.view.mount( - super::COMPONENT_INPUT_TEXT_EDITOR, - Box::new(Input::new( - InputPropsBuilder::default() - .with_foreground(Color::LightGreen) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) - .with_label(String::from("Text editor")) - .build(), - )), - ); - self.view.active(super::COMPONENT_INPUT_TEXT_EDITOR); // <-- Focus - self.view.mount( - super::COMPONENT_RADIO_DEFAULT_PROTOCOL, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightCyan) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan) - .with_options( - Some(String::from("Default file transfer protocol")), - vec![ - String::from("SFTP"), - String::from("SCP"), - String::from("FTP"), - String::from("FTPS"), - ], - ) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_RADIO_HIDDEN_FILES, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightRed) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) - .with_options( - Some(String::from("Show hidden files (by default)")), - vec![String::from("Yes"), String::from("No")], - ) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_RADIO_UPDATES, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightYellow) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) - .with_options( - Some(String::from("Check for updates?")), - vec![String::from("Yes"), String::from("No")], - ) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_RADIO_GROUP_DIRS, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightMagenta) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta) - .with_options( - Some(String::from("Group directories")), - vec![ - String::from("Display first"), - String::from("Display Last"), - String::from("No"), - ], - ) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_INPUT_LOCAL_FILE_FMT, - Box::new(Input::new( - InputPropsBuilder::default() - .with_foreground(Color::LightBlue) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue) - .with_label(String::from("File formatter syntax (local)")) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_INPUT_REMOTE_FILE_FMT, - Box::new(Input::new( - InputPropsBuilder::default() - .with_foreground(Color::LightGreen) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) - .with_label(String::from("File formatter syntax (remote)")) - .build(), - )), - ); - // Load values - self.load_input_values(); - // Set view - self.layout = ViewLayout::SetupForm; - } - - /// ### init_ssh_keys - /// - /// Initialize ssh keys view - pub(super) fn init_ssh_keys(&mut self) { - // Init view - self.view = View::init(); - // Common stuff - // Radio tab - self.view.mount( - super::COMPONENT_RADIO_TAB, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightYellow) - .with_inverted_color(Color::Black) - .with_borders(Borders::BOTTOM, BorderType::Thick, Color::LightYellow) - .with_options( - None, - vec![String::from("User Interface"), String::from("SSH Keys")], - ) - .with_value(1) - .build(), - )), - ); - // Footer - self.view.mount( - super::COMPONENT_TEXT_FOOTER, - Box::new(Span::new( - SpanPropsBuilder::default() - .with_spans(vec![ - TextSpanBuilder::new("Press ").bold().build(), - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - TextSpanBuilder::new(" to show keybindings").bold().build(), - ]) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_LIST_SSH_KEYS, - Box::new(BookmarkList::new( - BookmarkListPropsBuilder::default() - .with_bookmarks(Some(String::from("SSH Keys")), vec![]) - .with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen) - .with_background(Color::LightGreen) - .with_foreground(Color::Black) - .build(), - )), - ); - // Give focus - self.view.active(super::COMPONENT_LIST_SSH_KEYS); - // Load keys - self.reload_ssh_keys(); - // Set view - self.layout = ViewLayout::SshKeys; - } - - /// ### view - /// - /// View gui - pub(super) fn view(&mut self) { - let mut ctx: Context = self.context.take().unwrap(); - let _ = ctx.terminal.draw(|f| { - // Prepare main chunks - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - [ - Constraint::Length(3), // Current tab - Constraint::Percentage(90), // Main body - Constraint::Length(3), // Help footer - ] - .as_ref(), - ) - .split(f.size()); - // Render common widget - self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]); - self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]); - match self.layout { - ViewLayout::SetupForm => { - // Make chunks - let ui_cfg_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(3), // Text editor - Constraint::Length(3), // Protocol tab - Constraint::Length(3), // Hidden files - Constraint::Length(3), // Updates tab - Constraint::Length(3), // Group dirs - Constraint::Length(3), // Local Format input - Constraint::Length(3), // Remote Format input - Constraint::Length(1), // Empty ? - ] - .as_ref(), - ) - .split(chunks[1]); - self.view - .render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks[0]); - self.view - .render(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, f, ui_cfg_chunks[1]); - self.view - .render(super::COMPONENT_RADIO_HIDDEN_FILES, f, ui_cfg_chunks[2]); - self.view - .render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks[3]); - self.view - .render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[4]); - self.view - .render(super::COMPONENT_INPUT_LOCAL_FILE_FMT, f, ui_cfg_chunks[5]); - self.view - .render(super::COMPONENT_INPUT_REMOTE_FILE_FMT, f, ui_cfg_chunks[6]); - } - ViewLayout::SshKeys => { - let sshcfg_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)].as_ref()) - .split(chunks[1]); - self.view - .render(super::COMPONENT_LIST_SSH_KEYS, f, sshcfg_chunks[0]); - } - } - // Popups - if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) { - if props.visible { - let popup = draw_area_in(f.size(), 50, 10); - f.render_widget(Clear, popup); - // make popup - self.view.render(super::COMPONENT_TEXT_ERROR, f, popup); - } - } - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) { - if props.visible { - // make popup - let popup = draw_area_in(f.size(), 40, 10); - f.render_widget(Clear, popup); - self.view.render(super::COMPONENT_RADIO_QUIT, f, popup); - } - } - if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) { - if props.visible { - // make popup - let popup = draw_area_in(f.size(), 50, 70); - f.render_widget(Clear, popup); - self.view.render(super::COMPONENT_TEXT_HELP, f, popup); - } - } - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) { - if props.visible { - // make popup - let popup = draw_area_in(f.size(), 30, 10); - f.render_widget(Clear, popup); - self.view.render(super::COMPONENT_RADIO_SAVE, f, popup); - } - } - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEL_SSH_KEY) { - if props.visible { - // make popup - let popup = draw_area_in(f.size(), 30, 10); - f.render_widget(Clear, popup); - self.view - .render(super::COMPONENT_RADIO_DEL_SSH_KEY, f, popup); - } - } - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_SSH_HOST) { - if props.visible { - // make popup - let popup = draw_area_in(f.size(), 50, 20); - f.render_widget(Clear, popup); - let popup_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(3), // Host - Constraint::Length(3), // Username - ] - .as_ref(), - ) - .split(popup); - self.view - .render(super::COMPONENT_INPUT_SSH_HOST, f, popup_chunks[0]); - self.view - .render(super::COMPONENT_INPUT_SSH_USERNAME, f, popup_chunks[1]); - } - } - }); - // Put context back to context - self.context = Some(ctx); - } - - // -- mount - - /// ### mount_error - /// - /// Mount error box - pub(super) fn mount_error(&mut self, text: &str) { - // Mount - self.view.mount( - super::COMPONENT_TEXT_ERROR, - Box::new(MsgBox::new( - MsgBoxPropsBuilder::default() - .with_foreground(Color::Red) - .bold() - .with_borders(Borders::ALL, BorderType::Rounded, Color::Red) - .with_texts(None, vec![TextSpan::from(text)]) - .build(), - )), - ); - // Give focus to error - self.view.active(super::COMPONENT_TEXT_ERROR); - } - - /// ### umount_error - /// - /// Umount error message - pub(super) fn umount_error(&mut self) { - self.view.umount(super::COMPONENT_TEXT_ERROR); - } - - /// ### mount_del_ssh_key - /// - /// Mount delete ssh key component - pub(super) fn mount_del_ssh_key(&mut self) { - self.view.mount( - super::COMPONENT_RADIO_DEL_SSH_KEY, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightRed) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) - .with_options( - Some(String::from("Delete key?")), - vec![String::from("Yes"), String::from("No")], - ) - .with_value(1) // Default: No - .build(), - )), - ); - // Active - self.view.active(super::COMPONENT_RADIO_DEL_SSH_KEY); - } - - /// ### umount_del_ssh_key - /// - /// Umount delete ssh key - pub(super) fn umount_del_ssh_key(&mut self) { - self.view.umount(super::COMPONENT_RADIO_DEL_SSH_KEY); - } - - /// ### mount_new_ssh_key - /// - /// Mount new ssh key prompt - pub(super) fn mount_new_ssh_key(&mut self) { - self.view.mount( - super::COMPONENT_INPUT_SSH_HOST, - Box::new(Input::new( - InputPropsBuilder::default() - .with_label(String::from("Hostname or address")) - .with_borders( - Borders::TOP | Borders::RIGHT | Borders::LEFT, - BorderType::Plain, - Color::Reset, - ) - .build(), - )), - ); - self.view.mount( - super::COMPONENT_INPUT_SSH_USERNAME, - Box::new(Input::new( - InputPropsBuilder::default() - .with_label(String::from("Username")) - .with_borders( - Borders::BOTTOM | Borders::RIGHT | Borders::LEFT, - BorderType::Plain, - Color::Reset, - ) - .build(), - )), - ); - self.view.active(super::COMPONENT_INPUT_SSH_HOST); - } - - /// ### umount_new_ssh_key - /// - /// Umount new ssh key prompt - pub(super) fn umount_new_ssh_key(&mut self) { - self.view.umount(super::COMPONENT_INPUT_SSH_HOST); - self.view.umount(super::COMPONENT_INPUT_SSH_USERNAME); - } - - /// ### mount_quit - /// - /// Mount quit popup - pub(super) fn mount_quit(&mut self) { - self.view.mount( - super::COMPONENT_RADIO_QUIT, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightRed) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) - .with_options( - Some(String::from("Exit setup?")), - vec![ - String::from("Save"), - String::from("Don't save"), - String::from("Cancel"), - ], - ) - .build(), - )), - ); - // Active - self.view.active(super::COMPONENT_RADIO_QUIT); - } - - /// ### umount_quit - /// - /// Umount quit - pub(super) fn umount_quit(&mut self) { - self.view.umount(super::COMPONENT_RADIO_QUIT); - } - - /// ### mount_save_popup - /// - /// Mount save popup - pub(super) fn mount_save_popup(&mut self) { - self.view.mount( - super::COMPONENT_RADIO_SAVE, - Box::new(Radio::new( - RadioPropsBuilder::default() - .with_color(Color::LightYellow) - .with_inverted_color(Color::Black) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) - .with_options( - Some(String::from("Save changes?")), - vec![String::from("Yes"), String::from("No")], - ) - .build(), - )), - ); - // Active - self.view.active(super::COMPONENT_RADIO_SAVE); - } - - /// ### umount_quit - /// - /// Umount quit - pub(super) fn umount_save_popup(&mut self) { - self.view.umount(super::COMPONENT_RADIO_SAVE); - } - - /// ### mount_help - /// - /// Mount help - pub(super) fn mount_help(&mut self) { - self.view.mount( - super::COMPONENT_TEXT_HELP, - Box::new(Scrolltable::new( - ScrollTablePropsBuilder::default() - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) - .with_highlighted_str(Some("?")) - .with_max_scroll_step(8) - .bold() - .with_table( - Some(String::from("Help")), - TableBuilder::default() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Exit setup")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Change setup page")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Change cursor")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Change input field")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Select / Dismiss popup")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Delete SSH key")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" New SSH key")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Revert changes")) - .add_row() - .add_col( - TextSpanBuilder::new("") - .bold() - .with_foreground(Color::Cyan) - .build(), - ) - .add_col(TextSpan::from(" Save configuration")) - .build(), - ) - .build(), - )), - ); - // Active help - self.view.active(super::COMPONENT_TEXT_HELP); - } - - /// ### umount_help - /// - /// Umount help - pub(super) fn umount_help(&mut self) { - self.view.umount(super::COMPONENT_TEXT_HELP); - } - - /// ### load_input_values - /// - /// Load values from configuration into input fields - pub(super) fn load_input_values(&mut self) { - if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { - // Text editor - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_TEXT_EDITOR) { - let text_editor: String = - String::from(cli.get_text_editor().as_path().to_string_lossy()); - let props = InputPropsBuilder::from(props) - .with_value(text_editor) - .build(); - let _ = self.view.update(super::COMPONENT_INPUT_TEXT_EDITOR, props); - } - // Protocol - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) { - let protocol: usize = match cli.get_default_protocol() { - FileTransferProtocol::Sftp => 0, - FileTransferProtocol::Scp => 1, - FileTransferProtocol::Ftp(false) => 2, - FileTransferProtocol::Ftp(true) => 3, - }; - let props = RadioPropsBuilder::from(props).with_value(protocol).build(); - let _ = self - .view - .update(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, props); - } - // Hidden files - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_HIDDEN_FILES) { - let hidden: usize = match cli.get_show_hidden_files() { - true => 0, - false => 1, - }; - let props = RadioPropsBuilder::from(props).with_value(hidden).build(); - let _ = self.view.update(super::COMPONENT_RADIO_HIDDEN_FILES, props); - } - // Updates - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_UPDATES) { - let updates: usize = match cli.get_check_for_updates() { - true => 0, - false => 1, - }; - let props = RadioPropsBuilder::from(props).with_value(updates).build(); - let _ = self.view.update(super::COMPONENT_RADIO_UPDATES, props); - } - // Group dirs - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_GROUP_DIRS) { - let dirs: usize = match cli.get_group_dirs() { - Some(GroupDirs::First) => 0, - Some(GroupDirs::Last) => 1, - None => 2, - }; - let props = RadioPropsBuilder::from(props).with_value(dirs).build(); - let _ = self.view.update(super::COMPONENT_RADIO_GROUP_DIRS, props); - } - // Local File Fmt - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_LOCAL_FILE_FMT) { - let file_fmt: String = cli.get_local_file_fmt().unwrap_or_default(); - let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); - let _ = self - .view - .update(super::COMPONENT_INPUT_LOCAL_FILE_FMT, props); - } - // Remote File Fmt - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_REMOTE_FILE_FMT) { - let file_fmt: String = cli.get_remote_file_fmt().unwrap_or_default(); - let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); - let _ = self - .view - .update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props); - } - } - } - - /// ### collect_input_values - /// - /// Collect values from input and put them into the configuration - pub(super) fn collect_input_values(&mut self) { - if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { - if let Some(Payload::One(Value::Str(editor))) = - self.view.get_state(super::COMPONENT_INPUT_TEXT_EDITOR) - { - cli.set_text_editor(PathBuf::from(editor.as_str())); - } - if let Some(Payload::One(Value::Usize(protocol))) = - self.view.get_state(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) - { - let protocol: FileTransferProtocol = match protocol { - 1 => FileTransferProtocol::Scp, - 2 => FileTransferProtocol::Ftp(false), - 3 => FileTransferProtocol::Ftp(true), - _ => FileTransferProtocol::Sftp, - }; - cli.set_default_protocol(protocol); - } - if let Some(Payload::One(Value::Usize(opt))) = - self.view.get_state(super::COMPONENT_RADIO_HIDDEN_FILES) - { - let show: bool = matches!(opt, 0); - cli.set_show_hidden_files(show); - } - if let Some(Payload::One(Value::Usize(opt))) = - self.view.get_state(super::COMPONENT_RADIO_UPDATES) - { - let check: bool = matches!(opt, 0); - cli.set_check_for_updates(check); - } - if let Some(Payload::One(Value::Str(fmt))) = - self.view.get_state(super::COMPONENT_INPUT_LOCAL_FILE_FMT) - { - cli.set_local_file_fmt(fmt); - } - if let Some(Payload::One(Value::Str(fmt))) = - self.view.get_state(super::COMPONENT_INPUT_REMOTE_FILE_FMT) - { - cli.set_remote_file_fmt(fmt); - } - if let Some(Payload::One(Value::Usize(opt))) = - self.view.get_state(super::COMPONENT_RADIO_GROUP_DIRS) - { - let dirs: Option = match opt { - 0 => Some(GroupDirs::First), - 1 => Some(GroupDirs::Last), - _ => None, - }; - cli.set_group_dirs(dirs); - } - } - } - - /// ### reload_ssh_keys - /// - /// Reload ssh keys - pub(super) fn reload_ssh_keys(&mut self) { - if let Some(cli) = self.context.as_ref().unwrap().config_client.as_ref() { - // get props - if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS) { - // Create texts - let keys: Vec = cli - .iter_ssh_keys() - .map(|x| { - let (addr, username, _) = cli.get_ssh_key(x).ok().unwrap().unwrap(); - format!("{} at {}", addr, username) - }) - .collect(); - let props = BookmarkListPropsBuilder::from(props) - .with_bookmarks(Some(String::from("SSH Keys")), keys) - .build(); - self.view.update(super::COMPONENT_LIST_SSH_KEYS, props); - } - } - } -} diff --git a/src/ui/activities/setup/view/mod.rs b/src/ui/activities/setup/view/mod.rs new file mode 100644 index 0000000..1288913 --- /dev/null +++ b/src/ui/activities/setup/view/mod.rs @@ -0,0 +1,265 @@ +//! ## SetupActivity +//! +//! `setup_activity` is the module which implements the Setup activity, which is the activity to +//! work on termscp configuration + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +pub mod setup; +pub mod ssh_keys; +pub mod theme; + +use super::*; +pub use setup::*; +pub use ssh_keys::*; +pub use theme::*; +// Locals +use crate::ui::components::msgbox::{MsgBox, MsgBoxPropsBuilder}; +// Ext +use tuirealm::components::{ + radio::{Radio, RadioPropsBuilder}, + scrolltable::{ScrollTablePropsBuilder, Scrolltable}, +}; +use tuirealm::props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder}; +use tuirealm::tui::{ + style::Color, + widgets::{BorderType, Borders}, +}; + +impl SetupActivity { + // -- view + + pub(super) fn init(&mut self, layout: ViewLayout) { + self.layout = layout; + match self.layout { + ViewLayout::SetupForm => self.init_setup(), + ViewLayout::SshKeys => self.init_ssh_keys(), + ViewLayout::Theme => self.init_theme(), + } + } + + /// ### view + /// + /// View gui + pub(super) fn view(&mut self) { + match self.layout { + ViewLayout::SetupForm => self.view_setup(), + ViewLayout::SshKeys => self.view_ssh_keys(), + ViewLayout::Theme => self.view_theme(), + } + } + + // -- mount + + /// ### mount_error + /// + /// Mount error box + pub(super) fn mount_error(&mut self, text: &str) { + // Mount + self.view.mount( + super::COMPONENT_TEXT_ERROR, + Box::new(MsgBox::new( + MsgBoxPropsBuilder::default() + .with_foreground(Color::Red) + .bold() + .with_borders(Borders::ALL, BorderType::Rounded, Color::Red) + .with_texts(None, vec![TextSpan::from(text)]) + .build(), + )), + ); + // Give focus to error + self.view.active(super::COMPONENT_TEXT_ERROR); + } + + /// ### umount_error + /// + /// Umount error message + pub(super) fn umount_error(&mut self) { + self.view.umount(super::COMPONENT_TEXT_ERROR); + } + + /// ### mount_quit + /// + /// Mount quit popup + pub(super) fn mount_quit(&mut self) { + self.view.mount( + super::COMPONENT_RADIO_QUIT, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightRed) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) + .with_options( + Some(String::from("Exit setup?")), + vec![ + String::from("Save"), + String::from("Don't save"), + String::from("Cancel"), + ], + ) + .build(), + )), + ); + // Active + self.view.active(super::COMPONENT_RADIO_QUIT); + } + + /// ### umount_quit + /// + /// Umount quit + pub(super) fn umount_quit(&mut self) { + self.view.umount(super::COMPONENT_RADIO_QUIT); + } + + /// ### mount_save_popup + /// + /// Mount save popup + pub(super) fn mount_save_popup(&mut self) { + self.view.mount( + super::COMPONENT_RADIO_SAVE, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightYellow) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) + .with_options( + Some(String::from("Save changes?")), + vec![String::from("Yes"), String::from("No")], + ) + .build(), + )), + ); + // Active + self.view.active(super::COMPONENT_RADIO_SAVE); + } + + /// ### umount_quit + /// + /// Umount quit + pub(super) fn umount_save_popup(&mut self) { + self.view.umount(super::COMPONENT_RADIO_SAVE); + } + + /// ### mount_help + /// + /// Mount help + pub(super) fn mount_help(&mut self) { + self.view.mount( + super::COMPONENT_TEXT_HELP, + Box::new(Scrolltable::new( + ScrollTablePropsBuilder::default() + .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .with_highlighted_str(Some("?")) + .with_max_scroll_step(8) + .bold() + .with_table( + Some(String::from("Help")), + TableBuilder::default() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Exit setup")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Change setup page")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Change cursor")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Change input field")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Select / Dismiss popup")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Delete SSH key")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" New SSH key")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Revert changes")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Save configuration")) + .build(), + ) + .build(), + )), + ); + // Active help + self.view.active(super::COMPONENT_TEXT_HELP); + } + + /// ### umount_help + /// + /// Umount help + pub(super) fn umount_help(&mut self) { + self.view.umount(super::COMPONENT_TEXT_HELP); + } +} diff --git a/src/ui/activities/setup/view/setup.rs b/src/ui/activities/setup/view/setup.rs new file mode 100644 index 0000000..dcbb68c --- /dev/null +++ b/src/ui/activities/setup/view/setup.rs @@ -0,0 +1,414 @@ +//! ## SetupActivity +//! +//! `setup_activity` is the module which implements the Setup activity, which is the activity to +//! work on termscp configuration + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// Locals +use super::{Context, SetupActivity}; +use crate::filetransfer::FileTransferProtocol; +use crate::fs::explorer::GroupDirs; +use crate::utils::ui::draw_area_in; +// Ext +use std::path::PathBuf; +use tuirealm::components::{ + input::{Input, InputPropsBuilder}, + radio::{Radio, RadioPropsBuilder}, + span::{Span, SpanPropsBuilder}, +}; +use tuirealm::tui::{ + layout::{Constraint, Direction, Layout}, + style::Color, + widgets::{BorderType, Borders, Clear}, +}; +use tuirealm::{ + props::{PropsBuilder, TextSpanBuilder}, + Payload, Value, View, +}; + +impl SetupActivity { + // -- view + + /// ### init_setup + /// + /// Initialize setup view + pub(super) fn init_setup(&mut self) { + // Init view + self.view = View::init(); + // Common stuff + // Radio tab + self.view.mount( + super::COMPONENT_RADIO_TAB, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightYellow) + .with_inverted_color(Color::Black) + .with_borders(Borders::BOTTOM, BorderType::Thick, Color::White) + .with_options( + None, + vec![ + String::from("User Interface"), + String::from("SSH Keys"), + String::from("Theme"), + ], + ) + .with_value(0) + .build(), + )), + ); + // Footer + self.view.mount( + super::COMPONENT_TEXT_FOOTER, + Box::new(Span::new( + SpanPropsBuilder::default() + .with_spans(vec![ + TextSpanBuilder::new("Press ").bold().build(), + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + TextSpanBuilder::new(" to show keybindings").bold().build(), + ]) + .build(), + )), + ); + // Input fields + self.view.mount( + super::COMPONENT_INPUT_TEXT_EDITOR, + Box::new(Input::new( + InputPropsBuilder::default() + .with_foreground(Color::LightGreen) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) + .with_label(String::from("Text editor")) + .build(), + )), + ); + self.view.active(super::COMPONENT_INPUT_TEXT_EDITOR); // <-- Focus + self.view.mount( + super::COMPONENT_RADIO_DEFAULT_PROTOCOL, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightCyan) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan) + .with_options( + Some(String::from("Default file transfer protocol")), + vec![ + String::from("SFTP"), + String::from("SCP"), + String::from("FTP"), + String::from("FTPS"), + ], + ) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_RADIO_HIDDEN_FILES, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightRed) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) + .with_options( + Some(String::from("Show hidden files (by default)")), + vec![String::from("Yes"), String::from("No")], + ) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_RADIO_UPDATES, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightYellow) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) + .with_options( + Some(String::from("Check for updates?")), + vec![String::from("Yes"), String::from("No")], + ) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_RADIO_GROUP_DIRS, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightMagenta) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta) + .with_options( + Some(String::from("Group directories")), + vec![ + String::from("Display first"), + String::from("Display Last"), + String::from("No"), + ], + ) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_INPUT_LOCAL_FILE_FMT, + Box::new(Input::new( + InputPropsBuilder::default() + .with_foreground(Color::LightBlue) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue) + .with_label(String::from("File formatter syntax (local)")) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_INPUT_REMOTE_FILE_FMT, + Box::new(Input::new( + InputPropsBuilder::default() + .with_foreground(Color::LightGreen) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) + .with_label(String::from("File formatter syntax (remote)")) + .build(), + )), + ); + // Load values + self.load_input_values(); + } + + pub(super) fn view_setup(&mut self) { + let mut ctx: Context = self.context.take().unwrap(); + let _ = ctx.terminal.draw(|f| { + // Prepare main chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), // Current tab + Constraint::Length(21), // Main body + Constraint::Length(3), // Help footer + ] + .as_ref(), + ) + .split(f.size()); + // Render common widget + self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]); + self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]); + // Make chunks + let ui_cfg_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), // Text editor + Constraint::Length(3), // Protocol tab + Constraint::Length(3), // Hidden files + Constraint::Length(3), // Updates tab + Constraint::Length(3), // Group dirs + Constraint::Length(3), // Local Format input + Constraint::Length(3), // Remote Format input + ] + .as_ref(), + ) + .split(chunks[1]); + self.view + .render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks[0]); + self.view + .render(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, f, ui_cfg_chunks[1]); + self.view + .render(super::COMPONENT_RADIO_HIDDEN_FILES, f, ui_cfg_chunks[2]); + self.view + .render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks[3]); + self.view + .render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[4]); + self.view + .render(super::COMPONENT_INPUT_LOCAL_FILE_FMT, f, ui_cfg_chunks[5]); + self.view + .render(super::COMPONENT_INPUT_REMOTE_FILE_FMT, f, ui_cfg_chunks[6]); + // Popups + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) { + if props.visible { + let popup = draw_area_in(f.size(), 50, 10); + f.render_widget(Clear, popup); + // make popup + self.view.render(super::COMPONENT_TEXT_ERROR, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 40, 10); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_RADIO_QUIT, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 50, 70); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_TEXT_HELP, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 30, 10); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_RADIO_SAVE, f, popup); + } + } + }); + // Put context back to context + self.context = Some(ctx); + } + + /// ### load_input_values + /// + /// Load values from configuration into input fields + pub(crate) fn load_input_values(&mut self) { + if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { + // Text editor + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_TEXT_EDITOR) { + let text_editor: String = + String::from(cli.get_text_editor().as_path().to_string_lossy()); + let props = InputPropsBuilder::from(props) + .with_value(text_editor) + .build(); + let _ = self.view.update(super::COMPONENT_INPUT_TEXT_EDITOR, props); + } + // Protocol + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) { + let protocol: usize = match cli.get_default_protocol() { + FileTransferProtocol::Sftp => 0, + FileTransferProtocol::Scp => 1, + FileTransferProtocol::Ftp(false) => 2, + FileTransferProtocol::Ftp(true) => 3, + }; + let props = RadioPropsBuilder::from(props).with_value(protocol).build(); + let _ = self + .view + .update(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, props); + } + // Hidden files + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_HIDDEN_FILES) { + let hidden: usize = match cli.get_show_hidden_files() { + true => 0, + false => 1, + }; + let props = RadioPropsBuilder::from(props).with_value(hidden).build(); + let _ = self.view.update(super::COMPONENT_RADIO_HIDDEN_FILES, props); + } + // Updates + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_UPDATES) { + let updates: usize = match cli.get_check_for_updates() { + true => 0, + false => 1, + }; + let props = RadioPropsBuilder::from(props).with_value(updates).build(); + let _ = self.view.update(super::COMPONENT_RADIO_UPDATES, props); + } + // Group dirs + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_GROUP_DIRS) { + let dirs: usize = match cli.get_group_dirs() { + Some(GroupDirs::First) => 0, + Some(GroupDirs::Last) => 1, + None => 2, + }; + let props = RadioPropsBuilder::from(props).with_value(dirs).build(); + let _ = self.view.update(super::COMPONENT_RADIO_GROUP_DIRS, props); + } + // Local File Fmt + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_LOCAL_FILE_FMT) { + let file_fmt: String = cli.get_local_file_fmt().unwrap_or_default(); + let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); + let _ = self + .view + .update(super::COMPONENT_INPUT_LOCAL_FILE_FMT, props); + } + // Remote File Fmt + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_REMOTE_FILE_FMT) { + let file_fmt: String = cli.get_remote_file_fmt().unwrap_or_default(); + let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); + let _ = self + .view + .update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props); + } + } + } + + /// ### collect_input_values + /// + /// Collect values from input and put them into the configuration + pub(crate) fn collect_input_values(&mut self) { + if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { + if let Some(Payload::One(Value::Str(editor))) = + self.view.get_state(super::COMPONENT_INPUT_TEXT_EDITOR) + { + cli.set_text_editor(PathBuf::from(editor.as_str())); + } + if let Some(Payload::One(Value::Usize(protocol))) = + self.view.get_state(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) + { + let protocol: FileTransferProtocol = match protocol { + 1 => FileTransferProtocol::Scp, + 2 => FileTransferProtocol::Ftp(false), + 3 => FileTransferProtocol::Ftp(true), + _ => FileTransferProtocol::Sftp, + }; + cli.set_default_protocol(protocol); + } + if let Some(Payload::One(Value::Usize(opt))) = + self.view.get_state(super::COMPONENT_RADIO_HIDDEN_FILES) + { + let show: bool = matches!(opt, 0); + cli.set_show_hidden_files(show); + } + if let Some(Payload::One(Value::Usize(opt))) = + self.view.get_state(super::COMPONENT_RADIO_UPDATES) + { + let check: bool = matches!(opt, 0); + cli.set_check_for_updates(check); + } + if let Some(Payload::One(Value::Str(fmt))) = + self.view.get_state(super::COMPONENT_INPUT_LOCAL_FILE_FMT) + { + cli.set_local_file_fmt(fmt); + } + if let Some(Payload::One(Value::Str(fmt))) = + self.view.get_state(super::COMPONENT_INPUT_REMOTE_FILE_FMT) + { + cli.set_remote_file_fmt(fmt); + } + if let Some(Payload::One(Value::Usize(opt))) = + self.view.get_state(super::COMPONENT_RADIO_GROUP_DIRS) + { + let dirs: Option = match opt { + 0 => Some(GroupDirs::First), + 1 => Some(GroupDirs::Last), + _ => None, + }; + cli.set_group_dirs(dirs); + } + } + } +} diff --git a/src/ui/activities/setup/view/ssh_keys.rs b/src/ui/activities/setup/view/ssh_keys.rs new file mode 100644 index 0000000..107846f --- /dev/null +++ b/src/ui/activities/setup/view/ssh_keys.rs @@ -0,0 +1,296 @@ +//! ## SetupActivity +//! +//! `setup_activity` is the module which implements the Setup activity, which is the activity to +//! work on termscp configuration + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// Locals +use super::{Context, SetupActivity}; +use crate::ui::components::bookmark_list::{BookmarkList, BookmarkListPropsBuilder}; +use crate::utils::ui::draw_area_in; +// Ext +use tuirealm::components::{ + input::{Input, InputPropsBuilder}, + radio::{Radio, RadioPropsBuilder}, + span::{Span, SpanPropsBuilder}, +}; +use tuirealm::tui::{ + layout::{Constraint, Direction, Layout}, + style::Color, + widgets::{BorderType, Borders, Clear}, +}; +use tuirealm::{ + props::{PropsBuilder, TextSpanBuilder}, + View, +}; + +impl SetupActivity { + // -- view + + /// ### init_ssh_keys + /// + /// Initialize ssh keys view + pub(super) fn init_ssh_keys(&mut self) { + // Init view + self.view = View::init(); + // Common stuff + // Radio tab + self.view.mount( + super::COMPONENT_RADIO_TAB, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightYellow) + .with_inverted_color(Color::Black) + .with_borders(Borders::BOTTOM, BorderType::Thick, Color::LightYellow) + .with_options( + None, + vec![ + String::from("User Interface"), + String::from("SSH Keys"), + String::from("Theme"), + ], + ) + .with_value(1) + .build(), + )), + ); + // Footer + self.view.mount( + super::COMPONENT_TEXT_FOOTER, + Box::new(Span::new( + SpanPropsBuilder::default() + .with_spans(vec![ + TextSpanBuilder::new("Press ").bold().build(), + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + TextSpanBuilder::new(" to show keybindings").bold().build(), + ]) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_LIST_SSH_KEYS, + Box::new(BookmarkList::new( + BookmarkListPropsBuilder::default() + .with_bookmarks(Some(String::from("SSH Keys")), vec![]) + .with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen) + .with_background(Color::LightGreen) + .with_foreground(Color::Black) + .build(), + )), + ); + // Give focus + self.view.active(super::COMPONENT_LIST_SSH_KEYS); + // Load keys + self.reload_ssh_keys(); + } + + pub(crate) fn view_ssh_keys(&mut self) { + let mut ctx: Context = self.context.take().unwrap(); + let _ = ctx.terminal.draw(|f| { + // Prepare main chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), // Current tab + Constraint::Percentage(90), // Main body + Constraint::Length(3), // Help footer + ] + .as_ref(), + ) + .split(f.size()); + // Render common widget + self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]); + self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]); + self.view + .render(super::COMPONENT_LIST_SSH_KEYS, f, chunks[1]); + // Popups + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) { + if props.visible { + let popup = draw_area_in(f.size(), 50, 10); + f.render_widget(Clear, popup); + // make popup + self.view.render(super::COMPONENT_TEXT_ERROR, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 40, 10); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_RADIO_QUIT, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 50, 70); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_TEXT_HELP, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 30, 10); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_RADIO_SAVE, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEL_SSH_KEY) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 30, 10); + f.render_widget(Clear, popup); + self.view + .render(super::COMPONENT_RADIO_DEL_SSH_KEY, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_SSH_HOST) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 50, 20); + f.render_widget(Clear, popup); + let popup_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), // Host + Constraint::Length(3), // Username + ] + .as_ref(), + ) + .split(popup); + self.view + .render(super::COMPONENT_INPUT_SSH_HOST, f, popup_chunks[0]); + self.view + .render(super::COMPONENT_INPUT_SSH_USERNAME, f, popup_chunks[1]); + } + } + }); + // Put context back to context + self.context = Some(ctx); + } + + // -- mount + + /// ### mount_del_ssh_key + /// + /// Mount delete ssh key component + pub(crate) fn mount_del_ssh_key(&mut self) { + self.view.mount( + super::COMPONENT_RADIO_DEL_SSH_KEY, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightRed) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) + .with_options( + Some(String::from("Delete key?")), + vec![String::from("Yes"), String::from("No")], + ) + .with_value(1) // Default: No + .build(), + )), + ); + // Active + self.view.active(super::COMPONENT_RADIO_DEL_SSH_KEY); + } + + /// ### umount_del_ssh_key + /// + /// Umount delete ssh key + pub(crate) fn umount_del_ssh_key(&mut self) { + self.view.umount(super::COMPONENT_RADIO_DEL_SSH_KEY); + } + + /// ### mount_new_ssh_key + /// + /// Mount new ssh key prompt + pub(crate) fn mount_new_ssh_key(&mut self) { + self.view.mount( + super::COMPONENT_INPUT_SSH_HOST, + Box::new(Input::new( + InputPropsBuilder::default() + .with_label(String::from("Hostname or address")) + .with_borders( + Borders::TOP | Borders::RIGHT | Borders::LEFT, + BorderType::Plain, + Color::Reset, + ) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_INPUT_SSH_USERNAME, + Box::new(Input::new( + InputPropsBuilder::default() + .with_label(String::from("Username")) + .with_borders( + Borders::BOTTOM | Borders::RIGHT | Borders::LEFT, + BorderType::Plain, + Color::Reset, + ) + .build(), + )), + ); + self.view.active(super::COMPONENT_INPUT_SSH_HOST); + } + + /// ### umount_new_ssh_key + /// + /// Umount new ssh key prompt + pub(crate) fn umount_new_ssh_key(&mut self) { + self.view.umount(super::COMPONENT_INPUT_SSH_HOST); + self.view.umount(super::COMPONENT_INPUT_SSH_USERNAME); + } + + /// ### reload_ssh_keys + /// + /// Reload ssh keys + pub(crate) fn reload_ssh_keys(&mut self) { + if let Some(cli) = self.context.as_ref().unwrap().config_client.as_ref() { + // get props + if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS) { + // Create texts + let keys: Vec = cli + .iter_ssh_keys() + .map(|x| { + let (addr, username, _) = cli.get_ssh_key(x).ok().unwrap().unwrap(); + format!("{} at {}", addr, username) + }) + .collect(); + let props = BookmarkListPropsBuilder::from(props) + .with_bookmarks(Some(String::from("SSH Keys")), keys) + .build(); + self.view.update(super::COMPONENT_LIST_SSH_KEYS, props); + } + } + } +} diff --git a/src/ui/activities/setup/view/theme.rs b/src/ui/activities/setup/view/theme.rs new file mode 100644 index 0000000..3c7d2fd --- /dev/null +++ b/src/ui/activities/setup/view/theme.rs @@ -0,0 +1,656 @@ +//! ## SetupActivity +//! +//! `setup_activity` is the module which implements the Setup activity, which is the activity to +//! work on termscp configuration + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// Locals +use super::{Context, SetupActivity}; +use crate::config::themes::Theme; +use crate::ui::components::color_picker::{ColorPicker, ColorPickerPropsBuilder}; +use crate::utils::parser::parse_color; +use crate::utils::ui::draw_area_in; +// Ext +use tuirealm::components::{ + label::{Label, LabelPropsBuilder}, + radio::{Radio, RadioPropsBuilder}, + span::{Span, SpanPropsBuilder}, +}; +use tuirealm::tui::{ + layout::{Constraint, Direction, Layout}, + style::Color, + widgets::{BorderType, Borders, Clear}, +}; +use tuirealm::{ + props::{PropsBuilder, TextSpanBuilder}, + Payload, Value, View, +}; + +impl SetupActivity { + // -- view + + /// ### init_theme + /// + /// Initialize thene view + pub(super) fn init_theme(&mut self) { + // Init view + self.view = View::init(); + // Common stuff + // Radio tab + self.view.mount( + super::COMPONENT_RADIO_TAB, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightYellow) + .with_inverted_color(Color::Black) + .with_borders(Borders::BOTTOM, BorderType::Thick, Color::White) + .with_options( + None, + vec![ + String::from("User Interface"), + String::from("SSH Keys"), + String::from("Theme"), + ], + ) + .with_value(2) + .build(), + )), + ); + // Footer + self.view.mount( + super::COMPONENT_TEXT_FOOTER, + Box::new(Span::new( + SpanPropsBuilder::default() + .with_spans(vec![ + TextSpanBuilder::new("Press ").bold().build(), + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + TextSpanBuilder::new(" to show keybindings").bold().build(), + ]) + .build(), + )), + ); + // auth colors + self.mount_title(super::COMPONENT_COLOR_AUTH_TITLE, "Authentication styles"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_PROTOCOL, "Protocol"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_ADDR, "Ip address"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_PORT, "Port"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_USERNAME, "Username"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_PASSWORD, "Password"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_BOOKMARKS, "Bookmarks"); + self.mount_color_picker(super::COMPONENT_COLOR_AUTH_RECENTS, "Recent connections"); + // Misc + self.mount_title(super::COMPONENT_COLOR_MISC_TITLE, "Misc styles"); + self.mount_color_picker(super::COMPONENT_COLOR_MISC_ERROR, "Error"); + self.mount_color_picker(super::COMPONENT_COLOR_MISC_INPUT, "Input fields"); + self.mount_color_picker(super::COMPONENT_COLOR_MISC_KEYS, "Key strokes"); + self.mount_color_picker(super::COMPONENT_COLOR_MISC_QUIT, "Quit dialogs"); + self.mount_color_picker(super::COMPONENT_COLOR_MISC_SAVE, "Save confirmations"); + self.mount_color_picker(super::COMPONENT_COLOR_MISC_WARN, "Warnings"); + // Transfer (1) + self.mount_title(super::COMPONENT_COLOR_TRANSFER_TITLE, "Transfer styles"); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG, + "Local explorer background", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG, + "Local explorer foreground", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, + "Local explorer highlighted", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, + "Remote explorer background", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, + "Remote explorer foreground", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, + "Remote explorer highlighted", + ); + self.mount_color_picker(super::COMPONENT_COLOR_TRANSFER_PROG_BAR, "Progress bar"); + // Transfer (2) + self.mount_title( + super::COMPONENT_COLOR_TRANSFER_TITLE_2, + "Transfer styles (2)", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_LOG_BG, + "Log window background", + ); + self.mount_color_picker(super::COMPONENT_COLOR_TRANSFER_LOG_WIN, "Log window"); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING, + "File sorting", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, + "Hidden files", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC, + "Synchronized browsing", + ); + // Load styles + self.load_styles(); + // Active first field + self.view.active(super::COMPONENT_COLOR_AUTH_PROTOCOL); + } + + pub(super) fn view_theme(&mut self) { + let mut ctx: Context = self.context.take().unwrap(); + let _ = ctx.terminal.draw(|f| { + // Prepare main chunks + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), // Current tab + Constraint::Length(22), // Main body + Constraint::Length(3), // Help footer + ] + .as_ref(), + ) + .split(f.size()); + // Render common widget + self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]); + self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]); + // Make chunks + let colors_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ] + .as_ref(), + ) + .split(chunks[1]); + let auth_colors_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), // Title + Constraint::Length(3), // Protocol + Constraint::Length(3), // Addr + Constraint::Length(3), // Port + Constraint::Length(3), // Username + Constraint::Length(3), // Password + Constraint::Length(3), // Bookmarks + Constraint::Length(3), // Recents + ] + .as_ref(), + ) + .split(colors_layout[0]); + self.view + .render(super::COMPONENT_COLOR_AUTH_TITLE, f, auth_colors_layout[0]); + self.view.render( + super::COMPONENT_COLOR_AUTH_PROTOCOL, + f, + auth_colors_layout[1], + ); + self.view + .render(super::COMPONENT_COLOR_AUTH_ADDR, f, auth_colors_layout[2]); + self.view + .render(super::COMPONENT_COLOR_AUTH_PORT, f, auth_colors_layout[3]); + self.view.render( + super::COMPONENT_COLOR_AUTH_USERNAME, + f, + auth_colors_layout[4], + ); + self.view.render( + super::COMPONENT_COLOR_AUTH_PASSWORD, + f, + auth_colors_layout[5], + ); + self.view.render( + super::COMPONENT_COLOR_AUTH_BOOKMARKS, + f, + auth_colors_layout[6], + ); + self.view.render( + super::COMPONENT_COLOR_AUTH_RECENTS, + f, + auth_colors_layout[7], + ); + let misc_colors_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), // Title + Constraint::Length(3), // Error + Constraint::Length(3), // Input + Constraint::Length(3), // Keys + Constraint::Length(3), // Quit + Constraint::Length(3), // Save + Constraint::Length(3), // Warn + Constraint::Length(3), // Empty + ] + .as_ref(), + ) + .split(colors_layout[1]); + self.view + .render(super::COMPONENT_COLOR_MISC_TITLE, f, misc_colors_layout[0]); + self.view + .render(super::COMPONENT_COLOR_MISC_ERROR, f, misc_colors_layout[1]); + self.view + .render(super::COMPONENT_COLOR_MISC_INPUT, f, misc_colors_layout[2]); + self.view + .render(super::COMPONENT_COLOR_MISC_KEYS, f, misc_colors_layout[3]); + self.view + .render(super::COMPONENT_COLOR_MISC_QUIT, f, misc_colors_layout[4]); + self.view + .render(super::COMPONENT_COLOR_MISC_SAVE, f, misc_colors_layout[5]); + self.view + .render(super::COMPONENT_COLOR_MISC_WARN, f, misc_colors_layout[6]); + + let transfer_colors_layout_col1 = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), // Title + Constraint::Length(3), // local explorer bg + Constraint::Length(3), // local explorer fg + Constraint::Length(3), // local explorer hg + Constraint::Length(3), // remote explorer bg + Constraint::Length(3), // remote explorer fg + Constraint::Length(3), // remote explorer hg + Constraint::Length(3), // prog bar + ] + .as_ref(), + ) + .split(colors_layout[2]); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_TITLE, + f, + transfer_colors_layout_col1[0], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG, + f, + transfer_colors_layout_col1[1], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG, + f, + transfer_colors_layout_col1[2], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, + f, + transfer_colors_layout_col1[3], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, + f, + transfer_colors_layout_col1[4], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, + f, + transfer_colors_layout_col1[5], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, + f, + transfer_colors_layout_col1[6], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_PROG_BAR, + f, + transfer_colors_layout_col1[7], + ); + let transfer_colors_layout_col2 = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), // Title + Constraint::Length(3), // log bg + Constraint::Length(3), // log window + Constraint::Length(3), // status sorting + Constraint::Length(3), // status hidden + Constraint::Length(3), // sync browsing + Constraint::Length(3), // Empty + Constraint::Length(3), // Empty + ] + .as_ref(), + ) + .split(colors_layout[3]); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_TITLE_2, + f, + transfer_colors_layout_col2[0], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_LOG_BG, + f, + transfer_colors_layout_col2[1], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_LOG_WIN, + f, + transfer_colors_layout_col2[2], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING, + f, + transfer_colors_layout_col2[3], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, + f, + transfer_colors_layout_col2[4], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC, + f, + transfer_colors_layout_col2[5], + ); + // Popups + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) { + if props.visible { + let popup = draw_area_in(f.size(), 50, 10); + f.render_widget(Clear, popup); + // make popup + self.view.render(super::COMPONENT_TEXT_ERROR, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 40, 10); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_RADIO_QUIT, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 50, 70); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_TEXT_HELP, f, popup); + } + } + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) { + if props.visible { + // make popup + let popup = draw_area_in(f.size(), 30, 10); + f.render_widget(Clear, popup); + self.view.render(super::COMPONENT_RADIO_SAVE, f, popup); + } + } + }); + // Put context back to context + self.context = Some(ctx); + } + + /// ### load_styles + /// + /// Load values from theme into input fields + pub(crate) fn load_styles(&mut self) { + let theme: Theme = self.theme().clone(); + self.update_color(super::COMPONENT_COLOR_AUTH_ADDR, theme.auth_address); + self.update_color(super::COMPONENT_COLOR_AUTH_BOOKMARKS, theme.auth_bookmarks); + self.update_color(super::COMPONENT_COLOR_AUTH_PASSWORD, theme.auth_password); + self.update_color(super::COMPONENT_COLOR_AUTH_PORT, theme.auth_port); + self.update_color(super::COMPONENT_COLOR_AUTH_PROTOCOL, theme.auth_protocol); + self.update_color(super::COMPONENT_COLOR_AUTH_RECENTS, theme.auth_recents); + self.update_color(super::COMPONENT_COLOR_AUTH_USERNAME, theme.auth_username); + self.update_color(super::COMPONENT_COLOR_MISC_ERROR, theme.misc_error_dialog); + self.update_color(super::COMPONENT_COLOR_MISC_INPUT, theme.misc_input_dialog); + self.update_color(super::COMPONENT_COLOR_MISC_KEYS, theme.misc_keys); + self.update_color(super::COMPONENT_COLOR_MISC_QUIT, theme.misc_quit_dialog); + self.update_color(super::COMPONENT_COLOR_MISC_SAVE, theme.misc_save_dialog); + self.update_color(super::COMPONENT_COLOR_MISC_WARN, theme.misc_warn_dialog); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG, + theme.transfer_local_explorer_background, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG, + theme.transfer_local_explorer_foreground, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, + theme.transfer_local_explorer_highlighted, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, + theme.transfer_remote_explorer_background, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, + theme.transfer_remote_explorer_foreground, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, + theme.transfer_remote_explorer_highlighted, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_PROG_BAR, + theme.transfer_progress_bar, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_LOG_BG, + theme.transfer_log_background, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_LOG_WIN, + theme.transfer_log_window, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING, + theme.transfer_status_sorting, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, + theme.transfer_status_hidden, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC, + theme.transfer_status_sync_browsing, + ); + } + + /// ### collect_styles + /// + /// Collect values from input and put them into the theme. + /// If a component has an invalid color, returns Err(component_id) + pub(crate) fn collect_styles(&mut self) -> Result<(), &'static str> { + // auth + let auth_address: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_ADDR) + .map_err(|_| super::COMPONENT_COLOR_AUTH_ADDR)?; + let auth_bookmarks: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_BOOKMARKS) + .map_err(|_| super::COMPONENT_COLOR_AUTH_BOOKMARKS)?; + let auth_password: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_PASSWORD) + .map_err(|_| super::COMPONENT_COLOR_AUTH_PASSWORD)?; + let auth_port: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_PORT) + .map_err(|_| super::COMPONENT_COLOR_AUTH_PORT)?; + let auth_protocol: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_PROTOCOL) + .map_err(|_| super::COMPONENT_COLOR_AUTH_PROTOCOL)?; + let auth_recents: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_RECENTS) + .map_err(|_| super::COMPONENT_COLOR_AUTH_RECENTS)?; + let auth_username: Color = self + .get_color(super::COMPONENT_COLOR_AUTH_USERNAME) + .map_err(|_| super::COMPONENT_COLOR_AUTH_USERNAME)?; + // misc + let misc_error_dialog: Color = self + .get_color(super::COMPONENT_COLOR_MISC_ERROR) + .map_err(|_| super::COMPONENT_COLOR_MISC_ERROR)?; + let misc_input_dialog: Color = self + .get_color(super::COMPONENT_COLOR_MISC_INPUT) + .map_err(|_| super::COMPONENT_COLOR_MISC_INPUT)?; + let misc_keys: Color = self + .get_color(super::COMPONENT_COLOR_MISC_KEYS) + .map_err(|_| super::COMPONENT_COLOR_MISC_KEYS)?; + let misc_quit_dialog: Color = self + .get_color(super::COMPONENT_COLOR_MISC_QUIT) + .map_err(|_| super::COMPONENT_COLOR_MISC_QUIT)?; + let misc_save_dialog: Color = self + .get_color(super::COMPONENT_COLOR_MISC_SAVE) + .map_err(|_| super::COMPONENT_COLOR_MISC_SAVE)?; + let misc_warn_dialog: Color = self + .get_color(super::COMPONENT_COLOR_MISC_WARN) + .map_err(|_| super::COMPONENT_COLOR_MISC_WARN)?; + // transfer + let transfer_local_explorer_background: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_BG)?; + let transfer_local_explorer_foreground: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_FG)?; + let transfer_local_explorer_highlighted: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG)?; + let transfer_remote_explorer_background: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG)?; + let transfer_remote_explorer_foreground: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG)?; + let transfer_remote_explorer_highlighted: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG)?; + let transfer_log_background: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_LOG_BG) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_LOG_BG)?; + let transfer_log_window: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_LOG_WIN) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_LOG_WIN)?; + let transfer_progress_bar: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_PROG_BAR) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_PROG_BAR)?; + let transfer_status_hidden: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN)?; + let transfer_status_sorting: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING)?; + let transfer_status_sync_browsing: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC)?; + // Update theme + let mut theme: &mut Theme = self.theme_mut(); + theme.auth_address = auth_address; + theme.auth_bookmarks = auth_bookmarks; + theme.auth_password = auth_password; + theme.auth_port = auth_port; + theme.auth_protocol = auth_protocol; + theme.auth_recents = auth_recents; + theme.auth_username = auth_username; + theme.misc_error_dialog = misc_error_dialog; + theme.misc_input_dialog = misc_input_dialog; + theme.misc_keys = misc_keys; + theme.misc_quit_dialog = misc_quit_dialog; + theme.misc_save_dialog = misc_save_dialog; + theme.misc_warn_dialog = misc_warn_dialog; + theme.transfer_local_explorer_background = transfer_local_explorer_background; + theme.transfer_local_explorer_foreground = transfer_local_explorer_foreground; + theme.transfer_local_explorer_highlighted = transfer_local_explorer_highlighted; + theme.transfer_remote_explorer_background = transfer_remote_explorer_background; + theme.transfer_remote_explorer_foreground = transfer_remote_explorer_foreground; + theme.transfer_remote_explorer_highlighted = transfer_remote_explorer_highlighted; + theme.transfer_log_background = transfer_log_background; + theme.transfer_log_window = transfer_log_window; + theme.transfer_progress_bar = transfer_progress_bar; + theme.transfer_status_hidden = transfer_status_hidden; + theme.transfer_status_sorting = transfer_status_sorting; + theme.transfer_status_sync_browsing = transfer_status_sync_browsing; + Ok(()) + } + + /// ### update_color + /// + /// Update color for provided component + fn update_color(&mut self, component: &str, color: Color) { + if let Some(props) = self.view.get_props(component) { + self.view.update( + component, + ColorPickerPropsBuilder::from(props) + .with_color(&color) + .build(), + ); + } + } + + /// ### get_color + /// + /// Get color from component + fn get_color(&self, component: &str) -> Result { + match self.view.get_state(component) { + Some(Payload::One(Value::Str(color))) => match parse_color(color.as_str()) { + Some(c) => Ok(c), + None => Err(()), + }, + _ => Err(()), + } + } + + /// ### mount_color_picker + /// + /// Mount color picker with provided data + fn mount_color_picker(&mut self, id: &str, label: &str) { + self.view.mount( + id, + Box::new(ColorPicker::new( + ColorPickerPropsBuilder::default() + .with_borders(Borders::ALL, BorderType::Rounded, Color::Reset) + .with_label(label.to_string()) + .build(), + )), + ); + } + + /// ### mount_title + /// + /// Mount title + fn mount_title(&mut self, id: &str, text: &str) { + self.view.mount( + id, + Box::new(Label::new( + LabelPropsBuilder::default() + .bold() + .with_text(text.to_string()) + .build(), + )), + ); + } +} diff --git a/src/ui/components/color_picker.rs b/src/ui/components/color_picker.rs new file mode 100644 index 0000000..2fc9df8 --- /dev/null +++ b/src/ui/components/color_picker.rs @@ -0,0 +1,300 @@ +//! ## ColorPicker +//! +//! `ColorPicker` component extends an `Input` component in order to provide some extra features +//! for the color picker. + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// locals +use crate::utils::fmt::fmt_color; +use crate::utils::parser::parse_color; +// ext +use tuirealm::components::input::{Input, InputPropsBuilder}; +use tuirealm::event::Event; +use tuirealm::props::{Props, PropsBuilder}; +use tuirealm::tui::{ + layout::Rect, + style::Color, + widgets::{BorderType, Borders}, +}; +use tuirealm::{Canvas, Component, Msg, Payload, Value}; + +// -- props + +/// ## ColorPickerPropsBuilder +/// +/// A wrapper around an `InputPropsBuilder` +pub struct ColorPickerPropsBuilder { + puppet: InputPropsBuilder, +} + +impl Default for ColorPickerPropsBuilder { + fn default() -> Self { + Self { + puppet: InputPropsBuilder::default(), + } + } +} + +impl PropsBuilder for ColorPickerPropsBuilder { + fn build(&mut self) -> Props { + self.puppet.build() + } + + fn hidden(&mut self) -> &mut Self { + self.puppet.hidden(); + self + } + + fn visible(&mut self) -> &mut Self { + self.puppet.visible(); + self + } +} + +impl From for ColorPickerPropsBuilder { + fn from(props: Props) -> Self { + ColorPickerPropsBuilder { + puppet: InputPropsBuilder::from(props), + } + } +} + +impl ColorPickerPropsBuilder { + /// ### with_borders + /// + /// Set component borders style + pub fn with_borders( + &mut self, + borders: Borders, + variant: BorderType, + color: Color, + ) -> &mut Self { + self.puppet.with_borders(borders, variant, color); + self + } + + /// ### with_label + /// + /// Set input label + pub fn with_label(&mut self, label: String) -> &mut Self { + self.puppet.with_label(label); + self + } + + /// ### with_color + /// + /// Set initial value for component + pub fn with_color(&mut self, color: &Color) -> &mut Self { + self.puppet.with_value(fmt_color(color)); + self + } +} + +// -- component + +/// ## ColorPicker +/// +/// a wrapper component of `Input` which adds a superset of rules to behave as a color picker +pub struct ColorPicker { + input: Input, +} + +impl ColorPicker { + /// ### new + /// + /// Instantiate a new `ColorPicker` + pub fn new(props: Props) -> Self { + // Instantiate a new color picker using input + Self { + input: Input::new(props), + } + } + + /// ### update_colors + /// + /// Update colors to match selected color, with provided one + fn update_colors(&mut self, color: Color) { + let mut props = self.get_props(); + props.foreground = color; + props.borders.color = color; + let _ = self.input.update(props); + } +} + +impl Component for ColorPicker { + /// ### render + /// + /// Based on the current properties and states, renders a widget using the provided render engine in the provided Area + /// If focused, cursor is also set (if supported by widget) + #[cfg(not(tarpaulin_include))] + fn render(&self, render: &mut Canvas, area: Rect) { + self.input.render(render, area); + } + + /// ### update + /// + /// Update component properties + /// Properties should first be retrieved through `get_props` which creates a builder from + /// existing properties and then edited before calling update. + /// Returns a Msg to the view + fn update(&mut self, props: Props) -> Msg { + let msg: Msg = self.input.update(props); + match msg { + Msg::OnChange(Payload::One(Value::Str(input))) => match parse_color(input.as_str()) { + Some(color) => { + // Update color and return OK + self.update_colors(color); + Msg::OnChange(Payload::One(Value::Str(input))) + } + None => { + // Invalid color + self.update_colors(Color::Red); + Msg::None + } + }, + msg => msg, + } + } + + /// ### get_props + /// + /// Returns a props builder starting from component properties. + /// This returns a prop builder in order to make easier to create + /// new properties for the element. + fn get_props(&self) -> Props { + self.input.get_props() + } + + /// ### on + /// + /// Handle input event and update internal states. + /// Returns a Msg to the view + fn on(&mut self, ev: Event) -> Msg { + // Capture message from input + match self.input.on(ev) { + Msg::OnChange(Payload::One(Value::Str(input))) => { + // Capture color and validate + match parse_color(input.as_str()) { + Some(color) => { + // Update color and return OK + self.update_colors(color); + Msg::OnChange(Payload::One(Value::Str(input))) + } + None => { + // Invalid color + self.update_colors(Color::Red); + Msg::None + } + } + } + Msg::OnSubmit(_) => Msg::None, + msg => msg, + } + } + + /// ### get_state + /// + /// Get current state from component + /// For this component returns Unsigned if the input type is a number, otherwise a text + /// The value is always the current input. + fn get_state(&self) -> Payload { + match self.input.get_state() { + Payload::One(Value::Str(color)) => match parse_color(color.as_str()) { + None => Payload::None, + Some(_) => Payload::One(Value::Str(color)), + }, + _ => Payload::None, + } + } + + // -- events + + /// ### blur + /// + /// Blur component; basically remove focus + fn blur(&mut self) { + self.input.blur(); + } + + /// ### active + /// + /// Active component; basically give focus + fn active(&mut self) { + self.input.active(); + } +} + +#[cfg(test)] +mod test { + use super::*; + + use crossterm::event::{KeyCode, KeyEvent}; + use pretty_assertions::assert_eq; + + #[test] + fn test_ui_components_color_picker() { + let mut component: ColorPicker = ColorPicker::new( + ColorPickerPropsBuilder::default() + .visible() + .with_color(&Color::Rgb(204, 170, 0)) + .with_borders(Borders::ALL, BorderType::Double, Color::Rgb(204, 170, 0)) + .build(), + ); + // Focus + component.blur(); + component.active(); + // Get value + assert_eq!( + component.get_state(), + Payload::One(Value::Str(String::from("#ccaa00"))) + ); + // Set an invalid color + let props = InputPropsBuilder::from(component.get_props()) + .with_value(String::from("#pippo1")) + .hidden() + .build(); + assert_eq!(component.update(props), Msg::None); + assert_eq!(component.get_state(), Payload::None); + // Reset color + let props = ColorPickerPropsBuilder::from(component.get_props()) + .with_color(&Color::Rgb(204, 170, 0)) + .hidden() + .build(); + assert_eq!( + component.update(props), + Msg::OnChange(Payload::One(Value::Str("#ccaa00".to_string()))) + ); + // Backspace (invalid) + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))), + Msg::None + ); + // Press '1' + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('1')))), + Msg::OnChange(Payload::One(Value::Str(String::from("#ccaa01")))) + ); + } +} diff --git a/src/ui/components/file_list.rs b/src/ui/components/file_list.rs index 1c512a9..a0db059 100644 --- a/src/ui/components/file_list.rs +++ b/src/ui/components/file_list.rs @@ -28,7 +28,9 @@ // ext use tuirealm::components::utils::get_block; use tuirealm::event::{Event, KeyCode, KeyModifiers}; -use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan}; +use tuirealm::props::{ + BordersProps, PropPayload, PropValue, Props, PropsBuilder, TextParts, TextSpan, +}; use tuirealm::tui::{ layout::{Corner, Rect}, style::{Color, Style}, @@ -39,6 +41,8 @@ use tuirealm::{Canvas, Component, Msg, Payload, Value}; // -- props +const PROP_HIGHLIGHT_COLOR: &str = "props-highlight-color"; + pub struct FileListPropsBuilder { props: Option, } @@ -98,6 +102,19 @@ impl FileListPropsBuilder { self } + /// ### with_highlight_color + /// + /// Set highlighted color + pub fn with_highlight_color(&mut self, color: Color) -> &mut Self { + if let Some(props) = self.props.as_mut() { + props.own.insert( + PROP_HIGHLIGHT_COLOR, + PropPayload::One(PropValue::Color(color)), + ); + } + self + } + /// ### with_borders /// /// Set component borders style @@ -306,9 +323,13 @@ impl Component for FileList { }) .collect(), }; - let (fg, bg): (Color, Color) = match self.states.focus { - true => (Color::Black, self.props.background), - false => (self.props.foreground, Color::Reset), + let highlighted_color: Color = match self.props.own.get(PROP_HIGHLIGHT_COLOR) { + Some(PropPayload::One(PropValue::Color(c))) => *c, + _ => Color::Reset, + }; + let (h_fg, h_bg): (Color, Color) = match self.states.focus { + true => (Color::Black, highlighted_color), + false => (highlighted_color, self.props.background), }; // Render let mut state: ListState = ListState::default(); @@ -321,10 +342,15 @@ impl Component for FileList { self.states.focus, )) .start_corner(Corner::TopLeft) + .style( + Style::default() + .fg(self.props.foreground) + .bg(self.props.background), + ) .highlight_style( Style::default() - .bg(bg) - .fg(fg) + .bg(h_bg) + .fg(h_fg) .add_modifier(self.props.modifiers), ), area, @@ -523,6 +549,7 @@ mod tests { .visible() .with_foreground(Color::Red) .with_background(Color::Blue) + .with_highlight_color(Color::LightRed) .with_borders(Borders::ALL, BorderType::Double, Color::Red) .with_files( Some(String::from("files")), @@ -530,6 +557,10 @@ mod tests { ) .build(), ); + assert_eq!( + *component.props.own.get(PROP_HIGHLIGHT_COLOR).unwrap(), + PropPayload::One(PropValue::Color(Color::LightRed)) + ); assert_eq!(component.props.foreground, Color::Red); assert_eq!(component.props.background, Color::Blue); assert_eq!(component.props.visible, true); diff --git a/src/ui/components/logbox.rs b/src/ui/components/logbox.rs index 6237a7a..8e134a5 100644 --- a/src/ui/components/logbox.rs +++ b/src/ui/components/logbox.rs @@ -96,6 +96,16 @@ impl LogboxPropsBuilder { self } + /// ### with_background + /// + /// Set background color for area + pub fn with_background(&mut self, color: Color) -> &mut Self { + if let Some(props) = self.props.as_mut() { + props.background = color; + } + self + } + pub fn with_log(&mut self, title: Option, table: TextTable) -> &mut Self { if let Some(props) = self.props.as_mut() { props.texts = TextParts::table(title, table); @@ -219,6 +229,7 @@ impl Component for LogBox { )) .start_corner(Corner::BottomLeft) .highlight_symbol(">> ") + .style(Style::default().bg(self.props.background)) .highlight_style(Style::default().add_modifier(self.props.modifiers)); let mut state: ListState = ListState::default(); state.select(Some(self.states.list_index)); @@ -311,6 +322,7 @@ mod tests { .hidden() .visible() .with_borders(Borders::ALL, BorderType::Double, Color::Red) + .with_background(Color::Blue) .with_log( Some(String::from("Log")), TableBuilder::default() @@ -324,6 +336,7 @@ mod tests { .build(), ); assert_eq!(component.props.visible, true); + assert_eq!(component.props.background, Color::Blue); assert_eq!( component.props.texts.title.as_ref().unwrap().as_str(), "Log" diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 79831e5..a613ff9 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -27,6 +27,7 @@ */ // exports pub mod bookmark_list; +pub mod color_picker; pub mod file_list; pub mod logbox; pub mod msgbox; diff --git a/src/ui/context.rs b/src/ui/context.rs index e790eb7..b111bfe 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -30,6 +30,7 @@ use super::input::InputHandler; use super::store::Store; use crate::filetransfer::FileTransferProtocol; use crate::system::config_client::ConfigClient; +use crate::system::theme_provider::ThemeProvider; // Includes use crossterm::event::DisableMouseCapture; @@ -49,6 +50,7 @@ pub struct Context { pub(crate) store: Store, pub(crate) input_hnd: InputHandler, pub(crate) terminal: Terminal>, + pub(crate) theme_provider: ThemeProvider, error: Option, } @@ -68,7 +70,11 @@ impl Context { /// ### new /// /// Instantiates a new Context - pub fn new(config_client: Option, error: Option) -> Context { + pub fn new( + config_client: Option, + theme_provider: ThemeProvider, + error: Option, + ) -> Context { // Create terminal let mut stdout = stdout(); assert!(execute!(stdout, EnterAlternateScreen).is_ok()); @@ -78,6 +84,7 @@ impl Context { store: Store::init(), input_hnd: InputHandler::new(), terminal: Terminal::new(CrosstermBackend::new(stdout)).unwrap(), + theme_provider, error, } } @@ -172,27 +179,4 @@ mod tests { assert!(params.username.is_none()); assert!(params.password.is_none()); } - - #[test] - #[cfg(not(feature = "github-actions"))] - fn test_ui_context() { - // Prepare stuff - let mut ctx: Context = Context::new(None, Some(String::from("alles kaput"))); - assert!(ctx.error.is_some()); - assert_eq!(ctx.get_error().unwrap().as_str(), "alles kaput"); - assert!(ctx.error.is_none()); - assert!(ctx.get_error().is_none()); - ctx.set_error(String::from("err")); - assert!(ctx.error.is_some()); - assert!(ctx.get_error().is_some()); - assert!(ctx.get_error().is_none()); - // Try other methods - #[cfg(not(target_os = "windows"))] - { - ctx.enter_alternate_screen(); - ctx.clear_screen(); - ctx.leave_alternate_screen(); - } - drop(ctx); - } } diff --git a/src/utils/fmt.rs b/src/utils/fmt.rs index a7921e3..f1a6f98 100644 --- a/src/utils/fmt.rs +++ b/src/utils/fmt.rs @@ -28,6 +28,7 @@ use chrono::prelude::*; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; +use tuirealm::tui::style::Color; /// ### fmt_pex /// @@ -149,6 +150,174 @@ pub fn fmt_path_elide(p: &Path, width: usize) -> String { } } +/// ### fmt_color +/// +/// Format color +pub fn fmt_color(color: &Color) -> String { + match color { + Color::Black => "Black".to_string(), + Color::Blue => "Blue".to_string(), + Color::Cyan => "Cyan".to_string(), + Color::DarkGray => "DarkGray".to_string(), + Color::Gray => "Gray".to_string(), + Color::Green => "Green".to_string(), + Color::LightBlue => "LightBlue".to_string(), + Color::LightCyan => "LightCyan".to_string(), + Color::LightGreen => "LightGreen".to_string(), + Color::LightMagenta => "LightMagenta".to_string(), + Color::LightRed => "LightRed".to_string(), + Color::LightYellow => "LightYellow".to_string(), + Color::Magenta => "Magenta".to_string(), + Color::Red => "Red".to_string(), + Color::Reset => "Default".to_string(), + Color::White => "White".to_string(), + Color::Yellow => "Yellow".to_string(), + Color::Indexed(_) => "Default".to_string(), + // -- css colors + Color::Rgb(240, 248, 255) => "aliceblue".to_string(), + Color::Rgb(250, 235, 215) => "antiquewhite".to_string(), + Color::Rgb(0, 255, 255) => "aqua".to_string(), + Color::Rgb(127, 255, 212) => "aquamarine".to_string(), + Color::Rgb(240, 255, 255) => "azure".to_string(), + Color::Rgb(245, 245, 220) => "beige".to_string(), + Color::Rgb(255, 228, 196) => "bisque".to_string(), + Color::Rgb(0, 0, 0) => "black".to_string(), + Color::Rgb(255, 235, 205) => "blanchedalmond".to_string(), + Color::Rgb(0, 0, 255) => "blue".to_string(), + Color::Rgb(138, 43, 226) => "blueviolet".to_string(), + Color::Rgb(165, 42, 42) => "brown".to_string(), + Color::Rgb(222, 184, 135) => "burlywood".to_string(), + Color::Rgb(95, 158, 160) => "cadetblue".to_string(), + Color::Rgb(127, 255, 0) => "chartreuse".to_string(), + Color::Rgb(210, 105, 30) => "chocolate".to_string(), + Color::Rgb(255, 127, 80) => "coral".to_string(), + Color::Rgb(100, 149, 237) => "cornflowerblue".to_string(), + Color::Rgb(255, 248, 220) => "cornsilk".to_string(), + Color::Rgb(220, 20, 60) => "crimson".to_string(), + Color::Rgb(0, 0, 139) => "darkblue".to_string(), + Color::Rgb(0, 139, 139) => "darkcyan".to_string(), + Color::Rgb(184, 134, 11) => "darkgoldenrod".to_string(), + Color::Rgb(169, 169, 169) => "darkgray".to_string(), + Color::Rgb(0, 100, 0) => "darkgreen".to_string(), + Color::Rgb(189, 183, 107) => "darkkhaki".to_string(), + Color::Rgb(139, 0, 139) => "darkmagenta".to_string(), + Color::Rgb(85, 107, 47) => "darkolivegreen".to_string(), + Color::Rgb(255, 140, 0) => "darkorange".to_string(), + Color::Rgb(153, 50, 204) => "darkorchid".to_string(), + Color::Rgb(139, 0, 0) => "darkred".to_string(), + Color::Rgb(233, 150, 122) => "darksalmon".to_string(), + Color::Rgb(143, 188, 143) => "darkseagreen".to_string(), + Color::Rgb(72, 61, 139) => "darkslateblue".to_string(), + Color::Rgb(47, 79, 79) => "darkslategray".to_string(), + Color::Rgb(0, 206, 209) => "darkturquoise".to_string(), + Color::Rgb(148, 0, 211) => "darkviolet".to_string(), + Color::Rgb(255, 20, 147) => "deeppink".to_string(), + Color::Rgb(0, 191, 255) => "deepskyblue".to_string(), + Color::Rgb(105, 105, 105) => "dimgray".to_string(), + Color::Rgb(30, 144, 255) => "dodgerblue".to_string(), + Color::Rgb(178, 34, 34) => "firebrick".to_string(), + Color::Rgb(255, 250, 240) => "floralwhite".to_string(), + Color::Rgb(34, 139, 34) => "forestgreen".to_string(), + Color::Rgb(255, 0, 255) => "fuchsia".to_string(), + Color::Rgb(220, 220, 220) => "gainsboro".to_string(), + Color::Rgb(248, 248, 255) => "ghostwhite".to_string(), + Color::Rgb(255, 215, 0) => "gold".to_string(), + Color::Rgb(218, 165, 32) => "goldenrod".to_string(), + Color::Rgb(128, 128, 128) => "gray".to_string(), + Color::Rgb(0, 128, 0) => "green".to_string(), + Color::Rgb(173, 255, 47) => "greenyellow".to_string(), + Color::Rgb(240, 255, 240) => "honeydew".to_string(), + Color::Rgb(255, 105, 180) => "hotpink".to_string(), + Color::Rgb(205, 92, 92) => "indianred".to_string(), + Color::Rgb(75, 0, 130) => "indigo".to_string(), + Color::Rgb(255, 255, 240) => "ivory".to_string(), + Color::Rgb(240, 230, 140) => "khaki".to_string(), + Color::Rgb(230, 230, 250) => "lavender".to_string(), + Color::Rgb(255, 240, 245) => "lavenderblush".to_string(), + Color::Rgb(124, 252, 0) => "lawngreen".to_string(), + Color::Rgb(255, 250, 205) => "lemonchiffon".to_string(), + Color::Rgb(173, 216, 230) => "lightblue".to_string(), + Color::Rgb(240, 128, 128) => "lightcoral".to_string(), + Color::Rgb(224, 255, 255) => "lightcyan".to_string(), + Color::Rgb(250, 250, 210) => "lightgoldenrodyellow".to_string(), + Color::Rgb(211, 211, 211) => "lightgray".to_string(), + Color::Rgb(144, 238, 144) => "lightgreen".to_string(), + Color::Rgb(255, 182, 193) => "lightpink".to_string(), + Color::Rgb(255, 160, 122) => "lightsalmon".to_string(), + Color::Rgb(32, 178, 170) => "lightseagreen".to_string(), + Color::Rgb(135, 206, 250) => "lightskyblue".to_string(), + Color::Rgb(119, 136, 153) => "lightslategray".to_string(), + Color::Rgb(176, 196, 222) => "lightsteelblue".to_string(), + Color::Rgb(255, 255, 224) => "lightyellow".to_string(), + Color::Rgb(0, 255, 0) => "lime".to_string(), + Color::Rgb(50, 205, 50) => "limegreen".to_string(), + Color::Rgb(250, 240, 230) => "linen".to_string(), + Color::Rgb(128, 0, 0) => "maroon".to_string(), + Color::Rgb(102, 205, 170) => "mediumaquamarine".to_string(), + Color::Rgb(0, 0, 205) => "mediumblue".to_string(), + Color::Rgb(186, 85, 211) => "mediumorchid".to_string(), + Color::Rgb(147, 112, 219) => "mediumpurple".to_string(), + Color::Rgb(60, 179, 113) => "mediumseagreen".to_string(), + Color::Rgb(123, 104, 238) => "mediumslateblue".to_string(), + Color::Rgb(0, 250, 154) => "mediumspringgreen".to_string(), + Color::Rgb(72, 209, 204) => "mediumturquoise".to_string(), + Color::Rgb(199, 21, 133) => "mediumvioletred".to_string(), + Color::Rgb(25, 25, 112) => "midnightblue".to_string(), + Color::Rgb(245, 255, 250) => "mintcream".to_string(), + Color::Rgb(255, 228, 225) => "mistyrose".to_string(), + Color::Rgb(255, 228, 181) => "moccasin".to_string(), + Color::Rgb(255, 222, 173) => "navajowhite".to_string(), + Color::Rgb(0, 0, 128) => "navy".to_string(), + Color::Rgb(253, 245, 230) => "oldlace".to_string(), + Color::Rgb(128, 128, 0) => "olive".to_string(), + Color::Rgb(107, 142, 35) => "olivedrab".to_string(), + Color::Rgb(255, 165, 0) => "orange".to_string(), + Color::Rgb(255, 69, 0) => "orangered".to_string(), + Color::Rgb(218, 112, 214) => "orchid".to_string(), + Color::Rgb(238, 232, 170) => "palegoldenrod".to_string(), + Color::Rgb(152, 251, 152) => "palegreen".to_string(), + Color::Rgb(175, 238, 238) => "paleturquoise".to_string(), + Color::Rgb(219, 112, 147) => "palevioletred".to_string(), + Color::Rgb(255, 239, 213) => "papayawhip".to_string(), + Color::Rgb(255, 218, 185) => "peachpuff".to_string(), + Color::Rgb(205, 133, 63) => "peru".to_string(), + Color::Rgb(255, 192, 203) => "pink".to_string(), + Color::Rgb(221, 160, 221) => "plum".to_string(), + Color::Rgb(176, 224, 230) => "powderblue".to_string(), + Color::Rgb(128, 0, 128) => "purple".to_string(), + Color::Rgb(102, 51, 153) => "rebeccapurple".to_string(), + Color::Rgb(255, 0, 0) => "red".to_string(), + Color::Rgb(188, 143, 143) => "rosybrown".to_string(), + Color::Rgb(65, 105, 225) => "royalblue".to_string(), + Color::Rgb(139, 69, 19) => "saddlebrown".to_string(), + Color::Rgb(250, 128, 114) => "salmon".to_string(), + Color::Rgb(244, 164, 96) => "sandybrown".to_string(), + Color::Rgb(46, 139, 87) => "seagreen".to_string(), + Color::Rgb(255, 245, 238) => "seashell".to_string(), + Color::Rgb(160, 82, 45) => "sienna".to_string(), + Color::Rgb(192, 192, 192) => "silver".to_string(), + Color::Rgb(135, 206, 235) => "skyblue".to_string(), + Color::Rgb(106, 90, 205) => "slateblue".to_string(), + Color::Rgb(112, 128, 144) => "slategray".to_string(), + Color::Rgb(255, 250, 250) => "snow".to_string(), + Color::Rgb(0, 255, 127) => "springgreen".to_string(), + Color::Rgb(70, 130, 180) => "steelblue".to_string(), + Color::Rgb(210, 180, 140) => "tan".to_string(), + Color::Rgb(0, 128, 128) => "teal".to_string(), + Color::Rgb(216, 191, 216) => "thistle".to_string(), + Color::Rgb(255, 99, 71) => "tomato".to_string(), + Color::Rgb(64, 224, 208) => "turquoise".to_string(), + Color::Rgb(238, 130, 238) => "violet".to_string(), + Color::Rgb(245, 222, 179) => "wheat".to_string(), + Color::Rgb(255, 255, 255) => "white".to_string(), + Color::Rgb(245, 245, 245) => "whitesmoke".to_string(), + Color::Rgb(255, 255, 0) => "yellow".to_string(), + Color::Rgb(154, 205, 50) => "yellowgreen".to_string(), + // -- others + Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b), + } +} + /// ### shadow_password /// /// Return a string with the same length of input string, but each character is replaced by '*' @@ -224,6 +393,263 @@ mod tests { assert_eq!(fmt_path_elide(p, 16), String::from("/develop/.../foo/bar")); } + #[test] + fn test_utils_fmt_color() { + assert_eq!(fmt_color(&Color::Black).as_str(), "Black"); + assert_eq!(fmt_color(&Color::Blue).as_str(), "Blue"); + assert_eq!(fmt_color(&Color::Cyan).as_str(), "Cyan"); + assert_eq!(fmt_color(&Color::DarkGray).as_str(), "DarkGray"); + assert_eq!(fmt_color(&Color::Gray).as_str(), "Gray"); + assert_eq!(fmt_color(&Color::Green).as_str(), "Green"); + assert_eq!(fmt_color(&Color::LightBlue).as_str(), "LightBlue"); + assert_eq!(fmt_color(&Color::LightCyan).as_str(), "LightCyan"); + assert_eq!(fmt_color(&Color::LightGreen).as_str(), "LightGreen"); + assert_eq!(fmt_color(&Color::LightMagenta).as_str(), "LightMagenta"); + assert_eq!(fmt_color(&Color::LightRed).as_str(), "LightRed"); + assert_eq!(fmt_color(&Color::LightYellow).as_str(), "LightYellow"); + assert_eq!(fmt_color(&Color::Magenta).as_str(), "Magenta"); + assert_eq!(fmt_color(&Color::Red).as_str(), "Red"); + assert_eq!(fmt_color(&Color::Reset).as_str(), "Default"); + assert_eq!(fmt_color(&Color::White).as_str(), "White"); + assert_eq!(fmt_color(&Color::Yellow).as_str(), "Yellow"); + assert_eq!(fmt_color(&Color::Indexed(16)).as_str(), "Default"); + assert_eq!(fmt_color(&Color::Rgb(204, 170, 22)).as_str(), "#ccaa16"); + assert_eq!(fmt_color(&Color::Rgb(204, 170, 0)).as_str(), "#ccaa00"); + // css colors + assert_eq!(fmt_color(&Color::Rgb(240, 248, 255)).as_str(), "aliceblue"); + assert_eq!( + fmt_color(&Color::Rgb(250, 235, 215)).as_str(), + "antiquewhite" + ); + assert_eq!(fmt_color(&Color::Rgb(0, 255, 255)).as_str(), "aqua"); + assert_eq!(fmt_color(&Color::Rgb(127, 255, 212)).as_str(), "aquamarine"); + assert_eq!(fmt_color(&Color::Rgb(240, 255, 255)).as_str(), "azure"); + assert_eq!(fmt_color(&Color::Rgb(245, 245, 220)).as_str(), "beige"); + assert_eq!(fmt_color(&Color::Rgb(255, 228, 196)).as_str(), "bisque"); + assert_eq!(fmt_color(&Color::Rgb(0, 0, 0)).as_str(), "black"); + assert_eq!( + fmt_color(&Color::Rgb(255, 235, 205)).as_str(), + "blanchedalmond" + ); + assert_eq!(fmt_color(&Color::Rgb(0, 0, 255)).as_str(), "blue"); + assert_eq!(fmt_color(&Color::Rgb(138, 43, 226)).as_str(), "blueviolet"); + assert_eq!(fmt_color(&Color::Rgb(165, 42, 42)).as_str(), "brown"); + assert_eq!(fmt_color(&Color::Rgb(222, 184, 135)).as_str(), "burlywood"); + assert_eq!(fmt_color(&Color::Rgb(95, 158, 160)).as_str(), "cadetblue"); + assert_eq!(fmt_color(&Color::Rgb(127, 255, 0)).as_str(), "chartreuse"); + assert_eq!(fmt_color(&Color::Rgb(210, 105, 30)).as_str(), "chocolate"); + assert_eq!(fmt_color(&Color::Rgb(255, 127, 80)).as_str(), "coral"); + assert_eq!( + fmt_color(&Color::Rgb(100, 149, 237)).as_str(), + "cornflowerblue" + ); + assert_eq!(fmt_color(&Color::Rgb(255, 248, 220)).as_str(), "cornsilk"); + assert_eq!(fmt_color(&Color::Rgb(220, 20, 60)).as_str(), "crimson"); + assert_eq!(fmt_color(&Color::Rgb(0, 0, 139)).as_str(), "darkblue"); + assert_eq!(fmt_color(&Color::Rgb(0, 139, 139)).as_str(), "darkcyan"); + assert_eq!( + fmt_color(&Color::Rgb(184, 134, 11)).as_str(), + "darkgoldenrod" + ); + assert_eq!(fmt_color(&Color::Rgb(169, 169, 169)).as_str(), "darkgray"); + assert_eq!(fmt_color(&Color::Rgb(0, 100, 0)).as_str(), "darkgreen"); + assert_eq!(fmt_color(&Color::Rgb(189, 183, 107)).as_str(), "darkkhaki"); + assert_eq!(fmt_color(&Color::Rgb(139, 0, 139)).as_str(), "darkmagenta"); + assert_eq!( + fmt_color(&Color::Rgb(85, 107, 47)).as_str(), + "darkolivegreen" + ); + assert_eq!(fmt_color(&Color::Rgb(255, 140, 0)).as_str(), "darkorange"); + assert_eq!(fmt_color(&Color::Rgb(153, 50, 204)).as_str(), "darkorchid"); + assert_eq!(fmt_color(&Color::Rgb(139, 0, 0)).as_str(), "darkred"); + assert_eq!(fmt_color(&Color::Rgb(233, 150, 122)).as_str(), "darksalmon"); + assert_eq!( + fmt_color(&Color::Rgb(143, 188, 143)).as_str(), + "darkseagreen" + ); + assert_eq!( + fmt_color(&Color::Rgb(72, 61, 139)).as_str(), + "darkslateblue" + ); + assert_eq!(fmt_color(&Color::Rgb(47, 79, 79)).as_str(), "darkslategray"); + assert_eq!( + fmt_color(&Color::Rgb(0, 206, 209)).as_str(), + "darkturquoise" + ); + assert_eq!(fmt_color(&Color::Rgb(148, 0, 211)).as_str(), "darkviolet"); + assert_eq!(fmt_color(&Color::Rgb(255, 20, 147)).as_str(), "deeppink"); + assert_eq!(fmt_color(&Color::Rgb(0, 191, 255)).as_str(), "deepskyblue"); + assert_eq!(fmt_color(&Color::Rgb(105, 105, 105)).as_str(), "dimgray"); + assert_eq!(fmt_color(&Color::Rgb(30, 144, 255)).as_str(), "dodgerblue"); + assert_eq!(fmt_color(&Color::Rgb(178, 34, 34)).as_str(), "firebrick"); + assert_eq!( + fmt_color(&Color::Rgb(255, 250, 240)).as_str(), + "floralwhite" + ); + assert_eq!(fmt_color(&Color::Rgb(34, 139, 34)).as_str(), "forestgreen"); + assert_eq!(fmt_color(&Color::Rgb(255, 0, 255)).as_str(), "fuchsia"); + assert_eq!(fmt_color(&Color::Rgb(220, 220, 220)).as_str(), "gainsboro"); + assert_eq!(fmt_color(&Color::Rgb(248, 248, 255)).as_str(), "ghostwhite"); + assert_eq!(fmt_color(&Color::Rgb(255, 215, 0)).as_str(), "gold"); + assert_eq!(fmt_color(&Color::Rgb(218, 165, 32)).as_str(), "goldenrod"); + assert_eq!(fmt_color(&Color::Rgb(128, 128, 128)).as_str(), "gray"); + assert_eq!(fmt_color(&Color::Rgb(0, 128, 0)).as_str(), "green"); + assert_eq!(fmt_color(&Color::Rgb(173, 255, 47)).as_str(), "greenyellow"); + assert_eq!(fmt_color(&Color::Rgb(240, 255, 240)).as_str(), "honeydew"); + assert_eq!(fmt_color(&Color::Rgb(255, 105, 180)).as_str(), "hotpink"); + assert_eq!(fmt_color(&Color::Rgb(205, 92, 92)).as_str(), "indianred"); + assert_eq!(fmt_color(&Color::Rgb(75, 0, 130)).as_str(), "indigo"); + assert_eq!(fmt_color(&Color::Rgb(255, 255, 240)).as_str(), "ivory"); + assert_eq!(fmt_color(&Color::Rgb(240, 230, 140)).as_str(), "khaki"); + assert_eq!(fmt_color(&Color::Rgb(230, 230, 250)).as_str(), "lavender"); + assert_eq!( + fmt_color(&Color::Rgb(255, 240, 245)).as_str(), + "lavenderblush" + ); + assert_eq!(fmt_color(&Color::Rgb(124, 252, 0)).as_str(), "lawngreen"); + assert_eq!( + fmt_color(&Color::Rgb(255, 250, 205)).as_str(), + "lemonchiffon" + ); + assert_eq!(fmt_color(&Color::Rgb(173, 216, 230)).as_str(), "lightblue"); + assert_eq!(fmt_color(&Color::Rgb(240, 128, 128)).as_str(), "lightcoral"); + assert_eq!(fmt_color(&Color::Rgb(224, 255, 255)).as_str(), "lightcyan"); + assert_eq!( + fmt_color(&Color::Rgb(250, 250, 210)).as_str(), + "lightgoldenrodyellow" + ); + assert_eq!(fmt_color(&Color::Rgb(211, 211, 211)).as_str(), "lightgray"); + assert_eq!(fmt_color(&Color::Rgb(144, 238, 144)).as_str(), "lightgreen"); + assert_eq!(fmt_color(&Color::Rgb(255, 182, 193)).as_str(), "lightpink"); + assert_eq!( + fmt_color(&Color::Rgb(255, 160, 122)).as_str(), + "lightsalmon" + ); + assert_eq!( + fmt_color(&Color::Rgb(32, 178, 170)).as_str(), + "lightseagreen" + ); + assert_eq!( + fmt_color(&Color::Rgb(135, 206, 250)).as_str(), + "lightskyblue" + ); + assert_eq!( + fmt_color(&Color::Rgb(119, 136, 153)).as_str(), + "lightslategray" + ); + assert_eq!( + fmt_color(&Color::Rgb(176, 196, 222)).as_str(), + "lightsteelblue" + ); + assert_eq!( + fmt_color(&Color::Rgb(255, 255, 224)).as_str(), + "lightyellow" + ); + assert_eq!(fmt_color(&Color::Rgb(0, 255, 0)).as_str(), "lime"); + assert_eq!(fmt_color(&Color::Rgb(50, 205, 50)).as_str(), "limegreen"); + assert_eq!(fmt_color(&Color::Rgb(250, 240, 230)).as_str(), "linen"); + assert_eq!(fmt_color(&Color::Rgb(128, 0, 0)).as_str(), "maroon"); + assert_eq!( + fmt_color(&Color::Rgb(102, 205, 170)).as_str(), + "mediumaquamarine" + ); + assert_eq!(fmt_color(&Color::Rgb(0, 0, 205)).as_str(), "mediumblue"); + assert_eq!( + fmt_color(&Color::Rgb(186, 85, 211)).as_str(), + "mediumorchid" + ); + assert_eq!( + fmt_color(&Color::Rgb(147, 112, 219)).as_str(), + "mediumpurple" + ); + assert_eq!( + fmt_color(&Color::Rgb(60, 179, 113)).as_str(), + "mediumseagreen" + ); + assert_eq!( + fmt_color(&Color::Rgb(123, 104, 238)).as_str(), + "mediumslateblue" + ); + assert_eq!( + fmt_color(&Color::Rgb(0, 250, 154)).as_str(), + "mediumspringgreen" + ); + assert_eq!( + fmt_color(&Color::Rgb(72, 209, 204)).as_str(), + "mediumturquoise" + ); + assert_eq!( + fmt_color(&Color::Rgb(199, 21, 133)).as_str(), + "mediumvioletred" + ); + assert_eq!(fmt_color(&Color::Rgb(25, 25, 112)).as_str(), "midnightblue"); + assert_eq!(fmt_color(&Color::Rgb(245, 255, 250)).as_str(), "mintcream"); + assert_eq!(fmt_color(&Color::Rgb(255, 228, 225)).as_str(), "mistyrose"); + assert_eq!(fmt_color(&Color::Rgb(255, 228, 181)).as_str(), "moccasin"); + assert_eq!( + fmt_color(&Color::Rgb(255, 222, 173)).as_str(), + "navajowhite" + ); + assert_eq!(fmt_color(&Color::Rgb(0, 0, 128)).as_str(), "navy"); + assert_eq!(fmt_color(&Color::Rgb(253, 245, 230)).as_str(), "oldlace"); + assert_eq!(fmt_color(&Color::Rgb(128, 128, 0)).as_str(), "olive"); + assert_eq!(fmt_color(&Color::Rgb(107, 142, 35)).as_str(), "olivedrab"); + assert_eq!(fmt_color(&Color::Rgb(255, 165, 0)).as_str(), "orange"); + assert_eq!(fmt_color(&Color::Rgb(255, 69, 0)).as_str(), "orangered"); + assert_eq!(fmt_color(&Color::Rgb(218, 112, 214)).as_str(), "orchid"); + assert_eq!( + fmt_color(&Color::Rgb(238, 232, 170)).as_str(), + "palegoldenrod" + ); + assert_eq!(fmt_color(&Color::Rgb(152, 251, 152)).as_str(), "palegreen"); + assert_eq!( + fmt_color(&Color::Rgb(175, 238, 238)).as_str(), + "paleturquoise" + ); + assert_eq!( + fmt_color(&Color::Rgb(219, 112, 147)).as_str(), + "palevioletred" + ); + assert_eq!(fmt_color(&Color::Rgb(255, 239, 213)).as_str(), "papayawhip"); + assert_eq!(fmt_color(&Color::Rgb(255, 218, 185)).as_str(), "peachpuff"); + assert_eq!(fmt_color(&Color::Rgb(205, 133, 63)).as_str(), "peru"); + assert_eq!(fmt_color(&Color::Rgb(255, 192, 203)).as_str(), "pink"); + assert_eq!(fmt_color(&Color::Rgb(221, 160, 221)).as_str(), "plum"); + assert_eq!(fmt_color(&Color::Rgb(176, 224, 230)).as_str(), "powderblue"); + assert_eq!(fmt_color(&Color::Rgb(128, 0, 128)).as_str(), "purple"); + assert_eq!( + fmt_color(&Color::Rgb(102, 51, 153)).as_str(), + "rebeccapurple" + ); + assert_eq!(fmt_color(&Color::Rgb(255, 0, 0)).as_str(), "red"); + assert_eq!(fmt_color(&Color::Rgb(188, 143, 143)).as_str(), "rosybrown"); + assert_eq!(fmt_color(&Color::Rgb(65, 105, 225)).as_str(), "royalblue"); + assert_eq!(fmt_color(&Color::Rgb(139, 69, 19)).as_str(), "saddlebrown"); + assert_eq!(fmt_color(&Color::Rgb(250, 128, 114)).as_str(), "salmon"); + assert_eq!(fmt_color(&Color::Rgb(244, 164, 96)).as_str(), "sandybrown"); + assert_eq!(fmt_color(&Color::Rgb(46, 139, 87)).as_str(), "seagreen"); + assert_eq!(fmt_color(&Color::Rgb(255, 245, 238)).as_str(), "seashell"); + assert_eq!(fmt_color(&Color::Rgb(160, 82, 45)).as_str(), "sienna"); + assert_eq!(fmt_color(&Color::Rgb(192, 192, 192)).as_str(), "silver"); + assert_eq!(fmt_color(&Color::Rgb(135, 206, 235)).as_str(), "skyblue"); + assert_eq!(fmt_color(&Color::Rgb(106, 90, 205)).as_str(), "slateblue"); + assert_eq!(fmt_color(&Color::Rgb(112, 128, 144)).as_str(), "slategray"); + assert_eq!(fmt_color(&Color::Rgb(255, 250, 250)).as_str(), "snow"); + assert_eq!(fmt_color(&Color::Rgb(0, 255, 127)).as_str(), "springgreen"); + assert_eq!(fmt_color(&Color::Rgb(70, 130, 180)).as_str(), "steelblue"); + assert_eq!(fmt_color(&Color::Rgb(210, 180, 140)).as_str(), "tan"); + assert_eq!(fmt_color(&Color::Rgb(0, 128, 128)).as_str(), "teal"); + assert_eq!(fmt_color(&Color::Rgb(216, 191, 216)).as_str(), "thistle"); + assert_eq!(fmt_color(&Color::Rgb(255, 99, 71)).as_str(), "tomato"); + assert_eq!(fmt_color(&Color::Rgb(64, 224, 208)).as_str(), "turquoise"); + assert_eq!(fmt_color(&Color::Rgb(238, 130, 238)).as_str(), "violet"); + assert_eq!(fmt_color(&Color::Rgb(245, 222, 179)).as_str(), "wheat"); + assert_eq!(fmt_color(&Color::Rgb(255, 255, 255)).as_str(), "white"); + assert_eq!(fmt_color(&Color::Rgb(245, 245, 245)).as_str(), "whitesmoke"); + assert_eq!(fmt_color(&Color::Rgb(255, 255, 0)).as_str(), "yellow"); + assert_eq!(fmt_color(&Color::Rgb(154, 205, 50)).as_str(), "yellowgreen"); + } + #[test] fn test_utils_fmt_shadow_password() { assert_eq!(shadow_password("foobar"), String::from("******")); diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 86c0286..17a7382 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -39,6 +39,7 @@ use regex::Regex; use std::path::PathBuf; use std::str::FromStr; use std::time::{Duration, SystemTime}; +use tuirealm::tui::style::Color; // Regex lazy_static! { @@ -58,6 +59,20 @@ lazy_static! { * v0.4.0 => 0.4.0 */ static ref SEMVER_REGEX: Regex = Regex::new(r".*(:?[0-9]\.[0-9]\.[0-9])").unwrap(); + /** + * Regex matches: + * - group 1: Red + * - group 2: Green + * - group 3: Blue + */ + static ref COLOR_HEX_REGEX: Regex = Regex::new(r"#(:?[0-9a-fA-F]{2})(:?[0-9a-fA-F]{2})(:?[0-9a-fA-F]{2})").unwrap(); + /** + * Regex matches: + * - group 2: Red + * - group 4: Green + * - group 6: blue + */ + static ref COLOR_RGB_REGEX: Regex = Regex::new(r"^(rgb)?\(?([01]?\d\d?|2[0-4]\d|25[0-5])(\W+)([01]?\d\d?|2[0-4]\d|25[0-5])\W+(([01]?\d\d?|2[0-4]\d|25[0-5])\)?)").unwrap(); } pub struct RemoteOptions { @@ -219,6 +234,237 @@ pub fn parse_semver(haystack: &str) -> Option { } } +/// ### parse_color +/// +/// Parse color from string into a `Color` enum. +/// +/// Color may be in different format: +/// +/// 1. color name: +/// - Black, +/// - Blue, +/// - Cyan, +/// - DarkGray, +/// - Gray, +/// - Green, +/// - LightBlue, +/// - LightCyan, +/// - LightGreen, +/// - LightMagenta, +/// - LightRed, +/// - LightYellow, +/// - Magenta, +/// - Red, +/// - Reset, +/// - White, +/// - Yellow, +/// 2. Hex format: +/// - #f0ab05 +/// - #AA33BC +/// 3. Rgb format: +/// - rgb(255, 64, 32) +/// - rgb(255,64,32) +/// - 255, 64, 32 +pub fn parse_color(color: &str) -> Option { + match color.to_lowercase().as_str() { + // -- lib colors + "black" => Some(Color::Black), + "blue" => Some(Color::Blue), + "cyan" => Some(Color::Cyan), + "darkgray" | "darkgrey" => Some(Color::DarkGray), + "default" => Some(Color::Reset), + "gray" => Some(Color::Gray), + "green" => Some(Color::Green), + "lightblue" => Some(Color::LightBlue), + "lightcyan" => Some(Color::LightCyan), + "lightgreen" => Some(Color::LightGreen), + "lightmagenta" => Some(Color::LightMagenta), + "lightred" => Some(Color::LightRed), + "lightyellow" => Some(Color::LightYellow), + "magenta" => Some(Color::Magenta), + "red" => Some(Color::Red), + "white" => Some(Color::White), + "yellow" => Some(Color::Yellow), + // -- css colors + "aliceblue" => Some(Color::Rgb(240, 248, 255)), + "antiquewhite" => Some(Color::Rgb(250, 235, 215)), + "aqua" => Some(Color::Rgb(0, 255, 255)), + "aquamarine" => Some(Color::Rgb(127, 255, 212)), + "azure" => Some(Color::Rgb(240, 255, 255)), + "beige" => Some(Color::Rgb(245, 245, 220)), + "bisque" => Some(Color::Rgb(255, 228, 196)), + "blanchedalmond" => Some(Color::Rgb(255, 235, 205)), + "blueviolet" => Some(Color::Rgb(138, 43, 226)), + "brown" => Some(Color::Rgb(165, 42, 42)), + "burlywood" => Some(Color::Rgb(222, 184, 135)), + "cadetblue" => Some(Color::Rgb(95, 158, 160)), + "chartreuse" => Some(Color::Rgb(127, 255, 0)), + "chocolate" => Some(Color::Rgb(210, 105, 30)), + "coral" => Some(Color::Rgb(255, 127, 80)), + "cornflowerblue" => Some(Color::Rgb(100, 149, 237)), + "cornsilk" => Some(Color::Rgb(255, 248, 220)), + "crimson" => Some(Color::Rgb(220, 20, 60)), + "darkblue" => Some(Color::Rgb(0, 0, 139)), + "darkcyan" => Some(Color::Rgb(0, 139, 139)), + "darkgoldenrod" => Some(Color::Rgb(184, 134, 11)), + "darkgreen" => Some(Color::Rgb(0, 100, 0)), + "darkkhaki" => Some(Color::Rgb(189, 183, 107)), + "darkmagenta" => Some(Color::Rgb(139, 0, 139)), + "darkolivegreen" => Some(Color::Rgb(85, 107, 47)), + "darkorange" => Some(Color::Rgb(255, 140, 0)), + "darkorchid" => Some(Color::Rgb(153, 50, 204)), + "darkred" => Some(Color::Rgb(139, 0, 0)), + "darksalmon" => Some(Color::Rgb(233, 150, 122)), + "darkseagreen" => Some(Color::Rgb(143, 188, 143)), + "darkslateblue" => Some(Color::Rgb(72, 61, 139)), + "darkslategray" | "darkslategrey" => Some(Color::Rgb(47, 79, 79)), + "darkturquoise" => Some(Color::Rgb(0, 206, 209)), + "darkviolet" => Some(Color::Rgb(148, 0, 211)), + "deeppink" => Some(Color::Rgb(255, 20, 147)), + "deepskyblue" => Some(Color::Rgb(0, 191, 255)), + "dimgray" | "dimgrey" => Some(Color::Rgb(105, 105, 105)), + "dodgerblue" => Some(Color::Rgb(30, 144, 255)), + "firebrick" => Some(Color::Rgb(178, 34, 34)), + "floralwhite" => Some(Color::Rgb(255, 250, 240)), + "forestgreen" => Some(Color::Rgb(34, 139, 34)), + "fuchsia" => Some(Color::Rgb(255, 0, 255)), + "gainsboro" => Some(Color::Rgb(220, 220, 220)), + "ghostwhite" => Some(Color::Rgb(248, 248, 255)), + "gold" => Some(Color::Rgb(255, 215, 0)), + "goldenrod" => Some(Color::Rgb(218, 165, 32)), + "greenyellow" => Some(Color::Rgb(173, 255, 47)), + "grey" => Some(Color::Rgb(128, 128, 128)), + "honeydew" => Some(Color::Rgb(240, 255, 240)), + "hotpink" => Some(Color::Rgb(255, 105, 180)), + "indianred" => Some(Color::Rgb(205, 92, 92)), + "indigo" => Some(Color::Rgb(75, 0, 130)), + "ivory" => Some(Color::Rgb(255, 255, 240)), + "khaki" => Some(Color::Rgb(240, 230, 140)), + "lavender" => Some(Color::Rgb(230, 230, 250)), + "lavenderblush" => Some(Color::Rgb(255, 240, 245)), + "lawngreen" => Some(Color::Rgb(124, 252, 0)), + "lemonchiffon" => Some(Color::Rgb(255, 250, 205)), + "lightcoral" => Some(Color::Rgb(240, 128, 128)), + "lightgoldenrodyellow" => Some(Color::Rgb(250, 250, 210)), + "lightgray" | "lightgrey" => Some(Color::Rgb(211, 211, 211)), + "lightpink" => Some(Color::Rgb(255, 182, 193)), + "lightsalmon" => Some(Color::Rgb(255, 160, 122)), + "lightseagreen" => Some(Color::Rgb(32, 178, 170)), + "lightskyblue" => Some(Color::Rgb(135, 206, 250)), + "lightslategray" | "lightslategrey" => Some(Color::Rgb(119, 136, 153)), + "lightsteelblue" => Some(Color::Rgb(176, 196, 222)), + "lime" => Some(Color::Rgb(0, 255, 0)), + "limegreen" => Some(Color::Rgb(50, 205, 50)), + "linen" => Some(Color::Rgb(250, 240, 230)), + "maroon" => Some(Color::Rgb(128, 0, 0)), + "mediumaquamarine" => Some(Color::Rgb(102, 205, 170)), + "mediumblue" => Some(Color::Rgb(0, 0, 205)), + "mediumorchid" => Some(Color::Rgb(186, 85, 211)), + "mediumpurple" => Some(Color::Rgb(147, 112, 219)), + "mediumseagreen" => Some(Color::Rgb(60, 179, 113)), + "mediumslateblue" => Some(Color::Rgb(123, 104, 238)), + "mediumspringgreen" => Some(Color::Rgb(0, 250, 154)), + "mediumturquoise" => Some(Color::Rgb(72, 209, 204)), + "mediumvioletred" => Some(Color::Rgb(199, 21, 133)), + "midnightblue" => Some(Color::Rgb(25, 25, 112)), + "mintcream" => Some(Color::Rgb(245, 255, 250)), + "mistyrose" => Some(Color::Rgb(255, 228, 225)), + "moccasin" => Some(Color::Rgb(255, 228, 181)), + "navajowhite" => Some(Color::Rgb(255, 222, 173)), + "navy" => Some(Color::Rgb(0, 0, 128)), + "oldlace" => Some(Color::Rgb(253, 245, 230)), + "olive" => Some(Color::Rgb(128, 128, 0)), + "olivedrab" => Some(Color::Rgb(107, 142, 35)), + "orange" => Some(Color::Rgb(255, 165, 0)), + "orangered" => Some(Color::Rgb(255, 69, 0)), + "orchid" => Some(Color::Rgb(218, 112, 214)), + "palegoldenrod" => Some(Color::Rgb(238, 232, 170)), + "palegreen" => Some(Color::Rgb(152, 251, 152)), + "paleturquoise" => Some(Color::Rgb(175, 238, 238)), + "palevioletred" => Some(Color::Rgb(219, 112, 147)), + "papayawhip" => Some(Color::Rgb(255, 239, 213)), + "peachpuff" => Some(Color::Rgb(255, 218, 185)), + "peru" => Some(Color::Rgb(205, 133, 63)), + "pink" => Some(Color::Rgb(255, 192, 203)), + "plum" => Some(Color::Rgb(221, 160, 221)), + "powderblue" => Some(Color::Rgb(176, 224, 230)), + "purple" => Some(Color::Rgb(128, 0, 128)), + "rebeccapurple" => Some(Color::Rgb(102, 51, 153)), + "rosybrown" => Some(Color::Rgb(188, 143, 143)), + "royalblue" => Some(Color::Rgb(65, 105, 225)), + "saddlebrown" => Some(Color::Rgb(139, 69, 19)), + "salmon" => Some(Color::Rgb(250, 128, 114)), + "sandybrown" => Some(Color::Rgb(244, 164, 96)), + "seagreen" => Some(Color::Rgb(46, 139, 87)), + "seashell" => Some(Color::Rgb(255, 245, 238)), + "sienna" => Some(Color::Rgb(160, 82, 45)), + "silver" => Some(Color::Rgb(192, 192, 192)), + "skyblue" => Some(Color::Rgb(135, 206, 235)), + "slateblue" => Some(Color::Rgb(106, 90, 205)), + "slategray" | "slategrey" => Some(Color::Rgb(112, 128, 144)), + "snow" => Some(Color::Rgb(255, 250, 250)), + "springgreen" => Some(Color::Rgb(0, 255, 127)), + "steelblue" => Some(Color::Rgb(70, 130, 180)), + "tan" => Some(Color::Rgb(210, 180, 140)), + "teal" => Some(Color::Rgb(0, 128, 128)), + "thistle" => Some(Color::Rgb(216, 191, 216)), + "tomato" => Some(Color::Rgb(255, 99, 71)), + "turquoise" => Some(Color::Rgb(64, 224, 208)), + "violet" => Some(Color::Rgb(238, 130, 238)), + "wheat" => Some(Color::Rgb(245, 222, 179)), + "whitesmoke" => Some(Color::Rgb(245, 245, 245)), + "yellowgreen" => Some(Color::Rgb(154, 205, 50)), + // -- hex and rgb + other => { + // Try as hex + if let Some(color) = parse_hex_color(other) { + Some(color) + } else { + parse_rgb_color(other) + } + } + } +} + +/// ### parse_hex_color +/// +/// Try to parse a color in hex format, such as: +/// +/// - #f0ab05 +/// - #AA33BC +fn parse_hex_color(color: &str) -> Option { + COLOR_HEX_REGEX.captures(color).map(|groups| { + Color::Rgb( + u8::from_str_radix(groups.get(1).unwrap().as_str(), 16) + .ok() + .unwrap(), + u8::from_str_radix(groups.get(2).unwrap().as_str(), 16) + .ok() + .unwrap(), + u8::from_str_radix(groups.get(3).unwrap().as_str(), 16) + .ok() + .unwrap(), + ) + }) +} + +/// ### parse_rgb_color +/// +/// Try to parse a color in rgb format, such as: +/// +/// - rgb(255, 64, 32) +/// - rgb(255,64,32) +/// - 255, 64, 32 +fn parse_rgb_color(color: &str) -> Option { + COLOR_RGB_REGEX.captures(color).map(|groups| { + Color::Rgb( + u8::from_str(groups.get(2).unwrap().as_str()).ok().unwrap(), + u8::from_str(groups.get(4).unwrap().as_str()).ok().unwrap(), + u8::from_str(groups.get(6).unwrap().as_str()).ok().unwrap(), + ) + }) +} + #[cfg(test)] mod tests { @@ -405,4 +651,245 @@ mod tests { assert_eq!(parse_semver("1.0.0").unwrap(), String::from("1.0.0"),); assert!(parse_semver("v1.1").is_none()); } + + #[test] + fn test_utils_parse_color_hex() { + assert_eq!( + parse_hex_color("#f0f0f0").unwrap(), + Color::Rgb(240, 240, 240) + ); + assert_eq!( + parse_hex_color("#60AAcc").unwrap(), + Color::Rgb(96, 170, 204) + ); + assert!(parse_hex_color("#fatboy").is_none()); + } + + #[test] + fn test_utils_parse_color_rgb() { + assert_eq!( + parse_rgb_color("rgb(255, 64, 32)").unwrap(), + Color::Rgb(255, 64, 32) + ); + assert_eq!( + parse_rgb_color("rgb(255,64,32)").unwrap(), + Color::Rgb(255, 64, 32) + ); + assert_eq!( + parse_rgb_color("(255,64,32)").unwrap(), + Color::Rgb(255, 64, 32) + ); + assert_eq!( + parse_rgb_color("255,64,32").unwrap(), + Color::Rgb(255, 64, 32) + ); + assert!(parse_rgb_color("(300, 128, 512)").is_none()); + } + + #[test] + fn test_utils_parse_color() { + assert_eq!(parse_color("Black").unwrap(), Color::Black); + assert_eq!(parse_color("BLUE").unwrap(), Color::Blue); + assert_eq!(parse_color("Cyan").unwrap(), Color::Cyan); + assert_eq!(parse_color("DarkGray").unwrap(), Color::DarkGray); + assert_eq!(parse_color("Gray").unwrap(), Color::Gray); + assert_eq!(parse_color("Green").unwrap(), Color::Green); + assert_eq!(parse_color("LightBlue").unwrap(), Color::LightBlue); + assert_eq!(parse_color("LightCyan").unwrap(), Color::LightCyan); + assert_eq!(parse_color("LightGreen").unwrap(), Color::LightGreen); + assert_eq!(parse_color("LightMagenta").unwrap(), Color::LightMagenta); + assert_eq!(parse_color("LightRed").unwrap(), Color::LightRed); + assert_eq!(parse_color("LightYellow").unwrap(), Color::LightYellow); + assert_eq!(parse_color("Magenta").unwrap(), Color::Magenta); + assert_eq!(parse_color("Red").unwrap(), Color::Red); + assert_eq!(parse_color("Default").unwrap(), Color::Reset); + assert_eq!(parse_color("White").unwrap(), Color::White); + assert_eq!(parse_color("Yellow").unwrap(), Color::Yellow); + assert_eq!(parse_color("#f0f0f0").unwrap(), Color::Rgb(240, 240, 240)); + // -- css colors + assert_eq!(parse_color("aliceblue"), Some(Color::Rgb(240, 248, 255))); + assert_eq!(parse_color("antiquewhite"), Some(Color::Rgb(250, 235, 215))); + assert_eq!(parse_color("aqua"), Some(Color::Rgb(0, 255, 255))); + assert_eq!(parse_color("aquamarine"), Some(Color::Rgb(127, 255, 212))); + assert_eq!(parse_color("azure"), Some(Color::Rgb(240, 255, 255))); + assert_eq!(parse_color("beige"), Some(Color::Rgb(245, 245, 220))); + assert_eq!(parse_color("bisque"), Some(Color::Rgb(255, 228, 196))); + assert_eq!( + parse_color("blanchedalmond"), + Some(Color::Rgb(255, 235, 205)) + ); + assert_eq!(parse_color("blueviolet"), Some(Color::Rgb(138, 43, 226))); + assert_eq!(parse_color("brown"), Some(Color::Rgb(165, 42, 42))); + assert_eq!(parse_color("burlywood"), Some(Color::Rgb(222, 184, 135))); + assert_eq!(parse_color("cadetblue"), Some(Color::Rgb(95, 158, 160))); + assert_eq!(parse_color("chartreuse"), Some(Color::Rgb(127, 255, 0))); + assert_eq!(parse_color("chocolate"), Some(Color::Rgb(210, 105, 30))); + assert_eq!(parse_color("coral"), Some(Color::Rgb(255, 127, 80))); + assert_eq!( + parse_color("cornflowerblue"), + Some(Color::Rgb(100, 149, 237)) + ); + assert_eq!(parse_color("cornsilk"), Some(Color::Rgb(255, 248, 220))); + assert_eq!(parse_color("crimson"), Some(Color::Rgb(220, 20, 60))); + assert_eq!(parse_color("darkblue"), Some(Color::Rgb(0, 0, 139))); + assert_eq!(parse_color("darkcyan"), Some(Color::Rgb(0, 139, 139))); + assert_eq!(parse_color("darkgoldenrod"), Some(Color::Rgb(184, 134, 11))); + assert_eq!(parse_color("darkgreen"), Some(Color::Rgb(0, 100, 0))); + assert_eq!(parse_color("darkkhaki"), Some(Color::Rgb(189, 183, 107))); + assert_eq!(parse_color("darkmagenta"), Some(Color::Rgb(139, 0, 139))); + assert_eq!(parse_color("darkolivegreen"), Some(Color::Rgb(85, 107, 47))); + assert_eq!(parse_color("darkorange"), Some(Color::Rgb(255, 140, 0))); + assert_eq!(parse_color("darkorchid"), Some(Color::Rgb(153, 50, 204))); + assert_eq!(parse_color("darkred"), Some(Color::Rgb(139, 0, 0))); + assert_eq!(parse_color("darksalmon"), Some(Color::Rgb(233, 150, 122))); + assert_eq!(parse_color("darkseagreen"), Some(Color::Rgb(143, 188, 143))); + assert_eq!(parse_color("darkslateblue"), Some(Color::Rgb(72, 61, 139))); + assert_eq!(parse_color("darkslategray"), Some(Color::Rgb(47, 79, 79))); + assert_eq!(parse_color("darkslategrey"), Some(Color::Rgb(47, 79, 79))); + assert_eq!(parse_color("darkturquoise"), Some(Color::Rgb(0, 206, 209))); + assert_eq!(parse_color("darkviolet"), Some(Color::Rgb(148, 0, 211))); + assert_eq!(parse_color("deeppink"), Some(Color::Rgb(255, 20, 147))); + assert_eq!(parse_color("deepskyblue"), Some(Color::Rgb(0, 191, 255))); + assert_eq!(parse_color("dimgray"), Some(Color::Rgb(105, 105, 105))); + assert_eq!(parse_color("dimgrey"), Some(Color::Rgb(105, 105, 105))); + assert_eq!(parse_color("dodgerblue"), Some(Color::Rgb(30, 144, 255))); + assert_eq!(parse_color("firebrick"), Some(Color::Rgb(178, 34, 34))); + assert_eq!(parse_color("floralwhite"), Some(Color::Rgb(255, 250, 240))); + assert_eq!(parse_color("forestgreen"), Some(Color::Rgb(34, 139, 34))); + assert_eq!(parse_color("fuchsia"), Some(Color::Rgb(255, 0, 255))); + assert_eq!(parse_color("gainsboro"), Some(Color::Rgb(220, 220, 220))); + assert_eq!(parse_color("ghostwhite"), Some(Color::Rgb(248, 248, 255))); + assert_eq!(parse_color("gold"), Some(Color::Rgb(255, 215, 0))); + assert_eq!(parse_color("goldenrod"), Some(Color::Rgb(218, 165, 32))); + assert_eq!(parse_color("greenyellow"), Some(Color::Rgb(173, 255, 47))); + assert_eq!(parse_color("honeydew"), Some(Color::Rgb(240, 255, 240))); + assert_eq!(parse_color("hotpink"), Some(Color::Rgb(255, 105, 180))); + assert_eq!(parse_color("indianred"), Some(Color::Rgb(205, 92, 92))); + assert_eq!(parse_color("indigo"), Some(Color::Rgb(75, 0, 130))); + assert_eq!(parse_color("ivory"), Some(Color::Rgb(255, 255, 240))); + assert_eq!(parse_color("khaki"), Some(Color::Rgb(240, 230, 140))); + assert_eq!(parse_color("lavender"), Some(Color::Rgb(230, 230, 250))); + assert_eq!( + parse_color("lavenderblush"), + Some(Color::Rgb(255, 240, 245)) + ); + assert_eq!(parse_color("lawngreen"), Some(Color::Rgb(124, 252, 0))); + assert_eq!(parse_color("lemonchiffon"), Some(Color::Rgb(255, 250, 205))); + assert_eq!(parse_color("lightcoral"), Some(Color::Rgb(240, 128, 128))); + assert_eq!( + parse_color("lightgoldenrodyellow"), + Some(Color::Rgb(250, 250, 210)) + ); + assert_eq!(parse_color("lightpink"), Some(Color::Rgb(255, 182, 193))); + assert_eq!(parse_color("lightsalmon"), Some(Color::Rgb(255, 160, 122))); + assert_eq!(parse_color("lightseagreen"), Some(Color::Rgb(32, 178, 170))); + assert_eq!(parse_color("lightskyblue"), Some(Color::Rgb(135, 206, 250))); + assert_eq!( + parse_color("lightslategray"), + Some(Color::Rgb(119, 136, 153)) + ); + assert_eq!( + parse_color("lightslategrey"), + Some(Color::Rgb(119, 136, 153)) + ); + assert_eq!( + parse_color("lightsteelblue"), + Some(Color::Rgb(176, 196, 222)) + ); + assert_eq!(parse_color("lime"), Some(Color::Rgb(0, 255, 0))); + assert_eq!(parse_color("limegreen"), Some(Color::Rgb(50, 205, 50))); + assert_eq!(parse_color("linen"), Some(Color::Rgb(250, 240, 230))); + assert_eq!(parse_color("maroon"), Some(Color::Rgb(128, 0, 0))); + assert_eq!( + parse_color("mediumaquamarine"), + Some(Color::Rgb(102, 205, 170)) + ); + assert_eq!(parse_color("mediumblue"), Some(Color::Rgb(0, 0, 205))); + assert_eq!(parse_color("mediumorchid"), Some(Color::Rgb(186, 85, 211))); + assert_eq!(parse_color("mediumpurple"), Some(Color::Rgb(147, 112, 219))); + assert_eq!( + parse_color("mediumseagreen"), + Some(Color::Rgb(60, 179, 113)) + ); + assert_eq!( + parse_color("mediumslateblue"), + Some(Color::Rgb(123, 104, 238)) + ); + assert_eq!( + parse_color("mediumspringgreen"), + Some(Color::Rgb(0, 250, 154)) + ); + assert_eq!( + parse_color("mediumturquoise"), + Some(Color::Rgb(72, 209, 204)) + ); + assert_eq!( + parse_color("mediumvioletred"), + Some(Color::Rgb(199, 21, 133)) + ); + assert_eq!(parse_color("midnightblue"), Some(Color::Rgb(25, 25, 112))); + assert_eq!(parse_color("mintcream"), Some(Color::Rgb(245, 255, 250))); + assert_eq!(parse_color("mistyrose"), Some(Color::Rgb(255, 228, 225))); + assert_eq!(parse_color("moccasin"), Some(Color::Rgb(255, 228, 181))); + assert_eq!(parse_color("navajowhite"), Some(Color::Rgb(255, 222, 173))); + assert_eq!(parse_color("navy"), Some(Color::Rgb(0, 0, 128))); + assert_eq!(parse_color("oldlace"), Some(Color::Rgb(253, 245, 230))); + assert_eq!(parse_color("olive"), Some(Color::Rgb(128, 128, 0))); + assert_eq!(parse_color("olivedrab"), Some(Color::Rgb(107, 142, 35))); + assert_eq!(parse_color("orange"), Some(Color::Rgb(255, 165, 0))); + assert_eq!(parse_color("orangered"), Some(Color::Rgb(255, 69, 0))); + assert_eq!(parse_color("orchid"), Some(Color::Rgb(218, 112, 214))); + assert_eq!( + parse_color("palegoldenrod"), + Some(Color::Rgb(238, 232, 170)) + ); + assert_eq!(parse_color("palegreen"), Some(Color::Rgb(152, 251, 152))); + assert_eq!( + parse_color("paleturquoise"), + Some(Color::Rgb(175, 238, 238)) + ); + assert_eq!( + parse_color("palevioletred"), + Some(Color::Rgb(219, 112, 147)) + ); + assert_eq!(parse_color("papayawhip"), Some(Color::Rgb(255, 239, 213))); + assert_eq!(parse_color("peachpuff"), Some(Color::Rgb(255, 218, 185))); + assert_eq!(parse_color("peru"), Some(Color::Rgb(205, 133, 63))); + assert_eq!(parse_color("pink"), Some(Color::Rgb(255, 192, 203))); + assert_eq!(parse_color("plum"), Some(Color::Rgb(221, 160, 221))); + assert_eq!(parse_color("powderblue"), Some(Color::Rgb(176, 224, 230))); + assert_eq!(parse_color("purple"), Some(Color::Rgb(128, 0, 128))); + assert_eq!(parse_color("rebeccapurple"), Some(Color::Rgb(102, 51, 153))); + assert_eq!(parse_color("rosybrown"), Some(Color::Rgb(188, 143, 143))); + assert_eq!(parse_color("royalblue"), Some(Color::Rgb(65, 105, 225))); + assert_eq!(parse_color("saddlebrown"), Some(Color::Rgb(139, 69, 19))); + assert_eq!(parse_color("salmon"), Some(Color::Rgb(250, 128, 114))); + assert_eq!(parse_color("sandybrown"), Some(Color::Rgb(244, 164, 96))); + assert_eq!(parse_color("seagreen"), Some(Color::Rgb(46, 139, 87))); + assert_eq!(parse_color("seashell"), Some(Color::Rgb(255, 245, 238))); + assert_eq!(parse_color("sienna"), Some(Color::Rgb(160, 82, 45))); + assert_eq!(parse_color("silver"), Some(Color::Rgb(192, 192, 192))); + assert_eq!(parse_color("skyblue"), Some(Color::Rgb(135, 206, 235))); + assert_eq!(parse_color("slateblue"), Some(Color::Rgb(106, 90, 205))); + assert_eq!(parse_color("slategray"), Some(Color::Rgb(112, 128, 144))); + assert_eq!(parse_color("slategrey"), Some(Color::Rgb(112, 128, 144))); + assert_eq!(parse_color("snow"), Some(Color::Rgb(255, 250, 250))); + assert_eq!(parse_color("springgreen"), Some(Color::Rgb(0, 255, 127))); + assert_eq!(parse_color("steelblue"), Some(Color::Rgb(70, 130, 180))); + assert_eq!(parse_color("tan"), Some(Color::Rgb(210, 180, 140))); + assert_eq!(parse_color("teal"), Some(Color::Rgb(0, 128, 128))); + assert_eq!(parse_color("thistle"), Some(Color::Rgb(216, 191, 216))); + assert_eq!(parse_color("tomato"), Some(Color::Rgb(255, 99, 71))); + assert_eq!(parse_color("turquoise"), Some(Color::Rgb(64, 224, 208))); + assert_eq!(parse_color("violet"), Some(Color::Rgb(238, 130, 238))); + assert_eq!(parse_color("wheat"), Some(Color::Rgb(245, 222, 179))); + assert_eq!(parse_color("whitesmoke"), Some(Color::Rgb(245, 245, 245))); + assert_eq!(parse_color("yellowgreen"), Some(Color::Rgb(154, 205, 50))); + // -- hex and rgb + assert_eq!( + parse_color("rgb(255, 64, 32)").unwrap(), + Color::Rgb(255, 64, 32) + ); + assert!(parse_color("redd").is_none()); + } } diff --git a/src/utils/test_helpers.rs b/src/utils/test_helpers.rs index 9e6c9fe..45239c8 100644 --- a/src/utils/test_helpers.rs +++ b/src/utils/test_helpers.rs @@ -185,6 +185,13 @@ pub fn make_fsentry(path: PathBuf, is_dir: bool) -> FsEntry { } } +/// ### create_file_ioers +/// +/// Open a file with two handlers, the first is to read, the second is to write +pub fn create_file_ioers(p: &Path) -> (File, File) { + (File::open(p).ok().unwrap(), File::create(p).ok().unwrap()) +} + mod test { use super::*; @@ -245,4 +252,10 @@ mod test { assert!(make_dir_at(tmpdir.path(), "docs").is_ok()); assert!(make_dir_at(PathBuf::from("/aaaaa/bbbbb/cccc").as_path(), "docs").is_err()); } + + #[test] + fn test_utils_test_helpers_create_file_ioers() { + let (_, tmp) = create_sample_file_entry(); + let _ = create_file_ioers(tmp.path()); + } } diff --git a/themes/default.toml b/themes/default.toml new file mode 100644 index 0000000..af1f3f3 --- /dev/null +++ b/themes/default.toml @@ -0,0 +1,25 @@ +auth_address = "Yellow" +auth_bookmarks = "LightGreen" +auth_password = "LightBlue" +auth_port = "LightCyan" +auth_protocol = "LightGreen" +auth_recents = "LightBlue" +auth_username = "LightMagenta" +misc_error_dialog = "Red" +misc_input_dialog = "Default" +misc_keys = "Cyan" +misc_quit_dialog = "Yellow" +misc_save_dialog = "LightCyan" +misc_warn_dialog = "LightRed" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "Default" +transfer_local_explorer_highlighted = "Yellow" +transfer_log_background = "Default" +transfer_log_window = "LightGreen" +transfer_progress_bar = "Green" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "Default" +transfer_remote_explorer_highlighted = "LightBlue" +transfer_status_hidden = "LightBlue" +transfer_status_sorting = "LightYellow" +transfer_status_sync_browsing = "LightGreen" diff --git a/themes/earth-wind-fire.toml b/themes/earth-wind-fire.toml new file mode 100644 index 0000000..433eae6 --- /dev/null +++ b/themes/earth-wind-fire.toml @@ -0,0 +1,25 @@ +auth_address = "Yellow" +auth_bookmarks = "skyblue" +auth_password = "#c43bff" +auth_port = "lime" +auth_protocol = "orangered" +auth_recents = "deepskyblue" +auth_username = "aqua" +misc_error_dialog = "crimson" +misc_input_dialog = "turquoise" +misc_keys = "deeppink" +misc_quit_dialog = "lime" +misc_save_dialog = "gold" +misc_warn_dialog = "orangered" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "Default" +transfer_local_explorer_highlighted = "aquamarine" +transfer_log_background = "Default" +transfer_log_window = "#c43bff" +transfer_progress_bar = "deeppink" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "Default" +transfer_remote_explorer_highlighted = "greenyellow" +transfer_status_hidden = "lime" +transfer_status_sorting = "orangered" +transfer_status_sync_browsing = "darkturquoise" diff --git a/themes/horizon.toml b/themes/horizon.toml new file mode 100644 index 0000000..dcb7619 --- /dev/null +++ b/themes/horizon.toml @@ -0,0 +1,25 @@ +auth_address = "salmon" +auth_bookmarks = "cornflowerblue" +auth_password = "crimson" +auth_port = "tomato" +auth_protocol = "coral" +auth_recents = "royalblue" +auth_username = "orangered" +misc_error_dialog = "crimson" +misc_input_dialog = "gold" +misc_keys = "deeppink" +misc_quit_dialog = "coral" +misc_save_dialog = "tomato" +misc_warn_dialog = "orangered" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "lightcoral" +transfer_local_explorer_highlighted = "coral" +transfer_log_background = "Default" +transfer_log_window = "royalblue" +transfer_progress_bar = "deeppink" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "lightsalmon" +transfer_remote_explorer_highlighted = "salmon" +transfer_status_hidden = "orange" +transfer_status_sorting = "gold" +transfer_status_sync_browsing = "tomato" diff --git a/themes/mono-bright.toml b/themes/mono-bright.toml new file mode 100644 index 0000000..4a75148 --- /dev/null +++ b/themes/mono-bright.toml @@ -0,0 +1,25 @@ +auth_address = "black" +auth_bookmarks = "#bbbbbb" +auth_password = "black" +auth_port = "black" +auth_protocol = "black" +auth_recents = "#bbbbbb" +auth_username = "black" +misc_error_dialog = "black" +misc_input_dialog = "black" +misc_keys = "black" +misc_quit_dialog = "black" +misc_save_dialog = "black" +misc_warn_dialog = "black" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "Default" +transfer_local_explorer_highlighted = "#bbbbbb" +transfer_log_background = "Default" +transfer_log_window = "black" +transfer_progress_bar = "black" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "Default" +transfer_remote_explorer_highlighted = "#bbbbbb" +transfer_status_hidden = "black" +transfer_status_sorting = "black" +transfer_status_sync_browsing = "black" diff --git a/themes/mono-dark.toml b/themes/mono-dark.toml new file mode 100644 index 0000000..f283542 --- /dev/null +++ b/themes/mono-dark.toml @@ -0,0 +1,25 @@ +auth_address = "white" +auth_bookmarks = "white" +auth_password = "white" +auth_port = "white" +auth_protocol = "white" +auth_recents = "white" +auth_username = "white" +misc_error_dialog = "white" +misc_input_dialog = "white" +misc_keys = "white" +misc_quit_dialog = "white" +misc_save_dialog = "white" +misc_warn_dialog = "white" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "Default" +transfer_local_explorer_highlighted = "white" +transfer_log_background = "Default" +transfer_log_window = "white" +transfer_progress_bar = "white" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "Default" +transfer_remote_explorer_highlighted = "white" +transfer_status_hidden = "white" +transfer_status_sorting = "white" +transfer_status_sync_browsing = "white" diff --git a/themes/sugarplum.toml b/themes/sugarplum.toml new file mode 100644 index 0000000..875eb52 --- /dev/null +++ b/themes/sugarplum.toml @@ -0,0 +1,25 @@ +auth_address = "hotpink" +auth_bookmarks = "pink" +auth_password = "violet" +auth_port = "plum" +auth_protocol = "deeppink" +auth_recents = "lightpink" +auth_username = "orchid" +misc_error_dialog = "mediumvioletred" +misc_input_dialog = "plum" +misc_keys = "deeppink" +misc_quit_dialog = "lightcoral" +misc_save_dialog = "violet" +misc_warn_dialog = "hotpink" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "pink" +transfer_local_explorer_highlighted = "hotpink" +transfer_log_background = "Default" +transfer_log_window = "palevioletred" +transfer_progress_bar = "hotpink" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "plum" +transfer_remote_explorer_highlighted = "violet" +transfer_status_hidden = "violet" +transfer_status_sorting = "plum" +transfer_status_sync_browsing = "orchid" diff --git a/themes/ubuntu.toml b/themes/ubuntu.toml new file mode 100644 index 0000000..d1c88b6 --- /dev/null +++ b/themes/ubuntu.toml @@ -0,0 +1,25 @@ +auth_address = "LightYellow" +auth_bookmarks = "springgreen" +auth_password = "deepskyblue" +auth_port = "LightCyan" +auth_protocol = "LightGreen" +auth_recents = "aquamarine" +auth_username = "hotpink" +misc_error_dialog = "orangered" +misc_input_dialog = "snow" +misc_keys = "LightCyan" +misc_quit_dialog = "LightYellow" +misc_save_dialog = "LightCyan" +misc_warn_dialog = "tomato" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "Default" +transfer_local_explorer_highlighted = "Yellow" +transfer_log_background = "Default" +transfer_log_window = "lawngreen" +transfer_progress_bar = "lawngreen" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "Default" +transfer_remote_explorer_highlighted = "turquoise" +transfer_status_hidden = "deepskyblue" +transfer_status_sorting = "LightYellow" +transfer_status_sync_browsing = "LightGreen" diff --git a/themes/veeso.toml b/themes/veeso.toml new file mode 100644 index 0000000..5bc3af9 --- /dev/null +++ b/themes/veeso.toml @@ -0,0 +1,25 @@ +auth_address = "Yellow" +auth_bookmarks = "plum" +auth_password = "LightBlue" +auth_port = "turquoise" +auth_protocol = "greenyellow" +auth_recents = "paleturquoise" +auth_username = "deeppink" +misc_error_dialog = "crimson" +misc_input_dialog = "snow" +misc_keys = "deeppink" +misc_quit_dialog = "tomato" +misc_save_dialog = "gold" +misc_warn_dialog = "orangered" +transfer_local_explorer_background = "Default" +transfer_local_explorer_foreground = "Default" +transfer_local_explorer_highlighted = "orange" +transfer_log_background = "Default" +transfer_log_window = "limegreen" +transfer_progress_bar = "lawngreen" +transfer_remote_explorer_background = "Default" +transfer_remote_explorer_foreground = "Default" +transfer_remote_explorer_highlighted = "turquoise" +transfer_status_hidden = "dodgerblue" +transfer_status_sorting = "LightYellow" +transfer_status_sync_browsing = "palegreen" From 55c4b777fb82d153c23aae7f8e84f0093198c228 Mon Sep 17 00:00:00 2001 From: veeso Date: Wed, 7 Jul 2021 21:03:21 +0200 Subject: [PATCH 37/53] Fixed save bookmark dialog: you could switch out from dialog with `` --- CHANGELOG.md | 1 + src/ui/activities/auth/update.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45972b2..c8d29ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Released on FIXME: ?? - **Start termscp from configuration**: Start termscp with `-c` or `--config` to start termscp from configuration page - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) + - Fixed save bookmark dialog: you could switch out from dialog with `` - Dependencies: - Added `open 1.7.0` - Updated `rand` to `0.8.4` diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index 7173850..be6ad54 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -297,6 +297,7 @@ impl Update for AuthActivity { self.umount_bookmark_save_dialog(); None } + (COMPONENT_INPUT_BOOKMARK_NAME, _) | (COMPONENT_RADIO_BOOKMARK_SAVE_PWD, _) => None, // Quit dialog (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(choice)))) => { // If choice is 0, quit termscp From b9cb961da6d278d79986301d80557f68f74c9ed4 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 8 Jul 2021 14:29:28 +0200 Subject: [PATCH 38/53] full and partial progress bar colors --- docs/man.md | 3 +- src/config/serialization.rs | 3 +- src/config/themes.rs | 13 ++++-- src/ui/activities/filetransfer/view.rs | 7 +-- src/ui/activities/setup/actions.rs | 7 ++- src/ui/activities/setup/mod.rs | 3 +- src/ui/activities/setup/update.rs | 31 ++++++++----- src/ui/activities/setup/view/theme.rs | 62 +++++++++++++++++--------- themes/default.toml | 3 +- themes/earth-wind-fire.toml | 3 +- themes/horizon.toml | 3 +- themes/mono-bright.toml | 3 +- themes/mono-dark.toml | 3 +- themes/sugarplum.toml | 3 +- themes/ubuntu.toml | 3 +- themes/veeso.toml | 3 +- 16 files changed, 102 insertions(+), 51 deletions(-) diff --git a/docs/man.md b/docs/man.md index d521ab9..0b1df9d 100644 --- a/docs/man.md +++ b/docs/man.md @@ -355,7 +355,8 @@ Please, notice that **styles won't apply to configuration page**, in order to ma | transfer_remote_explorer_highlighted | Border and highlighted color for remote explorer | | transfer_log_background | Background color for log panel | | transfer_log_window | Window color for log panel | -| transfer_progress_bar | Progress bar color | +| transfer_progress_bar_partial | Partial progress bar color | +| transfer_progress_bar_total | Total progress bar color | | transfer_status_hidden | Color for status bar "hidden" label | | transfer_status_sorting | Color for status bar "sorting" label; applies also to file sorting dialog | | transfer_status_sync_browsing | Color for status bar "sync browsing" label | diff --git a/src/config/serialization.rs b/src/config/serialization.rs index 0a4b661..fa8db24 100644 --- a/src/config/serialization.rs +++ b/src/config/serialization.rs @@ -530,7 +530,8 @@ mod tests { transfer_local_explorer_highlighted = "Yellow" transfer_log_background = "255, 255, 255" transfer_log_window = "LightGreen" - transfer_progress_bar = "Green" + transfer_progress_bar_full = "forestgreen" + transfer_progress_bar_partial = "Green" transfer_remote_explorer_background = "#f0f0f0" transfer_remote_explorer_foreground = "rgb(40, 40, 40)" transfer_remote_explorer_highlighted = "LightBlue" diff --git a/src/config/themes.rs b/src/config/themes.rs index 90bf00e..39776d8 100644 --- a/src/config/themes.rs +++ b/src/config/themes.rs @@ -134,7 +134,12 @@ pub struct Theme { deserialize_with = "deserialize_color", serialize_with = "serialize_color" )] - pub transfer_progress_bar: Color, + pub transfer_progress_bar_full: Color, + #[serde( + deserialize_with = "deserialize_color", + serialize_with = "serialize_color" + )] + pub transfer_progress_bar_partial: Color, #[serde( deserialize_with = "deserialize_color", serialize_with = "serialize_color" @@ -188,7 +193,8 @@ impl Default for Theme { transfer_local_explorer_highlighted: Color::Yellow, transfer_log_background: Color::Reset, transfer_log_window: Color::LightGreen, - transfer_progress_bar: Color::Green, + transfer_progress_bar_partial: Color::Green, + transfer_progress_bar_full: Color::Green, transfer_remote_explorer_background: Color::Reset, transfer_remote_explorer_foreground: Color::Reset, transfer_remote_explorer_highlighted: Color::LightBlue, @@ -249,7 +255,8 @@ mod test { assert_eq!(theme.transfer_local_explorer_highlighted, Color::Yellow); assert_eq!(theme.transfer_log_background, Color::Reset); assert_eq!(theme.transfer_log_window, Color::LightGreen); - assert_eq!(theme.transfer_progress_bar, Color::Green); + assert_eq!(theme.transfer_progress_bar_full, Color::Green); + assert_eq!(theme.transfer_progress_bar_partial, Color::Green); assert_eq!(theme.transfer_remote_explorer_background, Color::Reset); assert_eq!(theme.transfer_remote_explorer_foreground, Color::Reset); assert_eq!(theme.transfer_remote_explorer_highlighted, Color::LightBlue); diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 0f26cac..0f44c47 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -711,12 +711,13 @@ impl FileTransferActivity { } pub(super) fn mount_progress_bar(&mut self, root_name: String) { - let prog_color = self.theme().transfer_progress_bar; + let prog_color_full = self.theme().transfer_progress_bar_full; + let prog_color_partial = self.theme().transfer_progress_bar_partial; self.view.mount( super::COMPONENT_PROGRESS_BAR_FULL, Box::new(ProgressBar::new( ProgressBarPropsBuilder::default() - .with_progbar_color(prog_color) + .with_progbar_color(prog_color_full) .with_background(Color::Black) .with_borders( Borders::TOP | Borders::RIGHT | Borders::LEFT, @@ -731,7 +732,7 @@ impl FileTransferActivity { super::COMPONENT_PROGRESS_BAR_PARTIAL, Box::new(ProgressBar::new( ProgressBarPropsBuilder::default() - .with_progbar_color(prog_color) + .with_progbar_color(prog_color_partial) .with_background(Color::Black) .with_borders( Borders::BOTTOM | Borders::RIGHT | Borders::LEFT, diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index 26e34df..5b71dda 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -248,8 +248,11 @@ impl SetupActivity { super::COMPONENT_COLOR_TRANSFER_LOG_WIN => { theme.transfer_log_window = color; } - super::COMPONENT_COLOR_TRANSFER_PROG_BAR => { - theme.transfer_progress_bar = color; + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL => { + theme.transfer_progress_bar_full = color; + } + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL => { + theme.transfer_progress_bar_partial = color; } super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN => { theme.transfer_status_hidden = color; diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index 21dc372..0a19893 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -91,7 +91,8 @@ const COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG: &str = "COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG"; const COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG: &str = "COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG"; -const COMPONENT_COLOR_TRANSFER_PROG_BAR: &str = "COMPONENT_COLOR_TRANSFER_PROG_BAR"; +const COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL: &str = "COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL"; +const COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL: &str = "COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL"; const COMPONENT_COLOR_TRANSFER_LOG_BG: &str = "COMPONENT_COLOR_TRANSFER_LOG_BG"; const COMPONENT_COLOR_TRANSFER_LOG_WIN: &str = "COMPONENT_COLOR_TRANSFER_LOG_WIN"; const COMPONENT_COLOR_TRANSFER_STATUS_SORTING: &str = "COMPONENT_COLOR_TRANSFER_STATUS_SORTING"; diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index 032399f..4f418c7 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -37,13 +37,14 @@ use super::{ COMPONENT_COLOR_TRANSFER_EXPLORER_LOCAL_HG, COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_BG, COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG, COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, COMPONENT_COLOR_TRANSFER_LOG_BG, COMPONENT_COLOR_TRANSFER_LOG_WIN, - COMPONENT_COLOR_TRANSFER_PROG_BAR, COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, - COMPONENT_COLOR_TRANSFER_STATUS_SORTING, COMPONENT_COLOR_TRANSFER_STATUS_SYNC, - COMPONENT_INPUT_LOCAL_FILE_FMT, COMPONENT_INPUT_REMOTE_FILE_FMT, COMPONENT_INPUT_SSH_HOST, - COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR, COMPONENT_LIST_SSH_KEYS, - COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY, COMPONENT_RADIO_GROUP_DIRS, - COMPONENT_RADIO_HIDDEN_FILES, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE, - COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP, + COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, + COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, COMPONENT_COLOR_TRANSFER_STATUS_SORTING, + COMPONENT_COLOR_TRANSFER_STATUS_SYNC, COMPONENT_INPUT_LOCAL_FILE_FMT, + COMPONENT_INPUT_REMOTE_FILE_FMT, COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME, + COMPONENT_INPUT_TEXT_EDITOR, COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL, + COMPONENT_RADIO_DEL_SSH_KEY, COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES, + COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE, COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, + COMPONENT_TEXT_HELP, }; use crate::ui::keymap::*; use crate::utils::parser::parse_color; @@ -467,10 +468,14 @@ impl SetupActivity { None } (COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, &MSG_KEY_DOWN) => { - self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR); + self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL); None } - (COMPONENT_COLOR_TRANSFER_PROG_BAR, &MSG_KEY_DOWN) => { + (COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, &MSG_KEY_DOWN) => { + self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL); + None + } + (COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, &MSG_KEY_DOWN) => { self.view.active(COMPONENT_COLOR_TRANSFER_LOG_BG); None } @@ -572,13 +577,17 @@ impl SetupActivity { .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_FG); None } - (COMPONENT_COLOR_TRANSFER_PROG_BAR, &MSG_KEY_UP) => { + (COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, &MSG_KEY_UP) => { self.view .active(COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG); None } + (COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, &MSG_KEY_UP) => { + self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL); + None + } (COMPONENT_COLOR_TRANSFER_LOG_BG, &MSG_KEY_UP) => { - self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR); + self.view.active(COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL); None } (COMPONENT_COLOR_TRANSFER_LOG_WIN, &MSG_KEY_UP) => { diff --git a/src/ui/activities/setup/view/theme.rs b/src/ui/activities/setup/view/theme.rs index 3c7d2fd..b3c0853 100644 --- a/src/ui/activities/setup/view/theme.rs +++ b/src/ui/activities/setup/view/theme.rs @@ -137,7 +137,14 @@ impl SetupActivity { super::COMPONENT_COLOR_TRANSFER_EXPLORER_REMOTE_HG, "Remote explorer highlighted", ); - self.mount_color_picker(super::COMPONENT_COLOR_TRANSFER_PROG_BAR, "Progress bar"); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, + "'Full transfer' Progress bar", + ); + self.mount_color_picker( + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, + "'Partial transfer' Progress bar", + ); // Transfer (2) self.mount_title( super::COMPONENT_COLOR_TRANSFER_TITLE_2, @@ -287,7 +294,7 @@ impl SetupActivity { Constraint::Length(3), // remote explorer bg Constraint::Length(3), // remote explorer fg Constraint::Length(3), // remote explorer hg - Constraint::Length(3), // prog bar + Constraint::Length(3), // empty ] .as_ref(), ) @@ -327,57 +334,62 @@ impl SetupActivity { f, transfer_colors_layout_col1[6], ); - self.view.render( - super::COMPONENT_COLOR_TRANSFER_PROG_BAR, - f, - transfer_colors_layout_col1[7], - ); let transfer_colors_layout_col2 = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(1), // Title + Constraint::Length(3), // Full prog bar + Constraint::Length(3), // Partial prog bar Constraint::Length(3), // log bg Constraint::Length(3), // log window Constraint::Length(3), // status sorting Constraint::Length(3), // status hidden Constraint::Length(3), // sync browsing - Constraint::Length(3), // Empty - Constraint::Length(3), // Empty ] .as_ref(), ) .split(colors_layout[3]); self.view.render( - super::COMPONENT_COLOR_TRANSFER_TITLE_2, + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, f, transfer_colors_layout_col2[0], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_LOG_BG, + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, f, transfer_colors_layout_col2[1], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_LOG_WIN, + super::COMPONENT_COLOR_TRANSFER_TITLE_2, f, transfer_colors_layout_col2[2], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING, + super::COMPONENT_COLOR_TRANSFER_LOG_BG, f, transfer_colors_layout_col2[3], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, + super::COMPONENT_COLOR_TRANSFER_LOG_WIN, f, transfer_colors_layout_col2[4], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC, + super::COMPONENT_COLOR_TRANSFER_STATUS_SORTING, f, transfer_colors_layout_col2[5], ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, + f, + transfer_colors_layout_col2[6], + ); + self.view.render( + super::COMPONENT_COLOR_TRANSFER_STATUS_SYNC, + f, + transfer_colors_layout_col2[7], + ); // Popups if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) { if props.visible { @@ -459,8 +471,12 @@ impl SetupActivity { theme.transfer_remote_explorer_highlighted, ); self.update_color( - super::COMPONENT_COLOR_TRANSFER_PROG_BAR, - theme.transfer_progress_bar, + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, + theme.transfer_progress_bar_full, + ); + self.update_color( + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, + theme.transfer_progress_bar_partial, ); self.update_color( super::COMPONENT_COLOR_TRANSFER_LOG_BG, @@ -555,9 +571,12 @@ impl SetupActivity { let transfer_log_window: Color = self .get_color(super::COMPONENT_COLOR_TRANSFER_LOG_WIN) .map_err(|_| super::COMPONENT_COLOR_TRANSFER_LOG_WIN)?; - let transfer_progress_bar: Color = self - .get_color(super::COMPONENT_COLOR_TRANSFER_PROG_BAR) - .map_err(|_| super::COMPONENT_COLOR_TRANSFER_PROG_BAR)?; + let transfer_progress_bar_full: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL)?; + let transfer_progress_bar_partial: Color = self + .get_color(super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL) + .map_err(|_| super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL)?; let transfer_status_hidden: Color = self .get_color(super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN) .map_err(|_| super::COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN)?; @@ -590,7 +609,8 @@ impl SetupActivity { theme.transfer_remote_explorer_highlighted = transfer_remote_explorer_highlighted; theme.transfer_log_background = transfer_log_background; theme.transfer_log_window = transfer_log_window; - theme.transfer_progress_bar = transfer_progress_bar; + theme.transfer_progress_bar_full = transfer_progress_bar_full; + theme.transfer_progress_bar_partial = transfer_progress_bar_partial; theme.transfer_status_hidden = transfer_status_hidden; theme.transfer_status_sorting = transfer_status_sorting; theme.transfer_status_sync_browsing = transfer_status_sync_browsing; diff --git a/themes/default.toml b/themes/default.toml index af1f3f3..273533d 100644 --- a/themes/default.toml +++ b/themes/default.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "Yellow" transfer_log_background = "Default" transfer_log_window = "LightGreen" -transfer_progress_bar = "Green" +transfer_progress_bar_full = "Green" +transfer_progress_bar_partial = "Green" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "LightBlue" diff --git a/themes/earth-wind-fire.toml b/themes/earth-wind-fire.toml index 433eae6..9ad776c 100644 --- a/themes/earth-wind-fire.toml +++ b/themes/earth-wind-fire.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "aquamarine" transfer_log_background = "Default" transfer_log_window = "#c43bff" -transfer_progress_bar = "deeppink" +transfer_progress_bar_full = "deeppink" +transfer_progress_bar_partial = "turquoise" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "greenyellow" diff --git a/themes/horizon.toml b/themes/horizon.toml index dcb7619..b793cdb 100644 --- a/themes/horizon.toml +++ b/themes/horizon.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "lightcoral" transfer_local_explorer_highlighted = "coral" transfer_log_background = "Default" transfer_log_window = "royalblue" -transfer_progress_bar = "deeppink" +transfer_progress_bar_full = "hotpink" +transfer_progress_bar_partial = "deeppink" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "lightsalmon" transfer_remote_explorer_highlighted = "salmon" diff --git a/themes/mono-bright.toml b/themes/mono-bright.toml index 4a75148..86f695c 100644 --- a/themes/mono-bright.toml +++ b/themes/mono-bright.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "#bbbbbb" transfer_log_background = "Default" transfer_log_window = "black" -transfer_progress_bar = "black" +transfer_progress_bar_full = "black" +transfer_progress_bar_partial = "black" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "#bbbbbb" diff --git a/themes/mono-dark.toml b/themes/mono-dark.toml index f283542..7fb9e01 100644 --- a/themes/mono-dark.toml +++ b/themes/mono-dark.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "white" transfer_log_background = "Default" transfer_log_window = "white" -transfer_progress_bar = "white" +transfer_progress_bar_full = "white" +transfer_progress_bar_partial = "white" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "white" diff --git a/themes/sugarplum.toml b/themes/sugarplum.toml index 875eb52..2cd9fc6 100644 --- a/themes/sugarplum.toml +++ b/themes/sugarplum.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "pink" transfer_local_explorer_highlighted = "hotpink" transfer_log_background = "Default" transfer_log_window = "palevioletred" -transfer_progress_bar = "hotpink" +transfer_progress_bar_full = "hotpink" +transfer_progress_bar_partial = "deeppink" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "plum" transfer_remote_explorer_highlighted = "violet" diff --git a/themes/ubuntu.toml b/themes/ubuntu.toml index d1c88b6..01e3bf1 100644 --- a/themes/ubuntu.toml +++ b/themes/ubuntu.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "Yellow" transfer_log_background = "Default" transfer_log_window = "lawngreen" -transfer_progress_bar = "lawngreen" +transfer_progress_bar_full = "lawngreen" +transfer_progress_bar_partial = "lawngreen" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "turquoise" diff --git a/themes/veeso.toml b/themes/veeso.toml index 5bc3af9..c3b829d 100644 --- a/themes/veeso.toml +++ b/themes/veeso.toml @@ -16,7 +16,8 @@ transfer_local_explorer_foreground = "Default" transfer_local_explorer_highlighted = "orange" transfer_log_background = "Default" transfer_log_window = "limegreen" -transfer_progress_bar = "lawngreen" +transfer_progress_bar_full = "lawngreen" +transfer_progress_bar_partial = "limegreen" transfer_remote_explorer_background = "Default" transfer_remote_explorer_foreground = "Default" transfer_remote_explorer_highlighted = "turquoise" From e6b44e146192ce6c81fb5274ba72cc39a8716f05 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 8 Jul 2021 15:07:24 +0200 Subject: [PATCH 39/53] ConfigClient is an option no more; config client degraded mode --- src/activity_manager.rs | 6 +- src/system/config_client.rs | 60 +++++- src/ui/activities/auth/mod.rs | 53 +++--- src/ui/activities/auth/view.rs | 11 +- src/ui/activities/filetransfer/misc.rs | 5 +- src/ui/activities/filetransfer/mod.rs | 9 +- src/ui/activities/setup/actions.rs | 153 ++++++++------- src/ui/activities/setup/config.rs | 84 +++------ src/ui/activities/setup/mod.rs | 11 +- src/ui/activities/setup/view/setup.rs | 229 +++++++++++------------ src/ui/activities/setup/view/ssh_keys.rs | 31 ++- src/ui/context.rs | 8 +- 12 files changed, 350 insertions(+), 310 deletions(-) diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 19dfb65..4fc21e5 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -67,12 +67,12 @@ impl ActivityManager { pub fn new(local_dir: &Path, interval: Duration) -> Result { // Prepare Context // Initialize configuration client - let (config_client, error): (Option, Option) = + let (config_client, error): (ConfigClient, Option) = match Self::init_config_client() { - Ok(cli) => (Some(cli), None), + Ok(cli) => (cli, None), Err(err) => { error!("Failed to initialize config client: {}", err); - (None, Some(err)) + (ConfigClient::degraded(), Some(err)) } }; let theme_provider: ThemeProvider = Self::init_theme_provider(); diff --git a/src/system/config_client.rs b/src/system/config_client.rs index fd0a481..12a77d3 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -49,13 +49,14 @@ pub struct ConfigClient { config: UserConfig, // Configuration loaded config_path: PathBuf, // Configuration TOML Path ssh_key_dir: PathBuf, // SSH Key storage directory + degraded: bool, // Indicates the `ConfigClient` is working in degraded mode } impl ConfigClient { /// ### new /// /// Instantiate a new `ConfigClient` with provided path - pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result { + pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result { // Initialize a default configuration let default_config: UserConfig = UserConfig::default(); info!( @@ -68,6 +69,7 @@ impl ConfigClient { config: default_config, config_path: PathBuf::from(config_path), ssh_key_dir: PathBuf::from(ssh_key_dir), + degraded: false, }; // If ssh key directory doesn't exist, create it if !ssh_key_dir.exists() { @@ -102,6 +104,20 @@ impl ConfigClient { Ok(client) } + /// ### degraded + /// + /// Instantiate a ConfigClient in degraded mode. + /// When in degraded mode, the configuration in use will be the default configuration + /// and the IO operation on configuration won't be available + pub fn degraded() -> Self { + Self { + config: UserConfig::default(), + config_path: PathBuf::default(), + ssh_key_dir: PathBuf::default(), + degraded: true, + } + } + // Text editor /// ### get_text_editor @@ -234,6 +250,12 @@ impl ConfigClient { username: &str, ssh_key: &str, ) -> Result<(), SerializerError> { + if self.degraded { + return Err(SerializerError::new_ex( + SerializerErrorKind::GenericError, + String::from("Configuration won't be saved, since in degraded mode"), + )); + } let host_name: String = Self::make_ssh_host_key(host, username); // Get key path let ssh_key_path: PathBuf = { @@ -267,6 +289,12 @@ impl ConfigClient { /// This operation also unlinks the key file in `ssh_key_dir` /// and also commits changes to configuration, to prevent incoerent data pub fn del_ssh_key(&mut self, host: &str, username: &str) -> Result<(), SerializerError> { + if self.degraded { + return Err(SerializerError::new_ex( + SerializerErrorKind::GenericError, + String::from("Configuration won't be saved, since in degraded mode"), + )); + } // Remove key from configuration and get key path info!("Removing key for {}@{}", host, username); let key_path: PathBuf = match self @@ -293,6 +321,9 @@ impl ConfigClient { /// None is returned if key doesn't exist /// `std::io::Error` is returned in case it was not possible to read the key file pub fn get_ssh_key(&self, mkey: &str) -> std::io::Result> { + if self.degraded { + return Ok(None); + } // Check if Key exists match self.config.remote.ssh_keys.get(mkey) { None => Ok(None), @@ -318,6 +349,12 @@ impl ConfigClient { /// /// Write configuration to file pub fn write_config(&self) -> Result<(), SerializerError> { + if self.degraded { + return Err(SerializerError::new_ex( + SerializerErrorKind::GenericError, + String::from("Configuration won't be saved, since in degraded mode"), + )); + } // Open file match OpenOptions::new() .create(true) @@ -340,6 +377,12 @@ impl ConfigClient { /// /// Read configuration from file (or reload it if already read) pub fn read_config(&mut self) -> Result<(), SerializerError> { + if self.degraded { + return Err(SerializerError::new_ex( + SerializerErrorKind::GenericError, + String::from("Configuration won't be loaded, since in degraded mode"), + )); + } // Open bookmarks file for read match OpenOptions::new() .read(true) @@ -415,6 +458,7 @@ mod tests { .unwrap(); // Verify parameters let default_config: UserConfig = UserConfig::default(); + assert_eq!(client.degraded, false); assert_eq!(client.config.remote.ssh_keys.len(), 0); assert_eq!( client.config.user_interface.default_protocol, @@ -428,6 +472,20 @@ mod tests { assert_eq!(client.ssh_key_dir, ssh_keys_path); } + #[test] + fn test_system_config_degraded() { + let mut client: ConfigClient = ConfigClient::degraded(); + assert_eq!(client.degraded, true); + assert_eq!(client.config_path, PathBuf::default()); + assert_eq!(client.ssh_key_dir, PathBuf::default()); + // I/O + assert!(client.add_ssh_key("Omar", "omar", "omar").is_err()); + assert!(client.del_ssh_key("omar", "omar").is_err()); + assert!(client.get_ssh_key("omar").ok().unwrap().is_none()); + assert!(client.write_config().is_err()); + assert!(client.read_config().is_err()); + } + #[test] fn test_system_config_new_err() { assert!( diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index 0a53272..e71d997 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -111,33 +111,36 @@ impl AuthActivity { fn check_for_updates(&mut self) { debug!("Check for updates..."); // Check version only if unset in the store - let ctx: &Context = self.context.as_ref().unwrap(); + let ctx: &mut Context = self.context.as_mut().unwrap(); if !ctx.store.isset(STORE_KEY_LATEST_VERSION) { debug!("Version is not set in storage"); - let mut github_tag: Option = match ctx.config_client.as_ref() { - Some(client) => { - if client.get_check_for_updates() { - debug!("Check for updates is enabled"); - // Send request - match git::check_for_updates(env!("CARGO_PKG_VERSION")) { - Ok(github_tag) => github_tag, - Err(err) => { - // Report error - error!("Failed to get latest version: {}", err); - self.mount_error( - format!("Could not check for new updates: {}", err).as_str(), - ); - // None - None - } - } - } else { - info!("Check for updates is disabled"); - None + if ctx.config_client.get_check_for_updates() { + debug!("Check for updates is enabled"); + // Send request + match git::check_for_updates(env!("CARGO_PKG_VERSION")) { + Ok(Some(git::GithubTag { tag_name, body })) => { + // If some, store version and release notes + info!("Latest version is: {}", tag_name); + ctx.store.set_string(STORE_KEY_LATEST_VERSION, tag_name); + ctx.store.set_string(STORE_KEY_RELEASE_NOTES, body); + } + Ok(None) => { + info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION")); + // Just set flag as check + ctx.store.set(STORE_KEY_LATEST_VERSION); + } + Err(err) => { + // Report error + error!("Failed to get latest version: {}", err); + self.mount_error( + format!("Could not check for new updates: {}", err).as_str(), + ); } } - None => None, - }; + } else { + info!("Check for updates is disabled"); + } + /* let ctx: &mut Context = self.context.as_mut().unwrap(); // Set version into the store (or just a flag) match github_tag.take() { @@ -152,7 +155,7 @@ impl AuthActivity { // Just set flag as check ctx.store.set(STORE_KEY_LATEST_VERSION); } - } + }*/ } } @@ -194,7 +197,7 @@ impl Activity for AuthActivity { self.view_recent_connections(); } // Verify error state from context - if let Some(err) = self.context.as_mut().unwrap().get_error() { + if let Some(err) = self.context.as_mut().unwrap().error() { self.mount_error(err.as_str()); } info!("Activity initialized"); diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index b256915..e196d58 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -109,11 +109,12 @@ impl AuthActivity { )), ); // Get default protocol - let default_protocol: FileTransferProtocol = - match self.context.as_ref().unwrap().config_client.as_ref() { - Some(cli) => cli.get_default_protocol(), - None => FileTransferProtocol::Sftp, - }; + let default_protocol: FileTransferProtocol = self + .context + .as_ref() + .unwrap() + .config_client + .get_default_protocol(); // Protocol self.view.mount( super::COMPONENT_RADIO_PROTOCOL, diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index ca14fad..a238a7e 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -103,10 +103,7 @@ impl FileTransferActivity { /// /// Set text editor to use pub(super) fn setup_text_editor(&self) { - if let Some(config_cli) = self.context.as_ref().unwrap().config_client.as_ref() { - // Set text editor - env::set_var("EDITOR", config_cli.get_text_editor()); - } + env::set_var("EDITOR", self.config().get_text_editor()); } /// ### read_input_event diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index ca4bbc5..fd97ca7 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -210,6 +210,13 @@ impl FileTransferActivity { }) } + /// ### config + /// + /// Returns config client reference + fn config(&self) -> &ConfigClient { + &self.context.as_ref().unwrap().config_client + } + /// ### theme /// /// Get a reference to `Theme` @@ -249,7 +256,7 @@ impl Activity for FileTransferActivity { self.init(); debug!("Initialized view"); // Verify error state from context - if let Some(err) = self.context.as_mut().unwrap().get_error() { + if let Some(err) = self.context.as_mut().unwrap().error() { error!("Fatal error on create: {}", err); self.mount_fatal(&err); } diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index 5b71dda..7c1e56d 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -80,32 +80,29 @@ impl SetupActivity { /// delete of a ssh key pub(super) fn action_delete_ssh_key(&mut self) { // Get key - if let Some(config_cli) = self.context.as_mut().unwrap().config_client.as_mut() { - // get index - let idx: Option = match self.view.get_state(super::COMPONENT_LIST_SSH_KEYS) { - Some(Payload::One(Value::Usize(idx))) => Some(idx), - _ => None, - }; - if let Some(idx) = idx { - let key: Option = config_cli.iter_ssh_keys().nth(idx).cloned(); - if let Some(key) = key { - match config_cli.get_ssh_key(&key) { - Ok(opt) => { - if let Some((host, username, _)) = opt { - if let Err(err) = - self.delete_ssh_key(host.as_str(), username.as_str()) - { - // Report error - self.mount_error(err.as_str()); - } + // get index + let idx: Option = match self.view.get_state(super::COMPONENT_LIST_SSH_KEYS) { + Some(Payload::One(Value::Usize(idx))) => Some(idx), + _ => None, + }; + if let Some(idx) = idx { + let key: Option = self.config().iter_ssh_keys().nth(idx).cloned(); + if let Some(key) = key { + match self.config().get_ssh_key(&key) { + Ok(opt) => { + if let Some((host, username, _)) = opt { + if let Err(err) = self.delete_ssh_key(host.as_str(), username.as_str()) + { + // Report error + self.mount_error(err.as_str()); } } - Err(err) => { - // Report error - self.mount_error( - format!("Could not get ssh key \"{}\": {}", key, err).as_str(), - ); - } + } + Err(err) => { + // Report error + self.mount_error( + format!("Could not get ssh key \"{}\": {}", key, err).as_str(), + ); } } } @@ -116,67 +113,63 @@ impl SetupActivity { /// /// Create a new ssh key pub(super) fn action_new_ssh_key(&mut self) { - if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { - // get parameters - let host: String = match self.view.get_state(super::COMPONENT_INPUT_SSH_HOST) { - Some(Payload::One(Value::Str(host))) => host, - _ => String::new(), - }; - let username: String = match self.view.get_state(super::COMPONENT_INPUT_SSH_USERNAME) { - Some(Payload::One(Value::Str(user))) => user, - _ => String::new(), - }; - // Prepare text editor - env::set_var("EDITOR", cli.get_text_editor()); - let placeholder: String = format!("# Type private SSH key for {}@{}\n", username, host); - // Put input mode back to normal - if let Err(err) = disable_raw_mode() { - error!("Failed to disable raw mode: {}", err); - } - // Leave alternate mode - #[cfg(not(target_os = "windows"))] - if let Some(ctx) = self.context.as_mut() { - ctx.leave_alternate_screen(); - } - // Re-enable raw mode - if let Err(err) = enable_raw_mode() { - error!("Failed to enter raw mode: {}", err); - } - // Write key to file - match edit::edit(placeholder.as_bytes()) { - Ok(rsa_key) => { - // Remove placeholder from `rsa_key` - let rsa_key: String = rsa_key.as_str().replace(placeholder.as_str(), ""); - if rsa_key.is_empty() { - // Report error: empty key - self.mount_error("SSH key is empty!"); - } else { - // Add key - if let Err(err) = - self.add_ssh_key(host.as_str(), username.as_str(), rsa_key.as_str()) - { - self.mount_error( - format!("Could not create new private key: {}", err).as_str(), - ); - } + // get parameters + let host: String = match self.view.get_state(super::COMPONENT_INPUT_SSH_HOST) { + Some(Payload::One(Value::Str(host))) => host, + _ => String::new(), + }; + let username: String = match self.view.get_state(super::COMPONENT_INPUT_SSH_USERNAME) { + Some(Payload::One(Value::Str(user))) => user, + _ => String::new(), + }; + // Prepare text editor + env::set_var("EDITOR", self.config().get_text_editor()); + let placeholder: String = format!("# Type private SSH key for {}@{}\n", username, host); + // Put input mode back to normal + if let Err(err) = disable_raw_mode() { + error!("Failed to disable raw mode: {}", err); + } + // Leave alternate mode + #[cfg(not(target_os = "windows"))] + if let Some(ctx) = self.context.as_mut() { + ctx.leave_alternate_screen(); + } + // Re-enable raw mode + if let Err(err) = enable_raw_mode() { + error!("Failed to enter raw mode: {}", err); + } + // Write key to file + match edit::edit(placeholder.as_bytes()) { + Ok(rsa_key) => { + // Remove placeholder from `rsa_key` + let rsa_key: String = rsa_key.as_str().replace(placeholder.as_str(), ""); + if rsa_key.is_empty() { + // Report error: empty key + self.mount_error("SSH key is empty!"); + } else { + // Add key + if let Err(err) = + self.add_ssh_key(host.as_str(), username.as_str(), rsa_key.as_str()) + { + self.mount_error( + format!("Could not create new private key: {}", err).as_str(), + ); } } - Err(err) => { - // Report error - self.mount_error( - format!("Could not write private key to file: {}", err).as_str(), - ); - } } - // Restore terminal - #[cfg(not(target_os = "windows"))] - if let Some(ctx) = self.context.as_mut() { - // Clear screen - ctx.clear_screen(); - // Enter alternate mode - ctx.enter_alternate_screen(); + Err(err) => { + // Report error + self.mount_error(format!("Could not write private key to file: {}", err).as_str()); } } + // Restore terminal + #[cfg(not(target_os = "windows"))] + if let Some(ctx) = self.context.as_mut() { + // Clear screen + ctx.clear_screen(); + // Enter alternate mode + ctx.enter_alternate_screen(); + } } /// ### set_color diff --git a/src/ui/activities/setup/config.rs b/src/ui/activities/setup/config.rs index 6e65b36..5e27dfd 100644 --- a/src/ui/activities/setup/config.rs +++ b/src/ui/activities/setup/config.rs @@ -37,12 +37,9 @@ impl SetupActivity { /// /// Save configuration pub(super) fn save_config(&mut self) -> Result<(), String> { - match self.context.as_ref().unwrap().config_client.as_ref() { - Some(cli) => match cli.write_config() { - Ok(_) => Ok(()), - Err(err) => Err(format!("Could not save configuration: {}", err)), - }, - None => Ok(()), + match self.config().write_config() { + Ok(_) => Ok(()), + Err(err) => Err(format!("Could not save configuration: {}", err)), } } @@ -51,13 +48,9 @@ impl SetupActivity { /// Reset configuration changes; pratically read config from file, overwriting any change made /// since last write action pub(super) fn reset_config_changes(&mut self) -> Result<(), String> { - match self.context.as_mut().unwrap().config_client.as_mut() { - Some(cli) => match cli.read_config() { - Ok(_) => Ok(()), - Err(err) => Err(format!("Could not restore configuration: {}", err)), - }, - None => Ok(()), - } + self.config_mut() + .read_config() + .map_err(|e| format!("Could not reload configuration: {}", e)) } /// ### save_theme @@ -82,15 +75,12 @@ impl SetupActivity { /// /// Delete ssh key from config cli pub(super) fn delete_ssh_key(&mut self, host: &str, username: &str) -> Result<(), String> { - match self.context.as_mut().unwrap().config_client.as_mut() { - Some(cli) => match cli.del_ssh_key(host, username) { - Ok(_) => Ok(()), - Err(err) => Err(format!( - "Could not delete ssh key \"{}@{}\": {}", - host, username, err - )), - }, - None => Ok(()), + match self.config_mut().del_ssh_key(host, username) { + Ok(_) => Ok(()), + Err(err) => Err(format!( + "Could not delete ssh key \"{}@{}\": {}", + host, username, err + )), } } @@ -102,9 +92,7 @@ impl SetupActivity { None => Ok(()), Some(ctx) => { // Set editor if config client exists - if let Some(config_cli) = ctx.config_client.as_ref() { - env::set_var("EDITOR", config_cli.get_text_editor()); - } + env::set_var("EDITOR", ctx.config_client.get_text_editor()); // Prepare terminal if let Err(err) = disable_raw_mode() { error!("Failed to disable raw mode: {}", err); @@ -113,27 +101,22 @@ impl SetupActivity { #[cfg(not(target_os = "windows"))] ctx.leave_alternate_screen(); // Get result - let result: Result<(), String> = match ctx.config_client.as_ref() { - Some(config_cli) => match config_cli.iter_ssh_keys().nth(idx) { - Some(key) => { - // Get key path - match config_cli.get_ssh_key(key) { - Ok(ssh_key) => match ssh_key { - None => Ok(()), - Some((_, _, key_path)) => { - match edit::edit_file(key_path.as_path()) { - Ok(_) => Ok(()), - Err(err) => { - Err(format!("Could not edit ssh key: {}", err)) - } - } + let result: Result<(), String> = match ctx.config_client.iter_ssh_keys().nth(idx) { + Some(key) => { + // Get key path + match ctx.config_client.get_ssh_key(key) { + Ok(ssh_key) => match ssh_key { + None => Ok(()), + Some((_, _, key_path)) => { + match edit::edit_file(key_path.as_path()) { + Ok(_) => Ok(()), + Err(err) => Err(format!("Could not edit ssh key: {}", err)), } - }, - Err(err) => Err(format!("Could not read ssh key: {}", err)), - } + } + }, + Err(err) => Err(format!("Could not read ssh key: {}", err)), } - None => Ok(()), - }, + } None => Ok(()), }; // Restore terminal @@ -161,15 +144,8 @@ impl SetupActivity { username: &str, rsa_key: &str, ) -> Result<(), String> { - match self.context.as_mut().unwrap().config_client.as_mut() { - Some(cli) => { - // Add key to client - match cli.add_ssh_key(host, username, rsa_key) { - Ok(_) => Ok(()), - Err(err) => Err(format!("Could not add SSH key: {}", err)), - } - } - None => Ok(()), - } + self.config_mut() + .add_ssh_key(host, username, rsa_key) + .map_err(|e| format!("Could not add SSH key: {}", e)) } } diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index 0a19893..21299f2 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -35,6 +35,7 @@ mod view; // Locals use super::{Activity, Context, ExitReason}; use crate::config::themes::Theme; +use crate::system::config_client::ConfigClient; use crate::system::theme_provider::ThemeProvider; // Ext use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; @@ -133,6 +134,14 @@ impl Default for SetupActivity { } impl SetupActivity { + fn config(&self) -> &ConfigClient { + &self.context.as_ref().unwrap().config_client + } + + fn config_mut(&mut self) -> &mut ConfigClient { + &mut self.context.as_mut().unwrap().config_client + } + fn theme(&self) -> &Theme { self.context.as_ref().unwrap().theme_provider.theme() } @@ -164,7 +173,7 @@ impl Activity for SetupActivity { // Init view self.init(ViewLayout::SetupForm); // Verify error state from context - if let Some(err) = self.context.as_mut().unwrap().get_error() { + if let Some(err) = self.context.as_mut().unwrap().error() { self.mount_error(err.as_str()); } } diff --git a/src/ui/activities/setup/view/setup.rs b/src/ui/activities/setup/view/setup.rs index dcbb68c..98aa239 100644 --- a/src/ui/activities/setup/view/setup.rs +++ b/src/ui/activities/setup/view/setup.rs @@ -286,73 +286,71 @@ impl SetupActivity { /// /// Load values from configuration into input fields pub(crate) fn load_input_values(&mut self) { - if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { - // Text editor - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_TEXT_EDITOR) { - let text_editor: String = - String::from(cli.get_text_editor().as_path().to_string_lossy()); - let props = InputPropsBuilder::from(props) - .with_value(text_editor) - .build(); - let _ = self.view.update(super::COMPONENT_INPUT_TEXT_EDITOR, props); - } - // Protocol - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) { - let protocol: usize = match cli.get_default_protocol() { - FileTransferProtocol::Sftp => 0, - FileTransferProtocol::Scp => 1, - FileTransferProtocol::Ftp(false) => 2, - FileTransferProtocol::Ftp(true) => 3, - }; - let props = RadioPropsBuilder::from(props).with_value(protocol).build(); - let _ = self - .view - .update(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, props); - } - // Hidden files - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_HIDDEN_FILES) { - let hidden: usize = match cli.get_show_hidden_files() { - true => 0, - false => 1, - }; - let props = RadioPropsBuilder::from(props).with_value(hidden).build(); - let _ = self.view.update(super::COMPONENT_RADIO_HIDDEN_FILES, props); - } - // Updates - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_UPDATES) { - let updates: usize = match cli.get_check_for_updates() { - true => 0, - false => 1, - }; - let props = RadioPropsBuilder::from(props).with_value(updates).build(); - let _ = self.view.update(super::COMPONENT_RADIO_UPDATES, props); - } - // Group dirs - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_GROUP_DIRS) { - let dirs: usize = match cli.get_group_dirs() { - Some(GroupDirs::First) => 0, - Some(GroupDirs::Last) => 1, - None => 2, - }; - let props = RadioPropsBuilder::from(props).with_value(dirs).build(); - let _ = self.view.update(super::COMPONENT_RADIO_GROUP_DIRS, props); - } - // Local File Fmt - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_LOCAL_FILE_FMT) { - let file_fmt: String = cli.get_local_file_fmt().unwrap_or_default(); - let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); - let _ = self - .view - .update(super::COMPONENT_INPUT_LOCAL_FILE_FMT, props); - } - // Remote File Fmt - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_REMOTE_FILE_FMT) { - let file_fmt: String = cli.get_remote_file_fmt().unwrap_or_default(); - let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); - let _ = self - .view - .update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props); - } + // Text editor + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_TEXT_EDITOR) { + let text_editor: String = + String::from(self.config().get_text_editor().as_path().to_string_lossy()); + let props = InputPropsBuilder::from(props) + .with_value(text_editor) + .build(); + let _ = self.view.update(super::COMPONENT_INPUT_TEXT_EDITOR, props); + } + // Protocol + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) { + let protocol: usize = match self.config().get_default_protocol() { + FileTransferProtocol::Sftp => 0, + FileTransferProtocol::Scp => 1, + FileTransferProtocol::Ftp(false) => 2, + FileTransferProtocol::Ftp(true) => 3, + }; + let props = RadioPropsBuilder::from(props).with_value(protocol).build(); + let _ = self + .view + .update(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, props); + } + // Hidden files + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_HIDDEN_FILES) { + let hidden: usize = match self.config().get_show_hidden_files() { + true => 0, + false => 1, + }; + let props = RadioPropsBuilder::from(props).with_value(hidden).build(); + let _ = self.view.update(super::COMPONENT_RADIO_HIDDEN_FILES, props); + } + // Updates + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_UPDATES) { + let updates: usize = match self.config().get_check_for_updates() { + true => 0, + false => 1, + }; + let props = RadioPropsBuilder::from(props).with_value(updates).build(); + let _ = self.view.update(super::COMPONENT_RADIO_UPDATES, props); + } + // Group dirs + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_GROUP_DIRS) { + let dirs: usize = match self.config().get_group_dirs() { + Some(GroupDirs::First) => 0, + Some(GroupDirs::Last) => 1, + None => 2, + }; + let props = RadioPropsBuilder::from(props).with_value(dirs).build(); + let _ = self.view.update(super::COMPONENT_RADIO_GROUP_DIRS, props); + } + // Local File Fmt + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_LOCAL_FILE_FMT) { + let file_fmt: String = self.config().get_local_file_fmt().unwrap_or_default(); + let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); + let _ = self + .view + .update(super::COMPONENT_INPUT_LOCAL_FILE_FMT, props); + } + // Remote File Fmt + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_REMOTE_FILE_FMT) { + let file_fmt: String = self.config().get_remote_file_fmt().unwrap_or_default(); + let props = InputPropsBuilder::from(props).with_value(file_fmt).build(); + let _ = self + .view + .update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props); } } @@ -360,55 +358,54 @@ impl SetupActivity { /// /// Collect values from input and put them into the configuration pub(crate) fn collect_input_values(&mut self) { - if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() { - if let Some(Payload::One(Value::Str(editor))) = - self.view.get_state(super::COMPONENT_INPUT_TEXT_EDITOR) - { - cli.set_text_editor(PathBuf::from(editor.as_str())); - } - if let Some(Payload::One(Value::Usize(protocol))) = - self.view.get_state(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) - { - let protocol: FileTransferProtocol = match protocol { - 1 => FileTransferProtocol::Scp, - 2 => FileTransferProtocol::Ftp(false), - 3 => FileTransferProtocol::Ftp(true), - _ => FileTransferProtocol::Sftp, - }; - cli.set_default_protocol(protocol); - } - if let Some(Payload::One(Value::Usize(opt))) = - self.view.get_state(super::COMPONENT_RADIO_HIDDEN_FILES) - { - let show: bool = matches!(opt, 0); - cli.set_show_hidden_files(show); - } - if let Some(Payload::One(Value::Usize(opt))) = - self.view.get_state(super::COMPONENT_RADIO_UPDATES) - { - let check: bool = matches!(opt, 0); - cli.set_check_for_updates(check); - } - if let Some(Payload::One(Value::Str(fmt))) = - self.view.get_state(super::COMPONENT_INPUT_LOCAL_FILE_FMT) - { - cli.set_local_file_fmt(fmt); - } - if let Some(Payload::One(Value::Str(fmt))) = - self.view.get_state(super::COMPONENT_INPUT_REMOTE_FILE_FMT) - { - cli.set_remote_file_fmt(fmt); - } - if let Some(Payload::One(Value::Usize(opt))) = - self.view.get_state(super::COMPONENT_RADIO_GROUP_DIRS) - { - let dirs: Option = match opt { - 0 => Some(GroupDirs::First), - 1 => Some(GroupDirs::Last), - _ => None, - }; - cli.set_group_dirs(dirs); - } + if let Some(Payload::One(Value::Str(editor))) = + self.view.get_state(super::COMPONENT_INPUT_TEXT_EDITOR) + { + self.config_mut() + .set_text_editor(PathBuf::from(editor.as_str())); + } + if let Some(Payload::One(Value::Usize(protocol))) = + self.view.get_state(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) + { + let protocol: FileTransferProtocol = match protocol { + 1 => FileTransferProtocol::Scp, + 2 => FileTransferProtocol::Ftp(false), + 3 => FileTransferProtocol::Ftp(true), + _ => FileTransferProtocol::Sftp, + }; + self.config_mut().set_default_protocol(protocol); + } + if let Some(Payload::One(Value::Usize(opt))) = + self.view.get_state(super::COMPONENT_RADIO_HIDDEN_FILES) + { + let show: bool = matches!(opt, 0); + self.config_mut().set_show_hidden_files(show); + } + if let Some(Payload::One(Value::Usize(opt))) = + self.view.get_state(super::COMPONENT_RADIO_UPDATES) + { + let check: bool = matches!(opt, 0); + self.config_mut().set_check_for_updates(check); + } + if let Some(Payload::One(Value::Str(fmt))) = + self.view.get_state(super::COMPONENT_INPUT_LOCAL_FILE_FMT) + { + self.config_mut().set_local_file_fmt(fmt); + } + if let Some(Payload::One(Value::Str(fmt))) = + self.view.get_state(super::COMPONENT_INPUT_REMOTE_FILE_FMT) + { + self.config_mut().set_remote_file_fmt(fmt); + } + if let Some(Payload::One(Value::Usize(opt))) = + self.view.get_state(super::COMPONENT_RADIO_GROUP_DIRS) + { + let dirs: Option = match opt { + 0 => Some(GroupDirs::First), + 1 => Some(GroupDirs::Last), + _ => None, + }; + self.config_mut().set_group_dirs(dirs); } } } diff --git a/src/ui/activities/setup/view/ssh_keys.rs b/src/ui/activities/setup/view/ssh_keys.rs index 107846f..499fff8 100644 --- a/src/ui/activities/setup/view/ssh_keys.rs +++ b/src/ui/activities/setup/view/ssh_keys.rs @@ -275,22 +275,21 @@ impl SetupActivity { /// /// Reload ssh keys pub(crate) fn reload_ssh_keys(&mut self) { - if let Some(cli) = self.context.as_ref().unwrap().config_client.as_ref() { - // get props - if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS) { - // Create texts - let keys: Vec = cli - .iter_ssh_keys() - .map(|x| { - let (addr, username, _) = cli.get_ssh_key(x).ok().unwrap().unwrap(); - format!("{} at {}", addr, username) - }) - .collect(); - let props = BookmarkListPropsBuilder::from(props) - .with_bookmarks(Some(String::from("SSH Keys")), keys) - .build(); - self.view.update(super::COMPONENT_LIST_SSH_KEYS, props); - } + // get props + if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS) { + // Create texts + let keys: Vec = self + .config() + .iter_ssh_keys() + .map(|x| { + let (addr, username, _) = self.config().get_ssh_key(x).ok().unwrap().unwrap(); + format!("{} at {}", addr, username) + }) + .collect(); + let props = BookmarkListPropsBuilder::from(props) + .with_bookmarks(Some(String::from("SSH Keys")), keys) + .build(); + self.view.update(super::COMPONENT_LIST_SSH_KEYS, props); } } } diff --git a/src/ui/context.rs b/src/ui/context.rs index b111bfe..0364435 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -46,7 +46,7 @@ use tuirealm::tui::Terminal; /// Context holds data structures used by the ui pub struct Context { pub ft_params: Option, - pub(crate) config_client: Option, + pub(crate) config_client: ConfigClient, pub(crate) store: Store, pub(crate) input_hnd: InputHandler, pub(crate) terminal: Terminal>, @@ -71,7 +71,7 @@ impl Context { /// /// Instantiates a new Context pub fn new( - config_client: Option, + config_client: ConfigClient, theme_provider: ThemeProvider, error: Option, ) -> Context { @@ -96,10 +96,10 @@ impl Context { self.error = Some(err); } - /// ### get_error + /// ### error /// /// Get error message and remove it from the context - pub fn get_error(&mut self) -> Option { + pub fn error(&mut self) -> Option { self.error.take() } From 37abe596c74c230a036edbfcaf161d793f4964ac Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 8 Jul 2021 15:43:23 +0200 Subject: [PATCH 40/53] context getters --- src/activity_manager.rs | 21 ++++---- src/ui/activities/auth/mod.rs | 53 ++++++++++---------- src/ui/activities/auth/update.rs | 26 +++++----- src/ui/activities/auth/view.rs | 20 +++----- src/ui/activities/filetransfer/misc.rs | 2 +- src/ui/activities/filetransfer/mod.rs | 22 +++++++-- src/ui/activities/filetransfer/session.rs | 10 ++-- src/ui/activities/filetransfer/update.rs | 14 ++---- src/ui/activities/setup/config.rs | 6 +-- src/ui/activities/setup/mod.rs | 26 +++++++--- src/ui/activities/setup/view/setup.rs | 2 +- src/ui/activities/setup/view/ssh_keys.rs | 2 +- src/ui/activities/setup/view/theme.rs | 2 +- src/ui/context.rs | 59 +++++++++++++++++++++-- 14 files changed, 168 insertions(+), 97 deletions(-) diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 4fc21e5..99cac71 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -97,14 +97,17 @@ impl ActivityManager { entry_directory: Option, ) { // Put params into the context - self.context.as_mut().unwrap().ft_params = Some(FileTransferParams { - address, - port, - protocol, - username, - password, - entry_directory, - }); + self.context + .as_mut() + .unwrap() + .set_ftparams(FileTransferParams { + address, + port, + protocol, + username, + password, + entry_directory, + }); } /// ### run @@ -202,7 +205,7 @@ impl ActivityManager { } }; // If ft params is None, return None - let ft_params: &FileTransferParams = match ctx.ft_params.as_ref() { + let ft_params: &FileTransferParams = match ctx.ft_params() { Some(ft_params) => &ft_params, None => { error!("Failed to start FileTransferActivity: file transfer params is None"); diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index e71d997..72121e2 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -111,23 +111,24 @@ impl AuthActivity { fn check_for_updates(&mut self) { debug!("Check for updates..."); // Check version only if unset in the store - let ctx: &mut Context = self.context.as_mut().unwrap(); - if !ctx.store.isset(STORE_KEY_LATEST_VERSION) { + let ctx: &mut Context = self.context_mut(); + if !ctx.store().isset(STORE_KEY_LATEST_VERSION) { debug!("Version is not set in storage"); - if ctx.config_client.get_check_for_updates() { + if ctx.config().get_check_for_updates() { debug!("Check for updates is enabled"); // Send request match git::check_for_updates(env!("CARGO_PKG_VERSION")) { Ok(Some(git::GithubTag { tag_name, body })) => { // If some, store version and release notes info!("Latest version is: {}", tag_name); - ctx.store.set_string(STORE_KEY_LATEST_VERSION, tag_name); - ctx.store.set_string(STORE_KEY_RELEASE_NOTES, body); + ctx.store_mut() + .set_string(STORE_KEY_LATEST_VERSION, tag_name); + ctx.store_mut().set_string(STORE_KEY_RELEASE_NOTES, body); } Ok(None) => { info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION")); // Just set flag as check - ctx.store.set(STORE_KEY_LATEST_VERSION); + ctx.store_mut().set(STORE_KEY_LATEST_VERSION); } Err(err) => { // Report error @@ -140,30 +141,28 @@ impl AuthActivity { } else { info!("Check for updates is disabled"); } - /* - let ctx: &mut Context = self.context.as_mut().unwrap(); - // Set version into the store (or just a flag) - match github_tag.take() { - Some(git::GithubTag { tag_name, body }) => { - // If some store version and release notes - info!("Latest version is: {}", tag_name); - ctx.store.set_string(STORE_KEY_LATEST_VERSION, tag_name); - ctx.store.set_string(STORE_KEY_RELEASE_NOTES, body); - } - None => { - info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION")); - // Just set flag as check - ctx.store.set(STORE_KEY_LATEST_VERSION); - } - }*/ } } + /// ### context + /// + /// Returns a reference to context + fn context(&self) -> &Context { + self.context.as_ref().unwrap() + } + + /// ### context_mut + /// + /// Returns a mutable reference to context + fn context_mut(&mut self) -> &mut Context { + self.context.as_mut().unwrap() + } + /// ### theme /// /// Returns a reference to theme fn theme(&self) -> &Theme { - self.context.as_ref().unwrap().theme_provider.theme() + self.context().theme_provider().theme() } } @@ -176,11 +175,11 @@ impl Activity for AuthActivity { fn on_create(&mut self, mut context: Context) { debug!("Initializing activity"); // Initialize file transfer params - context.ft_params = Some(FileTransferParams::default()); + context.set_ftparams(FileTransferParams::default()); // Set context self.context = Some(context); // Clear terminal - self.context.as_mut().unwrap().clear_screen(); + self.context_mut().clear_screen(); // Put raw mode on enabled if let Err(err) = enable_raw_mode() { error!("Failed to enter raw mode: {}", err); @@ -197,7 +196,7 @@ impl Activity for AuthActivity { self.view_recent_connections(); } // Verify error state from context - if let Some(err) = self.context.as_mut().unwrap().error() { + if let Some(err) = self.context_mut().error() { self.mount_error(err.as_str()); } info!("Activity initialized"); @@ -213,7 +212,7 @@ impl Activity for AuthActivity { return; } // Read one event - if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() { + if let Ok(Some(event)) = self.context().input_hnd().read_event() { // Set redraw to true self.redraw = true; // Handle on resize diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index be6ad54..9b0bbd8 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -331,19 +331,21 @@ impl Update for AuthActivity { self.save_recent(); let (address, port, protocol, username, password) = self.get_input(); // Set file transfer params to context - let mut ft_params: &mut FileTransferParams = - &mut self.context.as_mut().unwrap().ft_params.as_mut().unwrap(); - ft_params.address = address; - ft_params.port = port; - ft_params.protocol = protocol; - ft_params.username = match username.is_empty() { - true => None, - false => Some(username), - }; - ft_params.password = match password.is_empty() { - true => None, - false => Some(password), + let params: FileTransferParams = FileTransferParams { + address, + port, + protocol, + username: match username.is_empty() { + true => None, + false => Some(username), + }, + password: match password.is_empty() { + true => None, + false => Some(password), + }, + entry_directory: None, }; + self.context_mut().set_ftparams(params); // Set exit reason self.exit_reason = Some(super::ExitReason::Connect); // Return None diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index e196d58..88839e6 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -109,12 +109,7 @@ impl AuthActivity { )), ); // Get default protocol - let default_protocol: FileTransferProtocol = self - .context - .as_ref() - .unwrap() - .config_client - .get_default_protocol(); + let default_protocol: FileTransferProtocol = self.context().config().get_default_protocol(); // Protocol self.view.mount( super::COMPONENT_RADIO_PROTOCOL, @@ -186,12 +181,11 @@ impl AuthActivity { ); // Version notice if let Some(version) = self - .context - .as_ref() - .unwrap() - .store + .context() + .store() .get_string(super::STORE_KEY_LATEST_VERSION) { + let version: String = version.to_string(); self.view.mount( super::COMPONENT_TEXT_NEW_VERSION, Box::new(Span::new( @@ -199,7 +193,7 @@ impl AuthActivity { .with_foreground(Color::Yellow) .with_spans(vec![ TextSpan::from("termscp "), - TextSpanBuilder::new(version).underlined().bold().build(), + TextSpanBuilder::new(version.as_str()).underlined().bold().build(), TextSpan::from(" is NOW available! Get it from ; view release notes with "), ]) .build(), @@ -242,7 +236,7 @@ impl AuthActivity { /// Display view on canvas pub(super) fn view(&mut self) { let mut ctx: Context = self.context.take().unwrap(); - let _ = ctx.terminal.draw(|f| { + let _ = ctx.terminal().draw(|f| { // Check window size let height: u16 = f.size().height; self.check_minimum_window_size(height); @@ -784,7 +778,7 @@ impl AuthActivity { /// mount release notes text area pub(super) fn mount_release_notes(&mut self) { if let Some(ctx) = self.context.as_ref() { - if let Some(release_notes) = ctx.store.get_string(super::STORE_KEY_RELEASE_NOTES) { + if let Some(release_notes) = ctx.store().get_string(super::STORE_KEY_RELEASE_NOTES) { // make spans let spans: Vec = release_notes.lines().map(TextSpan::from).collect(); self.view.mount( diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index a238a7e..5838fc2 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -111,7 +111,7 @@ impl FileTransferActivity { /// Read one event. /// Returns whether at least one event has been handled pub(super) fn read_input_event(&mut self) -> bool { - if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() { + if let Ok(Some(event)) = self.context().input_hnd().read_event() { // Handle event let msg = self.view.on(event); self.update(msg); diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index fd97ca7..2cb4e18 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -210,18 +210,32 @@ impl FileTransferActivity { }) } + /// ### context + /// + /// Returns a reference to context + fn context(&self) -> &Context { + self.context.as_ref().unwrap() + } + + /// ### context_mut + /// + /// Returns a mutable reference to context + fn context_mut(&mut self) -> &mut Context { + self.context.as_mut().unwrap() + } + /// ### config /// /// Returns config client reference fn config(&self) -> &ConfigClient { - &self.context.as_ref().unwrap().config_client + &self.context().config() } /// ### theme /// /// Get a reference to `Theme` fn theme(&self) -> &Theme { - self.context.as_ref().unwrap().theme_provider.theme() + self.context().theme_provider().theme() } } @@ -241,7 +255,7 @@ impl Activity for FileTransferActivity { // Set context self.context = Some(context); // Clear terminal - self.context.as_mut().unwrap().clear_screen(); + self.context_mut().clear_screen(); // Put raw mode on enabled if let Err(err) = enable_raw_mode() { error!("Failed to enter raw mode: {}", err); @@ -276,7 +290,7 @@ impl Activity for FileTransferActivity { } // Check if connected (popup must be None, otherwise would try reconnecting in loop in case of error) if !self.client.is_connected() && self.view.get_props(COMPONENT_TEXT_FATAL).is_none() { - let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap(); + let params = self.context().ft_params().unwrap(); info!( "Client is not connected to remote; connecting to {}:{}", params.address, params.port diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index 6607a06..5180ef7 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -76,15 +76,15 @@ impl FileTransferActivity { /// /// Connect to remote pub(super) fn connect(&mut self) { - let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap(); + let params = self.context().ft_params().unwrap().clone(); let addr: String = params.address.clone(); let entry_dir: Option = params.entry_directory.clone(); // Connect to remote match self.client.connect( - params.address.clone(), + params.address, params.port, - params.username.clone(), - params.password.clone(), + params.username, + params.password, ) { Ok(welcome) => { if let Some(banner) = welcome { @@ -121,7 +121,7 @@ impl FileTransferActivity { /// /// disconnect from remote pub(super) fn disconnect(&mut self) { - let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap(); + let params = self.context().ft_params().unwrap(); let msg: String = format!("Disconnecting from {}...", params.address); // Show popup disconnecting self.mount_wait(msg.as_str()); diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 0443bd5..0ce5ac6 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -720,10 +720,8 @@ impl FileTransferActivity { Some(props) => { // Get width let width: usize = self - .context - .as_ref() - .unwrap() - .store + .context() + .store() .get_unsigned(super::STORAGE_EXPLORER_WIDTH) .unwrap_or(256); let hostname: String = match hostname::get() { @@ -768,13 +766,11 @@ impl FileTransferActivity { Some(props) => { // Get width let width: usize = self - .context - .as_ref() - .unwrap() - .store + .context() + .store() .get_unsigned(super::STORAGE_EXPLORER_WIDTH) .unwrap_or(256); - let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap(); + let params = self.context().ft_params().unwrap(); let hostname: String = format!( "{}:{} ", params.address, diff --git a/src/ui/activities/setup/config.rs b/src/ui/activities/setup/config.rs index 5e27dfd..1a2e5cd 100644 --- a/src/ui/activities/setup/config.rs +++ b/src/ui/activities/setup/config.rs @@ -92,7 +92,7 @@ impl SetupActivity { None => Ok(()), Some(ctx) => { // Set editor if config client exists - env::set_var("EDITOR", ctx.config_client.get_text_editor()); + env::set_var("EDITOR", ctx.config().get_text_editor()); // Prepare terminal if let Err(err) = disable_raw_mode() { error!("Failed to disable raw mode: {}", err); @@ -101,10 +101,10 @@ impl SetupActivity { #[cfg(not(target_os = "windows"))] ctx.leave_alternate_screen(); // Get result - let result: Result<(), String> = match ctx.config_client.iter_ssh_keys().nth(idx) { + let result: Result<(), String> = match ctx.config().iter_ssh_keys().nth(idx) { Some(key) => { // Get key path - match ctx.config_client.get_ssh_key(key) { + match ctx.config().get_ssh_key(key) { Ok(ssh_key) => match ssh_key { None => Ok(()), Some((_, _, key_path)) => { diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index 21299f2..f93cf75 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -134,24 +134,38 @@ impl Default for SetupActivity { } impl SetupActivity { + /// ### context + /// + /// Returns a reference to context + fn context(&self) -> &Context { + self.context.as_ref().unwrap() + } + + /// ### context_mut + /// + /// Returns a mutable reference to context + fn context_mut(&mut self) -> &mut Context { + self.context.as_mut().unwrap() + } + fn config(&self) -> &ConfigClient { - &self.context.as_ref().unwrap().config_client + &self.context().config() } fn config_mut(&mut self) -> &mut ConfigClient { - &mut self.context.as_mut().unwrap().config_client + self.context_mut().config_mut() } fn theme(&self) -> &Theme { - self.context.as_ref().unwrap().theme_provider.theme() + self.context().theme_provider().theme() } fn theme_mut(&mut self) -> &mut Theme { - self.context.as_mut().unwrap().theme_provider.theme_mut() + self.context_mut().theme_provider_mut().theme_mut() } fn theme_provider(&mut self) -> &mut ThemeProvider { - &mut self.context.as_mut().unwrap().theme_provider + self.context_mut().theme_provider_mut() } } @@ -188,7 +202,7 @@ impl Activity for SetupActivity { return; } // Read one event - if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() { + if let Ok(Some(event)) = self.context().input_hnd().read_event() { // Set redraw to true self.redraw = true; // Handle event diff --git a/src/ui/activities/setup/view/setup.rs b/src/ui/activities/setup/view/setup.rs index 98aa239..9a8c316 100644 --- a/src/ui/activities/setup/view/setup.rs +++ b/src/ui/activities/setup/view/setup.rs @@ -197,7 +197,7 @@ impl SetupActivity { pub(super) fn view_setup(&mut self) { let mut ctx: Context = self.context.take().unwrap(); - let _ = ctx.terminal.draw(|f| { + let _ = ctx.terminal().draw(|f| { // Prepare main chunks let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/src/ui/activities/setup/view/ssh_keys.rs b/src/ui/activities/setup/view/ssh_keys.rs index 499fff8..3517178 100644 --- a/src/ui/activities/setup/view/ssh_keys.rs +++ b/src/ui/activities/setup/view/ssh_keys.rs @@ -111,7 +111,7 @@ impl SetupActivity { pub(crate) fn view_ssh_keys(&mut self) { let mut ctx: Context = self.context.take().unwrap(); - let _ = ctx.terminal.draw(|f| { + let _ = ctx.terminal().draw(|f| { // Prepare main chunks let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/src/ui/activities/setup/view/theme.rs b/src/ui/activities/setup/view/theme.rs index b3c0853..58c2aa7 100644 --- a/src/ui/activities/setup/view/theme.rs +++ b/src/ui/activities/setup/view/theme.rs @@ -175,7 +175,7 @@ impl SetupActivity { pub(super) fn view_theme(&mut self) { let mut ctx: Context = self.context.take().unwrap(); - let _ = ctx.terminal.draw(|f| { + let _ = ctx.terminal().draw(|f| { // Prepare main chunks let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/src/ui/context.rs b/src/ui/context.rs index 0364435..9cb001c 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -41,22 +41,25 @@ use std::path::PathBuf; use tuirealm::tui::backend::CrosstermBackend; use tuirealm::tui::Terminal; +type TuiTerminal = Terminal>; + /// ## Context /// /// Context holds data structures used by the ui pub struct Context { - pub ft_params: Option, - pub(crate) config_client: ConfigClient, + ft_params: Option, + config_client: ConfigClient, pub(crate) store: Store, - pub(crate) input_hnd: InputHandler, - pub(crate) terminal: Terminal>, - pub(crate) theme_provider: ThemeProvider, + input_hnd: InputHandler, + pub(crate) terminal: TuiTerminal, + theme_provider: ThemeProvider, error: Option, } /// ### FileTransferParams /// /// Holds connection parameters for file transfers +#[derive(Clone)] pub struct FileTransferParams { pub address: String, pub port: u16, @@ -89,6 +92,52 @@ impl Context { } } + // -- getters + + pub fn ft_params(&self) -> Option<&FileTransferParams> { + self.ft_params.as_ref() + } + + pub fn config(&self) -> &ConfigClient { + &self.config_client + } + + pub fn config_mut(&mut self) -> &mut ConfigClient { + &mut self.config_client + } + + pub(crate) fn input_hnd(&self) -> &InputHandler { + &self.input_hnd + } + + pub(crate) fn store(&self) -> &Store { + &self.store + } + + pub(crate) fn store_mut(&mut self) -> &mut Store { + &mut self.store + } + + pub fn theme_provider(&self) -> &ThemeProvider { + &self.theme_provider + } + + pub fn theme_provider_mut(&mut self) -> &mut ThemeProvider { + &mut self.theme_provider + } + + pub fn terminal(&mut self) -> &mut TuiTerminal { + &mut self.terminal + } + + // -- setter + + pub fn set_ftparams(&mut self, params: FileTransferParams) { + self.ft_params = Some(params); + } + + // -- error + /// ### set_error /// /// Set context error From e15b79750b247e24cb845ce74385bd237c385fd1 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 8 Jul 2021 16:21:39 +0200 Subject: [PATCH 41/53] main() refactoring --- src/main.rs | 273 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 169 insertions(+), 104 deletions(-) diff --git a/src/main.rs b/src/main.rs index 384640d..76b35dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,9 +38,9 @@ extern crate magic_crypt; extern crate rpassword; // External libs -use getopts::Options; +use getopts::{Matches, Options}; use std::env; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::Duration; // Include @@ -59,31 +59,43 @@ use activity_manager::{ActivityManager, NextActivity}; use filetransfer::FileTransferProtocol; use system::logging; -/// ### print_usage -/// -/// Print usage +enum Task { + Activity(NextActivity), + ImportTheme(PathBuf), +} -fn print_usage(opts: Options) { - let brief = String::from( - "Usage: termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]", - ); - print!("{}", opts.usage(&brief)); - println!("\nPlease, report issues to "); - println!("Please, consider supporting the author ") +struct RunOpts { + address: Option, + port: u16, + username: Option, + password: Option, + remote_wrkdir: Option, + protocol: FileTransferProtocol, + ticks: Duration, + log_enabled: bool, + task: Task, +} + +impl Default for RunOpts { + fn default() -> Self { + Self { + address: None, + port: 22, + username: None, + password: None, + remote_wrkdir: None, + protocol: FileTransferProtocol::Sftp, + ticks: Duration::from_millis(10), + log_enabled: true, + task: Task::Activity(NextActivity::Authentication), + } + } } fn main() { let args: Vec = env::args().collect(); //Program CLI options - let mut address: Option = None; // None - let mut port: u16 = 22; // Default port - let mut username: Option = None; // Default username - let mut password: Option = None; // Default password - let mut remote_wrkdir: Option = None; - let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol - let mut ticks: Duration = Duration::from_millis(10); - let mut log_enabled: bool = true; - let mut start_activity: NextActivity = NextActivity::Authentication; + let mut run_opts: RunOpts = RunOpts::default(); //Process options let mut opts = Options::new(); opts.optflag("c", "config", "Open termscp configuration"); @@ -93,81 +105,113 @@ fn main() { opts.optopt("T", "ticks", "Set UI ticks; default 10ms", ""); opts.optflag("v", "version", ""); opts.optflag("h", "help", "Print this menu"); - let matches = match opts.parse(&args[1..]) { + let matches: Matches = match opts.parse(&args[1..]) { Ok(m) => m, Err(f) => { println!("{}", f.to_string()); std::process::exit(255); } }; - // Help - if matches.opt_present("h") { - print_usage(opts); + // Parse args + if let Err(err) = parse_run_opts(&mut run_opts, matches) { + if let Some(err) = err { + eprintln!("{}", err); + } else { + print_usage(opts); + } std::process::exit(255); } + // Setup logging + if run_opts.log_enabled { + if let Err(err) = logging::init() { + eprintln!("Failed to initialize logging: {}", err); + } + } + // Read password from remote + if let Err(err) = read_password(&mut run_opts) { + eprintln!("{}", err); + std::process::exit(255); + } + info!("termscp {} started!", TERMSCP_VERSION); + // Run + info!("Starting activity manager..."); + let rc: i32 = run(run_opts); + info!("termscp terminated"); + // Then return + std::process::exit(rc); +} + +/// ### print_usage +/// +/// Print usage +fn print_usage(opts: Options) { + let brief = String::from( + "Usage: termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]", + ); + print!("{}", opts.usage(&brief)); + println!("\nPlease, report issues to "); + println!("Please, consider supporting the author ") +} + +/// ### parse_run_opts +/// +/// Parse run options; in case something is wrong returns the error message +fn parse_run_opts(run_opts: &mut RunOpts, opts: Matches) -> Result<(), Option> { + // Help + if opts.opt_present("h") { + return Err(None); + } // Version - if matches.opt_present("v") { - eprintln!( + if opts.opt_present("v") { + return Err(Some(format!( "termscp - {} - Developed by {}", TERMSCP_VERSION, TERMSCP_AUTHORS, - ); - std::process::exit(255); + ))); } // Setup activity? - if matches.opt_present("c") { - start_activity = NextActivity::SetupActivity; + if opts.opt_present("c") { + run_opts.task = Task::Activity(NextActivity::SetupActivity); } // Logging - if matches.opt_present("q") { - log_enabled = false; + if opts.opt_present("q") { + run_opts.log_enabled = false; } // Match password - if let Some(passwd) = matches.opt_str("P") { - password = Some(passwd); + if let Some(passwd) = opts.opt_str("P") { + run_opts.password = Some(passwd); } // Match ticks - if let Some(val) = matches.opt_str("T") { + if let Some(val) = opts.opt_str("T") { match val.parse::() { - Ok(val) => ticks = Duration::from_millis(val as u64), + Ok(val) => run_opts.ticks = Duration::from_millis(val as u64), Err(_) => { - eprintln!("Ticks is not a number '{}'", val); - print_usage(opts); - std::process::exit(255); + return Err(Some(format!("Ticks is not a number: '{}'", val))); } } } // @! extra modes - if let Some(theme) = matches.opt_str("t") { - match support::import_theme(Path::new(theme.as_str())) { - Ok(_) => { - println!("Theme has been successfully imported!"); - std::process::exit(0) - } - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - } + if let Some(theme) = opts.opt_str("t") { + run_opts.task = Task::ImportTheme(PathBuf::from(theme)); } // @! Ordinary mode // Check free args - let extra_args: Vec = matches.free; + let extra_args: Vec = opts.free; // Remote argument if let Some(remote) = extra_args.get(0) { // Parse address match utils::parser::parse_remote_opt(remote) { Ok(host_opts) => { // Set params - address = Some(host_opts.hostname); - port = host_opts.port; - protocol = host_opts.protocol; - username = host_opts.username; - remote_wrkdir = host_opts.wrkdir; + run_opts.address = Some(host_opts.hostname); + run_opts.port = host_opts.port; + run_opts.protocol = host_opts.protocol; + run_opts.username = host_opts.username; + run_opts.remote_wrkdir = host_opts.wrkdir; + // In this case the first activity will be FileTransfer + run_opts.task = Task::Activity(NextActivity::FileTransfer); } Err(err) => { - eprintln!("Bad address option: {}", err); - print_usage(opts); - std::process::exit(255); + return Err(Some(format!("Bad address option: {}", err))); } } } @@ -176,64 +220,85 @@ fn main() { // Change working directory if local dir is set let localdir: PathBuf = PathBuf::from(localdir); if let Err(err) = env::set_current_dir(localdir.as_path()) { - eprintln!("Bad working directory argument: {}", err); - std::process::exit(255); + return Err(Some(format!("Bad working directory argument: {}", err))); } } - // Get working directory - let wrkdir: PathBuf = match env::current_dir() { - Ok(dir) => dir, - Err(_) => PathBuf::from("/"), - }; - // Setup logging - if log_enabled { - if let Err(err) = logging::init() { - eprintln!("Failed to initialize logging: {}", err); - } - } - info!("termscp {} started!", TERMSCP_VERSION); + Ok(()) +} + +/// ### read_password +/// +/// Read password from tty if address is specified +fn read_password(run_opts: &mut RunOpts) -> Result<(), String> { // Initialize client if necessary - if address.is_some() { - debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", address, port, protocol, username, utils::fmt::shadow_password(password.as_deref().unwrap_or(""))); - if password.is_none() { + if run_opts.address.is_some() { + debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", run_opts.address, run_opts.port, run_opts.protocol, run_opts.username, utils::fmt::shadow_password(run_opts.password.as_deref().unwrap_or(""))); + if run_opts.password.is_none() { // Ask password if unspecified - password = match rpassword::read_password_from_tty(Some("Password: ")) { + run_opts.password = match rpassword::read_password_from_tty(Some("Password: ")) { Ok(p) => { if p.is_empty() { None } else { + debug!( + "Read password from tty: {}", + utils::fmt::shadow_password(p.as_str()) + ); Some(p) } } Err(_) => { - eprintln!("Could not read password from prompt"); - std::process::exit(255); + return Err("Could not read password from prompt".to_string()); } }; - debug!( - "Read password from tty: {}", - utils::fmt::shadow_password(password.as_deref().unwrap_or("")) - ); } - // In this case the first activity will be FileTransfer - start_activity = NextActivity::FileTransfer; } - // Create activity manager (and context too) - let mut manager: ActivityManager = match ActivityManager::new(&wrkdir, ticks) { - Ok(m) => m, - Err(err) => { - eprintln!("Could not start activity manager: {}", err); - std::process::exit(255); - } - }; - // Set file transfer params if set - if let Some(address) = address { - manager.set_filetransfer_params(address, port, protocol, username, password, remote_wrkdir); - } - // Run - info!("Starting activity manager..."); - manager.run(start_activity); - info!("termscp terminated"); - // Then return - std::process::exit(0); + Ok(()) +} + +/// ### run +/// +/// Run task and return rc +fn run(mut run_opts: RunOpts) -> i32 { + match run_opts.task { + Task::ImportTheme(theme) => match support::import_theme(theme.as_path()) { + Ok(_) => { + println!("Theme has been successfully imported!"); + 0 + } + Err(err) => { + eprintln!("{}", err); + 1 + } + }, + Task::Activity(activity) => { + // Get working directory + let wrkdir: PathBuf = match env::current_dir() { + Ok(dir) => dir, + Err(_) => PathBuf::from("/"), + }; + // Create activity manager (and context too) + let mut manager: ActivityManager = + match ActivityManager::new(wrkdir.as_path(), run_opts.ticks) { + Ok(m) => m, + Err(err) => { + eprintln!("Could not start activity manager: {}", err); + return 1; + } + }; + // Set file transfer params if set + if let Some(address) = run_opts.address.take() { + manager.set_filetransfer_params( + address, + run_opts.port, + run_opts.protocol, + run_opts.username, + run_opts.password, + run_opts.remote_wrkdir, + ); + } + manager.run(activity); + 0 + } + } } From 421969c3da14d5664a9cbd8ae3e9d3cd9138d952 Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 10 Jul 2021 20:19:29 +0200 Subject: [PATCH 42/53] argh instead of getopts --- CHANGELOG.md | 2 + Cargo.lock | 53 ++++++++++++++++----- Cargo.toml | 2 +- src/main.rs | 129 ++++++++++++++++++++++++--------------------------- 4 files changed, 104 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d29ed..b43ba55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,9 @@ Released on FIXME: ?? - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Fixed save bookmark dialog: you could switch out from dialog with `` - Dependencies: + - Added `argh 0.1.5` - Added `open 1.7.0` + - Removed `getopts` - Updated `rand` to `0.8.4` - Updated `textwrap` to `0.14.2` - Updated `tui-realm` to `0.4.3` diff --git a/Cargo.lock b/Cargo.lock index ff3a8d5..3dab2b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,35 @@ dependencies = [ "winapi", ] +[[package]] +name = "argh" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7317a549bc17c5278d9e72bb6e62c6aa801ac2567048e39ebc1c194249323e" +dependencies = [ + "argh_derive", + "argh_shared", +] + +[[package]] +name = "argh_derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60949c42375351e9442e354434b0cba2ac402c1237edf673cac3a4bf983b8d3c" +dependencies = [ + "argh_shared", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argh_shared" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a61eb019cb8f415d162cb9f12130ee6bbe9168b7d953c17f4ad049e4051ca00" + [[package]] name = "autocfg" version = "1.0.1" @@ -402,15 +431,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getopts" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.1.16" @@ -433,6 +453,15 @@ dependencies = [ "wasi 0.10.0+wasi-snapshot-preview1", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "hkdf" version = "0.10.0" @@ -519,9 +548,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.97" +version = "0.2.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6" +checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790" [[package]] name = "libssh2-sys" @@ -1327,6 +1356,7 @@ dependencies = [ name = "termscp" version = "0.6.0" dependencies = [ + "argh", "bitflags", "bytesize", "chrono", @@ -1335,7 +1365,6 @@ dependencies = [ "dirs", "edit", "ftp4", - "getopts", "hostname", "keyring", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 619f494..de7ea32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ name = "termscp" path = "src/main.rs" [dependencies] +argh = "0.1.5" bitflags = "1.2.1" bytesize = "1.0.1" chrono = "0.4.19" @@ -35,7 +36,6 @@ crossterm = "0.19.0" dirs = "3.0.1" edit = "0.1.3" ftp4 = { version = "4.0.2", features = [ "secure" ] } -getopts = "0.2.21" hostname = "0.3.1" keyring = { version = "0.10.1", optional = true } lazy_static = "1.4.0" diff --git a/src/main.rs b/src/main.rs index 76b35dd..cb5a095 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ const TERMSCP_VERSION: &str = env!("CARGO_PKG_VERSION"); const TERMSCP_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); // Crates -extern crate getopts; +extern crate argh; #[macro_use] extern crate bitflags; #[macro_use] @@ -38,7 +38,7 @@ extern crate magic_crypt; extern crate rpassword; // External libs -use getopts::{Matches, Options}; +use argh::FromArgs; use std::env; use std::path::PathBuf; use std::time::Duration; @@ -64,6 +64,38 @@ enum Task { ImportTheme(PathBuf), } +#[derive(FromArgs)] +#[argh(description = " +where positional can be: [protocol://user@address:port:wrkdir] [local-wrkdir] + +Please, report issues to +Please, consider supporting the author ")] +struct Args { + #[argh(switch, short = 'c', description = "open termscp configuration")] + config: bool, + #[argh(option, short = 'P', description = "provide password from CLI")] + password: Option, + #[argh(switch, short = 'q', description = "disable logging")] + quiet: bool, + #[argh(option, short = 't', description = "import specified theme")] + theme: Option, + #[argh( + option, + short = 'T', + default = "10", + description = "set UI ticks; default 10ms" + )] + ticks: u64, + #[argh(switch, short = 'v', description = "print version")] + version: bool, + // -- positional + #[argh( + positional, + description = "protocol://user@address:port:wrkdir local-wrkdir" + )] + positional: Vec, +} + struct RunOpts { address: Option, port: u16, @@ -93,34 +125,15 @@ impl Default for RunOpts { } fn main() { - let args: Vec = env::args().collect(); - //Program CLI options - let mut run_opts: RunOpts = RunOpts::default(); - //Process options - let mut opts = Options::new(); - opts.optflag("c", "config", "Open termscp configuration"); - opts.optflag("q", "quiet", "Disable logging"); - opts.optopt("t", "theme", "Import specified theme", ""); - opts.optopt("P", "password", "Provide password from CLI", ""); - opts.optopt("T", "ticks", "Set UI ticks; default 10ms", ""); - opts.optflag("v", "version", ""); - opts.optflag("h", "help", "Print this menu"); - let matches: Matches = match opts.parse(&args[1..]) { - Ok(m) => m, - Err(f) => { - println!("{}", f.to_string()); + let args: Args = argh::from_env(); + // Parse args + let mut run_opts: RunOpts = match parse_args(args) { + Ok(opts) => opts, + Err(err) => { + eprintln!("{}", err); std::process::exit(255); } }; - // Parse args - if let Err(err) = parse_run_opts(&mut run_opts, matches) { - if let Some(err) = err { - eprintln!("{}", err); - } else { - print_usage(opts); - } - std::process::exit(255); - } // Setup logging if run_opts.log_enabled { if let Err(err) = logging::init() { @@ -141,65 +154,43 @@ fn main() { std::process::exit(rc); } -/// ### print_usage +/// ### parse_args /// -/// Print usage -fn print_usage(opts: Options) { - let brief = String::from( - "Usage: termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]", - ); - print!("{}", opts.usage(&brief)); - println!("\nPlease, report issues to "); - println!("Please, consider supporting the author ") -} - -/// ### parse_run_opts -/// -/// Parse run options; in case something is wrong returns the error message -fn parse_run_opts(run_opts: &mut RunOpts, opts: Matches) -> Result<(), Option> { - // Help - if opts.opt_present("h") { - return Err(None); - } +/// Parse arguments +/// In case of success returns `RunOpts` +/// in case something is wrong returns the error message +fn parse_args(args: Args) -> Result { + let mut run_opts: RunOpts = RunOpts::default(); // Version - if opts.opt_present("v") { - return Err(Some(format!( + if args.version { + return Err(format!( "termscp - {} - Developed by {}", TERMSCP_VERSION, TERMSCP_AUTHORS, - ))); + )); } // Setup activity? - if opts.opt_present("c") { + if args.config { run_opts.task = Task::Activity(NextActivity::SetupActivity); } // Logging - if opts.opt_present("q") { + if args.quiet { run_opts.log_enabled = false; } // Match password - if let Some(passwd) = opts.opt_str("P") { + if let Some(passwd) = args.password { run_opts.password = Some(passwd); } // Match ticks - if let Some(val) = opts.opt_str("T") { - match val.parse::() { - Ok(val) => run_opts.ticks = Duration::from_millis(val as u64), - Err(_) => { - return Err(Some(format!("Ticks is not a number: '{}'", val))); - } - } - } + run_opts.ticks = Duration::from_millis(args.ticks); // @! extra modes - if let Some(theme) = opts.opt_str("t") { + if let Some(theme) = args.theme { run_opts.task = Task::ImportTheme(PathBuf::from(theme)); } // @! Ordinary mode - // Check free args - let extra_args: Vec = opts.free; // Remote argument - if let Some(remote) = extra_args.get(0) { + if let Some(remote) = args.positional.get(0) { // Parse address - match utils::parser::parse_remote_opt(remote) { + match utils::parser::parse_remote_opt(remote.as_str()) { Ok(host_opts) => { // Set params run_opts.address = Some(host_opts.hostname); @@ -211,19 +202,19 @@ fn parse_run_opts(run_opts: &mut RunOpts, opts: Matches) -> Result<(), Option { - return Err(Some(format!("Bad address option: {}", err))); + return Err(format!("Bad address option: {}", err)); } } } // Local directory - if let Some(localdir) = extra_args.get(1) { + if let Some(localdir) = args.positional.get(1) { // Change working directory if local dir is set let localdir: PathBuf = PathBuf::from(localdir); if let Err(err) = env::set_current_dir(localdir.as_path()) { - return Err(Some(format!("Bad working directory argument: {}", err))); + return Err(format!("Bad working directory argument: {}", err)); } } - Ok(()) + Ok(run_opts) } /// ### read_password From 80c67c8aa8be04074407fd586f58eb5213ef09aa Mon Sep 17 00:00:00 2001 From: veeso Date: Tue, 13 Jul 2021 16:43:27 +0200 Subject: [PATCH 43/53] Fixed transfer interruption: it was not possible to abort a transfer if the size of the file was less than 65k --- CHANGELOG.md | 1 + src/ui/activities/filetransfer/session.rs | 28 ++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b43ba55..9bfe2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Released on FIXME: ?? - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Fixed save bookmark dialog: you could switch out from dialog with `` + - Fixed transfer interruption: it was not possible to abort a transfer if the size of the file was less than 65k - Dependencies: - Added `argh 0.1.5` - Added `open 1.7.0` diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index 5180ef7..e864f1b 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -450,16 +450,22 @@ impl FileTransferActivity { // Write remote file let mut total_bytes_written: usize = 0; let mut last_progress_val: f64 = 0.0; - let mut last_input_event_fetch: Instant = Instant::now(); + let mut last_input_event_fetch: Option = None; // While the entire file hasn't been completely written, // Or filetransfer has been aborted while total_bytes_written < file_size && !self.transfer.aborted() { - // Handle input events (each 500ms) - if last_input_event_fetch.elapsed().as_millis() >= 500 { + // Handle input events (each 500ms) or if never fetched before + if last_input_event_fetch.is_none() + || last_input_event_fetch + .unwrap_or_else(Instant::now) + .elapsed() + .as_millis() + >= 500 + { // Read events self.read_input_event(); // Reset instant - last_input_event_fetch = Instant::now(); + last_input_event_fetch = Some(Instant::now()); } // Read till you can let mut buffer: [u8; 65536] = [0; 65536]; @@ -790,16 +796,22 @@ impl FileTransferActivity { self.transfer.partial.init(remote.size); // Write local file let mut last_progress_val: f64 = 0.0; - let mut last_input_event_fetch: Instant = Instant::now(); + let mut last_input_event_fetch: Option = None; // While the entire file hasn't been completely read, // Or filetransfer has been aborted while total_bytes_written < remote.size && !self.transfer.aborted() { - // Handle input events (each 500 ms) - if last_input_event_fetch.elapsed().as_millis() >= 500 { + // Handle input events (each 500 ms) or is None + if last_input_event_fetch.is_none() + || last_input_event_fetch + .unwrap_or_else(Instant::now) + .elapsed() + .as_millis() + >= 500 + { // Read events self.read_input_event(); // Reset instant - last_input_event_fetch = Instant::now(); + last_input_event_fetch = Some(Instant::now()); } // Read till you can let mut buffer: [u8; 65536] = [0; 65536]; From e3a9d253f70188ecc428c8395af87b8bb19a9a84 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 15 Jul 2021 11:58:57 +0200 Subject: [PATCH 44/53] Show a 'wait' message when deleting, copying and moving files and when executing commands --- CHANGELOG.md | 2 ++ src/ui/activities/filetransfer/update.rs | 22 +++++++++++++------ src/ui/activities/filetransfer/view.rs | 27 +++++++++++++++++------- src/ui/components/msgbox.rs | 8 +++++++ 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfe2f9..7618171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Released on FIXME: ?? - **Installation script**: - From now on, in case cargo is used to install termscp, all the cargo dependencies will be installed - **Start termscp from configuration**: Start termscp with `-c` or `--config` to start termscp from configuration page +- Enhancements: + - Show a "wait" message when deleting, copying and moving files and when executing commands - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Fixed save bookmark dialog: you could switch out from dialog with `` diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 0ce5ac6..6067a32 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -366,12 +366,14 @@ impl Update for FileTransferActivity { } (COMPONENT_INPUT_COPY, Msg::OnSubmit(Payload::One(Value::Str(input)))) => { // Copy file + self.umount_copy(); + self.mount_blocking_wait("Copying file(s)…"); match self.browser.tab() { FileExplorerTab::Local => self.action_local_copy(input.to_string()), FileExplorerTab::Remote => self.action_remote_copy(input.to_string()), _ => panic!("Found tab doesn't support COPY"), } - self.umount_copy(); + self.umount_wait(); // Reload files match self.browser.tab() { FileExplorerTab::Local => self.update_local_filelist(), @@ -387,12 +389,14 @@ impl Update for FileTransferActivity { } (COMPONENT_INPUT_EXEC, Msg::OnSubmit(Payload::One(Value::Str(input)))) => { // Exex command + self.umount_exec(); + self.mount_blocking_wait(format!("Executing '{}'…", input).as_str()); match self.browser.tab() { FileExplorerTab::Local => self.action_local_exec(input.to_string()), FileExplorerTab::Remote => self.action_remote_exec(input.to_string()), _ => panic!("Found tab doesn't support EXEC"), } - self.umount_exec(); + self.umount_wait(); // Reload files match self.browser.tab() { FileExplorerTab::Local => self.update_local_filelist(), @@ -532,12 +536,14 @@ impl Update for FileTransferActivity { None } (COMPONENT_INPUT_RENAME, Msg::OnSubmit(Payload::One(Value::Str(input)))) => { + self.umount_rename(); + self.mount_blocking_wait("Moving file(s)…"); match self.browser.tab() { FileExplorerTab::Local => self.action_local_rename(input.to_string()), FileExplorerTab::Remote => self.action_remote_rename(input.to_string()), _ => panic!("Found tab doesn't support RENAME"), } - self.umount_rename(); + self.umount_wait(); // Reload files match self.browser.tab() { FileExplorerTab::Local => self.update_local_filelist(), @@ -586,6 +592,8 @@ impl Update for FileTransferActivity { } (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Choice is 'YES' + self.umount_radio_delete(); + self.mount_blocking_wait("Removing file(s)…"); match self.browser.tab() { FileExplorerTab::Local => self.action_local_delete(), FileExplorerTab::Remote => self.action_remote_delete(), @@ -612,7 +620,7 @@ impl Update for FileTransferActivity { self.update_find_list(); } } - self.umount_radio_delete(); + self.umount_wait(); // Reload files match self.browser.tab() { FileExplorerTab::Local => self.update_local_filelist(), @@ -906,7 +914,7 @@ impl FileTransferActivity { /// ### elide_wrkdir_path /// /// Elide working directory path if longer than width + host.len - /// In this case, the path is formatted to {ANCESTOR[0]}/.../{PARENT[0]}/{BASENAME} + /// In this case, the path is formatted to {ANCESTOR[0]}/…/{PARENT[0]}/{BASENAME} fn elide_wrkdir_path(wrkdir: &Path, host: &str, width: usize) -> PathBuf { let fmt_path: String = format!("{}", wrkdir.display()); // NOTE: +5 is const @@ -921,9 +929,9 @@ impl FileTransferActivity { if ancestors_len > 2 { elided_path.push(ancestors.nth(ancestors_len - 2).unwrap()); } - // If ancestors_len is bigger than 3, push '...' and parent too + // If ancestors_len is bigger than 3, push '…' and parent too if ancestors_len > 3 { - elided_path.push("..."); + elided_path.push("…"); if let Some(parent) = wrkdir.ancestors().nth(1) { elided_path.push(parent.file_name().unwrap()); } diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 0f44c47..41b7b49 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -422,17 +422,28 @@ impl FileTransferActivity { } pub(super) fn mount_wait(&mut self, text: &str) { + self.mount_wait_ex(text, false, Color::Reset); + } + + pub(super) fn mount_blocking_wait(&mut self, text: &str) { + self.mount_wait_ex(text, true, Color::Reset); + self.view(); + } + + fn mount_wait_ex(&mut self, text: &str, blink: bool, color: Color) { // Mount + let mut builder: MsgBoxPropsBuilder = MsgBoxPropsBuilder::default(); + builder + .with_foreground(color) + .with_borders(Borders::ALL, BorderType::Rounded, Color::White) + .bold() + .with_texts(None, vec![TextSpan::from(text)]); + if blink { + builder.blink(); + } self.view.mount( super::COMPONENT_TEXT_WAIT, - Box::new(MsgBox::new( - MsgBoxPropsBuilder::default() - .with_foreground(Color::White) - .with_borders(Borders::ALL, BorderType::Rounded, Color::White) - .bold() - .with_texts(None, vec![TextSpan::from(text)]) - .build(), - )), + Box::new(MsgBox::new(builder.build())), ); // Give focus to info self.view.active(super::COMPONENT_TEXT_WAIT); diff --git a/src/ui/components/msgbox.rs b/src/ui/components/msgbox.rs index aae1120..226864a 100644 --- a/src/ui/components/msgbox.rs +++ b/src/ui/components/msgbox.rs @@ -94,6 +94,13 @@ impl MsgBoxPropsBuilder { self } + pub fn blink(&mut self) -> &mut Self { + if let Some(props) = self.props.as_mut() { + props.modifiers |= Modifier::SLOW_BLINK; + } + self + } + pub fn with_borders( &mut self, borders: Borders, @@ -221,6 +228,7 @@ mod tests { .visible() .with_foreground(Color::Red) .bold() + .blink() .with_borders(Borders::ALL, BorderType::Double, Color::Red) .with_texts( None, From 4093ba169cf87f2a181f743e16a7f0a9cb764bd6 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 15 Jul 2021 12:24:20 +0200 Subject: [PATCH 45/53] =?UTF-8?q?Replaced=20'...'=20with=20'=E2=80=A6'=20i?= =?UTF-8?q?n=20texts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + src/fs/explorer/formatter.rs | 12 ++--- src/ui/activities/auth/view.rs | 2 +- .../activities/filetransfer/actions/edit.rs | 4 +- src/ui/activities/filetransfer/mod.rs | 2 +- src/ui/activities/filetransfer/session.rs | 16 +++--- src/ui/activities/filetransfer/update.rs | 50 ++----------------- src/ui/activities/filetransfer/view.rs | 8 +-- src/utils/fmt.rs | 19 +++++-- 9 files changed, 42 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7618171..1e69a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Released on FIXME: ?? - **Start termscp from configuration**: Start termscp with `-c` or `--config` to start termscp from configuration page - Enhancements: - Show a "wait" message when deleting, copying and moving files and when executing commands + - Replaced all `...` with `…` in texts - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Fixed save bookmark dialog: you could switch out from dialog with `` diff --git a/src/fs/explorer/formatter.rs b/src/fs/explorer/formatter.rs index d3448fb..54ba14d 100644 --- a/src/fs/explorer/formatter.rs +++ b/src/fs/explorer/formatter.rs @@ -316,13 +316,13 @@ impl Formatter { }; let name: &str = fsentry.get_name(); let last_idx: usize = match fsentry.is_dir() { - // NOTE: For directories is 19, since we push '/' to name - true => file_len - 5, - false => file_len - 4, + // NOTE: For directories is l - 2, since we push '/' to name + true => file_len - 2, + false => file_len - 1, }; let mut name: String = match name.len() >= file_len { false => name.to_string(), - true => format!("{}...", &name[0..last_idx]), + true => format!("{}…", &name[0..last_idx]), }; if fsentry.is_dir() { name.push('/'); @@ -635,7 +635,7 @@ mod tests { assert_eq!( formatter.fmt(&entry), format!( - "piroparoporoperoperu... -rw-r--r-- root 8.2 KB {}", + "piroparoporoperoperupup… -rw-r--r-- root 8.2 KB {}", fmt_time(t, "%b %d %Y %H:%M") ) ); @@ -643,7 +643,7 @@ mod tests { assert_eq!( formatter.fmt(&entry), format!( - "piroparoporoperoperu... -rw-r--r-- 0 8.2 KB {}", + "piroparoporoperoperupup… -rw-r--r-- 0 8.2 KB {}", fmt_time(t, "%b %d %Y %H:%M") ) ); diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index 88839e6..4d366da 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -640,7 +640,7 @@ impl AuthActivity { Box::new(Input::new( InputPropsBuilder::default() .with_foreground(save_color) - .with_label(String::from("Save bookmark as...")) + .with_label(String::from("Save bookmark as…")) .with_borders( Borders::TOP | Borders::RIGHT | Borders::LEFT, BorderType::Rounded, diff --git a/src/ui/activities/filetransfer/actions/edit.rs b/src/ui/activities/filetransfer/actions/edit.rs index e6d3837..ba064a6 100644 --- a/src/ui/activities/filetransfer/actions/edit.rs +++ b/src/ui/activities/filetransfer/actions/edit.rs @@ -48,7 +48,7 @@ impl FileTransferActivity { if entry.is_file() { self.log( LogLevel::Info, - format!("Opening file \"{}\"...", entry.get_abs_path().display()), + format!("Opening file \"{}\"…", entry.get_abs_path().display()), ); // Edit file if let Err(err) = self.edit_local_file(entry.get_abs_path().as_path()) { @@ -72,7 +72,7 @@ impl FileTransferActivity { if let FsEntry::File(file) = entry { self.log( LogLevel::Info, - format!("Opening file \"{}\"...", file.abs_path.display()), + format!("Opening file \"{}\"…", file.abs_path.display()), ); // Edit file if let Err(err) = self.edit_remote_file(file) { diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 2cb4e18..112d331 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -295,7 +295,7 @@ impl Activity for FileTransferActivity { "Client is not connected to remote; connecting to {}:{}", params.address, params.port ); - let msg: String = format!("Connecting to {}:{}...", params.address, params.port); + let msg: String = format!("Connecting to {}:{}…", params.address, params.port); // Set init state to connecting popup self.mount_wait(msg.as_str()); // Force ui draw diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index e864f1b..9ea0b12 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -122,7 +122,7 @@ impl FileTransferActivity { /// disconnect from remote pub(super) fn disconnect(&mut self) { let params = self.context().ft_params().unwrap(); - let msg: String = format!("Disconnecting from {}...", params.address); + let msg: String = format!("Disconnecting from {}…", params.address); // Show popup disconnecting self.mount_wait(msg.as_str()); // Disconnect @@ -236,7 +236,7 @@ impl FileTransferActivity { let total_transfer_size: usize = file.size; self.transfer.full.init(total_transfer_size); // Mount progress bar - self.mount_progress_bar(format!("Uploading {}...", file.abs_path.display())); + self.mount_progress_bar(format!("Uploading {}…", file.abs_path.display())); // Get remote path let file_name: String = file.name.clone(); let mut remote_path: PathBuf = PathBuf::from(curr_remote_path); @@ -268,7 +268,7 @@ impl FileTransferActivity { let total_transfer_size: usize = self.get_total_transfer_size_local(entry); self.transfer.full.init(total_transfer_size); // Mount progress bar - self.mount_progress_bar(format!("Uploading {}...", entry.get_abs_path().display())); + self.mount_progress_bar(format!("Uploading {}…", entry.get_abs_path().display())); // Send recurse self.filetransfer_send_recurse(entry, curr_remote_path, dst_name); // Umount progress bar @@ -293,7 +293,7 @@ impl FileTransferActivity { .sum(); self.transfer.full.init(total_transfer_size); // Mount progress bar - self.mount_progress_bar(format!("Uploading {} entries...", entries.len())); + self.mount_progress_bar(format!("Uploading {} entries…", entries.len())); // Send recurse entries .iter() @@ -502,7 +502,7 @@ impl FileTransferActivity { // Draw only if a significant progress has been made (performance improvement) if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { // Draw - self.update_progress_bar(format!("Uploading \"{}\"...", file_name)); + self.update_progress_bar(format!("Uploading \"{}\"…", file_name)); self.view(); last_progress_val = self.transfer.partial.calc_progress(); } @@ -571,7 +571,7 @@ impl FileTransferActivity { let total_transfer_size: usize = self.get_total_transfer_size_remote(entry); self.transfer.full.init(total_transfer_size); // Mount progress bar - self.mount_progress_bar(format!("Downloading {}...", entry.get_abs_path().display())); + self.mount_progress_bar(format!("Downloading {}…", entry.get_abs_path().display())); // Receive self.filetransfer_recv_recurse(entry, local_path, dst_name); // Umount progress bar @@ -589,7 +589,7 @@ impl FileTransferActivity { let total_transfer_size: usize = entry.size; self.transfer.full.init(total_transfer_size); // Mount progress bar - self.mount_progress_bar(format!("Downloading {}...", entry.abs_path.display())); + self.mount_progress_bar(format!("Downloading {}…", entry.abs_path.display())); // Receive let result = self.filetransfer_recv_one(local_path, entry, entry.name.clone()); // Umount progress bar @@ -615,7 +615,7 @@ impl FileTransferActivity { .sum(); self.transfer.full.init(total_transfer_size); // Mount progress bar - self.mount_progress_bar(format!("Downloading {} entries...", entries.len())); + self.mount_progress_bar(format!("Downloading {} entries…", entries.len())); // Send recurse entries .iter() diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 6067a32..723a0ff 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -40,8 +40,8 @@ use crate::fs::explorer::FileSorting; use crate::fs::FsEntry; use crate::ui::components::{file_list::FileListPropsBuilder, logbox::LogboxPropsBuilder}; use crate::ui::keymap::*; +use crate::utils::fmt::fmt_path_elide_ex; // externals -use std::path::{Path, PathBuf}; use tuirealm::{ components::progress_bar::ProgressBarPropsBuilder, props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder}, @@ -743,12 +743,7 @@ impl FileTransferActivity { let hostname: String = format!( "{}:{} ", hostname, - FileTransferActivity::elide_wrkdir_path( - self.local().wrkdir.as_path(), - hostname.as_str(), - width - ) - .display() + fmt_path_elide_ex(self.local().wrkdir.as_path(), width, hostname.len() + 3) // 3 because of '/…/' ); let files: Vec = self .local() @@ -782,12 +777,11 @@ impl FileTransferActivity { let hostname: String = format!( "{}:{} ", params.address, - FileTransferActivity::elide_wrkdir_path( + fmt_path_elide_ex( self.remote().wrkdir.as_path(), - params.address.as_str(), - width + width, + params.address.len() + 3 // 3 because of '/…/' ) - .display() ); let files: Vec = self .remote() @@ -910,38 +904,4 @@ impl FileTransferActivity { } } } - - /// ### elide_wrkdir_path - /// - /// Elide working directory path if longer than width + host.len - /// In this case, the path is formatted to {ANCESTOR[0]}/…/{PARENT[0]}/{BASENAME} - fn elide_wrkdir_path(wrkdir: &Path, host: &str, width: usize) -> PathBuf { - let fmt_path: String = format!("{}", wrkdir.display()); - // NOTE: +5 is const - match fmt_path.len() + host.len() + 5 > width { - false => PathBuf::from(wrkdir), - true => { - // Elide - let ancestors_len: usize = wrkdir.ancestors().count(); - let mut ancestors = wrkdir.ancestors(); - let mut elided_path: PathBuf = PathBuf::new(); - // If ancestors_len's size is bigger than 2, push count - 2 - if ancestors_len > 2 { - elided_path.push(ancestors.nth(ancestors_len - 2).unwrap()); - } - // If ancestors_len is bigger than 3, push '…' and parent too - if ancestors_len > 3 { - elided_path.push("…"); - if let Some(parent) = wrkdir.ancestors().nth(1) { - elided_path.push(parent.file_name().unwrap()); - } - } - // Push file_name - if let Some(name) = wrkdir.file_name() { - elided_path.push(name); - } - elided_path - } - } - } } diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 41b7b49..e9046a5 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -521,7 +521,7 @@ impl FileTransferActivity { InputPropsBuilder::default() .with_borders(Borders::ALL, BorderType::Rounded, input_color) .with_foreground(input_color) - .with_label(String::from("Copy file(s) to...")) + .with_label(String::from("Copy file(s) to…")) .build(), )), ); @@ -672,7 +672,7 @@ impl FileTransferActivity { InputPropsBuilder::default() .with_borders(Borders::ALL, BorderType::Rounded, input_color) .with_foreground(input_color) - .with_label(String::from("Open file with...")) + .with_label(String::from("Open file with…")) .build(), )), ); @@ -691,7 +691,7 @@ impl FileTransferActivity { InputPropsBuilder::default() .with_borders(Borders::ALL, BorderType::Rounded, input_color) .with_foreground(input_color) - .with_label(String::from("Move file(s) to...")) + .with_label(String::from("Move file(s) to…")) .build(), )), ); @@ -710,7 +710,7 @@ impl FileTransferActivity { InputPropsBuilder::default() .with_borders(Borders::ALL, BorderType::Rounded, input_color) .with_foreground(input_color) - .with_label(String::from("Save as...")) + .with_label(String::from("Save as…")) .build(), )), ); diff --git a/src/utils/fmt.rs b/src/utils/fmt.rs index f1a6f98..703cd91 100644 --- a/src/utils/fmt.rs +++ b/src/utils/fmt.rs @@ -120,10 +120,19 @@ pub fn align_text_center(text: &str, width: u16) -> String { /// ### elide_path /// /// Elide a path if longer than width -/// In this case, the path is formatted to {ANCESTOR[0]}/.../{PARENT[0]}/{BASENAME} +/// In this case, the path is formatted to {ANCESTOR[0]}/…/{PARENT[0]}/{BASENAME} pub fn fmt_path_elide(p: &Path, width: usize) -> String { + fmt_path_elide_ex(p, width, 0) +} + +/// ### fmt_path_elide_ex +/// +/// Elide a path if longer than width +/// In this case, the path is formatted to {ANCESTOR[0]}/…/{PARENT[0]}/{BASENAME} +/// This function allows to specify an extra length to consider to elide path +pub fn fmt_path_elide_ex(p: &Path, width: usize, extra_len: usize) -> String { let fmt_path: String = format!("{}", p.display()); - match fmt_path.len() > width as usize { + match fmt_path.len() + extra_len > width as usize { false => fmt_path, true => { // Elide @@ -134,9 +143,9 @@ pub fn fmt_path_elide(p: &Path, width: usize) -> String { if ancestors_len > 2 { elided_path.push(ancestors.nth(ancestors_len - 2).unwrap()); } - // If ancestors_len is bigger than 3, push '...' and parent too + // If ancestors_len is bigger than 3, push '…' and parent too if ancestors_len > 3 { - elided_path.push("..."); + elided_path.push("…"); if let Some(parent) = p.ancestors().nth(1) { elided_path.push(parent.file_name().unwrap()); } @@ -390,7 +399,7 @@ mod tests { // Above max size, only one ancestor assert_eq!(fmt_path_elide(p, 8), String::from("/develop/pippo")); let p: &Path = &Path::new("/develop/pippo/foo/bar"); - assert_eq!(fmt_path_elide(p, 16), String::from("/develop/.../foo/bar")); + assert_eq!(fmt_path_elide(p, 16), String::from("/develop/…/foo/bar")); } #[test] From 8277c80860d4e66bff1d185a2ce0914fa67f3447 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 15 Jul 2021 12:33:15 +0200 Subject: [PATCH 46/53] Fixed config save and theme layout --- src/ui/activities/setup/actions.rs | 10 ++++++++-- src/ui/activities/setup/update.rs | 12 ++++++------ src/ui/activities/setup/view/theme.rs | 6 +++--- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index 7c1e56d..a7bb1c4 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -39,10 +39,16 @@ impl SetupActivity { /// ### action_save_config /// /// Save configuration - pub(super) fn action_save_all(&mut self) -> Result<(), String> { + pub(super) fn action_save_config(&mut self) -> Result<(), String> { // Collect input values self.collect_input_values(); - self.save_config()?; + self.save_config() + } + + /// ### action_save_theme + /// + /// Save configuration + pub(super) fn action_save_theme(&mut self) -> Result<(), String> { // save theme self.collect_styles() .map_err(|e| format!("'{}' has an invalid color", e))?; diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index 4f418c7..fc84f2e 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -141,7 +141,7 @@ impl SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_all() { + if let Err(err) = self.action_save_config() { self.mount_error(err.as_str()); } // Exit @@ -170,7 +170,7 @@ impl SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_all() { + if let Err(err) = self.action_save_config() { self.mount_error(err.as_str()); } self.umount_save_popup(); @@ -234,7 +234,7 @@ impl SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_all() { + if let Err(err) = self.action_save_config() { self.mount_error(err.as_str()); } // Exit @@ -279,7 +279,7 @@ impl SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_all() { + if let Err(err) = self.action_save_config() { self.mount_error(err.as_str()); } self.umount_save_popup(); @@ -623,7 +623,7 @@ impl SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_all() { + if let Err(err) = self.action_save_theme() { self.mount_error(err.as_str()); } // Exit @@ -652,7 +652,7 @@ impl SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_all() { + if let Err(err) = self.action_save_theme() { self.mount_error(err.as_str()); } self.umount_save_popup(); diff --git a/src/ui/activities/setup/view/theme.rs b/src/ui/activities/setup/view/theme.rs index 58c2aa7..5bb092b 100644 --- a/src/ui/activities/setup/view/theme.rs +++ b/src/ui/activities/setup/view/theme.rs @@ -351,17 +351,17 @@ impl SetupActivity { ) .split(colors_layout[3]); self.view.render( - super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, + super::COMPONENT_COLOR_TRANSFER_TITLE_2, f, transfer_colors_layout_col2[0], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, f, transfer_colors_layout_col2[1], ); self.view.render( - super::COMPONENT_COLOR_TRANSFER_TITLE_2, + super::COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, f, transfer_colors_layout_col2[2], ); From 61f69017671c1806b1a15d1481989c12fb1dbe8e Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 15 Jul 2021 12:47:47 +0200 Subject: [PATCH 47/53] Save both theme and config at the same time --- src/ui/activities/setup/actions.rs | 44 +++++++++++++++++++++++++----- src/ui/activities/setup/update.rs | 24 ++++++++++------ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index a7bb1c4..7b283de 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -27,7 +27,7 @@ * SOFTWARE. */ // Locals -use super::SetupActivity; +use super::{SetupActivity, ViewLayout}; // Ext use crate::config::themes::Theme; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; @@ -36,25 +36,55 @@ use tuirealm::tui::style::Color; use tuirealm::{Payload, Value}; impl SetupActivity { + /// ### action_save_all + /// + /// Save all configurations. If current tab can load values, they will be loaded, otherwise they'll just be saved + pub(super) fn action_save_all(&mut self) -> Result<(), String> { + self.action_save_config()?; + self.action_save_theme() + } + /// ### action_save_config /// /// Save configuration - pub(super) fn action_save_config(&mut self) -> Result<(), String> { - // Collect input values - self.collect_input_values(); + fn action_save_config(&mut self) -> Result<(), String> { + // Collect input values if in setup form + if self.layout == ViewLayout::SetupForm { + self.collect_input_values(); + } self.save_config() } /// ### action_save_theme /// /// Save configuration - pub(super) fn action_save_theme(&mut self) -> Result<(), String> { + fn action_save_theme(&mut self) -> Result<(), String> { + // Collect input values if in theme form + if self.layout == ViewLayout::Theme { + self.collect_styles() + .map_err(|e| format!("'{}' has an invalid color", e))?; + } // save theme - self.collect_styles() - .map_err(|e| format!("'{}' has an invalid color", e))?; self.save_theme() } + /// ### action_change_tab + /// + /// Change view tab and load input values in order not to lose them + pub(super) fn action_change_tab(&mut self, new_tab: ViewLayout) -> Result<(), String> { + // load values for current tab first + match self.layout { + ViewLayout::SetupForm => self.collect_input_values(), + ViewLayout::Theme => self + .collect_styles() + .map_err(|e| format!("'{}' has an invalid color", e))?, + _ => {} + } + // Update view + self.init(new_tab); + Ok(()) + } + /// ### action_reset_config /// /// Reset configuration input fields diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index fc84f2e..53c92a7 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -141,7 +141,7 @@ impl SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_config() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } // Exit @@ -170,7 +170,7 @@ impl SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_config() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } self.umount_save_popup(); @@ -190,7 +190,9 @@ impl SetupActivity { } (_, &MSG_KEY_TAB) => { // Change view - self.init(ViewLayout::SshKeys); + if let Err(err) = self.action_change_tab(ViewLayout::SshKeys) { + self.mount_error(err.as_str()); + } None } // Revert changes @@ -234,7 +236,7 @@ impl SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_config() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } // Exit @@ -279,7 +281,7 @@ impl SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_config() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } self.umount_save_popup(); @@ -357,7 +359,9 @@ impl SetupActivity { } (_, &MSG_KEY_TAB) => { // Change view - self.init(ViewLayout::Theme); + if let Err(err) = self.action_change_tab(ViewLayout::Theme) { + self.mount_error(err.as_str()); + } None } // Revert changes @@ -623,7 +627,7 @@ impl SetupActivity { // Exit (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save changes - if let Err(err) = self.action_save_theme() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } // Exit @@ -652,7 +656,7 @@ impl SetupActivity { // Save popup (COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => { // Save config - if let Err(err) = self.action_save_theme() { + if let Err(err) = self.action_save_all() { self.mount_error(err.as_str()); } self.umount_save_popup(); @@ -673,7 +677,9 @@ impl SetupActivity { } (_, &MSG_KEY_TAB) => { // Change view - self.init(ViewLayout::SetupForm); + if let Err(err) = self.action_change_tab(ViewLayout::SetupForm) { + self.mount_error(err.as_str()); + } None } // Revert changes From 59c6567ff33f36863a123e2994d03747cd57a925 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 15 Jul 2021 13:00:55 +0200 Subject: [PATCH 48/53] Auth view enhanchements: check if port and host are valid --- CHANGELOG.md | 3 +++ src/ui/activities/auth/misc.rs | 35 ++++++++++++++++++++++++++- src/ui/activities/auth/update.rs | 41 +++++++++++++------------------- src/ui/activities/auth/view.rs | 9 ++++--- 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e69a9d..8faa6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,10 +48,13 @@ Released on FIXME: ?? - Enhancements: - Show a "wait" message when deleting, copying and moving files and when executing commands - Replaced all `...` with `…` in texts + - Check if remote host is valid in authentication form + - Check if port number is valid in authentication form - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Fixed save bookmark dialog: you could switch out from dialog with `` - Fixed transfer interruption: it was not possible to abort a transfer if the size of the file was less than 65k + - Changed `Remote address` to `Remote host` in authentication form - Dependencies: - Added `argh 0.1.5` - Added `open 1.7.0` diff --git a/src/ui/activities/auth/misc.rs b/src/ui/activities/auth/misc.rs index d30b877..85e9545 100644 --- a/src/ui/activities/auth/misc.rs +++ b/src/ui/activities/auth/misc.rs @@ -25,7 +25,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -use super::{AuthActivity, FileTransferProtocol}; +use super::{AuthActivity, FileTransferParams, FileTransferProtocol}; impl AuthActivity { /// ### protocol_opt_to_enum @@ -80,4 +80,37 @@ impl AuthActivity { self.umount_size_err(); } } + + /// ### collect_host_params + /// + /// Get input values from fields or return an error if fields are invalid + pub(super) fn collect_host_params(&self) -> Result { + let (address, port, protocol, username, password): ( + String, + u16, + FileTransferProtocol, + String, + String, + ) = self.get_input(); + if address.is_empty() { + return Err("Invalid host"); + } + if port == 0 { + return Err("Invalid port"); + } + Ok(FileTransferParams { + address, + port, + protocol, + username: match username.is_empty() { + true => None, + false => Some(username), + }, + password: match password.is_empty() { + true => None, + false => Some(password), + }, + entry_directory: None, + }) + } } diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index 9b0bbd8..9c6822a 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -27,9 +27,9 @@ */ // locals use super::{ - AuthActivity, FileTransferParams, FileTransferProtocol, COMPONENT_BOOKMARKS_LIST, - COMPONENT_INPUT_ADDR, COMPONENT_INPUT_BOOKMARK_NAME, COMPONENT_INPUT_PASSWORD, - COMPONENT_INPUT_PORT, COMPONENT_INPUT_USERNAME, COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, + AuthActivity, FileTransferProtocol, COMPONENT_BOOKMARKS_LIST, COMPONENT_INPUT_ADDR, + COMPONENT_INPUT_BOOKMARK_NAME, COMPONENT_INPUT_PASSWORD, COMPONENT_INPUT_PORT, + COMPONENT_INPUT_USERNAME, COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, COMPONENT_RADIO_BOOKMARK_DEL_RECENT, COMPONENT_RADIO_BOOKMARK_SAVE_PWD, COMPONENT_RADIO_PROTOCOL, COMPONENT_RADIO_QUIT, COMPONENT_RECENTS_LIST, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP, COMPONENT_TEXT_NEW_VERSION_NOTES, COMPONENT_TEXT_SIZE_ERR, @@ -327,27 +327,20 @@ impl Update for AuthActivity { } // On submit on any unhandled (connect) (_, Msg::OnSubmit(_)) | (_, &MSG_KEY_ENTER) => { - // Match key for all other components - self.save_recent(); - let (address, port, protocol, username, password) = self.get_input(); - // Set file transfer params to context - let params: FileTransferParams = FileTransferParams { - address, - port, - protocol, - username: match username.is_empty() { - true => None, - false => Some(username), - }, - password: match password.is_empty() { - true => None, - false => Some(password), - }, - entry_directory: None, - }; - self.context_mut().set_ftparams(params); - // Set exit reason - self.exit_reason = Some(super::ExitReason::Connect); + // Validate fields + match self.collect_host_params() { + Err(err) => { + // mount error + self.mount_error(err); + } + Ok(params) => { + self.save_recent(); + // Set file transfer params to context + self.context_mut().set_ftparams(params); + // Set exit reason + self.exit_reason = Some(super::ExitReason::Connect); + } + } // Return None None } diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index 4d366da..8ec4763 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -138,7 +138,7 @@ impl AuthActivity { InputPropsBuilder::default() .with_foreground(addr_color) .with_borders(Borders::ALL, BorderType::Rounded, addr_color) - .with_label(String::from("Remote address")) + .with_label(String::from("Remote host")) .build(), )), ); @@ -823,8 +823,11 @@ impl AuthActivity { pub(super) fn get_input_port(&self) -> u16 { match self.view.get_state(super::COMPONENT_INPUT_PORT) { - Some(Payload::One(Value::Usize(x))) => x as u16, - _ => Self::get_default_port_for_protocol(FileTransferProtocol::Sftp), + Some(Payload::One(Value::Usize(x))) => match x > 65535 { + true => 0, + false => x as u16, + }, + _ => 0, } } From e1109fff156350894164e4310dd320a9bdd96371 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 16 Jul 2021 15:32:39 +0200 Subject: [PATCH 49/53] From now on, if you try to leave setup without making any change, you won't be prompted whether to save configuration or not --- CHANGELOG.md | 1 + src/ui/activities/setup/actions.rs | 20 ++++++++++++++++++-- src/ui/activities/setup/mod.rs | 24 ++++++++++++++++++++++++ src/ui/activities/setup/update.rs | 17 +++++++++++------ src/ui/activities/setup/view/mod.rs | 4 +++- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8faa6ed..d1fb530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Released on FIXME: ?? - Replaced all `...` with `…` in texts - Check if remote host is valid in authentication form - Check if port number is valid in authentication form + - From now on, if you try to leave setup without making any change, you won't be prompted whether to save configuration or not - Bugfix: - Fixed broken input cursor when typing UTF8 characters (tui-realm 0.3.2) - Fixed save bookmark dialog: you could switch out from dialog with `` diff --git a/src/ui/activities/setup/actions.rs b/src/ui/activities/setup/actions.rs index 7b283de..7bf2b3f 100644 --- a/src/ui/activities/setup/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -36,12 +36,28 @@ use tuirealm::tui::style::Color; use tuirealm::{Payload, Value}; impl SetupActivity { + /// ### action_on_esc + /// + /// On , if there are changes in the configuration, the quit dialog must be shown, otherwise + /// we can exit without any problem + pub(super) fn action_on_esc(&mut self) { + if self.config_changed() { + self.mount_quit(); + } else { + self.exit_reason = Some(super::ExitReason::Quit); + } + } + /// ### action_save_all /// - /// Save all configurations. If current tab can load values, they will be loaded, otherwise they'll just be saved + /// Save all configurations. If current tab can load values, they will be loaded, otherwise they'll just be saved. + /// Once all the configuration has been changed, set config_changed to false pub(super) fn action_save_all(&mut self) -> Result<(), String> { self.action_save_config()?; - self.action_save_theme() + self.action_save_theme()?; + // Set config changed to false + self.set_config_changed(false); + Ok(()) } /// ### action_save_config diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index f93cf75..1a07b39 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -100,6 +100,9 @@ const COMPONENT_COLOR_TRANSFER_STATUS_SORTING: &str = "COMPONENT_COLOR_TRANSFER_ const COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN: &str = "COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN"; const COMPONENT_COLOR_TRANSFER_STATUS_SYNC: &str = "COMPONENT_COLOR_TRANSFER_STATUS_SYNC"; +// -- store +const STORE_CONFIG_CHANGED: &str = "SETUP_CONFIG_CHANGED"; + /// ### ViewLayout /// /// Current view layout @@ -167,6 +170,25 @@ impl SetupActivity { fn theme_provider(&mut self) -> &mut ThemeProvider { self.context_mut().theme_provider_mut() } + + /// ### config_changed + /// + /// Returns whether config has changed + fn config_changed(&self) -> bool { + self.context() + .store() + .get_boolean(STORE_CONFIG_CHANGED) + .unwrap_or(false) + } + + /// ### set_config_changed + /// + /// Set value for config changed key into the store + fn set_config_changed(&mut self, changed: bool) { + self.context_mut() + .store_mut() + .set_boolean(STORE_CONFIG_CHANGED, changed); + } } impl Activity for SetupActivity { @@ -180,6 +202,8 @@ impl Activity for SetupActivity { self.context = Some(context); // Clear terminal self.context.as_mut().unwrap().clear_screen(); + // Set config changed to false + self.set_config_changed(false); // Put raw mode on enabled if let Err(err) = enable_raw_mode() { error!("Failed to enter raw mode: {}", err); diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index 53c92a7..0f70114 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -182,6 +182,12 @@ impl SetupActivity { None } (COMPONENT_RADIO_SAVE, _) => None, + // Detect config changed + (_, Msg::OnChange(_)) => { + // An input field has changed value; report config changed + self.set_config_changed(true); + None + } // Show help (_, &MSG_KEY_CTRL_H) => { // Show help @@ -211,8 +217,7 @@ impl SetupActivity { } // (_, &MSG_KEY_ESC) => { - // Mount quit prompt - self.mount_quit(); + self.action_on_esc(); None } (_, _) => None, // Nothing to do @@ -380,8 +385,7 @@ impl SetupActivity { } // (_, &MSG_KEY_ESC) => { - // Mount quit prompt - self.mount_quit(); + self.action_on_esc(); None } (_, _) => None, // Nothing to do @@ -614,6 +618,8 @@ impl SetupActivity { (component, Msg::OnChange(Payload::One(Value::Str(color)))) => { if let Some(color) = parse_color(color) { self.action_save_color(component, color); + // Set unsaved changes to true + self.set_config_changed(true); } None } @@ -698,8 +704,7 @@ impl SetupActivity { } // (_, &MSG_KEY_ESC) => { - // Mount quit prompt - self.mount_quit(); + self.action_on_esc(); None } (_, _) => None, // Nothing to do diff --git a/src/ui/activities/setup/view/mod.rs b/src/ui/activities/setup/view/mod.rs index 1288913..a4b6784 100644 --- a/src/ui/activities/setup/view/mod.rs +++ b/src/ui/activities/setup/view/mod.rs @@ -111,7 +111,9 @@ impl SetupActivity { .with_inverted_color(Color::Black) .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) .with_options( - Some(String::from("Exit setup?")), + Some(String::from( + "There are unsaved changes! Save changes before leaving?", + )), vec![ String::from("Save"), String::from("Don't save"), From c722982c39f5280d8fc81b79765e70d6006e2295 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 16 Jul 2021 15:49:00 +0200 Subject: [PATCH 50/53] Removed unecessary `Option<&ConfigClient>` in filetransfer; use `degraded` mode instead --- src/system/sshkey_storage.rs | 1 + src/ui/activities/filetransfer/lib/browser.rs | 31 ++++++------------- src/ui/activities/filetransfer/misc.rs | 19 +++++------- src/ui/activities/filetransfer/mod.rs | 12 +++---- 4 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/system/sshkey_storage.rs b/src/system/sshkey_storage.rs index fd5418a..c6a2dd3 100644 --- a/src/system/sshkey_storage.rs +++ b/src/system/sshkey_storage.rs @@ -67,6 +67,7 @@ impl SshKeyStorage { /// ### empty /// /// Create an empty ssh key storage; used in case `ConfigClient` is not available + #[cfg(test)] pub fn empty() -> Self { SshKeyStorage { hosts: HashMap::new(), diff --git a/src/ui/activities/filetransfer/lib/browser.rs b/src/ui/activities/filetransfer/lib/browser.rs index 2726082..df49198 100644 --- a/src/ui/activities/filetransfer/lib/browser.rs +++ b/src/ui/activities/filetransfer/lib/browser.rs @@ -55,7 +55,7 @@ impl Browser { /// ### new /// /// Build a new `Browser` struct - pub fn new(cli: Option<&ConfigClient>) -> Self { + pub fn new(cli: &ConfigClient) -> Self { Self { local: Self::build_local_explorer(cli), remote: Self::build_remote_explorer(cli), @@ -120,45 +120,32 @@ impl Browser { /// ### build_local_explorer /// /// Build a file explorer with local host setup - pub fn build_local_explorer(cli: Option<&ConfigClient>) -> FileExplorer { + pub fn build_local_explorer(cli: &ConfigClient) -> FileExplorer { let mut builder = Self::build_explorer(cli); - if let Some(cli) = cli { - builder.with_formatter(cli.get_local_file_fmt().as_deref()); - } + builder.with_formatter(cli.get_local_file_fmt().as_deref()); builder.build() } /// ### build_remote_explorer /// /// Build a file explorer with remote host setup - pub fn build_remote_explorer(cli: Option<&ConfigClient>) -> FileExplorer { + pub fn build_remote_explorer(cli: &ConfigClient) -> FileExplorer { let mut builder = Self::build_explorer(cli); - if let Some(cli) = cli { - builder.with_formatter(cli.get_remote_file_fmt().as_deref()); - } + builder.with_formatter(cli.get_remote_file_fmt().as_deref()); builder.build() } /// ### build_explorer /// /// Build explorer reading configuration from `ConfigClient` - fn build_explorer(cli: Option<&ConfigClient>) -> FileExplorerBuilder { + fn build_explorer(cli: &ConfigClient) -> FileExplorerBuilder { let mut builder: FileExplorerBuilder = FileExplorerBuilder::new(); // Set common keys builder .with_file_sorting(FileSorting::ByName) - .with_stack_size(16); - match &cli { - Some(cli) => { - builder // Build according to current configuration - .with_group_dirs(cli.get_group_dirs()) - .with_hidden_files(cli.get_show_hidden_files()); - } - None => { - builder // Build default - .with_group_dirs(Some(GroupDirs::First)); - } - }; + .with_stack_size(16) + .with_group_dirs(cli.get_group_dirs()) + .with_hidden_files(cli.get_show_hidden_files()); builder } diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index 5838fc2..0d1c4ac 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -71,7 +71,7 @@ impl FileTransferActivity { /// /// Initialize configuration client if possible. /// This function doesn't return errors. - pub(super) fn init_config_client() -> Option { + pub(super) fn init_config_client() -> ConfigClient { match environment::init_config_dir() { Ok(termscp_dir) => match termscp_dir { Some(termscp_dir) => { @@ -79,24 +79,21 @@ impl FileTransferActivity { let (config_path, ssh_keys_path): (PathBuf, PathBuf) = environment::get_config_paths(termscp_dir.as_path()); match ConfigClient::new(config_path.as_path(), ssh_keys_path.as_path()) { - Ok(config_client) => Some(config_client), - Err(_) => None, + Ok(config_client) => config_client, + Err(_) => ConfigClient::degraded(), } } - None => None, + None => ConfigClient::degraded(), }, - Err(_) => None, + Err(_) => ConfigClient::degraded(), } } /// ### make_ssh_storage /// - /// Make ssh storage from `ConfigClient` if possible, empty otherwise - pub(super) fn make_ssh_storage(cli: Option<&ConfigClient>) -> SshKeyStorage { - match cli { - Some(cli) => SshKeyStorage::storage_from_config(cli), - None => SshKeyStorage::empty(), - } + /// Make ssh storage from `ConfigClient` if possible, empty otherwise (empty is implicit if degraded) + pub(super) fn make_ssh_storage(cli: &ConfigClient) -> SshKeyStorage { + SshKeyStorage::storage_from_config(cli) } /// ### setup_text_editor diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 112d331..91da013 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -141,7 +141,7 @@ impl FileTransferActivity { /// Instantiates a new FileTransferActivity pub fn new(host: Localhost, protocol: FileTransferProtocol) -> FileTransferActivity { // Get config client - let config_client: Option = Self::init_config_client(); + let config_client: ConfigClient = Self::init_config_client(); FileTransferActivity { exit_reason: None, context: None, @@ -149,14 +149,14 @@ impl FileTransferActivity { host, client: match protocol { FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new( - Self::make_ssh_storage(config_client.as_ref()), + Self::make_ssh_storage(&config_client), )), FileTransferProtocol::Ftp(ftps) => Box::new(FtpFileTransfer::new(ftps)), - FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new( - Self::make_ssh_storage(config_client.as_ref()), - )), + FileTransferProtocol::Scp => { + Box::new(ScpFileTransfer::new(Self::make_ssh_storage(&config_client))) + } }, - browser: Browser::new(config_client.as_ref()), + browser: Browser::new(&config_client), log_records: VecDeque::with_capacity(256), // 256 events is enough I guess transfer: TransferStates::default(), cache: match TempDir::new() { From f36bb65b45576a23d2223104848477178d86dbae Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 23 Jul 2021 14:31:29 +0200 Subject: [PATCH 51/53] Removed redundant remoteOpts struct; use FileTransferParams only --- src/activity_manager.rs | 26 ++------ src/filetransfer/mod.rs | 3 + src/main.rs | 51 +++++----------- src/ui/activities/auth/mod.rs | 3 +- src/ui/context.rs | 47 +-------------- src/utils/parser.rs | 109 ++++++++++++++++------------------ 6 files changed, 75 insertions(+), 164 deletions(-) diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 99cac71..52132dc 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -26,7 +26,7 @@ * SOFTWARE. */ // Deps -use crate::filetransfer::FileTransferProtocol; +use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; use crate::host::{HostError, Localhost}; use crate::system::config_client::ConfigClient; use crate::system::environment; @@ -35,7 +35,7 @@ use crate::ui::activities::{ auth::AuthActivity, filetransfer::FileTransferActivity, setup::SetupActivity, Activity, ExitReason, }; -use crate::ui::context::{Context, FileTransferParams}; +use crate::ui::context::Context; // Namespaces use std::path::{Path, PathBuf}; @@ -87,27 +87,9 @@ impl ActivityManager { /// ### set_filetransfer_params /// /// Set file transfer params - pub fn set_filetransfer_params( - &mut self, - address: String, - port: u16, - protocol: FileTransferProtocol, - username: Option, - password: Option, - entry_directory: Option, - ) { + pub fn set_filetransfer_params(&mut self, params: FileTransferParams) { // Put params into the context - self.context - .as_mut() - .unwrap() - .set_ftparams(FileTransferParams { - address, - port, - protocol, - username, - password, - entry_directory, - }); + self.context.as_mut().unwrap().set_ftparams(params); } /// ### run diff --git a/src/filetransfer/mod.rs b/src/filetransfer/mod.rs index a58a191..fc5e7f6 100644 --- a/src/filetransfer/mod.rs +++ b/src/filetransfer/mod.rs @@ -34,9 +34,12 @@ use thiserror::Error; use wildmatch::WildMatch; // exports pub mod ftp_transfer; +pub mod params; pub mod scp_transfer; pub mod sftp_transfer; +pub use params::FileTransferParams; + /// ## FileTransferProtocol /// /// This enum defines the different transfer protocol available in termscp diff --git a/src/main.rs b/src/main.rs index cb5a095..2797cbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,7 @@ mod utils; // namespaces use activity_manager::{ActivityManager, NextActivity}; -use filetransfer::FileTransferProtocol; +use filetransfer::FileTransferParams; use system::logging; enum Task { @@ -97,12 +97,7 @@ struct Args { } struct RunOpts { - address: Option, - port: u16, - username: Option, - password: Option, - remote_wrkdir: Option, - protocol: FileTransferProtocol, + remote: Option, ticks: Duration, log_enabled: bool, task: Task, @@ -111,12 +106,7 @@ struct RunOpts { impl Default for RunOpts { fn default() -> Self { Self { - address: None, - port: 22, - username: None, - password: None, - remote_wrkdir: None, - protocol: FileTransferProtocol::Sftp, + remote: None, ticks: Duration::from_millis(10), log_enabled: true, task: Task::Activity(NextActivity::Authentication), @@ -176,10 +166,6 @@ fn parse_args(args: Args) -> Result { if args.quiet { run_opts.log_enabled = false; } - // Match password - if let Some(passwd) = args.password { - run_opts.password = Some(passwd); - } // Match ticks run_opts.ticks = Duration::from_millis(args.ticks); // @! extra modes @@ -191,13 +177,13 @@ fn parse_args(args: Args) -> Result { if let Some(remote) = args.positional.get(0) { // Parse address match utils::parser::parse_remote_opt(remote.as_str()) { - Ok(host_opts) => { + Ok(mut remote) => { + // If password is provided, set password + if let Some(passwd) = args.password { + remote = remote.password(Some(passwd)); + } // Set params - run_opts.address = Some(host_opts.hostname); - run_opts.port = host_opts.port; - run_opts.protocol = host_opts.protocol; - run_opts.username = host_opts.username; - run_opts.remote_wrkdir = host_opts.wrkdir; + run_opts.remote = Some(remote); // In this case the first activity will be FileTransfer run_opts.task = Task::Activity(NextActivity::FileTransfer); } @@ -222,11 +208,11 @@ fn parse_args(args: Args) -> Result { /// Read password from tty if address is specified fn read_password(run_opts: &mut RunOpts) -> Result<(), String> { // Initialize client if necessary - if run_opts.address.is_some() { - debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", run_opts.address, run_opts.port, run_opts.protocol, run_opts.username, utils::fmt::shadow_password(run_opts.password.as_deref().unwrap_or(""))); - if run_opts.password.is_none() { + if let Some(remote) = run_opts.remote.as_mut() { + debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", remote.address, remote.port, remote.protocol, remote.username, utils::fmt::shadow_password(remote.password.as_deref().unwrap_or(""))); + if remote.password.is_none() { // Ask password if unspecified - run_opts.password = match rpassword::read_password_from_tty(Some("Password: ")) { + remote.password = match rpassword::read_password_from_tty(Some("Password: ")) { Ok(p) => { if p.is_empty() { None @@ -278,15 +264,8 @@ fn run(mut run_opts: RunOpts) -> i32 { } }; // Set file transfer params if set - if let Some(address) = run_opts.address.take() { - manager.set_filetransfer_params( - address, - run_opts.port, - run_opts.protocol, - run_opts.username, - run_opts.password, - run_opts.remote_wrkdir, - ); + if let Some(remote) = run_opts.remote.take() { + manager.set_filetransfer_params(remote); } manager.run(activity); 0 diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index 72121e2..d956412 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -34,9 +34,8 @@ mod view; // locals use super::{Activity, Context, ExitReason}; use crate::config::themes::Theme; -use crate::filetransfer::FileTransferProtocol; +use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; use crate::system::bookmarks_client::BookmarksClient; -use crate::ui::context::FileTransferParams; use crate::utils::git; // Includes diff --git a/src/ui/context.rs b/src/ui/context.rs index 9cb001c..5750ef1 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -28,7 +28,7 @@ // Locals use super::input::InputHandler; use super::store::Store; -use crate::filetransfer::FileTransferProtocol; +use crate::filetransfer::FileTransferParams; use crate::system::config_client::ConfigClient; use crate::system::theme_provider::ThemeProvider; @@ -37,7 +37,6 @@ use crossterm::event::DisableMouseCapture; use crossterm::execute; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use std::io::{stdout, Stdout}; -use std::path::PathBuf; use tuirealm::tui::backend::CrosstermBackend; use tuirealm::tui::Terminal; @@ -56,19 +55,6 @@ pub struct Context { error: Option, } -/// ### FileTransferParams -/// -/// Holds connection parameters for file transfers -#[derive(Clone)] -pub struct FileTransferParams { - pub address: String, - pub port: u16, - pub protocol: FileTransferProtocol, - pub username: Option, - pub password: Option, - pub entry_directory: Option, -} - impl Context { /// ### new /// @@ -198,34 +184,3 @@ impl Drop for Context { self.leave_alternate_screen(); } } - -impl Default for FileTransferParams { - fn default() -> Self { - Self { - address: String::new(), - port: 22, - protocol: FileTransferProtocol::Sftp, - username: None, - password: None, - entry_directory: None, - } - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - use pretty_assertions::assert_eq; - - #[test] - fn test_ui_context_ft_params() { - let params: FileTransferParams = FileTransferParams::default(); - assert_eq!(params.address.as_str(), ""); - assert_eq!(params.port, 22); - assert_eq!(params.protocol, FileTransferProtocol::Sftp); - assert!(params.username.is_none()); - assert!(params.password.is_none()); - } -} diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 17a7382..1cb15c5 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -26,7 +26,7 @@ * SOFTWARE. */ // Locals -use crate::filetransfer::FileTransferProtocol; +use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; #[cfg(not(test))] // NOTE: don't use configuration during tests use crate::system::config_client::ConfigClient; #[cfg(not(test))] // NOTE: don't use configuration during tests @@ -75,14 +75,6 @@ lazy_static! { static ref COLOR_RGB_REGEX: Regex = Regex::new(r"^(rgb)?\(?([01]?\d\d?|2[0-4]\d|25[0-5])(\W+)([01]?\d\d?|2[0-4]\d|25[0-5])\W+(([01]?\d\d?|2[0-4]\d|25[0-5])\)?)").unwrap(); } -pub struct RemoteOptions { - pub hostname: String, - pub port: u16, - pub protocol: FileTransferProtocol, - pub username: Option, - pub wrkdir: Option, -} - /// ### parse_remote_opt /// /// Parse remote option string. Returns in case of success a RemoteOptions struct @@ -101,8 +93,7 @@ pub struct RemoteOptions { /// - sftp://172.26.104.1:4022 /// - sftp://172.26.104.1 /// - ... -/// -pub fn parse_remote_opt(remote: &str) -> Result { +pub fn parse_remote_opt(remote: &str) -> Result { // Set protocol to default protocol #[cfg(not(test))] // NOTE: don't use configuration during tests let mut protocol: FileTransferProtocol = match environment::init_config_dir() { @@ -152,7 +143,7 @@ pub fn parse_remote_opt(remote: &str) -> Result { }, }; // Get address - let hostname: String = match groups.get(3) { + let address: String = match groups.get(3) { Some(group) => group.as_str().to_string(), None => return Err(String::from("Missing address")), }; @@ -164,14 +155,13 @@ pub fn parse_remote_opt(remote: &str) -> Result { }; } // Get workdir - let wrkdir: Option = groups.get(5).map(|group| PathBuf::from(group.as_str())); - Ok(RemoteOptions { - hostname, - port, - protocol, - username, - wrkdir, - }) + let entry_directory: Option = + groups.get(5).map(|group| PathBuf::from(group.as_str())); + Ok(FileTransferParams::new(address) + .port(port) + .protocol(protocol) + .username(username) + .entry_directory(entry_directory)) } None => Err(String::from("Bad remote host syntax!")), } @@ -476,107 +466,110 @@ mod tests { #[test] fn test_utils_parse_remote_opt() { // Base case - let result: RemoteOptions = parse_remote_opt(&String::from("172.26.104.1")) + let result: FileTransferParams = parse_remote_opt(&String::from("172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 22); assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert!(result.username.is_some()); // User case - let result: RemoteOptions = parse_remote_opt(&String::from("root@172.26.104.1")) + let result: FileTransferParams = parse_remote_opt(&String::from("root@172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 22); assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert_eq!(result.username.unwrap(), String::from("root")); - assert!(result.wrkdir.is_none()); + assert!(result.entry_directory.is_none()); // User + port - let result: RemoteOptions = parse_remote_opt(&String::from("root@172.26.104.1:8022")) + let result: FileTransferParams = parse_remote_opt(&String::from("root@172.26.104.1:8022")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 8022); assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert_eq!(result.username.unwrap(), String::from("root")); - assert!(result.wrkdir.is_none()); + assert!(result.entry_directory.is_none()); // Port only - let result: RemoteOptions = parse_remote_opt(&String::from("172.26.104.1:4022")) + let result: FileTransferParams = parse_remote_opt(&String::from("172.26.104.1:4022")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 4022); assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert!(result.username.is_some()); - assert!(result.wrkdir.is_none()); + assert!(result.entry_directory.is_none()); // Protocol - let result: RemoteOptions = parse_remote_opt(&String::from("ftp://172.26.104.1")) + let result: FileTransferParams = parse_remote_opt(&String::from("ftp://172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 21); // Fallback to ftp default assert_eq!(result.protocol, FileTransferProtocol::Ftp(false)); assert!(result.username.is_none()); // Doesn't fall back - assert!(result.wrkdir.is_none()); + assert!(result.entry_directory.is_none()); // Protocol - let result: RemoteOptions = parse_remote_opt(&String::from("sftp://172.26.104.1")) + let result: FileTransferParams = parse_remote_opt(&String::from("sftp://172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 22); // Fallback to sftp default assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert!(result.username.is_some()); // Doesn't fall back - assert!(result.wrkdir.is_none()); - let result: RemoteOptions = parse_remote_opt(&String::from("scp://172.26.104.1")) + assert!(result.entry_directory.is_none()); + let result: FileTransferParams = parse_remote_opt(&String::from("scp://172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 22); // Fallback to scp default assert_eq!(result.protocol, FileTransferProtocol::Scp); assert!(result.username.is_some()); // Doesn't fall back - assert!(result.wrkdir.is_none()); + assert!(result.entry_directory.is_none()); // Protocol + user - let result: RemoteOptions = parse_remote_opt(&String::from("ftps://anon@172.26.104.1")) - .ok() - .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + let result: FileTransferParams = + parse_remote_opt(&String::from("ftps://anon@172.26.104.1")) + .ok() + .unwrap(); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 21); // Fallback to ftp default assert_eq!(result.protocol, FileTransferProtocol::Ftp(true)); assert_eq!(result.username.unwrap(), String::from("anon")); - assert!(result.wrkdir.is_none()); + assert!(result.entry_directory.is_none()); // Path - let result: RemoteOptions = parse_remote_opt(&String::from("root@172.26.104.1:8022:/var")) - .ok() - .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + let result: FileTransferParams = + parse_remote_opt(&String::from("root@172.26.104.1:8022:/var")) + .ok() + .unwrap(); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 8022); assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert_eq!(result.username.unwrap(), String::from("root")); - assert_eq!(result.wrkdir.unwrap(), PathBuf::from("/var")); + assert_eq!(result.entry_directory.unwrap(), PathBuf::from("/var")); // Port only - let result: RemoteOptions = parse_remote_opt(&String::from("172.26.104.1:home")) + let result: FileTransferParams = parse_remote_opt(&String::from("172.26.104.1:home")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 22); assert_eq!(result.protocol, FileTransferProtocol::Sftp); assert!(result.username.is_some()); - assert_eq!(result.wrkdir.unwrap(), PathBuf::from("home")); + assert_eq!(result.entry_directory.unwrap(), PathBuf::from("home")); // All together now - let result: RemoteOptions = + let result: FileTransferParams = parse_remote_opt(&String::from("ftp://anon@172.26.104.1:8021:/tmp")) .ok() .unwrap(); - assert_eq!(result.hostname, String::from("172.26.104.1")); + assert_eq!(result.address, String::from("172.26.104.1")); assert_eq!(result.port, 8021); // Fallback to ftp default assert_eq!(result.protocol, FileTransferProtocol::Ftp(false)); assert_eq!(result.username.unwrap(), String::from("anon")); - assert_eq!(result.wrkdir.unwrap(), PathBuf::from("/tmp")); + assert_eq!(result.entry_directory.unwrap(), PathBuf::from("/tmp")); // bad syntax - assert!(parse_remote_opt(&String::from("omar://172.26.104.1")).is_err()); // Bad protocol - assert!(parse_remote_opt(&String::from("omar://172.26.104.1:650000")).is_err()); + // Bad protocol + assert!(parse_remote_opt(&String::from("omar://172.26.104.1")).is_err()); // Bad port + assert!(parse_remote_opt(&String::from("scp://172.26.104.1:650000")).is_err()); } #[test] From 3271377b7ec94959ae825a6fab82f38d5bb06552 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 23 Jul 2021 14:32:15 +0200 Subject: [PATCH 52/53] Removed redundant remoteOpts struct; use FileTransferParams only --- src/filetransfer/params.rs | 137 +++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/filetransfer/params.rs diff --git a/src/filetransfer/params.rs b/src/filetransfer/params.rs new file mode 100644 index 0000000..893b172 --- /dev/null +++ b/src/filetransfer/params.rs @@ -0,0 +1,137 @@ +//! ## Params +//! +//! file transfer parameters + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +use super::FileTransferProtocol; + +use std::path::{Path, PathBuf}; + +/// ### FileTransferParams +/// +/// Holds connection parameters for file transfers +#[derive(Clone)] +pub struct FileTransferParams { + pub address: String, + pub port: u16, + pub protocol: FileTransferProtocol, + pub username: Option, + pub password: Option, + pub entry_directory: Option, +} + +impl FileTransferParams { + /// ### new + /// + /// Instantiates a new `FileTransferParams` + pub fn new>(address: S) -> Self { + Self { + address: address.as_ref().to_string(), + port: 22, + protocol: FileTransferProtocol::Sftp, + username: None, + password: None, + entry_directory: None, + } + } + + /// ### port + /// + /// Set port for params + pub fn port(mut self, port: u16) -> Self { + self.port = port; + self + } + + /// ### protocol + /// + /// Set protocol for params + pub fn protocol(mut self, protocol: FileTransferProtocol) -> Self { + self.protocol = protocol; + self + } + + /// ### username + /// + /// Set username for params + pub fn username>(mut self, username: Option) -> Self { + self.username = username.map(|x| x.as_ref().to_string()); + self + } + + /// ### password + /// + /// Set password for params + pub fn password>(mut self, password: Option) -> Self { + self.password = password.map(|x| x.as_ref().to_string()); + self + } + + /// ### entry_directory + /// + /// Set entry directory + pub fn entry_directory>(mut self, dir: Option

) -> Self { + self.entry_directory = dir.map(|x| x.as_ref().to_path_buf()); + self + } +} + +impl Default for FileTransferParams { + fn default() -> Self { + Self::new("localhost") + } +} + +#[cfg(test)] +mod test { + + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_filetransfer_params() { + let params: FileTransferParams = FileTransferParams::new("test.rebex.net") + .port(2222) + .protocol(FileTransferProtocol::Scp) + .username(Some("omar")) + .password(Some("foobar")) + .entry_directory(Some(&Path::new("/tmp"))); + assert_eq!(params.address.as_str(), "test.rebex.net"); + assert_eq!(params.port, 2222); + assert_eq!(params.protocol, FileTransferProtocol::Scp); + assert_eq!(params.username.as_ref().unwrap(), "omar"); + assert_eq!(params.password.as_ref().unwrap(), "foobar"); + } + + #[test] + fn test_filetransfer_params_default() { + let params: FileTransferParams = FileTransferParams::default(); + assert_eq!(params.address.as_str(), "localhost"); + assert_eq!(params.port, 22); + assert_eq!(params.protocol, FileTransferProtocol::Sftp); + assert!(params.username.is_none()); + assert!(params.password.is_none()); + } +} From bf6f1625ecbf535f58b4328e3a5d223d549b9693 Mon Sep 17 00:00:00 2001 From: veeso Date: Fri, 23 Jul 2021 14:32:50 +0200 Subject: [PATCH 53/53] 0.6.0 release notes --- CHANGELOG.md | 6 +++--- README.md | 29 +++++++++++++++-------------- SECURITY.md | 1 - docs/deploy.md | 35 ----------------------------------- 4 files changed, 18 insertions(+), 53 deletions(-) delete mode 100644 docs/deploy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fb530..0322f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ ## 0.6.0 -Released on FIXME: ?? +Released on 23/07/2021 > 🍹 Summer update 2021 🍨 @@ -29,8 +29,8 @@ Released on FIXME: ?? - Open file with default program for file type with `` - Open file with a specific program with `` - **Themes**: - - You can now set colors for 25 elements in the application - - Colors can be any RGB, also supports **CSS colors** syntax + - You can now set colors for 26 elements in the application + - Colors can be any RGB, also **CSS colors** syntax is supported (e.g. `aquamarine`) - Configure theme from settings or import from CLI using the `-t ` argument - You can find several themes in the `themes/` directory - **Keyring support for Linux** diff --git a/README.md b/README.md index 83b373c..506d8a7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

Developed by @veeso

-

Current version: 0.6.0 FIXME: (21/06/2021)

+

Current version: 0.6.0 (23/07/2021)

[![License: MIT](https://img.shields.io/badge/License-MIT-teal.svg)](https://opensource.org/licenses/MIT) [![Stars](https://img.shields.io/github/stars/veeso/termscp.svg)](https://github.com/veeso/termscp) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.5.1-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp) @@ -32,7 +32,7 @@ Termscp is a feature rich terminal file transfer and explorer, with support for ## Features 🎁 -- 📁 Different communication protocols support +- 📁 Different communication protocols - SFTP - SCP - FTP and FTPS @@ -72,11 +72,13 @@ For more information or other platforms, please visit [veeso.github.io](https:// ### Requirements ❗ -- **Linux/BSD** users: +- **Linux** users: - libssh - libdbus-1 +- **BSD** users: + - libssh -### Soft Requirements ✔️ +### Optional Requirements ✔️ These requirements are not forcely required to run termscp, but to enjoy all of its features @@ -86,6 +88,7 @@ These requirements are not forcely required to run termscp, but to enjoy all of - *gio* - *gnome-open* - *kde-open* +- **Linux** users: - A keyring manager: read more in the [User manual](docs/man.md#linux-keyring) - **WSL** users - To **open** files via `V` (at least one of these) @@ -117,19 +120,17 @@ The developer documentation can be found on Rust Docs at