Merged 0.5.0 into main

This commit is contained in:
veeso
2021-05-23 15:45:04 +02:00
99 changed files with 6079 additions and 6328 deletions

View File

@@ -28,6 +28,11 @@ A clear and concise description of what you expected to happen.
- Protocol used
- Remote server version and name
## Log
Report the snippet of the log file containing the unexpected behaviour.
If there is any information you consider to be confidential, shadow it.
## Additional information
Add any other context about the problem here.

14
.github/actions-rs/grcov.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
branch: false
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

View File

@@ -12,11 +12,25 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Run cargo-tarpaulin
uses: actions-rs/tarpaulin@v0.1
- name: Setup rust toolchain
uses: actions-rs/toolchain@v1
with:
args: "--ignore-tests -- --test-threads 1"
- name: Upload to codecov.io
uses: codecov/codecov-action@v1
toolchain: nightly
override: true
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
args: --all-features --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"
RUSTDOCFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests"
- name: Coverage with grcov
id: coverage
uses: actions-rs/grcov@v0.1
- name: Coveralls
uses: coverallsapp/github-action@v1.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
path-to-lcov: ${{ steps.coverage.outputs.report }}

View File

@@ -1,6 +1,7 @@
# Changelog
- [Changelog](#changelog)
- [0.5.0](#050)
- [0.4.2](#042)
- [0.4.1](#041)
- [0.4.0](#040)
@@ -16,6 +17,54 @@
---
## 0.5.0
Released on 23/05/2021
> 🌸 Spring Update 2021 🌷
- **Synchronized browsing**:
- Added the possibility to enabled the synchronized brower navigation
- when you enter a directory, the same directory will be entered on the other tab
- Enable sync browser with `<Y>`
- Read more on manual: [Synchronized browsing](docs/man.md#Synchronized-browsing-)
- **Remote and Local hosts file formatter**:
- Added the possibility to set different formatters for local and remote hosts
- **Work on multiple files**:
- Added the possibility to work on **multiple files simultaneously**
- Select a file with `<M>`, the file when selected will have a `*` prepended to its name
- Select all files in the current directory with `<CTRL+A>`
- Read more on manual: [Work on multiple files](docs/man.md#Work-on-multiple-files-)
- **Logging**:
- termscp now writes a log file, useful to debug and to contribute to fix issues.
- Read more on [manual](docs/man.md)
- **File transfer changes**
- *SFTP*
- Added **COPY** command to SFTP (Please note that Copy command is not supported by SFTP natively, so here it just uses the `cp` shell command as it does in SCP).
- *FTP*
- Added support for file copy (achieved through *tricky-copy*: the file is first downloaded, then uploaded with a different file name)
- **Double progress bar**:
- From now one two progress bar will be displayed:
- the first, on top, displays the full transfer state (e.g. when downloading a directory of 10 files, the progress of the entire transfer)
- the second, on bottom, displays the transfer of the individual file being written (as happened for the old versions)
- changed the progress bar colour from `LightGreen` to `Green`
- Enhancements
- Added a status bar in the file explorer showing whether the sync browser is enabled and which file sorting mode is selected
- Removed the goold old figlet title
- Protocol input as first field in UI
- Port is now updated to standard for selected protocol
- when you change the protocol in the authentication form and the current port is standard (`< 1024`), the port will be automatically changed to default value for the selected protocol (e.g. current port: `123`, protocol changed to `FTP`, port becomes `21`)
- Bugfix:
- Fixed wrong text wrap in log box
- Fixed empty bookmark name causing termscp to crash
- Fixed error message not being shown after an upload failure
- Fixed default protocol not being loaded from config
- [Issue 23](https://github.com/veeso/termscp/issues/23): Remove created file if transfer failed or was abrupted
- Dependencies:
- Added `tui-realm 0.3.0`
- Removed `tui` (as direct dependency)
- Updated `regex` to `1.5.4`
## 0.4.2
Released on 13/04/2021
@@ -88,7 +137,7 @@ Released on 28/02/2021
- Added `EXTRA` and `LENGTH` parameters to format keys.
- Now keys are provided with this syntax `{KEY_NAME[:LEN[:EXTRA]}`
- **Check for updates**:
- TermSCP will now check for updates on startup and will show in the main page if there is a new version available
- termscp will now check for updates on startup and will show in the main page if there is a new version available
- This feature may be disabled from setup (Check for updates => No)
- Enhancements:
- Default choice for deleting file set to "NO" (way too easy to delete files by mistake)

View File

@@ -19,16 +19,16 @@ Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in
## Project mission
TermSCP was born because, as a terminal lover and Linux user, I wanted something like WinSCP on Linux and on terminal. I my previous job I used SFTP/SCP pratically everyday and that made me to desire an application like termscp so much, that eventually I started to work on it in the spare time. I saw there was a very cool library to create terminal user interface (`tui-rs`), so I started to code it. I wrote termscp as an experiment, I designed kinda nothing at the time. I just said
termscp was born because, as a terminal lover and Linux user, I wanted something like WinSCP on Linux and on terminal. I my previous job I used SFTP/SCP pratically everyday and that made me to desire an application like termscp so much, that eventually I started to work on it in the spare time. I saw there was a very cool library to create terminal user interface (`tui-rs`), so I started to code it. I wrote termscp as an experiment, I designed kinda nothing at the time. I just said
> Ok, there must be a `FileTransfer` trait somehow, I'll have more views, so I'll use something like Android activities, and there must be a module to interact with the local host".
And so in december 2020 I had the first version of termscp running and it worked, but was very simple, raw and minimal.
A lot of things have changed since them, both the features the project provides and my personal view of this project.
Today I don't see TermSCP as a WinSCP clone anymore. I've also thought about changing the name as the time passed by, but I liked it and it would be hard to change the name on the registries, etc.
Today I don't see termscp as a WinSCP clone anymore. I've also thought about changing the name as the time passed by, but I liked it and it would be hard to change the name on the registries, etc.
Right now I see TermSCP as a **rich-featured file transfer client for terminals**. All I want is to provide all the features users need to use it correctly, I want it to be **safe and reliable** and eventually I want people to consider termscp **the first choice as a file transfer client**.
Right now I see termscp as a **rich-featured file transfer client for terminals**. All I want is to provide all the features users need to use it correctly, I want it to be **safe and reliable** and eventually I want people to consider termscp **the first choice as a file transfer client**.
### Project goals
@@ -61,6 +61,7 @@ Don't set other labels to your issue, not even priority.
When you open a bug try to be the most precise as possible in describing your issue. I'm not saying you should always be that precise, since sometimes it's very easy for maintainers to understand what you're talking about. Just try to be reasonable to understand sometimes we might not know what you're talking about or we just don't have the technical knowledge you might think.
Please always provide the environment you're working on and consider that we don't provide any support for older version of termscp, at least for those not classified as LTS (if we'll ever have them).
If you can, provide the log file or the snippet involving your issue. You can find in the [user manual](docs/man.md) the location of the log file.
Last but not least: the template I've written must be used. Full stop.
Maintainers will may add additional labels to your issue:
@@ -68,7 +69,7 @@ Maintainers will may add additional labels to your issue:
- **duplicate**: the issue is duplicated; the reference to the related issue will be added to your description. Your issue will be closed.
- **priority**: this must be fixed asap
- **sorcery**: it is not possible to find out what's causing your bug, nor is reproducible on our test environments.
- **wontfix**: your bug has a very high ratio between the probability to encounter it and the difficult to fix it, or it just isn't a bug, but a feature.
- **wontfix**: your bug has a very high ratio between the difficulty to fix it and the probability to encounter it, or it just isn't a bug, but a feature.
### Feature requests

353
Cargo.lock generated
View File

@@ -33,24 +33,21 @@ dependencies = [
[[package]]
name = "aho-corasick"
version = "0.7.15"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
dependencies = [
"memchr",
]
[[package]]
name = "arrayref"
version = "0.3.6"
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "autocfg"
@@ -70,17 +67,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "blake2b_simd"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587"
dependencies = [
"arrayref",
"arrayvec",
"constant_time_eq",
]
[[package]]
name = "block-buffer"
version = "0.9.0"
@@ -186,12 +172,6 @@ dependencies = [
"bitflags",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "content_inspector"
version = "0.2.4"
@@ -234,47 +214,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
[[package]]
name = "cpuid-bool"
version = "0.1.2"
name = "cpufeatures"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8aebca1129a03dc6dc2b127edd729435bbc4a37e1d5f4d7513165089ceb02634"
checksum = "281f563b2c3a0e535ab12d81d3c5859045795256ad269afa7c19542585b68f93"
dependencies = [
"libc",
]
[[package]]
name = "crc-any"
version = "2.3.5"
version = "2.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3784befdf9469f4d51c69ef0b774f6a99de6bcc655285f746f16e0dd63d9007"
checksum = "0d98be01088633be44a2a82b55a96dca49b226d65297428a3c44d33de07528ff"
dependencies = [
"debug-helper",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49"
dependencies = [
"autocfg",
"cfg-if 1.0.0",
"lazy_static",
]
[[package]]
name = "crossterm"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e86d73f2a0b407b5768d10a8c720cf5d2df49a9efc10ca09176d201ead4b7fb"
dependencies = [
"bitflags",
"crossterm_winapi 0.6.2",
"lazy_static",
"libc",
"mio",
"parking_lot 0.11.1",
"signal-hook",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.19.0"
@@ -282,7 +238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c36c10130df424b2f3552fcc2ddcd9b28a27b1e54b358b45874f88d1ca6888c"
dependencies = [
"bitflags",
"crossterm_winapi 0.7.0",
"crossterm_winapi",
"lazy_static",
"libc",
"mio",
@@ -291,15 +247,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2265c3f8e080075d9b6417aa72293fc71662f34b4af2612d8d1b074d29510db"
dependencies = [
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.7.0"
@@ -319,6 +266,16 @@ dependencies = [
"subtle",
]
[[package]]
name = "ctor"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e98e2ad1a782e33928b96fc3948e7c355e5af34ba4de7670fe8bac2a3b2006d"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "dbus"
version = "0.2.3"
@@ -330,9 +287,9 @@ dependencies = [
[[package]]
name = "debug-helper"
version = "0.3.10"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8a5bb894f24f42c247f19b25928a88e31867c0f84552c05df41a9dd527435e"
checksum = "4460596867846f73bddca51f7403b6a29f5315125be10a1640259b4db5b9494c"
[[package]]
name = "des"
@@ -345,6 +302,12 @@ dependencies = [
"opaque-debug",
]
[[package]]
name = "diff"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499"
[[package]]
name = "digest"
version = "0.9.0"
@@ -356,18 +319,18 @@ dependencies = [
[[package]]
name = "dirs"
version = "3.0.1"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142995ed02755914747cc6ca76fc7e4583cd18578746716d0508ea6ed558b9ff"
checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a"
checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780"
dependencies = [
"libc",
"redox_users",
@@ -501,9 +464,9 @@ dependencies = [
[[package]]
name = "idna"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89829a5d69c23d348314a7ac337fe39173b61149a9864deabd260983aed48c21"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
dependencies = [
"matches",
"unicode-bidi",
@@ -527,9 +490,9 @@ checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
[[package]]
name = "js-sys"
version = "0.3.50"
version = "0.3.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d99f9e3e84b8f67f846ef5b4cbbc3b1c29f6c759fcbce6f01aa0e73d932a24c"
checksum = "83bdfbace3a0e81a4253f73b49e960b053e396a11012cbd49b9b74d6a2b67062"
dependencies = [
"wasm-bindgen",
]
@@ -554,9 +517,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.92"
version = "0.2.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56d855069fafbb9b344c0f962150cd2c1187975cb1c22c1522c240d8c4986714"
checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e"
[[package]]
name = "libssh2-sys"
@@ -574,9 +537,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602113192b08db8f38796c4e85c39e960c145965140e918018bcde1952429655"
checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66"
dependencies = [
"cc",
"libc",
@@ -595,9 +558,9 @@ dependencies = [
[[package]]
name = "lock_api"
version = "0.4.3"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3c91c24eae6777794bb1997ad98bbb87daf92890acab859f7eaa4320333176"
checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb"
dependencies = [
"scopeguard",
]
@@ -613,9 +576,9 @@ dependencies = [
[[package]]
name = "magic-crypt"
version = "3.1.7"
version = "3.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7d8d3790b76ab76cc459a707e09009fcd8ef8da8999d7a99c9bb9b9bef8890"
checksum = "d3c94f1281833c690f81e6a00c545063b4f034509d3af6d29b58d48e39aa64c9"
dependencies = [
"aes-soft",
"base64",
@@ -653,9 +616,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.3.4"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc"
[[package]]
name = "mio"
@@ -796,9 +759,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.33"
version = "0.10.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577"
checksum = "6d7830286ad6a3973c0f1d9b73738f69c76b739301d0229c4b96501695cbe4c8"
dependencies = [
"bitflags",
"cfg-if 1.0.0",
@@ -810,15 +773,15 @@ dependencies = [
[[package]]
name = "openssl-probe"
version = "0.1.2"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]]
name = "openssl-sys"
version = "0.9.61"
version = "0.9.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f"
checksum = "b6b0d6fb7d80f877617dfcb014e605e2b5ab2fb0afdf27935219bb6bd984cb98"
dependencies = [
"autocfg",
"cc",
@@ -827,6 +790,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "output_vt100"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9"
dependencies = [
"winapi",
]
[[package]]
name = "parking_lot"
version = "0.10.2"
@@ -844,7 +816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb"
dependencies = [
"instant",
"lock_api 0.4.3",
"lock_api 0.4.4",
"parking_lot_core 0.8.3",
]
@@ -871,7 +843,7 @@ dependencies = [
"cfg-if 1.0.0",
"instant",
"libc",
"redox_syscall 0.2.5",
"redox_syscall 0.2.8",
"smallvec",
"winapi",
]
@@ -900,6 +872,18 @@ version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
[[package]]
name = "pretty_assertions"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cab0e7c02cf376875e9335e0ba1da535775beb5450d21e1dffca068818ed98b"
dependencies = [
"ansi_term",
"ctor",
"diff",
"output_vt100",
]
[[package]]
name = "proc-macro2"
version = "1.0.26"
@@ -1007,29 +991,28 @@ checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
[[package]]
name = "redox_syscall"
version = "0.2.5"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9"
checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.3.5"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
dependencies = [
"getrandom 0.1.16",
"redox_syscall 0.1.57",
"rust-argon2",
"getrandom 0.2.2",
"redox_syscall 0.2.8",
]
[[package]]
name = "regex"
version = "1.4.5"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19"
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
dependencies = [
"aho-corasick",
"memchr",
@@ -1038,9 +1021,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.6.23"
version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "remove_dir_all"
@@ -1076,23 +1059,11 @@ dependencies = [
"winapi",
]
[[package]]
name = "rust-argon2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb"
dependencies = [
"base64",
"blake2b_simd",
"constant_time_eq",
"crossbeam-utils",
]
[[package]]
name = "rustls"
version = "0.19.0"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064fd21ff87c6e87ed4506e68beb42459caa4a0e2eb144932e6776768556980b"
checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7"
dependencies = [
"base64",
"log",
@@ -1125,9 +1096,9 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "sct"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3042af939fca8c3453b7af0f1c66e533a15a86169e39de2657310ade8f98d3c"
checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce"
dependencies = [
"ring",
"untrusted",
@@ -1197,18 +1168,18 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.125"
version = "1.0.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171"
checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.125"
version = "1.0.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d"
checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43"
dependencies = [
"proc-macro2",
"quote",
@@ -1228,13 +1199,13 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.9.3"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa827a14b29ab7f44778d14a88d3cb76e949c45083f7dbfa507d0cb699dc12de"
checksum = "b362ae5752fd2137731f9fa25fd4d9058af34666ca1966fb969119cc35719f12"
dependencies = [
"block-buffer",
"cfg-if 1.0.0",
"cpuid-bool",
"cpufeatures",
"digest",
"opaque-debug",
]
@@ -1259,6 +1230,17 @@ dependencies = [
"libc",
]
[[package]]
name = "simplelog"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59d0fe306a0ced1c88a58042dc22fc2ddd000982c26d75f6aa09a394547c41e0"
dependencies = [
"chrono",
"log",
"termcolor",
]
[[package]]
name = "smallvec"
version = "1.6.1"
@@ -1297,9 +1279,9 @@ checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
[[package]]
name = "syn"
version = "1.0.68"
version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ce15dd3ed8aa2f8eeac4716d6ef5ab58b6b9256db41d7e1a0224c2788e8fd87"
checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82"
dependencies = [
"proc-macro2",
"quote",
@@ -1315,20 +1297,29 @@ dependencies = [
"cfg-if 1.0.0",
"libc",
"rand 0.8.3",
"redox_syscall 0.2.5",
"redox_syscall 0.2.8",
"remove_dir_all",
"winapi",
]
[[package]]
name = "termcolor"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
dependencies = [
"winapi-util",
]
[[package]]
name = "termscp"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"bitflags",
"bytesize",
"chrono",
"content_inspector",
"crossterm 0.19.0",
"crossterm",
"dirs",
"edit",
"ftp4",
@@ -1336,18 +1327,21 @@ dependencies = [
"hostname",
"keyring",
"lazy_static",
"log",
"magic-crypt",
"path-slash",
"pretty_assertions",
"rand 0.8.3",
"regex",
"rpassword",
"serde",
"simplelog",
"ssh2",
"tempfile",
"textwrap",
"thiserror",
"toml",
"tui",
"tuirealm",
"ureq",
"users",
"whoami",
@@ -1408,9 +1402,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317cca572a0e89c3ce0ca1f1bdc9369547fe318a683418e42ac8f59d14701023"
checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342"
dependencies = [
"tinyvec_macros",
]
@@ -1432,17 +1426,29 @@ dependencies = [
[[package]]
name = "tui"
version = "0.14.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ced152a8e9295a5b168adc254074525c17ac4a83c90b2716274cc38118bddc9"
checksum = "861d8f3ad314ede6219bcb2ab844054b1de279ee37a9bc38e3d606f9d3fb2a71"
dependencies = [
"bitflags",
"cassowary",
"crossterm 0.18.2",
"crossterm",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "tuirealm"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aad880656efa943543c8048a28e1fa1d0ea6b5c4bf7f53636492ef8a49ec681f"
dependencies = [
"crossterm",
"textwrap",
"tui",
"unicode-width",
]
[[package]]
name = "typenum"
version = "1.13.0"
@@ -1451,9 +1457,9 @@ checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
[[package]]
name = "unicode-bidi"
version = "0.3.4"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5"
checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0"
dependencies = [
"matches",
]
@@ -1481,9 +1487,9 @@ checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
[[package]]
name = "unicode-xid"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "untrusted"
@@ -1493,9 +1499,9 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "ureq"
version = "2.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fbeb1aabb07378cf0e084971a74f24241273304653184f54cdce113c0d7df1b"
checksum = "2475a6781e9bc546e7b64f4013d2f4032c8c6a40fcffd7c6f4ee734a890972ab"
dependencies = [
"base64",
"chunked_transfer",
@@ -1511,9 +1517,9 @@ dependencies = [
[[package]]
name = "url"
version = "2.2.1"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
dependencies = [
"form_urlencoded",
"idna",
@@ -1533,9 +1539,9 @@ dependencies = [
[[package]]
name = "vcpkg"
version = "0.2.11"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb"
checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d"
[[package]]
name = "version_check"
@@ -1557,9 +1563,9 @@ checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "wasm-bindgen"
version = "0.2.73"
version = "0.2.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83240549659d187488f91f33c0f8547cbfef0b2088bc470c116d1d260ef623d9"
checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd"
dependencies = [
"cfg-if 1.0.0",
"wasm-bindgen-macro",
@@ -1567,9 +1573,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.73"
version = "0.2.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae70622411ca953215ca6d06d3ebeb1e915f0f6613e3b495122878d7ebec7dae"
checksum = "3b33f6a0694ccfea53d94db8b2ed1c3a8a4c86dd936b13b9f0a15ec4a451b900"
dependencies = [
"bumpalo",
"lazy_static",
@@ -1582,9 +1588,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.73"
version = "0.2.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e734d91443f177bfdb41969de821e15c516931c3c3db3d318fa1b68975d0f6f"
checksum = "088169ca61430fe1e58b8096c24975251700e7b1f6fd91cc9d59b04fb9b18bd4"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1592,9 +1598,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.73"
version = "0.2.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53739ff08c8a68b0fdbcd54c372b8ab800b1449ab3c9d706503bc7dd1621b2c"
checksum = "be2241542ff3d9f241f5e2cb6dd09b37efe786df8851c54957683a49f0987a97"
dependencies = [
"proc-macro2",
"quote",
@@ -1605,15 +1611,15 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.73"
version = "0.2.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9a543ae66aa233d14bb765ed9af4a33e81b8b58d1584cf1b47ff8cd0b9e4489"
checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f"
[[package]]
name = "web-sys"
version = "0.3.50"
version = "0.3.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a905d57e488fec8861446d3393670fb50d27a262344013181c2cdf9fff5481be"
checksum = "e828417b379f3df7111d3a2a9e5753706cae29c41f7c4029ee9fd77f3e09e582"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -1650,9 +1656,9 @@ dependencies = [
[[package]]
name = "whoami"
version = "1.1.1"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e296f550993cba2c5c3eba5da0fb335562b2fa3d97b7a8ac9dc91f40a3abc70"
checksum = "4abacf325c958dfeaf1046931d37f2a901b6dfe0968ee965a29e94c6766b2af6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -1660,9 +1666,9 @@ dependencies = [
[[package]]
name = "wildmatch"
version = "2.0.0"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07ae7ce410f81ba679081aac1d4874f3b1c328535b630209aa5b4cdaaf895e20"
checksum = "d6c48bd20df7e4ced539c12f570f937c6b4884928a87fee70a479d72f031d4e0"
[[package]]
name = "winapi"
@@ -1680,6 +1686,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View File

@@ -4,14 +4,14 @@ categories = ["command-line-utilities"]
description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP"
documentation = "https://docs.rs/termscp"
edition = "2018"
homepage = "https://github.com/veeso/termscp"
homepage = "https://veeso.github.io/termscp/"
include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
keywords = ["scp-client", "sftp-client", "ftp-client", "winscp", "command-line-utility"]
license = "MIT"
name = "termscp"
readme = "README.md"
repository = "https://github.com/veeso/termscp"
version = "0.4.2"
version = "0.5.0"
[package.metadata.rpm]
package = "termscp"
@@ -37,18 +37,24 @@ edit = "0.1.3"
getopts = "0.2.21"
hostname = "0.3.1"
lazy_static = "1.4.0"
log = "0.4.14"
magic-crypt = "3.1.7"
rand = "0.8.3"
regex = "1.4.5"
regex = "1.5.4"
rpassword = "5.0.1"
simplelog = "0.10.0"
ssh2 = "0.9.0"
tempfile = "3.1.0"
textwrap = "0.13.4"
thiserror = "^1.0.0"
toml = "0.5.8"
tuirealm = { version = "0.3.0", features = [ "with-components" ] }
whoami = "1.1.1"
wildmatch = "2.0.0"
[dev-dependencies]
pretty_assertions = "0.7.2"
[dependencies.ftp4]
features = ["secure"]
version = "^4.0.2"
@@ -57,11 +63,6 @@ version = "^4.0.2"
features = ["derive"]
version = "^1.0.0"
[dependencies.tui]
default-features = false
features = ["crossterm"]
version = "0.14.0"
[dependencies.ureq]
features = ["json"]
version = "2.1.0"

197
README.md
View File

@@ -1,44 +1,28 @@
# TermSCP
# termscp
<p align="center">
<img src="/assets/images/termscp.svg" width="256" height="256" />
</p>
[![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.4.2-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.5.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)
[![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)
~ A feature rich terminal file transfer ~
Developed by Christian Visintin
Current version: 0.4.2 (13/04/2021)
<p align="center">~ A feature rich terminal file transfer ~</p>
<p align="center">
<a href="https://veeso.github.io/termscp/" target="_blank">Website</a>
·
<a href="https://veeso.github.io/termscp/#get-started" target="_blank">Installation</a>
·
<a href="https://veeso.github.io/termscp/#user-manual" target="_blank">User manual</a>
</p>
<p align="center">Developed by Christian Visintin</p>
<p align="center">Current version: 0.5.0 (23/05/2021)</p>
---
- [TermSCP](#termscp)
- [About TermSCP 🖥](#about-termscp-)
- [Why TermSCP 🤔](#why-termscp-)
- [Features 🎁](#features-)
- [Installation 🛠](#installation-)
- [Cargo 🦀](#cargo-)
- [Deb package 📦](#deb-package-)
- [RPM package 📦](#rpm-package-)
- [AUR Package 🔼](#aur-package-)
- [Chocolatey 🍫](#chocolatey-)
- [Brew 🍻](#brew-)
- [User Manual 🎓](#user-manual-)
- [Documentation 📚](#documentation-)
- [Known issues 🧻](#known-issues-)
- [Upcoming Features 🧪](#upcoming-features-)
- [Contributing and issues 🤝🏻](#contributing-and-issues-)
- [Changelog ⏳](#changelog-)
- [Powered by 🚀](#powered-by-)
- [Gallery 🎬](#gallery-)
- [Buy me a coffee ☕](#buy-me-a-coffee-)
- [License 📃](#license-)
---
## About TermSCP 🖥
## About termscp 🖥
Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP. So basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and to interact with the local file system. It is **Linux**, **MacOS**, **BSD** and **Windows** compatible and supports SFTP, SCP, FTP and FTPS.
@@ -46,125 +30,52 @@ Termscp is a feature rich terminal file transfer and explorer, with support for
---
### Why TermSCP 🤔
It happens quite often to me, when using SCP at work to forget the path of a file on a remote machine, which forces me to connect through SSH, gather the file path and finally download it through SCP. I could use WinSCP, but I use Linux and I pratically use the terminal for everything, so I wanted something like WinSCP on my terminal. Yeah, I know there is midnight commander too, but actually I don't like it very much tbh (and hasn't a decent support for scp).
## Features 🎁
- 📁 Different communication protocols support
- 📁 Different communication protocols support
- SFTP
- SCP
- FTP and FTPS
- 🐧 Compatible with Windows, Linux, BSD and MacOS
- 🖥 Handy user interface to explore and operate on the remote and on the local machine file system
- 🖥 Explore and operate on the remote and on the local machine file system with a handy UI
- Create, remove, rename, search, view and edit files
-Bookmarks and recent connections can be saved to access quickly to your favourite hosts
- 📝 Supports text editors to view and edit text files
- 💁 Supports both SFTP/SCP authentication through SSH keys and username/password
- ✏ Customizations
- Connect to your favourite hosts through built-in bookmarks and recent connections
- 📝 View and edit text files with your favourite text editor
- 💁 SFTP/SCP authentication through SSH keys and username/password
- 🐧 Compatible with Windows, Linux, BSD and MacOS
- ✏ Customizable
- Custom file explorer format
- Customizable text editor
- Customizable file sorting
- 🔐 SSH key storage
- 🦀 Written in Rust
- 🤝 Easy to extend with new file transfers protocols
- 👀 Developed keeping an eye on performance
- 🦄 Frequent awesome updates
- 🔐 Save your password in your operating system key vault
- 🦀 Rust-powered
- 🤝 Easy to extend with new file transfers protocols
- 👀 Developed keeping an eye on performance
- 🦄 Frequent awesome updates
---
## Installation 🛠
## Get started 🚀
If you're considering to install TermSCP I want to thank you 💜 ! I hope you will enjoy TermSCP!
If you're considering to install termscp I want to thank you 💜 ! I hope you will enjoy termscp!
If you want to contribute to this project, don't forget to check out our contribute guide. [Read More](CONTRIBUTING.md)
### Cargo 🦀
If you are a Linux or a MacOS user this simple shell script will install termscp on your system with a single command:
```sh
# Install termscp through cargo
cargo install termscp
curl --proto '=https' --tlsv1.2 -sSf "https://raw.githubusercontent.com/veeso/termscp/main/install.sh" | sh
```
Requirements:
while if you're a Windows user, you can install termscp with [Chocolatey](https://chocolatey.org/).
- Linux
- pkg-config
- libssh2
- openssl
### Deb package 📦
Get `deb` package from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp_0.4.2_amd64.deb)
or run `wget https://github.com/veeso/termscp/releases/latest/download/termscp_0.4.2_amd64.deb`
then install through dpkg:
```sh
dpkg -i termscp_*.deb
# Or even better with gdebi
gdebi termscp_*.deb
```
### RPM package 📦
Get `rpm` package from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp-0.4.2-1.x86_64.rpm)
or run `wget https://github.com/veeso/termscp/releases/latest/download/termscp-0.4.2-1.x86_64.rpm`
then install through rpm:
```sh
rpm -U termscp_*.rpm
```
### AUR Package 🔼
On Arch Linux based distribution, you can install termscp using for istance [yay](https://github.com/Jguer/yay), which I recommend to install AUR packages.
```sh
yay -S termscp
```
### Chocolatey 🍫
You can install TermSCP on Windows using [chocolatey](https://chocolatey.org/)
Start PowerShell as administrator and run
```ps
choco install termscp
```
Alternatively you can download the ZIP file from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp.0.4.2.nupkg)
and then with PowerShell started with administrator previleges, run:
```ps
choco install termscp -s .
```
### Brew 🍻
You can install TermSCP on MacOS using [brew](https://brew.sh/)
From your terminal run
```sh
brew install veeso/termscp/termscp
```
For more information or other platforms, please visit [veeso.github.io](https://veeso.github.io/termscp/#get-started) to view all installation methods.
---
## User Manual 🎓
## Buy me a coffee ☕
[Click here](docs/man.md) to read the user manual!
If you like termscp and you'd love to see the project to grow, please consider a little donation 🥳
What you will find:
- CLI options
- Keybindings
- Bookmarks
- Configuration
[![Buy-me-a-coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=&slug=veeso&button_colour=404040&font_colour=ffffff&font_family=Comic&outline_colour=ffffff&coffee_colour=FFDD00)](https://www.buymeacoffee.com/veeso)
---
@@ -176,25 +87,21 @@ The developer documentation can be found on Rust Docs at <https://docs.rs/termsc
## Known issues 🧻
- `NoSuchFileOrDirectory` on connect (WSL): I know about this issue and it's a glitch of WSL I guess. Don't worry about it, just move the termscp executable into another PATH location, such as `/usr/bin`, or install it through the appropriate package format (e.g. deb).
- `NoSuchFileOrDirectory` on connect (WSL1): I know about this issue and it's a glitch of WSL I guess. Don't worry about it, just move the termscp executable into another PATH location, such as `/usr/bin`, or install it through the appropriate package format (e.g. deb).
---
## Upcoming Features 🧪
- **Themes provider 🎨**: I'm still thinking about how I will implement this, but basically the idea is to have a configuration file where it will be possible
to define the color schema for the entire application. I haven't planned this release yet
- **Local and remote file explorer format 🃏**: From 0.5.0 you will be able to customize the file format for both local and remote hosts.
- **Synchronized browsing of local and remote directories ⌚**: See [Issue 8](https://github.com/veeso/termscp/issues/8)
- **Group file select 🤩**: Possibility to select a group of files in explorers to operate on
Major termscp updates will now be seasonal, so expect 4 major updates during the year.
No other new feature is planned at the moment. I actually think that termscp is getting mature and now I should focus upcoming updates more on bug fixing and
code/performance improvements than on new features.
Anyway there are some ideas which I'd like to implement. If you want to start working on them, feel free to open a PR:
- **Keyring-rs on Linux 🔐**: Planned for the *summer update*, check for updates in [this issue](https://github.com/veeso/termscp/issues/2)
- **Samba Support 🎉**: This will require a long time to be implemented, since I'm thinking of implementing a Rust native samba library from scratch, since I don't want to add new C-bindings. It'll maybe included in the *summer update*.
- **Themes provider 🎨**: I'm still thinking about how I will implement this, but basically the idea is to have a configuration file where it will be possible to define the color schema for the entire application. I haven't planned this release yet
- **Configuration profile for bookmarks 📚**: I would like to, but I still have to analyze it.
- **AWS S3 support 🪣**: There is already a library for AWS S3, but this is really on bottom of my implementation list at the moment, due to interest and I don't really have a system where to test it.
- Amazon S3 support
- Samba support
- Themes provider
Along to new features, termscp developments is now focused on UI and performance improvements, so if you have any suggestion, feel free to open an issue.
---
@@ -209,13 +116,13 @@ Please follow [our contributing guidelines](CONTRIBUTING.md)
## Changelog ⏳
View TermSCP's changelog [HERE](CHANGELOG.md)
View termscp's changelog [HERE](CHANGELOG.md)
---
## Powered by 🚀
## Powered by 💪
TermSCP is powered by these aweseome projects:
termscp is powered by these aweseome projects:
- [bytesize](https://github.com/hyunsik/bytesize)
- [crossterm](https://github.com/crossterm-rs/crossterm)
@@ -226,7 +133,9 @@ TermSCP is powered by these aweseome projects:
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [textwrap](https://github.com/mgeisler/textwrap)
- [tui-rs](https://github.com/fdehau/tui-rs)
- [tui-realm](https://github.com/veeso/tui-realm)
- [whoami](https://github.com/libcala/whoami)
- [wildmatch](https://github.com/becheran/wildmatch)
---
@@ -250,14 +159,6 @@ TermSCP is powered by these aweseome projects:
---
## Buy me a coffee ☕
If you like termscp and you'd love to see the project to grow, please consider a little donation 🥳
[![Buy-me-a-coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=&slug=veeso&button_colour=404040&font_colour=ffffff&font_family=Comic&outline_colour=ffffff&coffee_colour=FFDD00)](https://www.buymeacoffee.com/veeso)
---
## License 📃
termscp is licensed under the MIT license.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -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.4.2
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
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.4.2/termscp-0.4.2-x86_64.tar.gz
source = https://github.com/veeso/termscp/releases/download/v0.5.0/termscp-0.5.0-x86_64.tar.gz
sha256sums = c72f78a4707402f7f970a883899f4f1583fd9eca6166cb7f7616be97cabf768a
pkgname = termscp

View File

@@ -1,8 +1,8 @@
# Maintainer: Christian Visintin
pkgname=termscp
pkgver=0.4.2
pkgver=0.5.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."
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")

View File

@@ -3,18 +3,18 @@
Document audience: developers
- [Developer Manual](#developer-manual)
- [How TermSCP works](#how-termscp-works)
- [How termscp works](#how-termscp-works)
- [Activities](#activities)
- [The Context](#the-context)
- [Tests fails due to receivers](#tests-fails-due-to-receivers)
- [Implementing File Transfers](#implementing-file-transfers)
Welcome to the developer manual for TermSCP. This chapter DOESN'T contain the documentation for TermSCP modules, which can instead be found on Rust Docs at <https://docs.rs/termscp>
This chapter describes how TermSCP works and the guide lines to implement stuff such as file transfers and add features to the user interface.
Welcome to the developer manual for termscp. This chapter DOESN'T contain the documentation for termscp modules, which can instead be found on Rust Docs at <https://docs.rs/termscp>
This chapter describes how termscp works and the guide lines to implement stuff such as file transfers and add features to the user interface.
## How TermSCP works
## How termscp works
TermSCP is basically made up of 4 components:
termscp is basically made up of 4 components:
- the **filetransfer**: the filetransfer takes care of managing the remote file system; it provides function to establish a connection with the remote, operating on the remote server file system (e.g. remove files, make directories, rename files, ...), read files and write files. The FileTransfer, as we'll see later, is actually a trait, and for each protocol a FileTransfer must be implement the trait.
- the **host**: the host module provides functions to interact with the local host file system.
@@ -70,7 +70,7 @@ Yes. This happens quite often and is related to the fact that I'm using public S
## Implementing File Transfers
This chapter describes how to implement a file transfer in TermSCP. A file transfer is a module which implements the `FileTransfer` trait. The file transfer provides different modules to interact with a remote server, which in addition to the most obvious methods, used to download and upload files, provides also methods to list files, delete files, create directories etc.
This chapter describes how to implement a file transfer in termscp. A file transfer is a module which implements the `FileTransfer` trait. The file transfer provides different modules to interact with a remote server, which in addition to the most obvious methods, used to download and upload files, provides also methods to list files, delete files, create directories etc.
In the following steps I will describe how to implement a new file transfer, in this case I will be implementing the SCP file transfer (which I'm actually implementing the moment I'm writing this lines).
@@ -81,7 +81,7 @@ In the following steps I will describe how to implement a new file transfer, in
```rs
/// ## FileTransferProtocol
///
/// This enum defines the different transfer protocol available in TermSCP
/// This enum defines the different transfer protocol available in termscp
#[derive(std::cmp::PartialEq, std::fmt::Debug, std::clone::Clone)]
pub enum FileTransferProtocol {
Sftp,

View File

@@ -1,31 +1,17 @@
# User manual 🎓
- [User manual 🎓](#user-manual-)
- [Usage ❓](#usage-)
- [Address argument 🌎](#address-argument-)
- [How Password can be provided 🔐](#how-password-can-be-provided-)
- [Keybindings ⌨](#keybindings-)
- [Bookmarks ⭐](#bookmarks-)
- [Are my passwords Safe 😈](#are-my-passwords-safe-)
- [Configuration ⚙️](#configuration-)
- [SSH Key Storage 🔐](#ssh-key-storage-)
- [File Explorer Format](#file-explorer-format)
- [Text Editor ✏](#text-editor-)
- [How do I configure the text editor 🦥](#how-do-i-configure-the-text-editor-)
---
## Usage ❓
TermSCP can be started with the following options:
termscp can be started with the following options:
`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]`
- `-P, --password <password>` if address is provided, password will be this argument
- `-q, --quiet` Disable logging
- `-v, --version` Print version info
- `-h, --help` Print help page
TermSCP can be started in two different mode, if no extra arguments is provided, TermSCP will show the authentication form, where the user will be able to provide the parameters required to connect to the remote peer.
termscp can be started in two different mode, if no extra arguments is provided, termscp will show the authentication form, where the user will be able to provide the parameters required to connect to the remote peer.
Alternatively, the user can provide an address as argument to skip the authentication form and starting directly the connection to the remote server.
@@ -76,7 +62,18 @@ Password can be basically provided through 3 ways when address argument is provi
---
## Keybindings ⌨
## File explorer 📂
When we refer to file explorers in termscp, we refer to the panels you can see after establishing a connection with the remote.
These panels are basically 3 (yes, three actually):
- Local explorer panel: it is displayed on the left of your screen and shows the current directory entries for localhost
- Remote explorer panel: it is displayed on the right of your screen and shows the current directory entries for the remote host.
- Find results panel: depending on where you're searching for files (local/remote) it will replace the local or the explorer panel. This panel shows the entries matching the search query you performed.
In order to change panel you need to type `<LEFT>` to move the remote explorer panel and `<RIGHT>` to move back to the local explorer panel. Whenever you are in the find results panel, you need to press `<ESC>` to exit panel and go back to the previous panel.
### Keybindings ⌨
| Key | Command | Reminder |
|---------------|-------------------------------------------------------|-------------|
@@ -100,23 +97,45 @@ Password can be basically provided through 3 ways when address argument is provi
| `<G>` | Go to supplied path | Go to |
| `<H>` | Show help | Help |
| `<I>` | Show info about selected file or directory | Info |
| `<L>` | Reload current directory's content | List |
| `<L>` | Reload current directory's content / Clear selection | List |
| `<M>` | Select a file | Mark |
| `<N>` | Create new file with provided name | New |
| `<O>` | Edit file; see [Text editor](#text-editor-) | Open |
| `<Q>` | Quit TermSCP | Quit |
| `<O>` | Edit file; see Text editor | Open |
| `<Q>` | Quit termscp | Quit |
| `<R>` | Rename file | Rename |
| `<S>` | Save file as... | Save |
| `<U>` | Go to parent directory | Upper |
| `<X>` | Execute a command | eXecute |
| `<Y>` | Toggle synchronized browsing | sYnc |
| `<DEL>` | Delete file | |
| `<CTRL+A>` | Select all files | |
| `<CTRL+C>` | Abort file transfer process | |
### Work on multiple files 🥷
You can opt to work on multiple files, selecting them pressing `<M>`, in order to select the current file, or pressing `<CTRL+A>`, which will select all the files in the working directory.
Once a file is marked for selection, it will be displayed with a `*` on the left.
When working on selection, only selected file will be processed for actions, while the current highlighted item will be ignored.
It is possible to work on multiple files also when in the find result panel.
All the actions are available when working with multiple files, but be aware that some actions work in a slightly different way. Let's dive in:
- *Copy*: whenever you copy a file, you'll be prompted to insert the destination name. When working with multiple file, this name refers to the destination directory where all these files will be copied.
- *Rename*: same as copy, but will move files there.
- *Save as*: same as copy, but will write them there.
### Synchronized browsing ⏲️
When enabled, synchronized browsing, will allow you to synchronize the navigation between the two panels.
This means that whenever you'll change the working directory on one panel, the same action will be reproduced on the other panel. If you want to enable synchronized browsing just press `<Y>`; press twice to disable. While enabled, the synchronized browising state will be reported on the status bar on `ON`.
*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.
---
## Bookmarks ⭐
In TermSCP it is possible to save favourites hosts, which can be then loaded quickly from the main layout of termscp.
TermSCP will also save the last 16 hosts you connected to.
In termscp it is possible to save favourites hosts, which can be then loaded quickly from the main layout of termscp.
termscp will also save the last 16 hosts you connected to.
This feature allows you to load all the parameters required to connect to a certain remote, simply selecting the bookmark in the tab under the authentication form.
Bookmarks will be saved, if possible at:
@@ -145,7 +164,7 @@ In order to create a new bookmark, just follow these steps:
whenever you want to use the previously saved connection, just press `<TAB>` to navigate to the bookmarks list and load the bookmark parameters into the form pressing `<ENTER>`.
![Bookmarks](assets/images/bookmarks.gif)
![Bookmarks](https://github.com/veeso/termscp/blob/main/assets/images/bookmarks.gif?raw=true)
### Are my passwords Safe 😈
@@ -161,7 +180,7 @@ Actually [keyring-rs](https://github.com/hwchen/keyring-rs), supports Linux, but
## Configuration ⚙️
TermSCP supports some user defined parameters, which can be defined in the configuration.
termscp supports some user defined parameters, which can be defined in the configuration.
Underhood termscp has a TOML file and some other directories where all the parameters will be saved, but don't worry, you won't touch any of these files manually, since I made possible to configure termscp from its user interface entirely.
termscp, like for bookmarks, just requires to have these paths accessible:
@@ -196,7 +215,7 @@ You can access the SSH key storage, from configuration moving to the `SSH Keys`
### File Explorer Format
It is possible through configuration to define a custom format for the file explorer. This field, with name `File formatter syntax` will define how the file entries will be displayed in the file explorer.
It is possible through configuration to define a custom format for the file explorer. This is possible both for local and remote host, so you can have two different syntax in use. These fields, with name `File formatter syntax (local)` and `File formatter syntax (remote)` will define how the file entries will be displayed in the file explorer.
The syntax for the formatter is the following `{KEY1}... {KEY2:LENGTH}... {KEY3:LENGTH:EXTRA} {KEYn}...`.
Each key in bracket will be replaced with the related attribute, while everything outside brackets will be left unchanged.
@@ -222,11 +241,41 @@ If left empty, the default formatter syntax will be used: `{NAME:24} {PEX} {USER
## 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.
In case the file is located on remote host, the file will be first downloaded into your temporary file directory and then, **only** if changes were made to the file, re-uploaded to the remote host. TermSCP checks if you made changes to the file verifying the last modification time of the file.
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.
In case the file is located on remote host, the file will be first downloaded into your temporary file directory and then, **only** if changes were made to the file, re-uploaded to the remote host. termscp checks if you made changes to the file verifying the last modification time of the file.
Just a reminder: **you can edit only textual file**; binary files are not supported.
### How do I configure the text editor 🦥
Text editor is automatically found using this [awesome crate](https://github.com/milkey-mouse/edit), if you want to change the text editor to use, change it in termscp configuration. [Read more](#configuration-)
---
## Logging 🩺
termscp writes a log file for each session, which is written at
- `$HOME/.config/termscp/termscp.log` on Linux/BSD
- `$HOME/Library/Application Support/termscp/termscp.log` on MacOs
- `FOLDERID_RoamingAppData\termscp\termscp.log` on Windows
the log won't be rotated, but will just be truncated after each launch of termscp, so if you want to report an issue and you want to attach your log file, keep in mind to save the log file in a safe place before using termscp again.
The log file always reports in *trace* level, so it is kinda verbose.
I know you might have some questions regarding log files, so I made a kind of a Q/A:
> Is it possible to reduce verbosity?
No. The reason is quite simple: when an issue happens, you must be able to know what's causing it and the only way to do that, is to have the log file with the maximum verbosity level set.
> If trace level is set for logging, is the file going to reach a huge size?
Probably not, unless you never quit termscp, but I think that's likely to happne. A long session may produce up to 10MB of log files (I said a long session), but I think a normal session won't exceed 2MB.
> I don't want logging, can I turn it off?
Yes, you can. Just start termscp with `-q or --quiet` option. You can alias termscp to make it persistent. Remember that logging is used to diagnose issues, so since behind every open source project, there should always be this kind of mutual help, keeping log files might be your way to support the project 😉. I don't want you to feel guilty, but just to say.
> Is logging safe?
If you're concerned about security, the log file doesn't contain any plain password, so don't worry and exposes the same information the sibling file `bookmarks` reports.

View File

@@ -8,7 +8,7 @@
# -f, -y, --force, --yes
# Skip the confirmation prompt during installation
TERMSCP_VERSION="0.4.2"
TERMSCP_VERSION="0.5.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"
@@ -177,7 +177,7 @@ install_on_linux() {
local msg
local sudo
local archive
if "${ARCH}" != "x86_64"; then
if [ "${ARCH}" != "x86_64" ]; then
try_with_cargo "we don't distribute packages for ${ARCH} at the moment"
elif has yay; then
info "Detected yay on your system"
@@ -246,7 +246,8 @@ install_on_macos() {
if has brew; then
if has termscp; then
info "Upgrading termscp..."
brew update && brew upgrade termscp
# The OR is used since someone could have installed via cargo previously
brew update && brew upgrade termscp || brew install veeso/termscp/termscp
else
info "Installing termscp..."
brew install veeso/termscp/termscp

View File

@@ -31,8 +31,8 @@ use crate::host::{HostError, Localhost};
use crate::system::config_client::ConfigClient;
use crate::system::environment;
use crate::ui::activities::{
auth_activity::AuthActivity, filetransfer_activity::FileTransferActivity,
setup_activity::SetupActivity, Activity, ExitReason,
auth::AuthActivity, filetransfer::FileTransferActivity, setup::SetupActivity, Activity,
ExitReason,
};
use crate::ui::context::{Context, FileTransferParams};
@@ -56,6 +56,7 @@ pub enum NextActivity {
pub struct ActivityManager {
context: Option<Context>,
interval: Duration,
local_dir: PathBuf,
}
impl ActivityManager {
@@ -64,19 +65,19 @@ impl ActivityManager {
/// Initializes a new Activity Manager
pub fn new(local_dir: &Path, interval: Duration) -> Result<ActivityManager, HostError> {
// Prepare Context
let host: Localhost = match Localhost::new(local_dir.to_path_buf()) {
Ok(h) => h,
Err(e) => return Err(e),
};
// Initialize configuration client
let (config_client, error): (Option<ConfigClient>, Option<String>) =
match Self::init_config_client() {
Ok(cli) => (Some(cli), None),
Err(err) => (None, Some(err)),
Err(err) => {
error!("Failed to initialize config client: {}", err);
(None, Some(err))
}
};
let ctx: Context = Context::new(host, config_client, error);
let ctx: Context = Context::new(config_client, error);
Ok(ActivityManager {
context: Some(ctx),
local_dir: local_dir.to_path_buf(),
interval,
})
}
@@ -133,6 +134,7 @@ impl ActivityManager {
/// Returns when activity terminates.
/// Returns the next activity to run
fn run_authentication(&mut self) -> Option<NextActivity> {
info!("Starting AuthActivity...");
// Prepare activity
let mut activity: AuthActivity = AuthActivity::default();
// Prepare result
@@ -140,7 +142,10 @@ impl ActivityManager {
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => return None,
None => {
error!("Failed to start AuthActivity: context is None");
return None;
}
};
// Create activity
activity.on_create(ctx);
@@ -151,16 +156,19 @@ impl ActivityManager {
if let Some(exit_reason) = activity.will_umount() {
match exit_reason {
ExitReason::Quit => {
info!("AuthActivity terminated due to 'Quit'");
result = None;
break;
}
ExitReason::EnterSetup => {
// User requested activity
info!("AuthActivity terminated due to 'EnterSetup'");
result = Some(NextActivity::SetupActivity);
break;
}
ExitReason::Connect => {
// User submitted, set next activity
info!("AuthActivity terminated due to 'Connect'");
result = Some(NextActivity::FileTransfer);
break;
}
@@ -172,6 +180,7 @@ impl ActivityManager {
}
// Destroy activity
self.context = activity.on_destroy();
info!("AuthActivity destroyed");
result
}
@@ -181,19 +190,35 @@ impl ActivityManager {
/// Returns when activity terminates.
/// Returns the next activity to run
fn run_filetransfer(&mut self) -> Option<NextActivity> {
info!("Starting FileTransferActivity");
// Get context
let ctx: Context = match self.context.take() {
let mut ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => return None,
None => {
error!("Failed to start FileTransferActivity: context is None");
return None;
}
};
// If ft params is None, return None
let ft_params: &FileTransferParams = match ctx.ft_params.as_ref() {
Some(ft_params) => &ft_params,
None => return None,
None => {
error!("Failed to start FileTransferActivity: file transfer params is None");
return None;
}
};
// Prepare activity
let protocol: FileTransferProtocol = ft_params.protocol;
let mut activity: FileTransferActivity = FileTransferActivity::new(protocol);
let host: Localhost = match Localhost::new(self.local_dir.clone()) {
Ok(host) => host,
Err(err) => {
// Set error in context
error!("Failed to initialize localhost: {}", err);
ctx.set_error(format!("Could not initialize localhost: {}", err));
return None;
}
};
let mut activity: FileTransferActivity = FileTransferActivity::new(host, protocol);
// Prepare result
let result: Option<NextActivity>;
// Create activity
@@ -205,11 +230,13 @@ impl ActivityManager {
if let Some(exit_reason) = activity.will_umount() {
match exit_reason {
ExitReason::Quit => {
info!("FileTransferActivity terminated due to 'Quit'");
result = None;
break;
}
ExitReason::Disconnect => {
// User disconnected, set next activity to authentication
info!("FileTransferActivity terminated due to 'Authentication'");
result = Some(NextActivity::Authentication);
break;
}
@@ -235,7 +262,10 @@ impl ActivityManager {
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => return None,
None => {
error!("Failed to start SetupActivity: context is None");
return None;
}
};
// Create activity
activity.on_create(ctx);
@@ -244,6 +274,7 @@ impl ActivityManager {
activity.on_draw();
// Check if activity has terminated
if let Some(ExitReason::Quit) = activity.will_umount() {
info!("SetupActivity terminated due to 'Quit'");
break;
}
// Sleep for ticks

View File

@@ -119,6 +119,7 @@ impl std::fmt::Display for SerializerError {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_bookmarks_bookmark_new() {

View File

@@ -50,6 +50,7 @@ impl BookmarkSerializer {
))
}
};
trace!("Serialized new bookmarks data: {}", data);
// Write file
match writable.write_all(data.as_bytes()) {
Ok(_) => Ok(()),
@@ -72,9 +73,13 @@ impl BookmarkSerializer {
err.to_string(),
));
}
trace!("Read bookmarks from file: {}", data);
// Deserialize
match toml::de::from_str(data.as_str()) {
Ok(hosts) => Ok(hosts),
Ok(bookmarks) => {
debug!("Read bookmarks from file {:?}", bookmarks);
Ok(bookmarks)
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::SyntaxError,
err.to_string(),
@@ -91,6 +96,7 @@ mod tests {
use super::super::Bookmark;
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::io::{Seek, SeekFrom};

View File

@@ -60,7 +60,8 @@ pub struct UserInterfaceConfig {
pub show_hidden_files: bool,
pub check_for_updates: Option<bool>, // @! Since 0.3.3
pub group_dirs: Option<String>,
pub file_fmt: Option<String>,
pub file_fmt: Option<String>, // Refers to local host (for backward compatibility)
pub remote_file_fmt: Option<String>, // @! Since 0.5.0
}
#[derive(Deserialize, Serialize, std::fmt::Debug)]
@@ -92,6 +93,7 @@ impl Default for UserInterfaceConfig {
check_for_updates: Some(true),
group_dirs: None,
file_fmt: None,
remote_file_fmt: None,
}
}
}
@@ -161,6 +163,7 @@ impl std::fmt::Display for SerializerError {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::env;
#[test]
@@ -178,6 +181,7 @@ mod tests {
check_for_updates: Some(true),
group_dirs: Some(String::from("first")),
file_fmt: Some(String::from("{NAME}")),
remote_file_fmt: Some(String::from("{USER}")),
};
let cfg: UserConfig = UserConfig {
user_interface: ui,
@@ -196,6 +200,10 @@ mod tests {
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]
@@ -218,6 +226,8 @@ mod tests {
);
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]

View File

@@ -50,6 +50,7 @@ impl ConfigSerializer {
))
}
};
trace!("Serialized new configuration data: {}", data);
// Write file
match writable.write_all(data.as_bytes()) {
Ok(_) => Ok(()),
@@ -72,9 +73,13 @@ impl ConfigSerializer {
err.to_string(),
));
}
trace!("Read configuration from file: {}", data);
// Deserialize
match toml::de::from_str(data.as_str()) {
Ok(hosts) => Ok(hosts),
Ok(config) => {
debug!("Read config from file {:?}", config);
Ok(config)
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::SyntaxError,
err.to_string(),
@@ -90,6 +95,7 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::io::{Seek, SeekFrom};
use std::path::PathBuf;
@@ -114,6 +120,10 @@ mod tests {
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
@@ -149,7 +159,8 @@ mod tests {
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_eq!(cfg.user_interface.file_fmt, None);
assert!(cfg.user_interface.file_fmt.is_none());
assert!(cfg.user_interface.remote_file_fmt.is_none());
// Verify keys
assert_eq!(
*cfg.remote
@@ -208,6 +219,7 @@ mod tests {
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"

View File

@@ -34,6 +34,7 @@ extern crate regex;
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
use crate::fs::{FsDirectory, FsEntry, FsFile};
use crate::utils::fmt::{fmt_time, shadow_password};
use crate::utils::parser::{parse_datetime, parse_lstime};
// Includes
@@ -105,6 +106,7 @@ impl FtpFileTransfer {
lazy_static! {
static ref LS_RE: Regex = Regex::new(r#"^([\-ld])([\-rwxs]{9})\s+(\d+)\s+(\w+)\s+(\w+)\s+(\d+)\s+(\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+(.+)$"#).unwrap();
}
debug!("Parsing LIST (UNIX) line: '{}'", line);
// Apply regex to result
match LS_RE.captures(line) {
// String matches regex
@@ -182,12 +184,12 @@ impl FtpFileTransfer {
};
// Check if file_name is '.' or '..'
if file_name.as_str() == "." || file_name.as_str() == ".." {
debug!("File name is {}; ignoring entry", file_name);
return Err(());
}
// Get symlink
let symlink: Option<Box<FsEntry>> = match symlink_path {
None => None,
Some(p) => Some(Box::new(match p.to_string_lossy().ends_with('/') {
let symlink: Option<Box<FsEntry>> = symlink_path.map(|p| {
Box::new(match p.to_string_lossy().ends_with('/') {
true => {
// NOTE: is_dir becomes true
is_dir = true;
@@ -226,8 +228,8 @@ impl FtpFileTransfer {
group: gid,
unix_pex: Some(unix_pex),
}),
})),
};
})
});
let mut abs_path: PathBuf = PathBuf::from(path);
abs_path.push(file_name.as_str());
let abs_path: PathBuf = Self::resolve(abs_path.as_path());
@@ -237,6 +239,19 @@ impl FtpFileTransfer {
.extension()
.map(|s| String::from(s.to_string_lossy()));
// Return
debug!("Follows LIST line '{}' attributes", line);
debug!("Is directory? {}", is_dir);
debug!("Is symlink? {}", is_symlink);
debug!("name: {}", file_name);
debug!("abs_path: {}", abs_path.display());
debug!("last_change_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("last_access_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("creation_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("symlink: {:?}", symlink);
debug!("user: {:?}", uid);
debug!("group: {:?}", gid);
debug!("unix_pex: {:?}", unix_pex);
debug!("---------------------------------------");
// Push to entries
Ok(match is_dir {
true => FsEntry::Directory(FsDirectory {
@@ -288,6 +303,7 @@ impl FtpFileTransfer {
)
.unwrap();
}
debug!("Parsing LIST (DOS) line: '{}'", line);
// Apply regex to result
match DOS_RE.captures(line) {
// String matches regex
@@ -325,6 +341,14 @@ impl FtpFileTransfer {
.as_path()
.extension()
.map(|s| String::from(s.to_string_lossy()));
debug!("Follows LIST line '{}' attributes", line);
debug!("Is directory? {}", is_dir);
debug!("name: {}", file_name);
debug!("abs_path: {}", abs_path.display());
debug!("last_change_time: {}", fmt_time(time, "%Y-%m-%dT%H:%M:%S"));
debug!("last_access_time: {}", fmt_time(time, "%Y-%m-%dT%H:%M:%S"));
debug!("creation_time: {}", fmt_time(time, "%Y-%m-%dT%H:%M:%S"));
debug!("---------------------------------------");
// Return entry
Ok(match is_dir {
true => FsEntry::Directory(FsDirectory {
@@ -383,17 +407,20 @@ impl FileTransfer for FtpFileTransfer {
password: Option<String>,
) -> Result<Option<String>, FileTransferError> {
// Get stream
info!("Connecting to {}:{}", address, port);
let mut stream: FtpStream = match FtpStream::connect(format!("{}:{}", address, port)) {
Ok(stream) => stream,
Err(err) => {
error!("Failed to connect: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
err.to_string(),
))
));
}
};
// If SSL, open secure session
if self.ftps {
info!("Setting up TLS stream...");
let ctx = match TlsConnector::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
@@ -401,19 +428,21 @@ impl FileTransfer for FtpFileTransfer {
{
Ok(tls) => tls,
Err(err) => {
error!("Failed to setup TLS stream: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::SslError,
err.to_string(),
))
));
}
};
stream = match stream.into_secure(ctx, address.as_str()) {
Ok(s) => s,
Err(err) => {
error!("Failed to setup TLS stream: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::SslError,
err.to_string(),
))
));
}
};
}
@@ -426,14 +455,22 @@ impl FileTransfer for FtpFileTransfer {
Some(pwd) => pwd,
None => String::new(),
};
info!(
"Signin in with username: {}, password: {}",
username,
shadow_password(password.as_str())
);
if let Err(err) = stream.login(username.as_str(), password.as_str()) {
error!("Login failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
err.to_string(),
));
}
debug!("Setting transfer type to Binary");
// Initialize file type
if let Err(err) = stream.transfer_type(FileType::Binary) {
error!("Failed to set transfer type to binary: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ProtocolError,
err.to_string(),
@@ -441,6 +478,7 @@ impl FileTransfer for FtpFileTransfer {
}
// Set stream
self.stream = Some(stream);
info!("Connection successfully established");
// Return OK
Ok(self.stream.as_ref().unwrap().get_welcome_msg())
}
@@ -450,6 +488,7 @@ impl FileTransfer for FtpFileTransfer {
/// Disconnect from the remote server
fn disconnect(&mut self) -> Result<(), FileTransferError> {
info!("Disconnecting from FTP server...");
match &mut self.stream {
Some(stream) => match stream.quit() {
Ok(_) => Ok(()),
@@ -476,6 +515,7 @@ impl FileTransfer for FtpFileTransfer {
/// Print working directory
fn pwd(&mut self) -> Result<PathBuf, FileTransferError> {
info!("PWD");
match &mut self.stream {
Some(stream) => match stream.pwd() {
Ok(path) => Ok(PathBuf::from(path.as_str())),
@@ -496,6 +536,7 @@ impl FileTransfer for FtpFileTransfer {
fn change_dir(&mut self, dir: &Path) -> Result<PathBuf, FileTransferError> {
let dir: PathBuf = Self::resolve(dir);
info!("Changing directory to {}", dir.display());
match &mut self.stream {
Some(stream) => match stream.cwd(&dir.as_path().to_string_lossy()) {
Ok(_) => Ok(dir),
@@ -515,6 +556,7 @@ impl FileTransfer for FtpFileTransfer {
/// Copy file to destination
fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> {
// FTP doesn't support file copy
debug!("COPY issues (will fail, since unsupported)");
Err(FileTransferError::new(
FileTransferErrorType::UnsupportedFeature,
))
@@ -526,9 +568,11 @@ impl FileTransfer for FtpFileTransfer {
fn list_dir(&mut self, path: &Path) -> Result<Vec<FsEntry>, FileTransferError> {
let dir: PathBuf = Self::resolve(path);
info!("LIST dir {}", dir.display());
match &mut self.stream {
Some(stream) => match stream.list(Some(&dir.as_path().to_string_lossy())) {
Ok(entries) => {
debug!("Got {} lines in LIST result", entries.len());
// Prepare result
let mut result: Vec<FsEntry> = Vec::with_capacity(entries.len());
// Iterate over entries
@@ -537,6 +581,11 @@ impl FileTransfer for FtpFileTransfer {
result.push(file);
}
}
debug!(
"{} out of {} were valid entries",
result.len(),
entries.len()
);
Ok(result)
}
Err(err) => Err(FileTransferError::new_ex(
@@ -555,6 +604,7 @@ impl FileTransfer for FtpFileTransfer {
/// Make directory
fn mkdir(&mut self, dir: &Path) -> Result<(), FileTransferError> {
let dir: PathBuf = Self::resolve(dir);
info!("MKDIR {}", dir.display());
match &mut self.stream {
Some(stream) => match stream.mkdir(&dir.as_path().to_string_lossy()) {
Ok(_) => Ok(()),
@@ -578,9 +628,11 @@ impl FileTransfer for FtpFileTransfer {
FileTransferErrorType::UninitializedSession,
));
}
info!("Removing entry {}", fsentry.get_abs_path().display());
match fsentry {
// Match fs entry...
FsEntry::File(file) => {
debug!("entry is a file; removing file");
// Remove file directly
match self.stream.as_mut().unwrap().rm(file.name.as_ref()) {
Ok(_) => Ok(()),
@@ -592,9 +644,11 @@ impl FileTransfer for FtpFileTransfer {
}
FsEntry::Directory(dir) => {
// Get directory files
debug!("Entry is a directory; iterating directory entries");
match self.list_dir(dir.abs_path.as_path()) {
Ok(files) => {
// Remove recursively files
debug!("Removing {} entries from directory...", files.len());
for file in files.iter() {
if let Err(err) = self.remove(&file) {
return Err(FileTransferError::new_ex(
@@ -604,6 +658,7 @@ impl FileTransfer for FtpFileTransfer {
}
}
// Once all files in directory have been deleted, remove directory
debug!("Finally removing directory {}", dir.name);
match self.stream.as_mut().unwrap().rmdir(dir.name.as_str()) {
Ok(_) => Ok(()),
Err(err) => Err(FileTransferError::new_ex(
@@ -626,6 +681,11 @@ impl FileTransfer for FtpFileTransfer {
/// Rename file or a directory
fn rename(&mut self, file: &FsEntry, dst: &Path) -> Result<(), FileTransferError> {
let dst: PathBuf = Self::resolve(dst);
info!(
"Renaming {} to {}",
file.get_abs_path().display(),
dst.display()
);
match &mut self.stream {
Some(stream) => {
// Get name
@@ -692,6 +752,7 @@ impl FileTransfer for FtpFileTransfer {
file_name: &Path,
) -> Result<Box<dyn Write>, FileTransferError> {
let file_name: PathBuf = Self::resolve(file_name);
info!("Sending file {}", file_name.display());
match &mut self.stream {
Some(stream) => match stream.put_with_stream(&file_name.as_path().to_string_lossy()) {
Ok(writer) => Ok(Box::new(writer)), // NOTE: don't use BufWriter here, since already returned by the library
@@ -711,6 +772,7 @@ impl FileTransfer for FtpFileTransfer {
/// Receive file from remote with provided name
/// Returns file and its size
fn recv_file(&mut self, file: &FsFile) -> Result<Box<dyn Read>, FileTransferError> {
info!("Receiving file {}", file.abs_path.display());
match &mut self.stream {
Some(stream) => match stream.get(&file.abs_path.as_path().to_string_lossy()) {
Ok(reader) => Ok(Box::new(reader)), // NOTE: don't use BufReader here, since already returned by the library
@@ -733,6 +795,7 @@ impl FileTransfer for FtpFileTransfer {
/// This is necessary for some protocols such as FTP.
/// You must call this method each time you want to finalize the write of the remote file.
fn on_sent(&mut self, writable: Box<dyn Write>) -> Result<(), FileTransferError> {
info!("Finalizing put stream");
match &mut self.stream {
Some(stream) => match stream.finalize_put_stream(writable) {
Ok(_) => Ok(()),
@@ -755,6 +818,7 @@ impl FileTransfer for FtpFileTransfer {
/// This mighe be necessary for some protocols.
/// You must call this method each time you want to finalize the read of the remote file.
fn on_recv(&mut self, readable: Box<dyn Read>) -> Result<(), FileTransferError> {
info!("Finalizing get");
match &mut self.stream {
Some(stream) => match stream.finalize_get(readable) {
Ok(_) => Ok(()),
@@ -775,6 +839,8 @@ mod tests {
use super::*;
use crate::utils::fmt::fmt_time;
use pretty_assertions::assert_eq;
use std::time::Duration;
#[test]

View File

@@ -41,7 +41,7 @@ pub mod sftp_transfer;
/// ## FileTransferProtocol
///
/// This enum defines the different transfer protocol available in TermSCP
/// This enum defines the different transfer protocol available in termscp
#[derive(PartialEq, std::fmt::Debug, std::clone::Clone, Copy)]
pub enum FileTransferProtocol {
@@ -59,11 +59,19 @@ pub struct FileTransferError {
msg: Option<String>,
}
impl FileTransferError {
/// ### kind
///
/// Returns the error kind
pub fn kind(&self) -> FileTransferErrorType {
self.code
}
}
/// ## FileTransferErrorType
///
/// FileTransferErrorType defines the possible errors available for a file transfer
#[allow(dead_code)]
#[derive(Error, Debug)]
#[derive(Error, Debug, Clone, Copy, PartialEq)]
pub enum FileTransferErrorType {
#[error("Authentication failed")]
AuthenticationFailed,
@@ -77,8 +85,6 @@ pub enum FileTransferErrorType {
DirStatFailed,
#[error("Failed to create file")]
FileCreateDenied,
#[error("IO error: {0}")]
IoErr(std::io::Error),
#[error("No such file or directory")]
NoSuchFileOrDirectory,
#[error("Not enough permissions")]
@@ -313,14 +319,14 @@ impl std::string::ToString for FileTransferProtocol {
}
impl std::str::FromStr for FileTransferProtocol {
type Err = ();
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"FTP" => Ok(FileTransferProtocol::Ftp(false)),
"FTPS" => Ok(FileTransferProtocol::Ftp(true)),
"SCP" => Ok(FileTransferProtocol::Scp),
"SFTP" => Ok(FileTransferProtocol::Sftp),
_ => Err(()),
_ => Err(s.to_string()),
}
}
}
@@ -332,6 +338,7 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::str::FromStr;
use std::string::ToString;
@@ -396,13 +403,13 @@ mod tests {
#[test]
fn test_filetransfer_mod_error() {
let err: FileTransferError = FileTransferError::new_ex(
FileTransferErrorType::IoErr(std::io::Error::from(std::io::ErrorKind::AddrInUse)),
FileTransferErrorType::NoSuchFileOrDirectory,
String::from("non va una mazza"),
);
assert_eq!(*err.msg.as_ref().unwrap(), String::from("non va una mazza"));
assert_eq!(
format!("{}", err),
String::from("IO error: address in use (non va una mazza)")
String::from("No such file or directory (non va una mazza)")
);
assert_eq!(
format!(
@@ -481,5 +488,7 @@ mod tests {
),
String::from("Unsupported feature")
);
let err = FileTransferError::new(FileTransferErrorType::UnsupportedFeature);
assert_eq!(err.kind(), FileTransferErrorType::UnsupportedFeature);
}
}

View File

@@ -35,6 +35,7 @@ extern crate ssh2;
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
use crate::fs::{FsDirectory, FsEntry, FsFile};
use crate::system::sshkey_storage::SshKeyStorage;
use crate::utils::fmt::{fmt_time, shadow_password};
use crate::utils::parser::parse_lstime;
// Includes
@@ -90,6 +91,7 @@ impl ScpFileTransfer {
lazy_static! {
static ref LS_RE: Regex = Regex::new(r#"^([\-ld])([\-rwxs]{9})\s+(\d+)\s+(\w+)\s+(\w+)\s+(\d+)\s+(\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+(.+)$"#).unwrap();
}
debug!("Parsing LS line: '{}'", line);
// Apply regex to result
match LS_RE.captures(line) {
// String matches regex
@@ -167,6 +169,7 @@ impl ScpFileTransfer {
};
// Check if file_name is '.' or '..'
if file_name.as_str() == "." || file_name.as_str() == ".." {
debug!("File name is {}; ignoring entry", file_name);
return Err(());
}
// Get symlink; PATH mustn't be equal to filename
@@ -200,6 +203,19 @@ impl ScpFileTransfer {
.extension()
.map(|s| String::from(s.to_string_lossy()));
// Return
debug!("Follows LS line '{}' attributes", line);
debug!("Is directory? {}", is_dir);
debug!("Is symlink? {}", is_symlink);
debug!("name: {}", file_name);
debug!("abs_path: {}", abs_path.display());
debug!("last_change_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("last_access_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("creation_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("symlink: {:?}", symlink);
debug!("user: {:?}", uid);
debug!("group: {:?}", gid);
debug!("unix_pex: {:?}", unix_pex);
debug!("---------------------------------------");
// Push to entries
Ok(match is_dir {
true => FsEntry::Directory(FsDirectory {
@@ -262,6 +278,7 @@ impl ScpFileTransfer {
fn perform_shell_cmd(&mut self, cmd: &str) -> Result<String, FileTransferError> {
match self.session.as_mut() {
Some(session) => {
debug!("Running command: {}", cmd);
// Create channel
let mut channel: Channel = match session.channel_session() {
Ok(ch) => ch,
@@ -285,6 +302,7 @@ impl ScpFileTransfer {
Ok(_) => {
// Wait close
let _ = channel.wait_close();
debug!("Command output: {}", output);
Ok(output)
}
Err(err) => Err(FileTransferError::new_ex(
@@ -312,6 +330,7 @@ impl FileTransfer for ScpFileTransfer {
password: Option<String>,
) -> Result<Option<String>, FileTransferError> {
// Setup tcp stream
info!("Connecting to {}:{}", address, port);
let socket_addresses: Vec<SocketAddr> =
match format!("{}:{}", address, port).to_socket_addrs() {
Ok(s) => s.collect(),
@@ -325,8 +344,10 @@ impl FileTransfer for ScpFileTransfer {
let mut tcp: Option<TcpStream> = None;
// Try addresses
for socket_addr in socket_addresses.iter() {
debug!("Trying socket address {}", socket_addr);
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
Ok(stream) => {
debug!("{} succeded", socket_addr);
tcp = Some(stream);
break;
}
@@ -337,26 +358,30 @@ impl FileTransfer for ScpFileTransfer {
let tcp: TcpStream = match tcp {
Some(t) => t,
None => {
error!("No suitable socket address found; connection timeout");
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
String::from("Connection timeout"),
))
));
}
};
// Create session
let mut session: Session = match Session::new() {
Ok(s) => s,
Err(err) => {
error!("Could not create session: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
err.to_string(),
))
));
}
};
// Set TCP stream
session.set_tcp_stream(tcp);
// Open connection
debug!("Initializing handshake");
if let Err(err) = session.handshake() {
error!("Handshake failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
err.to_string(),
@@ -372,6 +397,11 @@ impl FileTransfer for ScpFileTransfer {
.resolve(address.as_str(), username.as_str())
{
Some(rsa_key) => {
debug!(
"Authenticating with user {} and RSA key {}",
username,
rsa_key.display()
);
// Authenticate with RSA key
if let Err(err) = session.userauth_pubkey_file(
username.as_str(),
@@ -379,6 +409,7 @@ impl FileTransfer for ScpFileTransfer {
rsa_key.as_path(),
password.as_deref(),
) {
error!("Authentication failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
err.to_string(),
@@ -387,10 +418,16 @@ impl FileTransfer for ScpFileTransfer {
}
None => {
// Proceeed with username/password authentication
debug!(
"Authenticating with username {} and password {}",
username,
shadow_password(password.as_deref().unwrap_or(""))
);
if let Err(err) = session.userauth_password(
username.as_str(),
password.unwrap_or_else(|| String::from("")).as_str(),
) {
error!("Authentication failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
err.to_string(),
@@ -400,13 +437,22 @@ impl FileTransfer for ScpFileTransfer {
}
// Get banner
let banner: Option<String> = session.banner().map(String::from);
debug!(
"Connection established: {}",
banner.as_deref().unwrap_or("")
);
// Set session
self.session = Some(session);
// Get working directory
debug!("Getting working directory...");
match self.perform_shell_cmd("pwd") {
Ok(output) => self.wrkdir = PathBuf::from(output.as_str().trim()),
Err(err) => return Err(err),
}
info!(
"Connection established; working directory: {}",
self.wrkdir.display()
);
Ok(banner)
}
@@ -414,6 +460,7 @@ impl FileTransfer for ScpFileTransfer {
///
/// Disconnect from the remote server
fn disconnect(&mut self) -> Result<(), FileTransferError> {
info!("Disconnecting from remote...");
match self.session.as_ref() {
Some(session) => {
// Disconnect (greet server with 'Mandi' as they do in Friuli)
@@ -447,6 +494,7 @@ impl FileTransfer for ScpFileTransfer {
/// Print working directory
fn pwd(&mut self) -> Result<PathBuf, FileTransferError> {
info!("PWD: {}", self.wrkdir.display());
match self.is_connected() {
true => Ok(self.wrkdir.clone()),
false => Err(FileTransferError::new(
@@ -471,6 +519,7 @@ impl FileTransfer for ScpFileTransfer {
Self::resolve(p.as_path())
}
};
info!("Changing working directory to {}", remote_path.display());
// Change directory
match self.perform_shell_cmd_with_path(
p.as_path(),
@@ -484,6 +533,7 @@ impl FileTransfer for ScpFileTransfer {
true => {
// Set working directory
self.wrkdir = PathBuf::from(&output.as_str()[1..].trim());
info!("Changed working directory to {}", self.wrkdir.display());
Ok(self.wrkdir.clone())
}
false => Err(FileTransferError::new_ex(
@@ -512,6 +562,11 @@ impl FileTransfer for ScpFileTransfer {
match self.is_connected() {
true => {
let dst: PathBuf = Self::resolve(dst);
info!(
"Copying {} to {}",
src.get_abs_path().display(),
dst.display()
);
// Run `cp -rf`
let p: PathBuf = self.wrkdir.clone();
match self.perform_shell_cmd_with_path(
@@ -555,6 +610,7 @@ impl FileTransfer for ScpFileTransfer {
match self.is_connected() {
true => {
// Send ls -l to path
info!("Getting file entries in {}", path.display());
let path: PathBuf = Self::resolve(path);
let p: PathBuf = self.wrkdir.clone();
match self.perform_shell_cmd_with_path(
@@ -572,6 +628,11 @@ impl FileTransfer for ScpFileTransfer {
entries.push(entry);
}
}
info!(
"Found {} out of {} valid file entries",
entries.len(),
lines.len()
);
Ok(entries)
}
Err(err) => Err(FileTransferError::new_ex(
@@ -594,6 +655,7 @@ impl FileTransfer for ScpFileTransfer {
match self.is_connected() {
true => {
let dir: PathBuf = Self::resolve(dir);
info!("Making directory {}", dir.display());
let p: PathBuf = self.wrkdir.clone();
// Mkdir dir && echo 0
match self.perform_shell_cmd_with_path(
@@ -632,6 +694,7 @@ impl FileTransfer for ScpFileTransfer {
true => {
// Get path
let path: PathBuf = file.get_abs_path();
info!("Removing file {}", path.display());
let p: PathBuf = self.wrkdir.clone();
match self.perform_shell_cmd_with_path(
p.as_path(),
@@ -669,6 +732,7 @@ impl FileTransfer for ScpFileTransfer {
// Get path
let dst: PathBuf = Self::resolve(dst);
let path: PathBuf = file.get_abs_path();
info!("Renaming {} to {}", path.display(), dst.display());
let p: PathBuf = self.wrkdir.clone();
match self.perform_shell_cmd_with_path(
p.as_path(),
@@ -717,6 +781,7 @@ impl FileTransfer for ScpFileTransfer {
match self.is_connected() {
true => {
let p: PathBuf = self.wrkdir.clone();
info!("Stat {}", path.display());
// make command; Directories require `-d` option
let cmd: String = match path.to_string_lossy().ends_with('/') {
true => format!("ls -ld \"{}\"", path.display()),
@@ -729,7 +794,7 @@ impl FileTransfer for ScpFileTransfer {
Some(p) => PathBuf::from(p),
None => {
return Err(FileTransferError::new_ex(
FileTransferErrorType::UnsupportedFeature,
FileTransferErrorType::DirStatFailed,
String::from("Path has no parent"),
))
}
@@ -760,6 +825,7 @@ impl FileTransfer for ScpFileTransfer {
match self.is_connected() {
true => {
let p: PathBuf = self.wrkdir.clone();
info!("Executing command {}", cmd);
match self.perform_shell_cmd_with_path(p.as_path(), cmd) {
Ok(output) => Ok(output),
Err(err) => Err(FileTransferError::new_ex(
@@ -788,7 +854,13 @@ impl FileTransfer for ScpFileTransfer {
match self.session.as_ref() {
Some(session) => {
let file_name: PathBuf = Self::resolve(file_name);
info!(
"Sending file {} to {}",
local.abs_path.display(),
file_name.display()
);
// Set blocking to true
debug!("blocking channel...");
session.set_blocking(true);
// Calculate file mode
let mode: i32 = match local.unix_pex {
@@ -818,6 +890,10 @@ impl FileTransfer for ScpFileTransfer {
Ok(metadata) => metadata.len(),
Err(_) => local.size as u64, // NOTE: fallback to fsentry size
};
debug!(
"File mode {:?}; mtime: {}, atime: {}; file size: {}",
mode, times.0, times.1, file_size
);
// Send file
match session.scp_send(file_name.as_path(), mode, file_size, Some(times)) {
Ok(channel) => Ok(Box::new(BufWriter::with_capacity(65536, channel))),
@@ -840,7 +916,9 @@ impl FileTransfer for ScpFileTransfer {
fn recv_file(&mut self, file: &FsFile) -> Result<Box<dyn Read>, FileTransferError> {
match self.session.as_ref() {
Some(session) => {
info!("Receiving file {}", file.abs_path.display());
// Set blocking to true
debug!("Set blocking...");
session.set_blocking(true);
match session.scp_recv(file.abs_path.as_path()) {
Ok(reader) => Ok(Box::new(BufReader::with_capacity(65536, reader.0))),
@@ -885,6 +963,7 @@ impl FileTransfer for ScpFileTransfer {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_filetransfer_scp_new() {

View File

@@ -32,6 +32,7 @@ extern crate ssh2;
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
use crate::fs::{FsDirectory, FsEntry, FsFile};
use crate::system::sshkey_storage::SshKeyStorage;
use crate::utils::fmt::{fmt_time, shadow_password};
// Includes
use ssh2::{Channel, FileStat, OpenFlags, OpenType, Session, Sftp};
@@ -159,6 +160,19 @@ impl SftpFileTransfer {
}
false => None,
};
debug!("Follows {} attributes", path.display());
debug!("Is directory? {}", metadata.is_dir());
debug!("Is symlink? {}", is_symlink);
debug!("name: {}", file_name);
debug!("abs_path: {}", path.display());
debug!("last_change_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("last_access_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("creation_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
debug!("symlink: {:?}", symlink);
debug!("user: {:?}", uid);
debug!("group: {:?}", gid);
debug!("unix_pex: {:?}", pex);
debug!("---------------------------------------");
// Is a directory?
match metadata.is_dir() {
true => FsEntry::Directory(FsDirectory {
@@ -205,6 +219,7 @@ impl SftpFileTransfer {
match self.session.as_mut() {
Some(session) => {
// Create channel
debug!("Running command: {}", cmd);
let mut channel: Channel = match session.channel_session() {
Ok(ch) => ch,
Err(err) => {
@@ -227,6 +242,7 @@ impl SftpFileTransfer {
Ok(_) => {
// Wait close
let _ = channel.wait_close();
debug!("Command output: {}", output);
Ok(output)
}
Err(err) => Err(FileTransferError::new_ex(
@@ -254,6 +270,7 @@ impl FileTransfer for SftpFileTransfer {
password: Option<String>,
) -> Result<Option<String>, FileTransferError> {
// Setup tcp stream
info!("Connecting to {}:{}", address, port);
let socket_addresses: Vec<SocketAddr> =
match format!("{}:{}", address, port).to_socket_addrs() {
Ok(s) => s.collect(),
@@ -267,6 +284,7 @@ impl FileTransfer for SftpFileTransfer {
let mut tcp: Option<TcpStream> = None;
// Try addresses
for socket_addr in socket_addresses.iter() {
debug!("Trying socket address {}", socket_addr);
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
Ok(stream) => {
tcp = Some(stream);
@@ -279,26 +297,30 @@ impl FileTransfer for SftpFileTransfer {
let tcp: TcpStream = match tcp {
Some(t) => t,
None => {
error!("No suitable socket address found; connection timeout");
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
String::from("Connection timeout"),
))
));
}
};
// Create session
let mut session: Session = match Session::new() {
Ok(s) => s,
Err(err) => {
error!("Could not create session: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
err.to_string(),
))
));
}
};
// Set TCP stream
session.set_tcp_stream(tcp);
// Open connection
debug!("Initializing handshake");
if let Err(err) = session.handshake() {
error!("Handshake failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ConnectionError,
err.to_string(),
@@ -314,6 +336,11 @@ impl FileTransfer for SftpFileTransfer {
.resolve(address.as_str(), username.as_str())
{
Some(rsa_key) => {
debug!(
"Authenticating with user {} and RSA key {}",
username,
rsa_key.display()
);
// Authenticate with RSA key
if let Err(err) = session.userauth_pubkey_file(
username.as_str(),
@@ -321,6 +348,7 @@ impl FileTransfer for SftpFileTransfer {
rsa_key.as_path(),
password.as_deref(),
) {
error!("Authentication failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
err.to_string(),
@@ -329,10 +357,16 @@ impl FileTransfer for SftpFileTransfer {
}
None => {
// Proceeed with username/password authentication
debug!(
"Authenticating with username {} and password {}",
username,
shadow_password(password.as_deref().unwrap_or(""))
);
if let Err(err) = session.userauth_password(
username.as_str(),
password.unwrap_or_else(|| String::from("")).as_str(),
) {
error!("Authentication failed: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
err.to_string(),
@@ -343,16 +377,19 @@ impl FileTransfer for SftpFileTransfer {
// Set blocking to true
session.set_blocking(true);
// Get Sftp client
debug!("Getting SFTP client...");
let sftp: Sftp = match session.sftp() {
Ok(s) => s,
Err(err) => {
error!("Could not get sftp client: {}", err);
return Err(FileTransferError::new_ex(
FileTransferErrorType::ProtocolError,
err.to_string(),
))
));
}
};
// Get working directory
debug!("Getting working directory...");
self.wrkdir = match sftp.realpath(PathBuf::from(".").as_path()) {
Ok(p) => p,
Err(err) => {
@@ -367,6 +404,11 @@ impl FileTransfer for SftpFileTransfer {
self.session = Some(session);
// Set sftp
self.sftp = Some(sftp);
info!(
"Connection established: {}; working directory {}",
banner.as_deref().unwrap_or(""),
self.wrkdir.display()
);
Ok(banner)
}
@@ -374,6 +416,7 @@ impl FileTransfer for SftpFileTransfer {
///
/// Disconnect from the remote server
fn disconnect(&mut self) -> Result<(), FileTransferError> {
info!("Disconnecting from remote...");
match self.session.as_ref() {
Some(session) => {
// Disconnect (greet server with 'Mandi' as they do in Friuli)
@@ -407,6 +450,7 @@ impl FileTransfer for SftpFileTransfer {
///
/// Print working directory
fn pwd(&mut self) -> Result<PathBuf, FileTransferError> {
info!("PWD: {}", self.wrkdir.display());
match self.sftp {
Some(_) => Ok(self.wrkdir.clone()),
None => Err(FileTransferError::new(
@@ -426,6 +470,7 @@ impl FileTransfer for SftpFileTransfer {
Ok(p) => p,
Err(err) => return Err(err),
};
info!("Changed working directory to {}", self.wrkdir.display());
Ok(self.wrkdir.clone())
}
None => Err(FileTransferError::new(
@@ -437,11 +482,47 @@ impl FileTransfer for SftpFileTransfer {
/// ### copy
///
/// Copy file to destination
fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> {
// SFTP doesn't support file copy
Err(FileTransferError::new(
FileTransferErrorType::UnsupportedFeature,
))
fn copy(&mut self, src: &FsEntry, dst: &Path) -> Result<(), FileTransferError> {
// NOTE: use SCP command to perform copy (UNSAFE)
match self.is_connected() {
true => {
let dst: PathBuf = self.get_abs_path(dst);
info!(
"Copying {} to {}",
src.get_abs_path().display(),
dst.display()
);
// Run `cp -rf`
match self.perform_shell_cmd_with_path(
format!(
"cp -rf \"{}\" \"{}\"; echo $?",
src.get_abs_path().display(),
dst.display()
)
.as_str(),
) {
Ok(output) =>
// Check if output is 0
{
match output.as_str().trim() == "0" {
true => Ok(()), // File copied
false => Err(FileTransferError::new_ex(
// Could not copy file
FileTransferErrorType::FileCreateDenied,
format!("\"{}\"", dst.display()),
)),
}
}
Err(err) => Err(FileTransferError::new_ex(
FileTransferErrorType::ProtocolError,
err.to_string(),
)),
}
}
false => Err(FileTransferError::new(
FileTransferErrorType::UninitializedSession,
)),
}
}
/// ### list_dir
@@ -455,6 +536,7 @@ impl FileTransfer for SftpFileTransfer {
Ok(p) => p,
Err(err) => return Err(err),
};
info!("Getting file entries in {}", path.display());
// Get files
match sftp.readdir(dir.as_path()) {
Err(err) => Err(FileTransferError::new_ex(
@@ -486,6 +568,7 @@ impl FileTransfer for SftpFileTransfer {
Some(sftp) => {
// Make directory
let path: PathBuf = self.get_abs_path(PathBuf::from(dir).as_path());
info!("Making directory {}", path.display());
match sftp.mkdir(path.as_path(), 0o775) {
Ok(_) => Ok(()),
Err(err) => Err(FileTransferError::new_ex(
@@ -510,6 +593,7 @@ impl FileTransfer for SftpFileTransfer {
));
}
// Match if file is a file or a directory
info!("Removing file {}", file.get_abs_path().display());
match file {
FsEntry::File(f) => {
// Remove file
@@ -523,6 +607,7 @@ impl FileTransfer for SftpFileTransfer {
}
FsEntry::Directory(d) => {
// Remove recursively
debug!("{} is a directory; removing all directory entries", d.name);
// Get directory files
let directory_content: Vec<FsEntry> = match self.list_dir(d.abs_path.as_path()) {
Ok(entries) => entries,
@@ -554,6 +639,11 @@ impl FileTransfer for SftpFileTransfer {
FileTransferErrorType::UninitializedSession,
)),
Some(sftp) => {
info!(
"Moving {} to {}",
file.get_abs_path().display(),
dst.display()
);
// Resolve destination path
let abs_dst: PathBuf = self.get_abs_path(dst);
// Get abs path of entry
@@ -580,6 +670,7 @@ impl FileTransfer for SftpFileTransfer {
Ok(p) => p,
Err(err) => return Err(err),
};
info!("Stat file {}", dir.display());
// Get file
match sftp.stat(dir.as_path()) {
Ok(metadata) => Ok(self.make_fsentry(dir.as_path(), &metadata)),
@@ -599,6 +690,7 @@ impl FileTransfer for SftpFileTransfer {
///
/// Execute a command on remote host
fn exec(&mut self, cmd: &str) -> Result<String, FileTransferError> {
info!("Executing command {}", cmd);
match self.is_connected() {
true => match self.perform_shell_cmd_with_path(cmd) {
Ok(output) => Ok(output),
@@ -629,14 +721,20 @@ impl FileTransfer for SftpFileTransfer {
)),
Some(sftp) => {
let remote_path: PathBuf = self.get_abs_path(file_name);
info!(
"Sending file {} to {}",
local.abs_path.display(),
remote_path.display()
);
// Calculate file mode
let mode: i32 = match local.unix_pex {
None => 0o644,
Some((u, g, o)) => ((u as i32) << 6) + ((g as i32) << 3) + (o as i32),
};
debug!("File mode {:?}", mode);
match sftp.open_mode(
remote_path.as_path(),
OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::APPEND | OpenFlags::TRUNCATE,
OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE,
mode,
OpenType::File,
) {
@@ -664,6 +762,7 @@ impl FileTransfer for SftpFileTransfer {
Ok(p) => p,
Err(err) => return Err(err),
};
info!("Receiving file {}", remote_path.display());
// Open remote file
match sftp.open(remote_path.as_path()) {
Ok(file) => Ok(Box::new(BufReader::with_capacity(65536, file))),
@@ -702,6 +801,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_filetransfer_sftp_new() {
let client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());

View File

@@ -117,6 +117,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_fs_explorer_builder_new_default() {
let explorer: FileExplorer = FileExplorerBuilder::new().build();

View File

@@ -539,6 +539,8 @@ mod tests {
use super::*;
use crate::fs::{FsDirectory, FsFile};
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::time::SystemTime;

View File

@@ -361,6 +361,7 @@ mod tests {
use crate::fs::{FsDirectory, FsFile};
use crate::utils::fmt::fmt_time;
use pretty_assertions::assert_eq;
use std::thread::sleep;
use std::time::{Duration, SystemTime};

View File

@@ -232,6 +232,7 @@ impl FsEntry {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_fs_fsentry_dir() {

View File

@@ -68,7 +68,7 @@ pub enum HostErrorType {
/// ### HostError
///
/// HostError is a wrapper for the error type and the exact io error
#[derive(Debug)]
pub struct HostError {
pub error: HostErrorType,
ioerr: Option<std::io::Error>,
@@ -125,12 +125,17 @@ impl Localhost {
///
/// Instantiates a new Localhost struct
pub fn new(wrkdir: PathBuf) -> Result<Localhost, HostError> {
debug!("Initializing localhost at {}", wrkdir.display());
let mut host: Localhost = Localhost {
wrkdir,
files: Vec::new(),
};
// Check if dir exists
if !host.file_exists(host.wrkdir.as_path()) {
error!(
"Failed to initialize localhost: {} doesn't exist",
host.wrkdir.display()
);
return Err(HostError::new(
HostErrorType::NoSuchFileOrDirectory,
None,
@@ -140,8 +145,15 @@ impl Localhost {
// Retrieve files for provided path
host.files = match host.scan_dir(host.wrkdir.as_path()) {
Ok(files) => files,
Err(err) => return Err(err),
Err(err) => {
error!(
"Failed to initialize localhost: could not scan wrkdir: {}",
err
);
return Err(err);
}
};
info!("Localhost initialized with success");
Ok(host)
}
@@ -165,8 +177,10 @@ impl Localhost {
/// Change working directory with the new provided directory
pub fn change_wrkdir(&mut self, new_dir: &Path) -> Result<PathBuf, HostError> {
let new_dir: PathBuf = self.to_abs_path(new_dir);
info!("Changing localhost directory to {}...", new_dir.display());
// Check whether directory exists
if !self.file_exists(new_dir.as_path()) {
error!("Could not change directory: No such file or directory");
return Err(HostError::new(
HostErrorType::NoSuchFileOrDirectory,
None,
@@ -174,10 +188,11 @@ impl Localhost {
));
}
// Change directory
if std::env::set_current_dir(new_dir.as_path()).is_err() {
if let Err(err) = std::env::set_current_dir(new_dir.as_path()) {
error!("Could not enter directory: {}", err);
return Err(HostError::new(
HostErrorType::NoSuchFileOrDirectory,
None,
Some(err),
new_dir.as_path(),
));
}
@@ -189,11 +204,13 @@ impl Localhost {
self.files = match self.scan_dir(self.wrkdir.as_path()) {
Ok(files) => files,
Err(err) => {
error!("Could not scan new directory: {}", err);
// Restore directory
self.wrkdir = prev_dir;
return Err(err);
}
};
debug!("Changed directory to {}", self.wrkdir.display());
Ok(self.wrkdir.clone())
}
@@ -210,6 +227,7 @@ impl Localhost {
/// ignex: don't report error if directory already exists
pub fn mkdir_ex(&mut self, dir_name: &Path, ignex: bool) -> Result<(), HostError> {
let dir_path: PathBuf = self.to_abs_path(dir_name);
info!("Making directory {}", dir_path.display());
// If dir already exists, return Error
if dir_path.exists() {
match ignex {
@@ -229,13 +247,17 @@ impl Localhost {
if dir_name.is_relative() {
self.files = self.scan_dir(self.wrkdir.as_path())?;
}
info!("Created directory {}", dir_path.display());
Ok(())
}
Err(err) => Err(HostError::new(
HostErrorType::CouldNotCreateFile,
Some(err),
dir_path.as_path(),
)),
Err(err) => {
error!("Could not make directory: {}", err);
Err(HostError::new(
HostErrorType::CouldNotCreateFile,
Some(err),
dir_path.as_path(),
))
}
}
}
@@ -246,7 +268,9 @@ impl Localhost {
match entry {
FsEntry::Directory(dir) => {
// If file doesn't exist; return error
debug!("Removing directory {}", dir.abs_path.display());
if !dir.abs_path.as_path().exists() {
error!("Directory doesn't exist");
return Err(HostError::new(
HostErrorType::NoSuchFileOrDirectory,
None,
@@ -258,18 +282,24 @@ impl Localhost {
Ok(_) => {
// Update dir
self.files = self.scan_dir(self.wrkdir.as_path())?;
info!("Removed directory {}", dir.abs_path.display());
Ok(())
}
Err(err) => Err(HostError::new(
HostErrorType::DeleteFailed,
Some(err),
dir.abs_path.as_path(),
)),
Err(err) => {
error!("Could not remove directory: {}", err);
Err(HostError::new(
HostErrorType::DeleteFailed,
Some(err),
dir.abs_path.as_path(),
))
}
}
}
FsEntry::File(file) => {
// If file doesn't exist; return error
debug!("Removing file {}", file.abs_path.display());
if !file.abs_path.as_path().exists() {
error!("File doesn't exist");
return Err(HostError::new(
HostErrorType::NoSuchFileOrDirectory,
None,
@@ -281,13 +311,17 @@ impl Localhost {
Ok(_) => {
// Update dir
self.files = self.scan_dir(self.wrkdir.as_path())?;
info!("Removed file {}", file.abs_path.display());
Ok(())
}
Err(err) => Err(HostError::new(
HostErrorType::DeleteFailed,
Some(err),
file.abs_path.as_path(),
)),
Err(err) => {
error!("Could not remove file: {}", err);
Err(HostError::new(
HostErrorType::DeleteFailed,
Some(err),
file.abs_path.as_path(),
))
}
}
}
}
@@ -302,13 +336,26 @@ impl Localhost {
Ok(_) => {
// Scan dir
self.files = self.scan_dir(self.wrkdir.as_path())?;
debug!(
"Moved file {} to {}",
entry.get_abs_path().display(),
dst_path.display()
);
Ok(())
}
Err(err) => Err(HostError::new(
HostErrorType::CouldNotCreateFile,
Some(err),
abs_path.as_path(),
)),
Err(err) => {
error!(
"Failed to move {} to {}: {}",
entry.get_abs_path().display(),
dst_path.display(),
err
);
Err(HostError::new(
HostErrorType::CouldNotCreateFile,
Some(err),
abs_path.as_path(),
))
}
}
}
@@ -318,6 +365,11 @@ impl Localhost {
pub fn copy(&mut self, entry: &FsEntry, dst: &Path) -> Result<(), HostError> {
// Get absolute path of dest
let dst: PathBuf = self.to_abs_path(dst);
info!(
"Copying file {} to {}",
entry.get_abs_path().display(),
dst.display()
);
// Match entry
match entry {
FsEntry::File(file) => {
@@ -333,16 +385,19 @@ impl Localhost {
};
// Copy entry path to dst path
if let Err(err) = std::fs::copy(file.abs_path.as_path(), dst.as_path()) {
error!("Failed to copy file: {}", err);
return Err(HostError::new(
HostErrorType::CouldNotCreateFile,
Some(err),
file.abs_path.as_path(),
));
}
info!("File copied");
}
FsEntry::Directory(dir) => {
// If destination path doesn't exist, create destination
if !dst.exists() {
debug!("Directory {} doesn't exist; creating it", dst.display());
self.mkdir(dst.as_path())?;
}
// Scan dir
@@ -386,15 +441,17 @@ impl Localhost {
/// Stat file and create a FsEntry
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
pub fn stat(&self, path: &Path) -> Result<FsEntry, HostError> {
info!("Stating file {}", path.display());
let path: PathBuf = self.to_abs_path(path);
let attr: Metadata = match fs::metadata(path.as_path()) {
Ok(metadata) => metadata,
Err(err) => {
error!("Could not read file metadata: {}", err);
return Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
path.as_path(),
))
));
}
};
let file_name: String = String::from(path.file_name().unwrap().to_str().unwrap_or(""));
@@ -454,14 +511,16 @@ impl Localhost {
#[cfg(not(tarpaulin_include))]
pub fn stat(&self, path: &Path) -> Result<FsEntry, HostError> {
let path: PathBuf = self.to_abs_path(path);
info!("Stating file {}", path.display());
let attr: Metadata = match fs::metadata(path.as_path()) {
Ok(metadata) => metadata,
Err(err) => {
error!("Could not read file metadata: {}", err);
return Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
path.as_path(),
))
));
}
};
let file_name: String = String::from(path.file_name().unwrap().to_str().unwrap_or(""));
@@ -523,16 +582,23 @@ impl Localhost {
let args: Vec<&str> = cmd.split(' ').collect();
let cmd: &str = args.first().unwrap();
let argv: &[&str] = &args[1..];
info!("Executing command: {} {:?}", cmd, argv);
match std::process::Command::new(cmd).args(argv).output() {
Ok(output) => match std::str::from_utf8(&output.stdout) {
Ok(s) => Ok(s.to_string()),
Ok(s) => {
info!("Command output: {}", s);
Ok(s.to_string())
}
Err(_) => Ok(String::new()),
},
Err(err) => Err(HostError::new(
HostErrorType::ExecutionFailed,
Some(err),
self.wrkdir.as_path(),
)),
Err(err) => {
error!("Failed to run command: {}", err);
Err(HostError::new(
HostErrorType::ExecutionFailed,
Some(err),
self.wrkdir.as_path(),
))
}
}
}
@@ -548,19 +614,32 @@ impl Localhost {
let mut mpex = metadata.permissions();
mpex.set_mode(self.mode_to_u32(pex));
match set_permissions(path.as_path(), mpex) {
Ok(_) => Ok(()),
Err(err) => Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
path.as_path(),
)),
Ok(_) => {
info!("Changed mode for {} to {:?}", path.display(), pex);
Ok(())
}
Err(err) => {
error!("Could not change mode for file {}: {}", path.display(), err);
Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
path.as_path(),
))
}
}
}
Err(err) => Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
path.as_path(),
)),
Err(err) => {
error!(
"Chmod failed; could not read metadata for file {}: {}",
path.display(),
err
);
Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
path.as_path(),
))
}
}
}
@@ -569,7 +648,9 @@ impl Localhost {
/// Open file for read
pub fn open_file_read(&self, file: &Path) -> Result<File, HostError> {
let file: PathBuf = self.to_abs_path(file);
info!("Opening file {} for read", file.display());
if !self.file_exists(file.as_path()) {
error!("File doesn't exist!");
return Err(HostError::new(
HostErrorType::NoSuchFileOrDirectory,
None,
@@ -583,11 +664,14 @@ impl Localhost {
.open(file.as_path())
{
Ok(f) => Ok(f),
Err(err) => Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
file.as_path(),
)),
Err(err) => {
error!("Could not open file for read: {}", err);
Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
file.as_path(),
))
}
}
}
@@ -596,6 +680,7 @@ impl Localhost {
/// Open file for write
pub fn open_file_write(&self, file: &Path) -> Result<File, HostError> {
let file: PathBuf = self.to_abs_path(file);
info!("Opening file {} for write", file.display());
match OpenOptions::new()
.create(true)
.write(true)
@@ -603,18 +688,21 @@ impl Localhost {
.open(file.as_path())
{
Ok(f) => Ok(f),
Err(err) => match self.file_exists(file.as_path()) {
true => Err(HostError::new(
HostErrorType::ReadonlyFile,
Some(err),
file.as_path(),
)),
false => Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
file.as_path(),
)),
},
Err(err) => {
error!("Failed to open file: {}", err);
match self.file_exists(file.as_path()) {
true => Err(HostError::new(
HostErrorType::ReadonlyFile,
Some(err),
file.as_path(),
)),
false => Err(HostError::new(
HostErrorType::FileNotAccessible,
Some(err),
file.as_path(),
)),
}
}
}
}
@@ -629,13 +717,15 @@ impl Localhost {
///
/// Get content of the current directory as a list of fs entry
pub fn scan_dir(&self, dir: &Path) -> Result<Vec<FsEntry>, HostError> {
info!("Reading directory {}", dir.display());
match std::fs::read_dir(dir) {
Ok(e) => {
let mut fs_entries: Vec<FsEntry> = Vec::new();
for entry in e.flatten() {
// NOTE: 0.4.1, don't fail if stat for one file fails
if let Ok(entry) = self.stat(entry.path().as_path()) {
fs_entries.push(entry);
match self.stat(entry.path().as_path()) {
Ok(entry) => fs_entries.push(entry),
Err(e) => error!("Failed to stat {}: {}", entry.path().display(), e),
}
}
Ok(fs_entries)
@@ -739,6 +829,8 @@ impl Localhost {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs::File;
use std::io::Write;

View File

@@ -1,3 +1,11 @@
#![doc(html_playground_url = "https://play.rust-lang.org")]
#![doc(
html_favicon_url = "https://raw.githubusercontent.com/veeso/termscp/main/assets/images/termscp-128.png"
)]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/veeso/termscp/main/assets/images/termscp-512.png"
)]
/**
* MIT License
*
@@ -27,6 +35,8 @@ extern crate bitflags;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
#[macro_use]
extern crate magic_crypt;
pub mod activity_manager;

View File

@@ -32,6 +32,8 @@ extern crate bitflags;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
#[macro_use]
extern crate magic_crypt;
extern crate rpassword;
@@ -55,6 +57,7 @@ mod utils;
// namespaces
use activity_manager::{ActivityManager, NextActivity};
use filetransfer::FileTransferProtocol;
use system::logging;
/// ### print_usage
///
@@ -66,6 +69,7 @@ fn print_usage(opts: Options) {
);
print!("{}", opts.usage(&brief));
println!("\nPlease, report issues to <https://github.com/veeso/termscp>");
println!("Please, consider supporting the author <https://www.buymeacoffee.com/veeso>")
}
fn main() {
@@ -78,15 +82,12 @@ fn main() {
let mut remote_wrkdir: Option<PathBuf> = None;
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol
let mut ticks: Duration = Duration::from_millis(10);
let mut log_enabled: bool = true;
//Process options
let mut opts = Options::new();
opts.optopt(
"P",
"password",
"Provide password from CLI (use at your own risk)",
"<password>",
);
opts.optopt("P", "password", "Provide password from CLI", "<password>");
opts.optopt("T", "ticks", "Set UI ticks; default 10ms", "<ms>");
opts.optflag("q", "quiet", "Disable logging");
opts.optflag("v", "version", "");
opts.optflag("h", "help", "Print this menu");
let matches = match opts.parse(&args[1..]) {
@@ -104,11 +105,15 @@ fn main() {
// Version
if matches.opt_present("v") {
eprintln!(
"TermSCP - {} - Developed by {}",
"termscp - {} - Developed by {}",
TERMSCP_VERSION, TERMSCP_AUTHORS,
);
std::process::exit(255);
}
// Logging
if matches.opt_present("q") {
log_enabled = false;
}
// Match password
if let Some(passwd) = matches.opt_str("P") {
password = Some(passwd);
@@ -159,9 +164,17 @@ fn main() {
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);
// 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() {
// Ask password if unspecified
password = match rpassword::read_password_from_tty(Some("Password: ")) {
@@ -177,6 +190,10 @@ fn main() {
std::process::exit(255);
}
};
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;
@@ -194,7 +211,9 @@ fn main() {
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);
}

View File

@@ -68,9 +68,11 @@ impl BookmarksClient {
) -> Result<BookmarksClient, SerializerError> {
// 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"))]
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
debug!("Setting up KeyStorage");
let username: String = whoami::username();
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
// Check if keyring storage is supported
@@ -79,8 +81,14 @@ impl BookmarksClient {
#[cfg(test)] // NOTE: when running test, add -test
let app_name: &str = "termscp-test";
match storage.is_supported() {
true => (Box::new(storage), app_name),
false => (Box::new(FileStorage::new(storage_path)), "bookmarks"),
true => {
debug!("Using KeyringStorage");
(Box::new(storage), app_name)
}
false => {
warn!("KeyringStorage is not supported; using FileStorage");
(Box::new(FileStorage::new(storage_path)), "bookmarks")
}
}
};
// Make a key storage (linux / unix)
@@ -90,16 +98,22 @@ impl BookmarksClient {
let app_name: &str = "bookmarks";
#[cfg(test)] // NOTE: when running test, add -test
let app_name: &str = "bookmarks-test";
debug!("Using FileStorage");
(Box::new(FileStorage::new(storage_path)), app_name)
};
// Load key
let key: String = match key_storage.get_key(service_id) {
Ok(k) => k,
Ok(k) => {
debug!("Key loaded with success");
k
}
Err(e) => match e {
KeyStorageError::NoSuchKey => {
// If no such key, generate key and set it into the storage
let key: String = Self::generate_key();
debug!("Key doesn't exist yet or could not be loaded; generated a new key");
if let Err(e) = key_storage.set_key(service_id, key.as_str()) {
error!("Failed to set new key into storage: {}", e);
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
format!("Could not write key to storage: {}", e),
@@ -109,10 +123,11 @@ impl BookmarksClient {
key
}
_ => {
error!("Failed to get key from storage: {}", e);
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
format!("Could not get key from storage: {}", e),
))
));
}
},
};
@@ -124,15 +139,19 @@ impl BookmarksClient {
};
// If bookmark file doesn't exist, initialize it
if !bookmarks_file.exists() {
info!("Bookmarks file doesn't exist yet; creating it...");
if let Err(err) = client.write_bookmarks() {
error!("Failed to create bookmarks file: {}", err);
return Err(err);
}
} else {
// Load bookmarks from file
if let Err(err) = client.read_bookmarks() {
error!("Failed to load bookmarks: {}", err);
return Err(err);
}
}
info!("Bookmarks client initialized");
// Load key
Ok(client)
}
@@ -152,19 +171,29 @@ impl BookmarksClient {
key: &str,
) -> Option<(String, u16, FileTransferProtocol, String, Option<String>)> {
let entry: &Bookmark = self.hosts.bookmarks.get(key)?;
debug!("Getting bookmark {}", key);
Some((
entry.address.clone(),
entry.port,
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
Ok(proto) => proto,
Err(_) => FileTransferProtocol::Sftp, // Default
Err(err) => {
error!(
"Found invalid protocol in bookmarks: {}; defaulting to SFTP",
err
);
FileTransferProtocol::Sftp // Default
}
},
entry.username.clone(),
match &entry.password {
// Decrypted password if Some; if decryption fails return None
Some(pwd) => match self.decrypt_str(pwd.as_str()) {
Ok(decrypted_pwd) => Some(decrypted_pwd),
Err(_) => None,
Err(err) => {
error!("Failed to decrypt password for bookmark: {}", err);
None
}
},
None => None,
},
@@ -184,9 +213,11 @@ impl BookmarksClient {
password: Option<String>,
) {
if name.is_empty() {
error!("Fatal error; bookmark name is empty");
panic!("Bookmark name can't be empty");
}
// Make bookmark
info!("Added bookmark {} with address {}", name, addr);
let host: Bookmark = self.make_bookmark(addr, port, protocol, username, password);
self.hosts.bookmarks.insert(name, host);
}
@@ -196,6 +227,7 @@ impl BookmarksClient {
/// Delete entry from bookmarks
pub fn del_bookmark(&mut self, name: &str) {
let _ = self.hosts.bookmarks.remove(name);
info!("Removed bookmark {}", name);
}
/// ### iter_recents
///
@@ -209,13 +241,20 @@ impl BookmarksClient {
/// Get recent associated to key
pub fn get_recent(&self, key: &str) -> Option<(String, u16, FileTransferProtocol, String)> {
// NOTE: password is not decrypted; recents will never have password
info!("Getting bookmark {}", key);
let entry: &Bookmark = self.hosts.recents.get(key)?;
Some((
entry.address.clone(),
entry.port,
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
Ok(proto) => proto,
Err(_) => FileTransferProtocol::Sftp, // Default
Err(err) => {
error!(
"Found invalid protocol in bookmarks: {}; defaulting to SFTP",
err
);
FileTransferProtocol::Sftp // Default
}
},
entry.username.clone(),
))
@@ -236,6 +275,7 @@ impl BookmarksClient {
// Check if duplicated
for recent_host in self.hosts.recents.values() {
if *recent_host == host {
debug!("Discarding recent since duplicated ({})", host.address);
// Don't save duplicates
return;
}
@@ -252,6 +292,7 @@ impl BookmarksClient {
// Delete keys starting from the last one
for key in keys.iter() {
let _ = self.hosts.recents.remove(key);
debug!("Removed recent bookmark {}", key);
// If length is < self.recents_size; break
if self.hosts.recents.len() < self.recents_size {
break;
@@ -259,6 +300,7 @@ impl BookmarksClient {
}
}
let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S");
info!("Saved recent host {} ({})", name, host.address);
self.hosts.recents.insert(name, host);
}
@@ -267,6 +309,7 @@ impl BookmarksClient {
/// Delete entry from recents
pub fn del_recent(&mut self, name: &str) {
let _ = self.hosts.recents.remove(name);
info!("Removed recent host {}", name);
}
/// ### write_bookmarks
@@ -274,6 +317,7 @@ impl BookmarksClient {
/// Write bookmarks to file
pub fn write_bookmarks(&self) -> Result<(), SerializerError> {
// Open file
debug!("Writing bookmarks");
match OpenOptions::new()
.create(true)
.write(true)
@@ -284,10 +328,13 @@ impl BookmarksClient {
let serializer: BookmarkSerializer = BookmarkSerializer {};
serializer.serialize(Box::new(writer), &self.hosts)
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
Err(err) => {
error!("Failed to write bookmarks: {}", err);
Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
))
}
}
}
@@ -296,6 +343,7 @@ impl BookmarksClient {
/// Read bookmarks from file
fn read_bookmarks(&mut self) -> Result<(), SerializerError> {
// Open bookmarks file for read
debug!("Reading bookmarks");
match OpenOptions::new()
.read(true)
.open(self.bookmarks_file.as_path())
@@ -311,10 +359,13 @@ impl BookmarksClient {
Err(err) => Err(err),
}
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
Err(err) => {
error!("Failed to read bookmarks: {}", err);
Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
))
}
}
}
@@ -372,6 +423,8 @@ impl BookmarksClient {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::thread::sleep;
use std::time::Duration;

View File

@@ -58,6 +58,11 @@ impl ConfigClient {
pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result<ConfigClient, SerializerError> {
// Initialize a default configuration
let default_config: UserConfig = UserConfig::default();
info!(
"Setting up config client with config path {} and SSH key directory {}",
config_path.display(),
ssh_key_dir.display()
);
// Create client
let mut client: ConfigClient = ConfigClient {
config: default_config,
@@ -67,6 +72,7 @@ impl ConfigClient {
// If ssh key directory doesn't exist, create it
if !ssh_key_dir.exists() {
if let Err(err) = create_dir(ssh_key_dir) {
error!("Failed to create SSH key dir: {}", err);
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
format!(
@@ -76,17 +82,22 @@ impl ConfigClient {
),
));
}
debug!("Created SSH key directory");
}
// If Config file doesn't exist, create it
if !config_path.exists() {
if let Err(err) = client.write_config() {
error!("Couldn't create configuration file: {}", err);
return Err(err);
}
debug!("Config file didn't exist; created file");
} else {
// otherwise Load configuration from file
if let Err(err) = client.read_config() {
error!("Couldn't read configuration file: {}", err);
return Err(err);
}
debug!("Read configuration file");
}
Ok(client)
}
@@ -176,23 +187,40 @@ impl ConfigClient {
self.config.user_interface.group_dirs = val.map(|val| val.to_string());
}
/// ### get_file_fmt
/// ### get_local_file_fmt
///
/// Get current file fmt
pub fn get_file_fmt(&self) -> Option<String> {
/// Get current file fmt for local host
pub fn get_local_file_fmt(&self) -> Option<String> {
self.config.user_interface.file_fmt.clone()
}
/// ### set_file_fmt
/// ### set_local_file_fmt
///
/// Set file fmt parameter
pub fn set_file_fmt(&mut self, s: String) {
/// Set file fmt parameter for local host
pub fn set_local_file_fmt(&mut self, s: String) {
self.config.user_interface.file_fmt = match s.is_empty() {
true => None,
false => Some(s),
};
}
/// ### get_remote_file_fmt
///
/// Get current file fmt for remote host
pub fn get_remote_file_fmt(&self) -> Option<String> {
self.config.user_interface.remote_file_fmt.clone()
}
/// ### set_remote_file_fmt
///
/// Set file fmt parameter for remote host
pub fn set_remote_file_fmt(&mut self, s: String) {
self.config.user_interface.remote_file_fmt = match s.is_empty() {
true => None,
false => Some(s),
};
}
// SSH Keys
/// ### save_ssh_key
@@ -213,12 +241,18 @@ impl ConfigClient {
p.push(format!("{}.key", host_name));
p
};
info!(
"Writing SSH file to {} for host {}",
ssh_key_path.display(),
host_name
);
// Write key to file
let mut f: File = match File::create(ssh_key_path.as_path()) {
Ok(f) => f,
Err(err) => return Self::make_io_err(err),
};
if let Err(err) = f.write_all(ssh_key.as_bytes()) {
error!("Failed to write SSH key to file: {}", err);
return Self::make_io_err(err);
}
// Add host to keys
@@ -234,6 +268,7 @@ impl ConfigClient {
/// and also commits changes to configuration, to prevent incoerent data
pub fn del_ssh_key(&mut self, host: &str, username: &str) -> Result<(), SerializerError> {
// Remove key from configuration and get key path
info!("Removing key for {}@{}", host, username);
let key_path: PathBuf = match self
.config
.remote
@@ -245,6 +280,7 @@ impl ConfigClient {
};
// Remove file
if let Err(err) = remove_file(key_path.as_path()) {
error!("Failed to remove key file {}: {}", key_path.display(), err);
return Self::make_io_err(err);
}
// Commit changes to configuration
@@ -293,10 +329,13 @@ impl ConfigClient {
let serializer: ConfigSerializer = ConfigSerializer {};
serializer.serialize(Box::new(writer), &self.config)
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
Err(err) => {
error!("Failed to write configuration file: {}", err);
Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
))
}
}
}
@@ -320,10 +359,13 @@ impl ConfigClient {
Err(err) => Err(err),
}
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
Err(err) => {
error!("Failed to read configuration: {}", err);
Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
))
}
}
}
@@ -364,6 +406,7 @@ mod tests {
use crate::config::UserConfig;
use crate::utils::random::random_alphanumeric_with_len;
use pretty_assertions::assert_eq;
use std::io::Read;
#[test]
@@ -496,18 +539,36 @@ mod tests {
}
#[test]
fn test_system_config_file_fmt() {
fn test_system_config_local_file_fmt() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
assert_eq!(client.get_file_fmt(), None);
client.set_file_fmt(String::from("{NAME}"));
assert_eq!(client.get_file_fmt().unwrap(), String::from("{NAME}"));
assert_eq!(client.get_local_file_fmt(), None);
client.set_local_file_fmt(String::from("{NAME}"));
assert_eq!(client.get_local_file_fmt().unwrap(), String::from("{NAME}"));
// Delete
client.set_file_fmt(String::from(""));
assert_eq!(client.get_file_fmt(), None);
client.set_local_file_fmt(String::from(""));
assert_eq!(client.get_local_file_fmt(), None);
}
#[test]
fn test_system_config_remote_file_fmt() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
assert_eq!(client.get_remote_file_fmt(), None);
client.set_remote_file_fmt(String::from("{NAME}"));
assert_eq!(
client.get_remote_file_fmt().unwrap(),
String::from("{NAME}")
);
// Delete
client.set_remote_file_fmt(String::from(""));
assert_eq!(client.get_remote_file_fmt(), None);
}
#[test]

View File

@@ -87,11 +87,21 @@ pub fn get_config_paths(config_dir: &Path) -> (PathBuf, PathBuf) {
(bookmarks_file, keys_dir)
}
/// ### get_log_paths
///
/// Returns the path for the supposed log file
pub fn get_log_paths(config_dir: &Path) -> PathBuf {
let mut log_file: PathBuf = PathBuf::from(config_dir);
log_file.push("termscp.log");
log_file
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs::{File, OpenOptions};
use std::io::Write;
@@ -142,4 +152,12 @@ mod tests {
)
);
}
#[test]
fn test_system_environment_get_log_paths() {
assert_eq!(
get_log_paths(&Path::new("/home/omar/.config/termscp/")),
PathBuf::from("/home/omar/.config/termscp/termscp.log"),
);
}
}

View File

@@ -125,6 +125,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_system_keys_filestorage_make_dir() {
let storage: FileStorage = FileStorage::new(&Path::new("/tmp/"));

View File

@@ -104,6 +104,7 @@ mod tests {
extern crate whoami;
use super::*;
use pretty_assertions::assert_eq;
use whoami::username;
#[test]

View File

@@ -78,6 +78,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_system_keys_mod_errors() {
assert_eq!(

72
src/system/logging.rs Normal file
View File

@@ -0,0 +1,72 @@
//! ## Logging
//!
//! `logging` is the module which initializes the logging system for termscp
/**
* 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::system::environment::{get_log_paths, init_config_dir};
use crate::utils::file::open_file;
// ext
use simplelog::{ConfigBuilder, LevelFilter, WriteLogger};
use std::fs::File;
use std::path::PathBuf;
/// ### init
///
/// Initialize logger
pub fn init() -> Result<(), String> {
// Init config dir
let config_dir: PathBuf = match init_config_dir() {
Ok(Some(p)) => p,
Ok(None) => {
return Err(String::from(
"This system doesn't seem to support CONFIG_DIR",
))
}
Err(err) => return Err(err),
};
let log_file_path: PathBuf = get_log_paths(config_dir.as_path());
// Open log file
let file: File = open_file(log_file_path.as_path(), true, true, false)
.map_err(|e| format!("Failed to open file {}: {}", log_file_path.display(), e))?;
// Prepare log config
let config = ConfigBuilder::new()
.set_time_format_str("%Y-%m-%dT%H:%M:%S%z")
.build();
// Make logger
WriteLogger::init(LevelFilter::Trace, config, file)
.map_err(|e| format!("Failed to initialize logger: {}", e))
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_system_logging_setup() {
assert!(init().is_ok());
}
}

View File

@@ -30,4 +30,5 @@ pub mod bookmarks_client;
pub mod config_client;
pub mod environment;
pub(crate) mod keys;
pub mod logging;
pub mod sshkey_storage;

View File

@@ -42,6 +42,7 @@ impl SshKeyStorage {
pub fn storage_from_config(cfg_client: &ConfigClient) -> Self {
let mut hosts: HashMap<String, PathBuf> =
HashMap::with_capacity(cfg_client.iter_ssh_keys().count());
debug!("Setting up SSH key storage");
// Iterate over keys
for key in cfg_client.iter_ssh_keys() {
match cfg_client.get_ssh_key(key) {
@@ -52,8 +53,12 @@ impl SshKeyStorage {
}
None => continue,
},
Err(_) => continue,
Err(err) => {
error!("Failed to get SSH key for {}: {}", key, err);
continue;
}
}
info!("Got SSH key for {}", key);
}
// Return storage
SshKeyStorage { hosts }
@@ -88,8 +93,9 @@ impl SshKeyStorage {
mod tests {
use super::*;
use crate::system::config_client::ConfigClient;
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]

View File

@@ -32,11 +32,11 @@ extern crate dirs;
use super::{AuthActivity, FileTransferProtocol};
use crate::system::bookmarks_client::BookmarksClient;
use crate::system::environment;
use crate::ui::layout::props::PropValue;
use crate::ui::layout::Payload;
// Ext
use std::path::PathBuf;
use tuirealm::components::{input::InputPropsBuilder, radio::RadioPropsBuilder};
use tuirealm::{Payload, PropsBuilder, Value};
impl AuthActivity {
/// ### del_bookmark
@@ -83,10 +83,10 @@ impl AuthActivity {
let password: Option<String> = match save_password {
true => match self
.view
.get_value(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
.get_state(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
{
Some(Payload::Unsigned(0)) => Some(password), // Yes
_ => None, // No such component / No
Some(Payload::One(Value::Usize(0))) => Some(password), // Yes
_ => None, // No such component / No
},
false => None,
};
@@ -246,32 +246,29 @@ impl AuthActivity {
password: Option<String>,
) {
// Load parameters into components
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_ADDR) {
let props = props.with_value(PropValue::Str(addr)).build();
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_ADDR) {
let props = InputPropsBuilder::from(props).with_value(addr).build();
self.view.update(super::COMPONENT_INPUT_ADDR, props);
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_PORT) {
let props = props.with_value(PropValue::Str(port.to_string())).build();
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PORT) {
let props = InputPropsBuilder::from(props)
.with_value(port.to_string())
.build();
self.view.update(super::COMPONENT_INPUT_PORT, props);
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_PROTOCOL) {
let props = props
.with_value(PropValue::Unsigned(match protocol {
FileTransferProtocol::Sftp => 0,
FileTransferProtocol::Scp => 1,
FileTransferProtocol::Ftp(false) => 2,
FileTransferProtocol::Ftp(true) => 3,
}))
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_PROTOCOL) {
let props = RadioPropsBuilder::from(props)
.with_value(Self::protocol_enum_to_opt(protocol))
.build();
self.view.update(super::COMPONENT_RADIO_PROTOCOL, props);
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_USERNAME) {
let props = props.with_value(PropValue::Str(username)).build();
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_USERNAME) {
let props = InputPropsBuilder::from(props).with_value(username).build();
self.view.update(super::COMPONENT_INPUT_USERNAME, props);
}
if let Some(password) = password {
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_PASSWORD) {
let props = props.with_value(PropValue::Str(password)).build();
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PASSWORD) {
let props = InputPropsBuilder::from(props).with_value(password).build();
self.view.update(super::COMPONENT_INPUT_PASSWORD, props);
}
}

View File

@@ -0,0 +1,71 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/**
* 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::{AuthActivity, FileTransferProtocol};
impl AuthActivity {
/// ### protocol_opt_to_enum
///
/// Convert radio index for protocol into a `FileTransferProtocol`
pub(super) fn protocol_opt_to_enum(protocol: usize) -> FileTransferProtocol {
match protocol {
1 => FileTransferProtocol::Scp,
2 => FileTransferProtocol::Ftp(false),
3 => FileTransferProtocol::Ftp(true),
_ => FileTransferProtocol::Sftp,
}
}
/// ### protocol_enum_to_opt
///
/// Convert `FileTransferProtocol` enum into radio group index
pub(super) fn protocol_enum_to_opt(protocol: FileTransferProtocol) -> usize {
match protocol {
FileTransferProtocol::Sftp => 0,
FileTransferProtocol::Scp => 1,
FileTransferProtocol::Ftp(false) => 2,
FileTransferProtocol::Ftp(true) => 3,
}
}
/// ### get_default_port_for_protocol
///
/// Get the default port for protocol
pub(super) fn get_default_port_for_protocol(protocol: FileTransferProtocol) -> u16 {
match protocol {
FileTransferProtocol::Sftp | FileTransferProtocol::Scp => 22,
FileTransferProtocol::Ftp(_) => 21,
}
}
/// ### is_port_standard
///
/// Returns whether the port is standard or not
pub(super) fn is_port_standard(port: u16) -> bool {
port < 1024
}
}

View File

@@ -27,26 +27,28 @@
*/
// Sub modules
mod bookmarks;
mod misc;
mod update;
mod view;
// Dependencies
extern crate crossterm;
extern crate tui;
extern crate tuirealm;
// locals
use super::{Activity, Context, ExitReason};
use crate::filetransfer::FileTransferProtocol;
use crate::system::bookmarks_client::BookmarksClient;
use crate::ui::context::FileTransferParams;
use crate::ui::layout::view::View;
use crate::utils::git;
// Includes
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use tuirealm::View;
// -- components
const COMPONENT_TEXT_HEADER: &str = "TEXT_HEADER";
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_FOOTER: &str = "TEXT_FOOTER";
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
@@ -106,17 +108,24 @@ impl AuthActivity {
///
/// If enabled in configuration, check for updates from Github
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();
if !ctx.store.isset(STORE_KEY_LATEST_VERSION) {
debug!("Version is not set in storage");
let mut new_version: Option<String> = 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) => version,
Ok(version) => {
info!("Latest version is: {:?}", version);
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(),
);
@@ -125,6 +134,7 @@ impl AuthActivity {
}
}
} else {
info!("Check for updates is disabled");
None
}
}
@@ -147,6 +157,7 @@ impl Activity for AuthActivity {
/// `on_create` must initialize all the data structures used by the activity
/// Context is taken from activity manager and will be released only when activity is destroyed
fn on_create(&mut self, mut context: Context) {
debug!("Initializing activity");
// Initialize file transfer params
context.ft_params = Some(FileTransferParams::default());
// Set context
@@ -154,19 +165,25 @@ impl Activity for AuthActivity {
// Clear terminal
self.context.as_mut().unwrap().clear_screen();
// Put raw mode on enabled
let _ = enable_raw_mode();
// Init bookmarks client
if self.bookmarks_client.is_none() {
self.init_bookmarks_client();
}
// Verify error state from context
if let Some(err) = self.context.as_mut().unwrap().get_error() {
self.mount_error(err.as_str());
if let Err(err) = enable_raw_mode() {
error!("Failed to enter raw mode: {}", err);
}
// If check for updates is enabled, check for updates
self.check_for_updates();
// Initialize view
self.init();
// Init bookmarks client
if self.bookmarks_client.is_none() {
self.init_bookmarks_client();
// View bookarmsk
self.view_bookmarks();
self.view_recent_connections();
}
// Verify error state from context
if let Some(err) = self.context.as_mut().unwrap().get_error() {
self.mount_error(err.as_str());
}
info!("Activity initialized");
}
/// ### on_draw
@@ -211,7 +228,9 @@ impl Activity for AuthActivity {
/// This function finally releases the context
fn on_destroy(&mut self) -> Option<Context> {
// Disable raw mode
let _ = disable_raw_mode();
if let Err(err) = disable_raw_mode() {
error!("Failed to disable raw mode: {}", err);
}
self.context.as_ref()?;
// Clear terminal and return
match self.context.take() {

View File

@@ -27,15 +27,16 @@
*/
// locals
use super::{
AuthActivity, FileTransferParams, 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, 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,
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,
};
use crate::ui::activities::keymap::*;
use crate::ui::layout::{Msg, Payload};
use crate::ui::keymap::*;
use tuirealm::components::InputPropsBuilder;
use tuirealm::{Msg, Payload, PropsBuilder, Value};
// -- update
@@ -51,17 +52,17 @@ impl AuthActivity {
None => None, // Exit after None
Some(msg) => match msg {
// Focus ( DOWN )
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_DOWN) => {
// Give focus to port
self.view.active(COMPONENT_INPUT_ADDR);
None
}
(COMPONENT_INPUT_ADDR, &MSG_KEY_DOWN) => {
// Give focus to port
self.view.active(COMPONENT_INPUT_PORT);
None
}
(COMPONENT_INPUT_PORT, &MSG_KEY_DOWN) => {
// Give focus to port
self.view.active(COMPONENT_RADIO_PROTOCOL);
None
}
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_DOWN) => {
// Give focus to port
self.view.active(COMPONENT_INPUT_USERNAME);
None
@@ -73,7 +74,7 @@ impl AuthActivity {
}
(COMPONENT_INPUT_PASSWORD, &MSG_KEY_DOWN) => {
// Give focus to port
self.view.active(COMPONENT_INPUT_ADDR);
self.view.active(COMPONENT_RADIO_PROTOCOL);
None
}
// Focus ( UP )
@@ -83,11 +84,6 @@ impl AuthActivity {
None
}
(COMPONENT_INPUT_USERNAME, &MSG_KEY_UP) => {
// Give focus to port
self.view.active(COMPONENT_RADIO_PROTOCOL);
None
}
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_UP) => {
// Give focus to port
self.view.active(COMPONENT_INPUT_PORT);
None
@@ -98,10 +94,28 @@ impl AuthActivity {
None
}
(COMPONENT_INPUT_ADDR, &MSG_KEY_UP) => {
// Give focus to port
self.view.active(COMPONENT_RADIO_PROTOCOL);
None
}
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_UP) => {
// Give focus to port
self.view.active(COMPONENT_INPUT_PASSWORD);
None
}
// Protocol - On Change
(COMPONENT_RADIO_PROTOCOL, Msg::OnChange(Payload::One(Value::Usize(protocol)))) => {
// If port is standard, update the current port with default for selected protocol
let protocol: FileTransferProtocol = Self::protocol_opt_to_enum(*protocol);
// Get port
let port: u16 = self.get_input_port();
match Self::is_port_standard(port) {
false => None, // Return None
true => {
self.update_input_port(Self::get_default_port_for_protocol(protocol))
}
}
}
// <TAB> bookmarks
(COMPONENT_BOOKMARKS_LIST, &MSG_KEY_TAB)
| (COMPONENT_RECENTS_LIST, &MSG_KEY_TAB) => {
@@ -140,13 +154,13 @@ impl AuthActivity {
None
}
// Enter
(COMPONENT_BOOKMARKS_LIST, Msg::OnSubmit(Payload::Unsigned(idx))) => {
(COMPONENT_BOOKMARKS_LIST, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
self.load_bookmark(*idx);
// Give focus to input password
self.view.active(COMPONENT_INPUT_PASSWORD);
None
}
(COMPONENT_RECENTS_LIST, Msg::OnSubmit(Payload::Unsigned(idx))) => {
(COMPONENT_RECENTS_LIST, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
self.load_recent(*idx);
// Give focus to input password
self.view.active(COMPONENT_INPUT_PASSWORD);
@@ -156,7 +170,7 @@ impl AuthActivity {
// Del bookmarks
(
COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
Msg::OnSubmit(Payload::Unsigned(index)),
Msg::OnSubmit(Payload::One(Value::Usize(index))),
) => {
// hide bookmark delete
self.umount_bookmark_del_dialog();
@@ -164,8 +178,8 @@ impl AuthActivity {
match *index {
0 => {
// Get selected bookmark
match self.view.get_value(COMPONENT_BOOKMARKS_LIST) {
Some(Payload::Unsigned(index)) => {
match self.view.get_state(COMPONENT_BOOKMARKS_LIST) {
Some(Payload::One(Value::Usize(index))) => {
// Delete bookmark
self.del_bookmark(index);
// Update bookmarks
@@ -177,15 +191,18 @@ impl AuthActivity {
_ => None,
}
}
(COMPONENT_RADIO_BOOKMARK_DEL_RECENT, Msg::OnSubmit(Payload::Unsigned(index))) => {
(
COMPONENT_RADIO_BOOKMARK_DEL_RECENT,
Msg::OnSubmit(Payload::One(Value::Usize(index))),
) => {
// hide bookmark delete
self.umount_recent_del_dialog();
// Index must be 0 => YES
match *index {
0 => {
// Get selected bookmark
match self.view.get_value(COMPONENT_RECENTS_LIST) {
Some(Payload::Unsigned(index)) => {
match self.view.get_state(COMPONENT_RECENTS_LIST) {
Some(Payload::One(Value::Usize(index))) => {
// Delete recent
self.del_recent(index);
// Update bookmarks
@@ -199,37 +216,20 @@ impl AuthActivity {
}
// <ESC> hide tab
(COMPONENT_RADIO_BOOKMARK_DEL_RECENT, &MSG_KEY_ESC) => {
match self
.view
.get_props(COMPONENT_RADIO_BOOKMARK_DEL_RECENT)
.as_mut()
{
Some(props) => {
let msg = self.view.update(
COMPONENT_RADIO_BOOKMARK_DEL_RECENT,
props.hidden().build(),
);
self.update(msg)
}
None => None,
}
self.umount_recent_del_dialog();
None
}
(COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, &MSG_KEY_ESC) => {
match self
.view
.get_props(COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK)
.as_mut()
{
Some(props) => {
let msg = self.view.update(
COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
props.hidden().build(),
);
self.update(msg)
}
None => None,
}
self.umount_bookmark_del_dialog();
None
}
// Error message
(COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) => {
// Umount text error
self.umount_error();
None
}
(COMPONENT_TEXT_ERROR, _) => None,
// Help
(_, &MSG_KEY_CTRL_H) => {
// Show help
@@ -269,16 +269,18 @@ impl AuthActivity {
| (COMPONENT_RADIO_BOOKMARK_SAVE_PWD, Msg::OnSubmit(_)) => {
// Get values
let bookmark_name: String =
match self.view.get_value(COMPONENT_INPUT_BOOKMARK_NAME) {
Some(Payload::Text(s)) => s,
match self.view.get_state(COMPONENT_INPUT_BOOKMARK_NAME) {
Some(Payload::One(Value::Str(s))) => s,
_ => String::new(),
};
let save_pwd: bool = matches!(
self.view.get_value(COMPONENT_RADIO_BOOKMARK_SAVE_PWD),
Some(Payload::Unsigned(0))
self.view.get_state(COMPONENT_RADIO_BOOKMARK_SAVE_PWD),
Some(Payload::One(Value::Usize(0)))
);
// Save bookmark
self.save_bookmark(bookmark_name, save_pwd);
if !bookmark_name.is_empty() {
self.save_bookmark(bookmark_name, save_pwd);
}
// Umount popup
self.umount_bookmark_save_dialog();
// Reload bookmarks
@@ -291,14 +293,8 @@ impl AuthActivity {
self.umount_bookmark_save_dialog();
None
}
// Error message
(COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) => {
// Umount text error
self.umount_error();
None
}
// Quit dialog
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(choice))) => {
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(choice)))) => {
// If choice is 0, quit termscp
if *choice == 0 {
self.exit_reason = Some(super::ExitReason::Quit);
@@ -343,4 +339,16 @@ impl AuthActivity {
},
}
}
fn update_input_port(&mut self, port: u16) -> Option<(String, Msg)> {
match self.view.get_props(COMPONENT_INPUT_PORT) {
None => None,
Some(props) => {
let props = InputPropsBuilder::from(props)
.with_value(port.to_string())
.build();
self.view.update(COMPONENT_INPUT_PORT, props)
}
}
}
}

View File

@@ -27,20 +27,27 @@
*/
// Locals
use super::{AuthActivity, Context, FileTransferProtocol};
use crate::ui::layout::components::{
bookmark_list::BookmarkList, input::Input, msgbox::MsgBox, radio_group::RadioGroup,
table::Table, text::Text, title::Title,
use crate::ui::components::{
bookmark_list::{BookmarkList, BookmarkListPropsBuilder},
msgbox::{MsgBox, MsgBoxPropsBuilder},
};
use crate::ui::layout::props::{
InputType, PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
};
use crate::ui::layout::utils::draw_area_in;
use crate::ui::layout::{Msg, Payload};
use crate::utils::ui::draw_area_in;
// Ext
use tui::{
use tuirealm::components::{
input::{Input, InputPropsBuilder},
label::{Label, LabelPropsBuilder},
radio::{Radio, RadioPropsBuilder},
span::{Span, SpanPropsBuilder},
table::{Table, TablePropsBuilder},
};
use tuirealm::tui::{
layout::{Constraint, Direction, Layout},
style::Color,
widgets::Clear,
widgets::{BorderType, Borders, Clear},
};
use tuirealm::{
props::{InputType, PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder},
Msg, Payload, Value,
};
impl AuthActivity {
@@ -48,37 +55,74 @@ impl AuthActivity {
///
/// Initialize view, mounting all startup components inside the view
pub(super) fn init(&mut self) {
// Header
self.view.mount(super::COMPONENT_TEXT_HEADER, Box::new(
Title::new(
PropsBuilder::default().with_foreground(Color::White).with_texts(
TextParts::new(Some(String::from(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n")), None)
).bold().build()
)
));
// Headers
self.view.mount(
super::COMPONENT_TEXT_H1,
Box::new(Label::new(
LabelPropsBuilder::default()
.bold()
.italic()
.with_text(String::from("$ termscp"))
.build(),
)),
);
self.view.mount(
super::COMPONENT_TEXT_H2,
Box::new(Label::new(
LabelPropsBuilder::default()
.bold()
.italic()
.with_text(format!("$ version {}", env!("CARGO_PKG_VERSION")))
.build(),
)),
);
// Footer
self.view.mount(
super::COMPONENT_TEXT_FOOTER,
Box::new(Text::new(
PropsBuilder::default()
.with_texts(TextParts::new(
None,
Some(vec![
TextSpanBuilder::new("Press ").bold().build(),
TextSpanBuilder::new("<CTRL+H>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to show keybindings; ")
.bold()
.build(),
TextSpanBuilder::new("<CTRL+C>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to enter setup").bold().build(),
]),
))
Box::new(Span::new(
SpanPropsBuilder::default()
.with_spans(vec![
TextSpanBuilder::new("Press ").bold().build(),
TextSpanBuilder::new("<CTRL+H>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to show keybindings; ")
.bold()
.build(),
TextSpanBuilder::new("<CTRL+C>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to enter setup").bold().build(),
])
.build(),
)),
);
// 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,
};
// Protocol
self.view.mount(
super::COMPONENT_RADIO_PROTOCOL,
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::LightGreen)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen)
.with_options(
Some(String::from("Protocol")),
vec![
String::from("SFTP"),
String::from("SCP"),
String::from("FTP"),
String::from("FTPS"),
],
)
.with_value(Self::protocol_enum_to_opt(default_protocol))
.build(),
)),
);
@@ -86,9 +130,10 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_INPUT_ADDR,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::Yellow)
.with_texts(TextParts::new(Some(String::from("Remote address")), None))
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow)
.with_label(String::from("Remote address"))
.build(),
)),
);
@@ -96,31 +141,13 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_INPUT_PORT,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::LightCyan)
.with_texts(TextParts::new(Some(String::from("Port number")), None))
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan)
.with_label(String::from("Port number"))
.with_input(InputType::Number)
.with_input_len(5)
.with_value(PropValue::Str(String::from("22")))
.build(),
)),
);
// Protocol
self.view.mount(
super::COMPONENT_RADIO_PROTOCOL,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightGreen)
.with_background(Color::Black)
.with_texts(TextParts::new(
Some(String::from("Protocol")),
Some(vec![
TextSpan::from("SFTP"),
TextSpan::from("SCP"),
TextSpan::from("FTP"),
TextSpan::from("FTPS"),
]),
))
.with_value(Self::get_default_port_for_protocol(default_protocol).to_string())
.build(),
)),
);
@@ -128,9 +155,10 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_INPUT_USERNAME,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::LightMagenta)
.with_texts(TextParts::new(Some(String::from("Username")), None))
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta)
.with_label(String::from("Username"))
.build(),
)),
);
@@ -138,9 +166,10 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_INPUT_PASSWORD,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::LightBlue)
.with_texts(TextParts::new(Some(String::from("Password")), None))
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue)
.with_label(String::from("Password"))
.with_input(InputType::Password)
.build(),
)),
@@ -155,10 +184,16 @@ impl AuthActivity {
{
self.view.mount(
super::COMPONENT_TEXT_NEW_VERSION,
Box::new(Text::new(
PropsBuilder::default()
Box::new(Span::new(
SpanPropsBuilder::default()
.with_foreground(Color::Yellow)
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(format!("TermSCP {} is now available! Download it from <https://github.com/veeso/termscp/releases/latest>", version))])))
.with_spans(
vec![
TextSpan::from("termscp "),
TextSpanBuilder::new(version).underlined().bold().build(),
TextSpan::from(" is now available! Download it from <https://github.com/veeso/termscp/releases/latest>")
]
)
.build()
))
);
@@ -167,10 +202,11 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_BOOKMARKS_LIST,
Box::new(BookmarkList::new(
PropsBuilder::default()
BookmarkListPropsBuilder::default()
.with_background(Color::LightGreen)
.with_foreground(Color::Black)
.with_texts(TextParts::new(Some(String::from("Bookmarks")), None))
.with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen)
.with_bookmarks(Some(String::from("Bookmarks")), vec![])
.build(),
)),
);
@@ -179,19 +215,17 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_RECENTS_LIST,
Box::new(BookmarkList::new(
PropsBuilder::default()
BookmarkListPropsBuilder::default()
.with_background(Color::LightBlue)
.with_foreground(Color::Black)
.with_texts(TextParts::new(
Some(String::from("Recent connections")),
None,
))
.with_borders(Borders::ALL, BorderType::Plain, Color::LightBlue)
.with_bookmarks(Some(String::from("Recent connections")), vec![])
.build(),
)),
);
let _ = self.view_recent_connections();
// Active address
self.view.active(super::COMPONENT_INPUT_ADDR);
// Active protocol
self.view.active(super::COMPONENT_RADIO_PROTOCOL);
}
/// ### view
@@ -216,11 +250,12 @@ impl AuthActivity {
let auth_chunks = Layout::default()
.constraints(
[
Constraint::Length(6), // header
Constraint::Length(1), // h1
Constraint::Length(1), // h2
Constraint::Length(1), // Version
Constraint::Length(3), // protocol
Constraint::Length(3), // host
Constraint::Length(3), // port
Constraint::Length(3), // protocol
Constraint::Length(3), // username
Constraint::Length(3), // password
Constraint::Length(3), // footer
@@ -237,48 +272,50 @@ impl AuthActivity {
// Render
// Auth chunks
self.view
.render(super::COMPONENT_TEXT_HEADER, f, auth_chunks[0]);
.render(super::COMPONENT_TEXT_H1, f, auth_chunks[0]);
self.view
.render(super::COMPONENT_TEXT_NEW_VERSION, f, auth_chunks[1]);
.render(super::COMPONENT_TEXT_H2, f, auth_chunks[1]);
self.view
.render(super::COMPONENT_INPUT_ADDR, f, auth_chunks[2]);
.render(super::COMPONENT_TEXT_NEW_VERSION, f, auth_chunks[2]);
self.view
.render(super::COMPONENT_INPUT_PORT, f, auth_chunks[3]);
.render(super::COMPONENT_RADIO_PROTOCOL, f, auth_chunks[3]);
self.view
.render(super::COMPONENT_RADIO_PROTOCOL, f, auth_chunks[4]);
.render(super::COMPONENT_INPUT_ADDR, f, auth_chunks[4]);
self.view
.render(super::COMPONENT_INPUT_USERNAME, f, auth_chunks[5]);
.render(super::COMPONENT_INPUT_PORT, f, auth_chunks[5]);
self.view
.render(super::COMPONENT_INPUT_PASSWORD, f, auth_chunks[6]);
.render(super::COMPONENT_INPUT_USERNAME, f, auth_chunks[6]);
self.view
.render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[7]);
.render(super::COMPONENT_INPUT_PASSWORD, f, auth_chunks[7]);
self.view
.render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[8]);
// Bookmark chunks
self.view
.render(super::COMPONENT_BOOKMARKS_LIST, f, bookmark_chunks[0]);
self.view
.render(super::COMPONENT_RECENTS_LIST, f, bookmark_chunks[1]);
// Popups
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
if props.build().visible {
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(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
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_QUIT, f, popup);
}
}
if let Some(mut props) = self
if let Some(props) = self
.view
.get_props(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK)
{
if props.build().visible {
if props.visible {
// make popup
let popup = draw_area_in(f.size(), 30, 10);
f.render_widget(Clear, popup);
@@ -286,11 +323,11 @@ impl AuthActivity {
.render(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, f, popup);
}
}
if let Some(mut props) = self
if let Some(props) = self
.view
.get_props(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT)
{
if props.build().visible {
if props.visible {
// make popup
let popup = draw_area_in(f.size(), 30, 10);
f.render_widget(Clear, popup);
@@ -298,19 +335,19 @@ impl AuthActivity {
.render(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
if props.build().visible {
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(mut props) = self
if let Some(props) = self
.view
.get_props(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
{
if props.build().visible {
if props.visible {
// make popup
let popup = draw_area_in(f.size(), 20, 20);
f.render_widget(Clear, popup);
@@ -340,7 +377,7 @@ impl AuthActivity {
///
/// Make text span from bookmarks
pub(super) fn view_bookmarks(&mut self) -> Option<(String, Msg)> {
let bookmarks: Vec<TextSpan> = self
let bookmarks: Vec<String> = self
.bookmarks_list
.iter()
.map(|x| {
@@ -350,33 +387,23 @@ impl AuthActivity {
.unwrap()
.get_bookmark(x)
.unwrap();
TextSpan::from(
format!(
"{} ({}://{}@{}:{})",
x,
entry.2.to_string().to_lowercase(),
entry.3,
entry.0,
entry.1
)
.as_str(),
format!(
"{} ({}://{}@{}:{})",
x,
entry.2.to_string().to_lowercase(),
entry.3,
entry.0,
entry.1
)
})
.collect();
match self
.view
.get_props(super::COMPONENT_BOOKMARKS_LIST)
.as_mut()
{
match self.view.get_props(super::COMPONENT_BOOKMARKS_LIST) {
None => None,
Some(props) => {
let msg = self.view.update(
super::COMPONENT_BOOKMARKS_LIST,
props
.with_texts(TextParts::new(
Some(String::from("Bookmarks")),
Some(bookmarks),
))
BookmarkListPropsBuilder::from(props)
.with_bookmarks(Some(String::from("Bookmarks")), bookmarks)
.build(),
);
msg
@@ -388,7 +415,7 @@ impl AuthActivity {
///
/// View recent connections
pub(super) fn view_recent_connections(&mut self) -> Option<(String, Msg)> {
let bookmarks: Vec<TextSpan> = self
let bookmarks: Vec<String> = self
.recents_list
.iter()
.map(|x| {
@@ -398,28 +425,23 @@ impl AuthActivity {
.unwrap()
.get_recent(x)
.unwrap();
TextSpan::from(
format!(
"{}://{}@{}:{}",
entry.2.to_string().to_lowercase(),
entry.3,
entry.0,
entry.1
)
.as_str(),
format!(
"{}://{}@{}:{}",
entry.2.to_string().to_lowercase(),
entry.3,
entry.0,
entry.1
)
})
.collect();
match self.view.get_props(super::COMPONENT_RECENTS_LIST).as_mut() {
match self.view.get_props(super::COMPONENT_RECENTS_LIST) {
None => None,
Some(props) => {
let msg = self.view.update(
super::COMPONENT_RECENTS_LIST,
props
.with_texts(TextParts::new(
Some(String::from("Recent connections")),
Some(bookmarks),
))
BookmarkListPropsBuilder::from(props)
.with_bookmarks(Some(String::from("Recent connections")), bookmarks)
.build(),
);
msg
@@ -437,10 +459,11 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_TEXT_ERROR,
Box::new(MsgBox::new(
PropsBuilder::default()
MsgBoxPropsBuilder::default()
.with_foreground(Color::Red)
.with_borders(Borders::ALL, BorderType::Thick, Color::Red)
.bold()
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
.with_texts(None, vec![TextSpan::from(text)])
.build(),
)),
);
@@ -462,14 +485,15 @@ impl AuthActivity {
// Protocol
self.view.mount(
super::COMPONENT_RADIO_QUIT,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Yellow)
.with_background(Color::Black)
.with_texts(TextParts::new(
Some(String::from("Quit TermSCP?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Yellow)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
.with_inverted_color(Color::Black)
.with_options(
Some(String::from("Quit termscp?")),
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
@@ -489,15 +513,16 @@ impl AuthActivity {
pub(super) fn mount_bookmark_del_dialog(&mut self) {
self.view.mount(
super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Yellow)
.with_background(Color::Black)
.with_texts(TextParts::new(
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Yellow)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
.with_options(
Some(String::from("Delete bookmark?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
.with_value(PropValue::Unsigned(1))
vec![String::from("Yes"), String::from("No")],
)
.with_value(1)
.build(),
)),
);
@@ -520,15 +545,16 @@ impl AuthActivity {
pub(super) fn mount_recent_del_dialog(&mut self) {
self.view.mount(
super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Yellow)
.with_background(Color::Black)
.with_texts(TextParts::new(
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Yellow)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
.with_options(
Some(String::from("Delete bookmark?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
.with_value(PropValue::Unsigned(1))
vec![String::from("Yes"), String::from("No")],
)
.with_value(1)
.build(),
)),
);
@@ -550,27 +576,31 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_INPUT_BOOKMARK_NAME,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::LightCyan)
.with_texts(TextParts::new(
Some(String::from("Save bookmark as...")),
None,
))
//.with_borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
.with_label(String::from("Save bookmark as..."))
.with_borders(
Borders::TOP | Borders::RIGHT | Borders::LEFT,
BorderType::Rounded,
Color::Reset,
)
.build(),
)),
);
self.view.mount(
super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Red)
//.with_borders(Borders::BOTTOM | Borders::RIGHT | Borders::LEFT)
.with_texts(TextParts::new(
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Red)
.with_borders(
Borders::BOTTOM | Borders::RIGHT | Borders::LEFT,
BorderType::Rounded,
Color::Reset,
)
.with_options(
Some(String::from("Save password?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
//.with_value(PropValue::Unsigned(1))
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
@@ -593,8 +623,9 @@ impl AuthActivity {
self.view.mount(
super::COMPONENT_TEXT_HELP,
Box::new(Table::new(
PropsBuilder::default()
.with_texts(TextParts::table(
TablePropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_table(
Some(String::from("Help")),
TableBuilder::default()
.add_col(
@@ -603,7 +634,7 @@ impl AuthActivity {
.with_foreground(Color::Cyan)
.build(),
)
.add_col(TextSpan::from(" Quit TermSCP"))
.add_col(TextSpan::from(" Quit termscp"))
.add_row()
.add_col(
TextSpanBuilder::new("<TAB>")
@@ -661,7 +692,7 @@ impl AuthActivity {
)
.add_col(TextSpan::from(" Save bookmark"))
.build(),
))
)
.build(),
)),
);
@@ -680,32 +711,46 @@ impl AuthActivity {
///
/// Collect input values from view
pub(super) fn get_input(&self) -> (String, u16, FileTransferProtocol, String, String) {
let addr: String = match self.view.get_value(super::COMPONENT_INPUT_ADDR) {
Some(Payload::Text(a)) => a,
_ => String::new(),
};
let port: u16 = match self.view.get_value(super::COMPONENT_INPUT_PORT) {
Some(Payload::Unsigned(p)) => p as u16,
_ => 0,
};
let protocol: FileTransferProtocol =
match self.view.get_value(super::COMPONENT_RADIO_PROTOCOL) {
Some(Payload::Unsigned(p)) => match p {
1 => FileTransferProtocol::Scp,
2 => FileTransferProtocol::Ftp(false),
3 => FileTransferProtocol::Ftp(true),
_ => FileTransferProtocol::Sftp,
},
_ => FileTransferProtocol::Sftp,
};
let username: String = match self.view.get_value(super::COMPONENT_INPUT_USERNAME) {
Some(Payload::Text(a)) => a,
_ => String::new(),
};
let password: String = match self.view.get_value(super::COMPONENT_INPUT_PASSWORD) {
Some(Payload::Text(a)) => a,
_ => String::new(),
};
let addr: String = self.get_input_addr();
let port: u16 = self.get_input_port();
let protocol: FileTransferProtocol = self.get_input_protocol();
let username: String = self.get_input_username();
let password: String = self.get_input_password();
(addr, port, protocol, username, password)
}
pub(super) fn get_input_addr(&self) -> String {
match self.view.get_state(super::COMPONENT_INPUT_ADDR) {
Some(Payload::One(Value::Str(x))) => x,
_ => String::new(),
}
}
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),
}
}
pub(super) fn get_input_protocol(&self) -> FileTransferProtocol {
match self.view.get_state(super::COMPONENT_RADIO_PROTOCOL) {
Some(Payload::One(Value::Usize(x))) => Self::protocol_opt_to_enum(x),
_ => FileTransferProtocol::Sftp,
}
}
pub(super) fn get_input_username(&self) -> String {
match self.view.get_state(super::COMPONENT_INPUT_USERNAME) {
Some(Payload::One(Value::Str(x))) => x,
_ => String::new(),
}
}
pub(super) fn get_input_password(&self) -> String {
match self.view.get_state(super::COMPONENT_INPUT_PASSWORD) {
Some(Payload::One(Value::Str(x))) => x,
_ => String::new(),
}
}
}

View File

@@ -0,0 +1,184 @@
//! ## 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};
use std::path::PathBuf;
impl FileTransferActivity {
/// ### action_enter_local_dir
///
/// Enter a directory on local host from entry
/// Return true whether the directory changed
pub(crate) fn action_enter_local_dir(&mut self, entry: FsEntry, block_sync: bool) -> bool {
match entry {
FsEntry::Directory(dir) => {
self.local_changedir(dir.abs_path.as_path(), true);
if self.browser.sync_browsing && !block_sync {
self.action_change_remote_dir(dir.name, true);
}
true
}
FsEntry::File(file) => {
match &file.symlink {
Some(symlink_entry) => {
// If symlink and is directory, point to symlink
match &**symlink_entry {
FsEntry::Directory(dir) => {
self.local_changedir(dir.abs_path.as_path(), true);
// Check whether to sync
if self.browser.sync_browsing && !block_sync {
self.action_change_remote_dir(dir.name.clone(), true);
}
true
}
_ => false,
}
}
None => false,
}
}
}
}
/// ### action_enter_remote_dir
///
/// Enter a directory on local host from entry
/// Return true whether the directory changed
pub(crate) fn action_enter_remote_dir(&mut self, entry: FsEntry, block_sync: bool) -> bool {
match entry {
FsEntry::Directory(dir) => {
self.remote_changedir(dir.abs_path.as_path(), true);
if self.browser.sync_browsing && !block_sync {
self.action_change_local_dir(dir.name, true);
}
true
}
FsEntry::File(file) => {
match &file.symlink {
Some(symlink_entry) => {
// If symlink and is directory, point to symlink
match &**symlink_entry {
FsEntry::Directory(dir) => {
self.remote_changedir(dir.abs_path.as_path(), true);
// Check whether to sync
if self.browser.sync_browsing && !block_sync {
self.action_change_local_dir(dir.name.clone(), true);
}
true
}
_ => false,
}
}
None => false,
}
}
}
}
/// ### action_change_local_dir
///
/// Change local directory reading value from input
pub(crate) fn action_change_local_dir(&mut self, input: String, block_sync: bool) {
let dir_path: PathBuf = self.local_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.local_changedir(dir_path.as_path(), true);
// Check whether to sync
if self.browser.sync_browsing && !block_sync {
self.action_change_remote_dir(input, true);
}
}
/// ### action_change_remote_dir
///
/// Change remote directory reading value from input
pub(crate) fn action_change_remote_dir(&mut self, input: String, block_sync: bool) {
let dir_path: PathBuf = self.remote_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.remote_changedir(dir_path.as_path(), true);
// Check whether to sync
if self.browser.sync_browsing && !block_sync {
self.action_change_local_dir(input, true);
}
}
/// ### action_go_to_previous_local_dir
///
/// Go to previous directory from localhost
pub(crate) fn action_go_to_previous_local_dir(&mut self, block_sync: bool) {
if let Some(d) = self.local_mut().popd() {
self.local_changedir(d.as_path(), false);
// Check whether to sync
if self.browser.sync_browsing && !block_sync {
self.action_go_to_previous_remote_dir(true);
}
}
}
/// ### action_go_to_previous_remote_dir
///
/// Go to previous directory from remote host
pub(crate) fn action_go_to_previous_remote_dir(&mut self, block_sync: bool) {
if let Some(d) = self.remote_mut().popd() {
self.remote_changedir(d.as_path(), false);
// Check whether to sync
if self.browser.sync_browsing && !block_sync {
self.action_go_to_previous_local_dir(true);
}
}
}
/// ### action_go_to_local_upper_dir
///
/// Go to upper directory on local host
pub(crate) fn action_go_to_local_upper_dir(&mut self, block_sync: bool) {
// Get pwd
let path: PathBuf = self.local().wrkdir.clone();
// Go to parent directory
if let Some(parent) = path.as_path().parent() {
self.local_changedir(parent, true);
// If sync is enabled update remote too
if self.browser.sync_browsing && !block_sync {
self.action_go_to_remote_upper_dir(true);
}
}
}
/// #### action_go_to_remote_upper_dir
///
/// Go to upper directory on remote host
pub(crate) fn action_go_to_remote_upper_dir(&mut self, block_sync: bool) {
// Get pwd
let path: PathBuf = self.remote().wrkdir.clone();
// Go to parent directory
if let Some(parent) = path.as_path().parent() {
self.remote_changedir(parent, true);
// If sync is enabled update local too
if self.browser.sync_browsing && !block_sync {
self.action_go_to_local_upper_dir(true);
}
}
}
}

View File

@@ -0,0 +1,142 @@
//! ## 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.
*/
extern crate tempfile;
// locals
use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry};
use crate::filetransfer::FileTransferErrorType;
use std::path::{Path, PathBuf};
impl FileTransferActivity {
/// ### action_local_copy
///
/// Copy file on local
pub(crate) fn action_local_copy(&mut self, input: String) {
match self.get_local_selected_entries() {
SelectedEntry::One(entry) => {
let dest_path: PathBuf = PathBuf::from(input);
self.local_copy_file(&entry, dest_path.as_path());
// Reload entries
self.reload_local_dir();
}
SelectedEntry::Many(entries) => {
// Try to copy each file to Input/{FILE_NAME}
let base_path: PathBuf = PathBuf::from(input);
// Iter files
for entry in entries.iter() {
let mut dest_path: PathBuf = base_path.clone();
dest_path.push(entry.get_name());
self.local_copy_file(entry, dest_path.as_path());
}
// Reload entries
self.reload_local_dir();
}
SelectedEntry::None => {}
}
}
/// ### action_remote_copy
///
/// Copy file on remote
pub(crate) fn action_remote_copy(&mut self, input: String) {
match self.get_remote_selected_entries() {
SelectedEntry::One(entry) => {
let dest_path: PathBuf = PathBuf::from(input);
self.remote_copy_file(&entry, dest_path.as_path());
// Reload entries
self.reload_remote_dir();
}
SelectedEntry::Many(entries) => {
// Try to copy each file to Input/{FILE_NAME}
let base_path: PathBuf = PathBuf::from(input);
// Iter files
for entry in entries.iter() {
let mut dest_path: PathBuf = base_path.clone();
dest_path.push(entry.get_name());
self.remote_copy_file(entry, dest_path.as_path());
}
// Reload entries
self.reload_remote_dir();
}
SelectedEntry::None => {}
}
}
fn local_copy_file(&mut self, entry: &FsEntry, dest: &Path) {
match self.host.copy(entry, dest) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest.display()
),
);
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest.display(),
err
),
),
}
}
fn remote_copy_file(&mut self, entry: &FsEntry, dest: &Path) {
match self.client.as_mut().copy(entry, dest) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest.display()
),
);
}
Err(err) => match err.kind() {
FileTransferErrorType::UnsupportedFeature => {
// If copy is not supported, perform the tricky copy
self.tricky_copy(entry, dest);
}
_ => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest.display(),
err
),
),
},
}
}
}

View File

@@ -0,0 +1,116 @@
//! ## 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, LogLevel, SelectedEntry};
impl FileTransferActivity {
pub(crate) fn action_local_delete(&mut self) {
match self.get_local_selected_entries() {
SelectedEntry::One(entry) => {
// Delete file
self.local_remove_file(&entry);
// Reload
self.reload_local_dir();
}
SelectedEntry::Many(entries) => {
// Iter files
for entry in entries.iter() {
// Delete file
self.local_remove_file(entry);
}
// Reload entries
self.reload_local_dir();
}
SelectedEntry::None => {}
}
}
pub(crate) fn action_remote_delete(&mut self) {
match self.get_remote_selected_entries() {
SelectedEntry::One(entry) => {
// Delete file
self.remote_remove_file(&entry);
// Reload
self.reload_remote_dir();
}
SelectedEntry::Many(entries) => {
// Iter files
for entry in entries.iter() {
// Delete file
self.remote_remove_file(entry);
}
// Reload entries
self.reload_remote_dir();
}
SelectedEntry::None => {}
}
}
pub(crate) fn local_remove_file(&mut self, entry: &FsEntry) {
match self.host.remove(&entry) {
Ok(_) => {
// Log
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", entry.get_abs_path().display()),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
entry.get_abs_path().display(),
err
),
);
}
}
}
pub(crate) fn remote_remove_file(&mut self, entry: &FsEntry) {
match self.client.remove(&entry) {
Ok(_) => {
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", entry.get_abs_path().display()),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
entry.get_abs_path().display(),
err
),
);
}
}
}
}

View File

@@ -0,0 +1,79 @@
//! ## 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, LogLevel, SelectedEntry};
impl FileTransferActivity {
pub(crate) fn action_edit_local_file(&mut self) {
let entries: Vec<FsEntry> = match self.get_local_selected_entries() {
SelectedEntry::One(entry) => vec![entry],
SelectedEntry::Many(entries) => entries,
SelectedEntry::None => vec![],
};
// Edit all entries
for entry in entries.iter() {
// Check if file
if entry.is_file() {
self.log(
LogLevel::Info,
format!("Opening file \"{}\"...", entry.get_abs_path().display()),
);
// Edit file
if let Err(err) = self.edit_local_file(entry.get_abs_path().as_path()) {
self.log_and_alert(LogLevel::Error, err);
}
}
}
// Reload entries
self.reload_local_dir();
}
pub(crate) fn action_edit_remote_file(&mut self) {
let entries: Vec<FsEntry> = match self.get_remote_selected_entries() {
SelectedEntry::One(entry) => vec![entry],
SelectedEntry::Many(entries) => entries,
SelectedEntry::None => vec![],
};
// Edit all entries
for entry in entries.iter() {
// Check if file
if let FsEntry::File(file) = entry {
self.log(
LogLevel::Info,
format!("Opening file \"{}\"...", entry.get_abs_path().display()),
);
// Edit file
if let Err(err) = self.edit_remote_file(&file) {
self.log_and_alert(LogLevel::Error, err);
}
}
}
// Reload entries
self.reload_remote_dir();
}
}

View File

@@ -0,0 +1,66 @@
//! ## 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, LogLevel};
impl FileTransferActivity {
pub(crate) fn action_local_exec(&mut self, input: String) {
match self.host.exec(input.as_str()) {
Ok(output) => {
// Reload files
self.log(LogLevel::Info, format!("\"{}\": {}", input, output));
// Reload entries
self.reload_local_dir();
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not execute command \"{}\": {}", input, err),
);
}
}
}
pub(crate) fn action_remote_exec(&mut self, input: String) {
match self.client.as_mut().exec(input.as_str()) {
Ok(output) => {
// Reload files
self.log(LogLevel::Info, format!("\"{}\": {}", input, output));
self.reload_remote_dir();
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not execute command \"{}\": {}", input, err),
);
}
}
}
}

View File

@@ -0,0 +1,143 @@
//! ## 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::super::browser::FileExplorerTab;
use super::{FileTransferActivity, FsEntry, SelectedEntry};
use std::path::PathBuf;
impl FileTransferActivity {
pub(crate) fn action_local_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
match self.host.find(input.as_str()) {
Ok(entries) => Ok(entries),
Err(err) => Err(format!("Could not search for files: {}", err)),
}
}
pub(crate) fn action_remote_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
match self.client.as_mut().find(input.as_str()) {
Ok(entries) => Ok(entries),
Err(err) => Err(format!("Could not search for files: {}", err)),
}
}
pub(crate) fn action_find_changedir(&mut self) {
// Match entry
if let SelectedEntry::One(entry) = self.get_found_selected_entries() {
// Get path: if a directory, use directory path; if it is a File, get parent path
let path: PathBuf = match entry {
FsEntry::Directory(dir) => dir.abs_path,
FsEntry::File(file) => match file.abs_path.parent() {
None => PathBuf::from("."),
Some(p) => p.to_path_buf(),
},
};
// Change directory
match self.browser.tab() {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
self.local_changedir(path.as_path(), true)
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.remote_changedir(path.as_path(), true)
}
}
}
}
pub(crate) fn action_find_transfer(&mut self, save_as: Option<String>) {
let wrkdir: PathBuf = match self.browser.tab() {
FileExplorerTab::FindLocal | FileExplorerTab::Local => self.remote().wrkdir.clone(),
FileExplorerTab::FindRemote | FileExplorerTab::Remote => self.local().wrkdir.clone(),
};
match self.get_found_selected_entries() {
SelectedEntry::One(entry) => match self.browser.tab() {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), save_as);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), save_as);
}
},
SelectedEntry::Many(entries) => {
// In case of selection: save multiple files in wrkdir/input
let mut dest_path: PathBuf = wrkdir;
if let Some(save_as) = save_as {
dest_path.push(save_as);
}
// Iter files
for entry in entries.iter() {
match self.browser.tab() {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
self.filetransfer_send(
&entry.get_realfile(),
dest_path.as_path(),
None,
);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.filetransfer_recv(
&entry.get_realfile(),
dest_path.as_path(),
None,
);
}
}
}
}
SelectedEntry::None => {}
}
}
pub(crate) fn action_find_delete(&mut self) {
match self.get_found_selected_entries() {
SelectedEntry::One(entry) => {
// Delete file
self.remove_found_file(&entry);
}
SelectedEntry::Many(entries) => {
// Iter files
for entry in entries.iter() {
// Delete file
self.remove_found_file(entry);
}
}
SelectedEntry::None => {}
}
}
fn remove_found_file(&mut self, entry: &FsEntry) {
match self.browser.tab() {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
self.local_remove_file(entry);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.remote_remove_file(entry);
}
}
}
}

View File

@@ -0,0 +1,70 @@
//! ## 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, LogLevel};
use std::path::PathBuf;
impl FileTransferActivity {
pub(crate) fn action_local_mkdir(&mut self, input: String) {
match self.host.mkdir(PathBuf::from(input.as_str()).as_path()) {
Ok(_) => {
// Reload files
self.log(LogLevel::Info, format!("Created directory \"{}\"", input));
// Reload entries
self.reload_local_dir();
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{}\": {}", input, err),
);
}
}
}
pub(crate) fn action_remote_mkdir(&mut self, input: String) {
match self
.client
.as_mut()
.mkdir(PathBuf::from(input.as_str()).as_path())
{
Ok(_) => {
// Reload files
self.log(LogLevel::Info, format!("Created directory \"{}\"", input));
self.reload_remote_dir();
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{}\": {}", input, err),
);
}
}
}
}

View File

@@ -0,0 +1,148 @@
//! ## 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.
*/
pub(self) use super::{FileTransferActivity, FsEntry, LogLevel};
use tuirealm::{Payload, Value};
// actions
pub(crate) mod change_dir;
pub(crate) mod copy;
pub(crate) mod delete;
pub(crate) mod edit;
pub(crate) mod exec;
pub(crate) mod find;
pub(crate) mod mkdir;
pub(crate) mod newfile;
pub(crate) mod rename;
pub(crate) mod save;
pub(crate) enum SelectedEntry {
One(FsEntry),
Many(Vec<FsEntry>),
None,
}
enum SelectedEntryIndex {
One(usize),
Many(Vec<usize>),
None,
}
impl From<Option<&FsEntry>> for SelectedEntry {
fn from(opt: Option<&FsEntry>) -> Self {
match opt {
Some(e) => SelectedEntry::One(e.clone()),
None => SelectedEntry::None,
}
}
}
impl From<Vec<&FsEntry>> for SelectedEntry {
fn from(files: Vec<&FsEntry>) -> Self {
SelectedEntry::Many(files.into_iter().cloned().collect())
}
}
impl FileTransferActivity {
/// ### get_local_selected_entries
///
/// Get local file entry
pub(crate) fn get_local_selected_entries(&self) -> SelectedEntry {
match self.get_selected_index(super::COMPONENT_EXPLORER_LOCAL) {
SelectedEntryIndex::One(idx) => SelectedEntry::from(self.local().get(idx)),
SelectedEntryIndex::Many(files) => {
let files: Vec<&FsEntry> = files
.iter()
.map(|x| self.local().get(*x)) // Usize to Option<FsEntry>
.filter(|x| x.is_some()) // Get only some values
.map(|x| x.unwrap()) // Option to FsEntry
.collect();
SelectedEntry::from(files)
}
SelectedEntryIndex::None => SelectedEntry::None,
}
}
/// ### get_remote_selected_entries
///
/// Get remote file entry
pub(crate) fn get_remote_selected_entries(&self) -> SelectedEntry {
match self.get_selected_index(super::COMPONENT_EXPLORER_REMOTE) {
SelectedEntryIndex::One(idx) => SelectedEntry::from(self.remote().get(idx)),
SelectedEntryIndex::Many(files) => {
let files: Vec<&FsEntry> = files
.iter()
.map(|x| self.remote().get(*x)) // Usize to Option<FsEntry>
.filter(|x| x.is_some()) // Get only some values
.map(|x| x.unwrap()) // Option to FsEntry
.collect();
SelectedEntry::from(files)
}
SelectedEntryIndex::None => SelectedEntry::None,
}
}
/// ### get_remote_selected_entries
///
/// Get remote file entry
pub(crate) fn get_found_selected_entries(&self) -> SelectedEntry {
match self.get_selected_index(super::COMPONENT_EXPLORER_FIND) {
SelectedEntryIndex::One(idx) => {
SelectedEntry::from(self.found().as_ref().unwrap().get(idx))
}
SelectedEntryIndex::Many(files) => {
let files: Vec<&FsEntry> = files
.iter()
.map(|x| self.found().as_ref().unwrap().get(*x)) // Usize to Option<FsEntry>
.filter(|x| x.is_some()) // Get only some values
.map(|x| x.unwrap()) // Option to FsEntry
.collect();
SelectedEntry::from(files)
}
SelectedEntryIndex::None => SelectedEntry::None,
}
}
// -- private
fn get_selected_index(&self, component: &str) -> SelectedEntryIndex {
match self.view.get_state(component) {
Some(Payload::One(Value::Usize(idx))) => SelectedEntryIndex::One(idx),
Some(Payload::Vec(files)) => {
let list: Vec<usize> = files
.iter()
.map(|x| match x {
Value::Usize(v) => *v,
_ => 0,
})
.collect();
SelectedEntryIndex::Many(list)
}
_ => SelectedEntryIndex::None,
}
}
}

View File

@@ -0,0 +1,128 @@
//! ## 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, LogLevel};
use std::path::PathBuf;
impl FileTransferActivity {
pub(crate) fn action_local_newfile(&mut self, input: String) {
// Check if file exists
let mut file_exists: bool = false;
for file in self.local().iter_files_all() {
if input == file.get_name() {
file_exists = true;
}
}
if file_exists {
self.log_and_alert(
LogLevel::Warn,
format!("File \"{}\" already exists", input,),
);
return;
}
// Create file
let file_path: PathBuf = PathBuf::from(input.as_str());
if let Err(err) = self.host.open_file_write(file_path.as_path()) {
self.log_and_alert(
LogLevel::Error,
format!("Could not create file \"{}\": {}", file_path.display(), err),
);
} else {
self.log(
LogLevel::Info,
format!("Created file \"{}\"", file_path.display()),
);
}
// Reload files
self.reload_local_dir();
}
pub(crate) fn action_remote_newfile(&mut self, input: String) {
// Check if file exists
let mut file_exists: bool = false;
for file in self.remote().iter_files_all() {
if input == file.get_name() {
file_exists = true;
}
}
if file_exists {
self.log_and_alert(
LogLevel::Warn,
format!("File \"{}\" already exists", input,),
);
return;
}
// Get path on remote
let file_path: PathBuf = PathBuf::from(input.as_str());
// Create file (on local)
match tempfile::NamedTempFile::new() {
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create tempfile: {}", err),
),
Ok(tfile) => {
// Stat tempfile
let local_file: FsEntry = match self.host.stat(tfile.path()) {
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not stat tempfile: {}", err),
);
return;
}
Ok(f) => f,
};
if let FsEntry::File(local_file) = local_file {
// Create file
match self.client.send_file(&local_file, file_path.as_path()) {
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create file \"{}\": {}", file_path.display(), err),
),
Ok(writer) => {
// Finalize write
if let Err(err) = self.client.on_sent(writer) {
self.log_and_alert(
LogLevel::Warn,
format!("Could not finalize file: {}", err),
);
} else {
self.log(
LogLevel::Info,
format!("Created file \"{}\"", file_path.display()),
);
}
// Reload files
self.reload_remote_dir();
}
}
}
}
}
}
}

View File

@@ -0,0 +1,128 @@
//! ## 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, LogLevel, SelectedEntry};
use std::path::{Path, PathBuf};
impl FileTransferActivity {
pub(crate) fn action_local_rename(&mut self, input: String) {
match self.get_local_selected_entries() {
SelectedEntry::One(entry) => {
let dest_path: PathBuf = PathBuf::from(input);
self.local_rename_file(&entry, dest_path.as_path());
// Reload entries
self.reload_local_dir();
}
SelectedEntry::Many(entries) => {
// Try to copy each file to Input/{FILE_NAME}
let base_path: PathBuf = PathBuf::from(input);
// Iter files
for entry in entries.iter() {
let mut dest_path: PathBuf = base_path.clone();
dest_path.push(entry.get_name());
self.local_rename_file(entry, dest_path.as_path());
}
// Reload entries
self.reload_local_dir();
}
SelectedEntry::None => {}
}
}
pub(crate) fn action_remote_rename(&mut self, input: String) {
match self.get_remote_selected_entries() {
SelectedEntry::One(entry) => {
let dest_path: PathBuf = PathBuf::from(input);
self.remote_rename_file(&entry, dest_path.as_path());
// Reload entries
self.reload_remote_dir();
}
SelectedEntry::Many(entries) => {
// Try to copy each file to Input/{FILE_NAME}
let base_path: PathBuf = PathBuf::from(input);
// Iter files
for entry in entries.iter() {
let mut dest_path: PathBuf = base_path.clone();
dest_path.push(entry.get_name());
self.remote_rename_file(entry, dest_path.as_path());
}
// Reload entries
self.reload_remote_dir();
}
SelectedEntry::None => {}
}
}
fn local_rename_file(&mut self, entry: &FsEntry, dest: &Path) {
match self.host.rename(entry, dest) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Moved \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest.display()
),
);
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not move \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest.display(),
err
),
),
}
}
fn remote_rename_file(&mut self, entry: &FsEntry, dest: &Path) {
match self.client.as_mut().rename(entry, dest) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Moved \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest.display()
),
);
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not move \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest.display(),
err
),
),
}
}
}

View File

@@ -0,0 +1,90 @@
//! ## 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, SelectedEntry};
use std::path::PathBuf;
impl FileTransferActivity {
pub(crate) fn action_local_saveas(&mut self, input: String) {
self.action_local_send_file(Some(input));
}
pub(crate) fn action_remote_saveas(&mut self, input: String) {
self.action_remote_recv_file(Some(input));
}
pub(crate) fn action_local_send(&mut self) {
self.action_local_send_file(None);
}
pub(crate) fn action_remote_recv(&mut self) {
self.action_remote_recv_file(None);
}
fn action_local_send_file(&mut self, save_as: Option<String>) {
let wrkdir: PathBuf = self.remote().wrkdir.clone();
match self.get_local_selected_entries() {
SelectedEntry::One(entry) => {
self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), save_as);
}
SelectedEntry::Many(entries) => {
// In case of selection: save multiple files in wrkdir/input
let mut dest_path: PathBuf = wrkdir;
if let Some(save_as) = save_as {
dest_path.push(save_as);
}
// Iter files
for entry in entries.iter() {
self.filetransfer_send(&entry.get_realfile(), dest_path.as_path(), None);
}
}
SelectedEntry::None => {}
}
}
fn action_remote_recv_file(&mut self, save_as: Option<String>) {
let wrkdir: PathBuf = self.local().wrkdir.clone();
match self.get_remote_selected_entries() {
SelectedEntry::One(entry) => {
self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), save_as);
}
SelectedEntry::Many(entries) => {
// In case of selection: save multiple files in wrkdir/input
let mut dest_path: PathBuf = wrkdir;
if let Some(save_as) = save_as {
dest_path.push(save_as);
}
// Iter files
for entry in entries.iter() {
self.filetransfer_recv(&entry.get_realfile(), dest_path.as_path(), None);
}
}
SelectedEntry::None => {}
}
}
}

View File

@@ -0,0 +1,177 @@
//! ## 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.
*/
use crate::fs::explorer::{builder::FileExplorerBuilder, FileExplorer, FileSorting, GroupDirs};
use crate::fs::FsEntry;
use crate::system::config_client::ConfigClient;
/// ## FileExplorerTab
///
/// File explorer tab
#[derive(Clone, Copy)]
pub enum FileExplorerTab {
Local,
Remote,
FindLocal, // Find result tab
FindRemote, // Find result tab
}
/// ## Browser
///
/// Browser contains the browser options
pub struct Browser {
local: FileExplorer, // Local File explorer state
remote: FileExplorer, // Remote File explorer state
found: Option<FileExplorer>, // File explorer for find result
tab: FileExplorerTab, // Current selected tab
pub sync_browsing: bool,
}
impl Browser {
/// ### new
///
/// Build a new `Browser` struct
pub fn new(cli: Option<&ConfigClient>) -> Self {
Self {
local: Self::build_local_explorer(cli),
remote: Self::build_remote_explorer(cli),
found: None,
tab: FileExplorerTab::Local,
sync_browsing: false,
}
}
pub fn local(&self) -> &FileExplorer {
&self.local
}
pub fn local_mut(&mut self) -> &mut FileExplorer {
&mut self.local
}
pub fn remote(&self) -> &FileExplorer {
&self.remote
}
pub fn remote_mut(&mut self) -> &mut FileExplorer {
&mut self.remote
}
pub fn found(&self) -> Option<&FileExplorer> {
self.found.as_ref()
}
pub fn found_mut(&mut self) -> Option<&mut FileExplorer> {
self.found.as_mut()
}
pub fn set_found(&mut self, files: Vec<FsEntry>) {
let mut explorer = Self::build_found_explorer();
explorer.set_files(files);
self.found = Some(explorer);
}
pub fn del_found(&mut self) {
self.found = None;
}
pub fn tab(&self) -> FileExplorerTab {
self.tab
}
/// ### change_tab
///
/// Update tab value
pub fn change_tab(&mut self, tab: FileExplorerTab) {
self.tab = tab;
}
/// ### toggle_sync_browsing
///
/// Invert the current state for the sync browsing
pub fn toggle_sync_browsing(&mut self) {
self.sync_browsing = !self.sync_browsing;
}
/// ### build_local_explorer
///
/// Build a file explorer with local host setup
pub fn build_local_explorer(cli: Option<&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.build()
}
/// ### build_remote_explorer
///
/// Build a file explorer with remote host setup
pub fn build_remote_explorer(cli: Option<&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.build()
}
/// ### build_explorer
///
/// Build explorer reading configuration from `ConfigClient`
fn build_explorer(cli: Option<&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));
}
};
builder
}
/// ### build_found_explorer
///
/// Build explorer reading from `ConfigClient`, for found result (has some differences)
fn build_found_explorer() -> FileExplorer {
FileExplorerBuilder::new()
.with_file_sorting(FileSorting::ByName)
.with_group_dirs(Some(GroupDirs::First))
.with_hidden_files(true)
.with_stack_size(0)
.with_formatter(Some("{NAME} {SYMLINK}"))
.build()
}
}

View File

@@ -0,0 +1,29 @@
//! ## 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.
*/
pub(crate) mod browser;
pub(crate) mod transfer;

View File

@@ -0,0 +1,259 @@
//! ## 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.
*/
use bytesize::ByteSize;
use std::fmt;
use std::time::Instant;
/// ### TransferStates
///
/// TransferStates contains the states related to the transfer process
pub struct TransferStates {
aborted: bool, // Describes whether the transfer process has been aborted
pub full: ProgressStates, // full transfer states
pub partial: ProgressStates, // Partial transfer states
}
/// ### ProgressStates
///
/// Progress states describes the states for the progress of a single transfer part
pub struct ProgressStates {
started: Instant,
total: usize,
written: usize,
}
impl Default for TransferStates {
fn default() -> Self {
Self::new()
}
}
impl TransferStates {
/// ### new
///
/// Instantiates a new transfer states
pub fn new() -> TransferStates {
TransferStates {
aborted: false,
full: ProgressStates::default(),
partial: ProgressStates::default(),
}
}
/// ### reset
///
/// Re-intiialize transfer states
pub fn reset(&mut self) {
self.aborted = false;
}
/// ### abort
///
/// Set aborted to true
pub fn abort(&mut self) {
self.aborted = true;
}
/// ### aborted
///
/// Returns whether transfer has been aborted
pub fn aborted(&self) -> bool {
self.aborted
}
}
impl Default for ProgressStates {
fn default() -> Self {
ProgressStates {
started: Instant::now(),
written: 0,
total: 0,
}
}
}
impl fmt::Display for ProgressStates {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let eta: String = match self.calc_eta() {
0 => String::from("--:--"),
seconds => format!(
"{:0width$}:{:0width$}",
(seconds / 60),
(seconds % 60),
width = 2
),
};
write!(
f,
"{:.2}% - ETA {} ({}/s)",
self.calc_progress_percentage(),
eta,
ByteSize(self.calc_bytes_per_second())
)
}
}
impl ProgressStates {
/// ### init
///
/// Initialize a new Progress State
pub fn init(&mut self, sz: usize) {
self.started = Instant::now();
self.total = sz;
self.written = 0;
}
/// ### update_progress
///
/// Update progress state
pub fn update_progress(&mut self, delta: usize) -> f64 {
self.written += delta;
self.calc_progress_percentage()
}
/// ### calc_progress
///
/// Calculate progress in a range between 0.0 to 1.0
pub fn calc_progress(&self) -> f64 {
let prog: f64 = (self.written as f64) / (self.total as f64);
match prog > 1.0 {
true => 1.0,
false => prog,
}
}
/// ### started
///
/// Get started
pub fn started(&self) -> Instant {
self.started
}
/// ### calc_progress_percentage
///
/// Calculate the current transfer progress as percentage
fn calc_progress_percentage(&self) -> f64 {
self.calc_progress() * 100.0
}
/// ### calc_bytes_per_second
///
/// Generic function to calculate bytes per second using elapsed time since transfer started and the bytes written
/// and the total amount of bytes to write
pub fn calc_bytes_per_second(&self) -> u64 {
// bytes_written : elapsed_secs = x : 1
let elapsed_secs: u64 = self.started.elapsed().as_secs();
match elapsed_secs {
0 => match self.written == self.total {
// NOTE: would divide by 0 :D
true => self.total as u64, // Download completed in less than 1 second
false => 0, // 0 B/S
},
_ => self.written as u64 / elapsed_secs,
}
}
/// ### calc_eta
///
/// Calculate ETA for current transfer as seconds
fn calc_eta(&self) -> u64 {
let elapsed_secs: u64 = self.started.elapsed().as_secs();
let prog: f64 = self.calc_progress_percentage();
match prog as u64 {
0 => 0,
_ => ((elapsed_secs * 100) / (prog as u64)) - elapsed_secs,
}
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use std::time::Duration;
#[test]
fn test_ui_activities_filetransfer_lib_transfer_progress_states() {
let mut states: ProgressStates = ProgressStates::default();
assert_eq!(states.total, 0);
assert_eq!(states.written, 0);
assert!(states.started().elapsed().as_secs() < 5);
// Init new transfer
states.init(1024);
assert_eq!(states.total, 1024);
assert_eq!(states.written, 0);
assert_eq!(states.calc_bytes_per_second(), 0);
assert_eq!(states.calc_eta(), 0);
assert_eq!(states.calc_progress_percentage(), 0.0);
assert_eq!(states.calc_progress(), 0.0);
assert_eq!(states.to_string().as_str(), "0.00% - ETA --:-- (0 B/s)");
// Wait 4 second (virtually)
states.started = states.started.checked_sub(Duration::from_secs(4)).unwrap();
// Update state
states.update_progress(256);
assert_eq!(states.total, 1024);
assert_eq!(states.written, 256);
assert_eq!(states.calc_bytes_per_second(), 64); // 256 bytes in 4 seconds
assert_eq!(states.calc_eta(), 12); // 16 total sub 4
assert_eq!(states.calc_progress_percentage(), 25.0);
assert_eq!(states.calc_progress(), 0.25);
assert_eq!(states.to_string().as_str(), "25.00% - ETA 00:12 (64 B/s)");
// 100%
states.started = states.started.checked_sub(Duration::from_secs(12)).unwrap();
states.update_progress(768);
assert_eq!(states.total, 1024);
assert_eq!(states.written, 1024);
assert_eq!(states.calc_bytes_per_second(), 64); // 256 bytes in 4 seconds
assert_eq!(states.calc_eta(), 0); // 16 total sub 4
assert_eq!(states.calc_progress_percentage(), 100.0);
assert_eq!(states.calc_progress(), 1.0);
assert_eq!(states.to_string().as_str(), "100.00% - ETA --:-- (64 B/s)");
// Check if terminated at started
states.started = Instant::now();
assert_eq!(states.calc_bytes_per_second(), 1024);
}
#[test]
fn test_ui_activities_filetransfer_lib_transfer_states() {
let mut states: TransferStates = TransferStates::default();
assert_eq!(states.aborted, false);
assert_eq!(states.full.total, 0);
assert_eq!(states.full.written, 0);
assert!(states.full.started.elapsed().as_secs() < 5);
assert_eq!(states.partial.total, 0);
assert_eq!(states.partial.written, 0);
assert!(states.partial.started.elapsed().as_secs() < 5);
// Aborted
states.abort();
assert_eq!(states.aborted(), true);
states.reset();
assert_eq!(states.aborted(), false);
}
}

View File

@@ -23,22 +23,29 @@
*/
// Locals
use super::{ConfigClient, FileTransferActivity, LogLevel, LogRecord};
use crate::fs::explorer::{builder::FileExplorerBuilder, FileExplorer, FileSorting, GroupDirs};
use crate::system::environment;
use crate::system::sshkey_storage::SshKeyStorage;
// Ext
use std::env;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
const LOG_CAPACITY: usize = 256;
impl FileTransferActivity {
/// ### log
///
/// Add message to log events
pub(super) fn log(&mut self, level: LogLevel, msg: &str) {
pub(super) fn log(&mut self, level: LogLevel, msg: String) {
// Log to file
match level {
LogLevel::Error => error!("{}", msg),
LogLevel::Info => info!("{}", msg),
LogLevel::Warn => warn!("{}", msg),
}
// Create log record
let record: LogRecord = LogRecord::new(level, msg);
//Check if history overflows the size
if self.log_records.len() + 1 > self.log_size {
if self.log_records.len() + 1 > LOG_CAPACITY {
self.log_records.pop_back(); // Start cleaning events from back
}
// Eventually push front the new record
@@ -52,8 +59,8 @@ impl FileTransferActivity {
///
/// Add message to log events and also display it as an alert
pub(super) fn log_and_alert(&mut self, level: LogLevel, msg: String) {
self.log(level, msg.as_str());
self.mount_error(msg.as_str());
self.log(level, msg);
// Update log
let msg = self.update_logbox();
self.update(msg);
@@ -91,39 +98,6 @@ impl FileTransferActivity {
}
}
/// ### build_explorer
///
/// Build explorer reading configuration from `ConfigClient`
pub(super) fn build_explorer(cli: Option<&ConfigClient>) -> FileExplorer {
match &cli {
Some(cli) => FileExplorerBuilder::new() // Build according to current configuration
.with_file_sorting(FileSorting::ByName)
.with_group_dirs(cli.get_group_dirs())
.with_hidden_files(cli.get_show_hidden_files())
.with_stack_size(16)
.with_formatter(cli.get_file_fmt().as_deref())
.build(),
None => FileExplorerBuilder::new() // Build default
.with_file_sorting(FileSorting::ByName)
.with_group_dirs(Some(GroupDirs::First))
.with_stack_size(16)
.build(),
}
}
/// ### build_found_explorer
///
/// Build explorer reading from `ConfigClient`, for found result (has some differences)
pub(super) fn build_found_explorer() -> FileExplorer {
FileExplorerBuilder::new()
.with_file_sorting(FileSorting::ByName)
.with_group_dirs(Some(GroupDirs::First))
.with_hidden_files(true)
.with_stack_size(0)
.with_formatter(Some("{NAME} {SYMLINK}"))
.build()
}
/// ### setup_text_editor
///
/// Set text editor to use
@@ -150,4 +124,32 @@ impl FileTransferActivity {
false
}
}
/// ### local_to_abs_path
///
/// Convert a path to absolute according to local explorer
pub(super) fn local_to_abs_path(&self, path: &Path) -> PathBuf {
match path.is_relative() {
true => {
let mut d: PathBuf = self.local().wrkdir.clone();
d.push(path);
d
}
false => path.to_path_buf(),
}
}
/// ### remote_to_abs_path
///
/// Convert a path to absolute according to remote explorer
pub(super) fn remote_to_abs_path(&self, path: &Path) -> PathBuf {
match path.is_relative() {
true => {
let mut wrkdir: PathBuf = self.remote().wrkdir.clone();
wrkdir.push(path);
wrkdir
}
false => path.to_path_buf(),
}
}
}

View File

@@ -26,17 +26,18 @@
* SOFTWARE.
*/
// This module is split into files, cause it's just too big
mod actions;
mod misc;
mod session;
mod update;
mod view;
pub(self) mod actions;
pub(self) mod lib;
pub(self) mod misc;
pub(self) mod session;
pub(self) mod update;
pub(self) mod view;
// Dependencies
extern crate chrono;
extern crate crossterm;
extern crate textwrap;
extern crate tui;
extern crate tuirealm;
// locals
use super::{Activity, Context, ExitReason};
@@ -46,20 +47,22 @@ use crate::filetransfer::sftp_transfer::SftpFileTransfer;
use crate::filetransfer::{FileTransfer, FileTransferProtocol};
use crate::fs::explorer::FileExplorer;
use crate::fs::FsEntry;
use crate::host::Localhost;
use crate::system::config_client::ConfigClient;
use crate::ui::layout::view::View;
pub(crate) use lib::browser;
use lib::browser::Browser;
use lib::transfer::TransferStates;
// Includes
use chrono::{DateTime, Local};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::Instant;
use tuirealm::View;
// -- Storage keys
const STORAGE_EXPLORER_WIDTH: &str = "FILETRANSFER_EXPLORER_WIDTH";
const STORAGE_LOGBOX_WIDTH: &str = "LOGBOX_WIDTH";
// -- components
@@ -67,7 +70,8 @@ const COMPONENT_EXPLORER_LOCAL: &str = "EXPLORER_LOCAL";
const COMPONENT_EXPLORER_REMOTE: &str = "EXPLORER_REMOTE";
const COMPONENT_EXPLORER_FIND: &str = "EXPLORER_FIND";
const COMPONENT_LOG_BOX: &str = "LOG_BOX";
const COMPONENT_PROGRESS_BAR: &str = "PROGRESS_BAR";
const COMPONENT_PROGRESS_BAR_FULL: &str = "PROGRESS_BAR_FULL";
const COMPONENT_PROGRESS_BAR_PARTIAL: &str = "PROGRESS_BAR_PARTIAL";
const COMPONENT_TEXT_ERROR: &str = "TEXT_ERROR";
const COMPONENT_TEXT_FATAL: &str = "TEXT_FATAL";
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
@@ -84,18 +88,9 @@ const COMPONENT_RADIO_DELETE: &str = "RADIO_DELETE";
const COMPONENT_RADIO_DISCONNECT: &str = "RADIO_DISCONNECT";
const COMPONENT_RADIO_QUIT: &str = "RADIO_QUIT";
const COMPONENT_RADIO_SORTING: &str = "RADIO_SORTING";
const COMPONENT_SPAN_STATUS_BAR: &str = "STATUS_BAR";
const COMPONENT_LIST_FILEINFO: &str = "LIST_FILEINFO";
/// ## FileExplorerTab
///
/// File explorer tab
enum FileExplorerTab {
Local,
Remote,
FindLocal, // Find result tab
FindRemote, // Find result tab
}
/// ## LogLevel
///
/// Log level type
@@ -118,90 +113,15 @@ impl LogRecord {
/// ### new
///
/// Instantiates a new LogRecord
pub fn new(level: LogLevel, msg: &str) -> LogRecord {
pub fn new(level: LogLevel, msg: String) -> LogRecord {
LogRecord {
time: Local::now(),
level,
msg: String::from(msg),
msg,
}
}
}
/// ### TransferStates
///
/// TransferStates contains the states related to the transfer process
struct TransferStates {
pub progress: f64, // Current read/write progress (percentage)
pub started: Instant, // Instant the transfer process started
pub aborted: bool, // Describes whether the transfer process has been aborted
pub bytes_written: usize, // Bytes written during transfer
pub bytes_total: usize, // Total bytes to write
}
impl TransferStates {
/// ### new
///
/// Instantiates a new transfer states
pub fn new() -> TransferStates {
TransferStates {
progress: 0.0,
started: Instant::now(),
aborted: false,
bytes_written: 0,
bytes_total: 0,
}
}
/// ### reset
///
/// Re-intiialize transfer states
pub fn reset(&mut self) {
self.progress = 0.0;
self.started = Instant::now();
self.aborted = false;
self.bytes_written = 0;
self.bytes_total = 0;
}
/// ### set_progress
///
/// Calculate progress percentage based on current progress
pub fn set_progress(&mut self, w: usize, sz: usize) {
self.bytes_written = w;
self.bytes_total = sz;
let mut prog: f64 = ((self.bytes_written as f64) * 100.0) / (self.bytes_total as f64);
// Check value
if prog > 100.0 {
prog = 100.0;
} else if prog < 0.0 {
prog = 0.0;
}
self.progress = prog;
}
/// ### byte_per_second
///
/// Calculate bytes per second
pub fn bytes_per_second(&self) -> u64 {
// bytes_written : elapsed_secs = x : 1
let elapsed_secs: u64 = self.started.elapsed().as_secs();
match elapsed_secs {
0 => match self.bytes_written == self.bytes_total {
// NOTE: would divide by 0 :D
true => self.bytes_total as u64, // Download completed in less than 1 second
false => 0, // 0 B/S
},
_ => self.bytes_written as u64 / elapsed_secs,
}
}
}
impl Default for TransferStates {
fn default() -> Self {
Self::new()
}
}
/// ## FileTransferActivity
///
/// FileTransferActivity is the data holder for the file transfer activity
@@ -209,13 +129,10 @@ pub struct FileTransferActivity {
exit_reason: Option<ExitReason>, // Exit reason
context: Option<Context>, // Context holder
view: View, // View
host: Localhost, // Localhost
client: Box<dyn FileTransfer>, // File transfer client
local: FileExplorer, // Local File explorer state
remote: FileExplorer, // Remote File explorer state
found: Option<FileExplorer>, // File explorer for find result
tab: FileExplorerTab, // Current selected tab
browser: Browser, // Browser
log_records: VecDeque<LogRecord>, // Log records
log_size: usize, // Log records size (max)
transfer: TransferStates, // Transfer states
}
@@ -223,13 +140,14 @@ impl FileTransferActivity {
/// ### new
///
/// Instantiates a new FileTransferActivity
pub fn new(protocol: FileTransferProtocol) -> FileTransferActivity {
pub fn new(host: Localhost, protocol: FileTransferProtocol) -> FileTransferActivity {
// Get config client
let config_client: Option<ConfigClient> = Self::init_config_client();
FileTransferActivity {
exit_reason: None,
context: None,
view: View::init(),
host,
client: match protocol {
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new(
Self::make_ssh_storage(config_client.as_ref()),
@@ -239,15 +157,35 @@ impl FileTransferActivity {
Self::make_ssh_storage(config_client.as_ref()),
)),
},
local: Self::build_explorer(config_client.as_ref()),
remote: Self::build_explorer(config_client.as_ref()),
found: None,
tab: FileExplorerTab::Local,
browser: Browser::new(config_client.as_ref()),
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
log_size: 256, // Must match with capacity
transfer: TransferStates::default(),
}
}
pub(crate) fn local(&self) -> &FileExplorer {
self.browser.local()
}
pub(crate) fn local_mut(&mut self) -> &mut FileExplorer {
self.browser.local_mut()
}
pub(crate) fn remote(&self) -> &FileExplorer {
self.browser.remote()
}
pub(crate) fn remote_mut(&mut self) -> &mut FileExplorer {
self.browser.remote_mut()
}
pub(crate) fn found(&self) -> Option<&FileExplorer> {
self.browser.found()
}
pub(crate) fn found_mut(&mut self) -> Option<&mut FileExplorer> {
self.browser.found_mut()
}
}
/**
@@ -262,25 +200,33 @@ impl Activity for FileTransferActivity {
/// `on_create` is the function which must be called to initialize the activity.
/// `on_create` must initialize all the data structures used by the activity
fn on_create(&mut self, context: Context) {
debug!("Initializing activity...");
// Set context
self.context = Some(context);
// Clear terminal
self.context.as_mut().unwrap().clear_screen();
// Put raw mode on enabled
let _ = enable_raw_mode();
if let Err(err) = enable_raw_mode() {
error!("Failed to enter raw mode: {}", err);
}
// Set working directory
let pwd: PathBuf = self.context.as_ref().unwrap().local.pwd();
let pwd: PathBuf = self.host.pwd();
// Get files at current wd
self.local_scan(pwd.as_path());
self.local.wrkdir = pwd;
self.local_mut().wrkdir = pwd;
debug!("Read working directory");
// Configure text editor
self.setup_text_editor();
debug!("Setup text editor");
// init view
self.init();
debug!("Initialized view");
// Verify error state from context
if let Some(err) = self.context.as_mut().unwrap().get_error() {
error!("Fatal error on create: {}", err);
self.mount_fatal(&err);
}
info!("Created FileTransferActivity");
}
/// ### on_draw
@@ -297,6 +243,10 @@ 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();
info!(
"Client is not connected to remote; 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());
@@ -330,7 +280,9 @@ impl Activity for FileTransferActivity {
/// This function must be called once before terminating the activity.
fn on_destroy(&mut self) -> Option<Context> {
// Disable raw mode
let _ = disable_raw_mode();
if let Err(err) = disable_raw_mode() {
error!("Failed to disable raw mode: {}", err);
}
// Disconnect client
if self.client.is_connected() {
let _ = self.client.disconnect();

View File

@@ -29,25 +29,27 @@
extern crate bytesize;
// locals
use super::{
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, COMPONENT_RADIO_DELETE,
COMPONENT_RADIO_DISCONNECT, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SORTING,
COMPONENT_TEXT_ERROR, COMPONENT_TEXT_FATAL, COMPONENT_TEXT_HELP,
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,
};
use crate::fs::explorer::FileSorting;
use crate::fs::FsEntry;
use crate::ui::activities::keymap::*;
use crate::ui::layout::props::{
PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
};
use crate::ui::layout::{Msg, Payload};
use crate::ui::components::{file_list::FileListPropsBuilder, logbox::LogboxPropsBuilder};
use crate::ui::keymap::*;
// externals
use bytesize::ByteSize;
use std::path::{Path, PathBuf};
use tui::style::Color;
use tuirealm::{
components::progress_bar::ProgressBarPropsBuilder,
props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder},
tui::style::Color,
Msg, Payload, Value,
};
impl FileTransferActivity {
// -- update
@@ -66,229 +68,144 @@ impl FileTransferActivity {
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_RIGHT) => {
// Change tab
self.view.active(COMPONENT_EXPLORER_REMOTE);
self.tab = FileExplorerTab::Remote;
self.browser.change_tab(FileExplorerTab::Remote);
None
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_BACKSPACE) => {
// Go to previous directory
if let Some(d) = self.local.popd() {
self.local_changedir(d.as_path(), false);
self.action_go_to_previous_local_dir(false);
if self.browser.sync_browsing {
let _ = self.update_remote_filelist();
}
// Reload file list component
self.update_local_filelist()
}
(COMPONENT_EXPLORER_LOCAL, Msg::OnSubmit(Payload::Unsigned(idx))) => {
(COMPONENT_EXPLORER_LOCAL, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
// Match selected file
let mut entry: Option<FsEntry> = None;
if let Some(e) = self.local.get(*idx) {
if let Some(e) = self.local().get(*idx) {
entry = Some(e.clone());
}
if let Some(entry) = entry {
// If directory, enter directory, otherwise check if symlink
match entry {
FsEntry::Directory(dir) => {
self.local_changedir(dir.abs_path.as_path(), true);
self.update_local_filelist()
}
FsEntry::File(file) => {
// Check if symlink
match &file.symlink {
Some(pointer) => match &**pointer {
FsEntry::Directory(dir) => {
self.local_changedir(dir.abs_path.as_path(), true);
self.update_local_filelist()
}
_ => None,
},
None => None,
}
if self.action_enter_local_dir(entry, false) {
// Update file list if sync
if self.browser.sync_browsing {
let _ = self.update_remote_filelist();
}
self.update_local_filelist()
} else {
None
}
} else {
None
}
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_SPACE) => {
// Get pwd
let wrkdir: PathBuf = self.remote.wrkdir.clone();
// Get file and clone (due to mutable / immutable stuff...)
if self.get_local_file_entry().is_some() {
let file: FsEntry = self.get_local_file_entry().unwrap().clone();
let name: String = file.get_name().to_string();
// Call upload; pass realfile, keep link name
self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(name));
self.update_remote_filelist()
} else {
None
}
self.action_local_send();
self.update_remote_filelist()
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_A) => {
// Toggle hidden files
self.local.toggle_hidden_files();
self.local_mut().toggle_hidden_files();
// Reload file list component
self.update_local_filelist()
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_I) => {
let file: Option<FsEntry> = self.get_local_file_entry().cloned();
if let Some(file) = file {
if let SelectedEntry::One(file) = self.get_local_selected_entries() {
self.mount_file_info(&file);
}
None
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_L) => {
// Reload directory
let pwd: PathBuf = self.local.wrkdir.clone();
let pwd: PathBuf = self.local().wrkdir.clone();
self.local_scan(pwd.as_path());
// Reload file list component
self.update_local_filelist()
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_O) => {
// Clone entry due to mutable stuff...
if self.get_local_file_entry().is_some() {
let fsentry: FsEntry = self.get_local_file_entry().unwrap().clone();
// Check if file
if fsentry.is_file() {
self.log(
LogLevel::Info,
format!("Opening file \"{}\"...", fsentry.get_abs_path().display())
.as_str(),
);
// Edit file
match self.edit_local_file(fsentry.get_abs_path().as_path()) {
Ok(_) => {
// Reload directory
let pwd: PathBuf = self.local.wrkdir.clone();
self.local_scan(pwd.as_path());
}
Err(err) => self.log_and_alert(LogLevel::Error, err),
}
}
}
self.action_edit_local_file();
// Reload file list component
self.update_local_filelist()
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_U) => {
// Get pwd
let path: PathBuf = self.local.wrkdir.clone();
// Go to parent directory
if let Some(parent) = path.as_path().parent() {
self.local_changedir(parent, true);
// Reload file list component
self.action_go_to_local_upper_dir(false);
if self.browser.sync_browsing {
let _ = self.update_remote_filelist();
}
// Reload file list component
self.update_local_filelist()
}
// -- remote tab
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_LEFT) => {
// Change tab
self.view.active(COMPONENT_EXPLORER_LOCAL);
self.tab = FileExplorerTab::Local;
self.browser.change_tab(FileExplorerTab::Local);
None
}
(COMPONENT_EXPLORER_REMOTE, Msg::OnSubmit(Payload::Unsigned(idx))) => {
(COMPONENT_EXPLORER_REMOTE, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
// Match selected file
let mut entry: Option<FsEntry> = None;
if let Some(e) = self.remote.get(*idx) {
if let Some(e) = self.remote().get(*idx) {
entry = Some(e.clone());
}
if let Some(entry) = entry {
// If directory, enter directory; if file, check if is symlink
match entry {
FsEntry::Directory(dir) => {
self.remote_changedir(dir.abs_path.as_path(), true);
self.update_remote_filelist()
}
FsEntry::File(file) => {
match &file.symlink {
Some(symlink_entry) => {
// If symlink and is directory, point to symlink
match &**symlink_entry {
FsEntry::Directory(dir) => {
self.remote_changedir(dir.abs_path.as_path(), true);
self.update_remote_filelist()
}
_ => None,
}
}
None => None,
}
if self.action_enter_remote_dir(entry, false) {
// Update file list if sync
if self.browser.sync_browsing {
let _ = self.update_local_filelist();
}
self.update_remote_filelist()
} else {
None
}
} else {
None
}
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_SPACE) => {
// Get file and clone (due to mutable / immutable stuff...)
if self.get_remote_file_entry().is_some() {
let file: FsEntry = self.get_remote_file_entry().unwrap().clone();
let name: String = file.get_name().to_string();
// Call upload; pass realfile, keep link name
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(name));
self.update_local_filelist()
} else {
None
}
self.action_remote_recv();
self.update_local_filelist()
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_BACKSPACE) => {
// Go to previous directory
if let Some(d) = self.remote.popd() {
self.remote_changedir(d.as_path(), false);
self.action_go_to_previous_remote_dir(false);
// If sync is enabled update local too
if self.browser.sync_browsing {
let _ = self.update_local_filelist();
}
// Reload file list component
self.update_remote_filelist()
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_A) => {
// Toggle hidden files
self.remote.toggle_hidden_files();
self.remote_mut().toggle_hidden_files();
// Reload file list component
self.update_remote_filelist()
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_I) => {
let file: Option<FsEntry> = self.get_remote_file_entry().cloned();
if let Some(file) = file {
if let SelectedEntry::One(file) = self.get_remote_selected_entries() {
self.mount_file_info(&file);
}
None
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_L) => {
// Reload directory
let pwd: PathBuf = self.remote.wrkdir.clone();
let pwd: PathBuf = self.remote().wrkdir.clone();
self.remote_scan(pwd.as_path());
// Reload file list component
self.update_remote_filelist()
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_O) => {
// Clone entry due to mutable stuff...
if self.get_remote_file_entry().is_some() {
let fsentry: FsEntry = self.get_remote_file_entry().unwrap().clone();
// Check if file
if let FsEntry::File(file) = fsentry.clone() {
self.log(
LogLevel::Info,
format!("Opening file \"{}\"...", fsentry.get_abs_path().display())
.as_str(),
);
// Edit file
match self.edit_remote_file(&file) {
Ok(_) => {
// Reload directory
let pwd: PathBuf = self.remote.wrkdir.clone();
self.remote_scan(pwd.as_path());
}
Err(err) => self.log_and_alert(LogLevel::Error, err),
}
}
}
// Edit file
self.action_edit_remote_file();
// Reload file list component
self.update_remote_filelist()
}
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_U) => {
// Get pwd
let path: PathBuf = self.remote.wrkdir.clone();
// Go to parent directory
if let Some(parent) = path.as_path().parent() {
self.remote_changedir(parent, true);
self.action_go_to_remote_upper_dir(false);
if self.browser.sync_browsing {
let _ = self.update_local_filelist();
}
// Reload file list component
self.update_remote_filelist()
@@ -355,6 +272,14 @@ impl FileTransferActivity {
self.mount_exec();
None
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_Y)
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_Y) => {
// Toggle browser sync
self.browser.toggle_sync_browsing();
// Update status bar
self.refresh_status_bar();
None
}
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_ESC)
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_ESC)
| (COMPONENT_LOG_BOX, &MSG_KEY_ESC) => {
@@ -378,15 +303,15 @@ impl FileTransferActivity {
self.finalize_find();
None
}
(COMPONENT_EXPLORER_FIND, Msg::OnSubmit(Payload::Unsigned(idx))) => {
(COMPONENT_EXPLORER_FIND, Msg::OnSubmit(_)) => {
// Find changedir
self.action_find_changedir(*idx);
self.action_find_changedir();
// Umount find
self.umount_find();
// Finalize find
self.finalize_find();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -394,17 +319,12 @@ impl FileTransferActivity {
}
(COMPONENT_EXPLORER_FIND, &MSG_KEY_SPACE) => {
// Get entry
match self.view.get_value(COMPONENT_EXPLORER_FIND) {
Some(Payload::Unsigned(idx)) => {
self.action_find_transfer(idx, None);
// Reload files
match self.tab {
// NOTE: swapped by purpose
FileExplorerTab::FindLocal => self.update_remote_filelist(),
FileExplorerTab::FindRemote => self.update_local_filelist(),
_ => None,
}
}
self.action_find_transfer(None);
// Reload files
match self.browser.tab() {
// NOTE: swapped by purpose
FileExplorerTab::FindLocal => self.update_remote_filelist(),
FileExplorerTab::FindRemote => self.update_local_filelist(),
_ => None,
}
}
@@ -424,16 +344,16 @@ impl FileTransferActivity {
self.umount_copy();
None
}
(COMPONENT_INPUT_COPY, Msg::OnSubmit(Payload::Text(input))) => {
(COMPONENT_INPUT_COPY, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
// Copy file
match self.tab {
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();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -444,16 +364,16 @@ impl FileTransferActivity {
self.umount_exec();
None
}
(COMPONENT_INPUT_EXEC, Msg::OnSubmit(Payload::Text(input))) => {
(COMPONENT_INPUT_EXEC, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
// Exex command
match self.tab {
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();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -464,10 +384,10 @@ impl FileTransferActivity {
self.umount_find_input();
None
}
(COMPONENT_INPUT_FIND, Msg::OnSubmit(Payload::Text(input))) => {
(COMPONENT_INPUT_FIND, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
self.umount_find_input();
// Find
let res: Result<Vec<FsEntry>, String> = match self.tab {
let res: Result<Vec<FsEntry>, String> = match self.browser.tab() {
FileExplorerTab::Local => self.action_local_find(input.to_string()),
FileExplorerTab::Remote => self.action_remote_find(input.to_string()),
_ => panic!("Trying to search for files, while already in a find result"),
@@ -480,18 +400,16 @@ impl FileTransferActivity {
}
Ok(files) => {
// Create explorer and load files
let mut explorer = Self::build_found_explorer();
explorer.set_files(files);
self.found = Some(explorer);
self.browser.set_found(files);
// Mount result widget
self.mount_find(input);
self.update_find_list();
// Initialize tab
self.tab = match self.tab {
self.browser.change_tab(match self.browser.tab() {
FileExplorerTab::Local => FileExplorerTab::FindLocal,
FileExplorerTab::Remote => FileExplorerTab::FindRemote,
_ => FileExplorerTab::FindLocal,
};
});
}
}
None
@@ -501,16 +419,28 @@ impl FileTransferActivity {
self.umount_goto();
None
}
(COMPONENT_INPUT_GOTO, Msg::OnSubmit(Payload::Text(input))) => {
match self.tab {
FileExplorerTab::Local => self.action_change_local_dir(input.to_string()),
FileExplorerTab::Remote => self.action_change_remote_dir(input.to_string()),
(COMPONENT_INPUT_GOTO, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
match self.browser.tab() {
FileExplorerTab::Local => {
self.action_change_local_dir(input.to_string(), false)
}
FileExplorerTab::Remote => {
self.action_change_remote_dir(input.to_string(), false)
}
_ => panic!("Found tab doesn't support GOTO"),
}
// Umount
self.umount_goto();
// Reload files if sync
if self.browser.sync_browsing {
match self.browser.tab() {
FileExplorerTab::Remote => self.update_local_filelist(),
FileExplorerTab::Local => self.update_remote_filelist(),
_ => None,
};
}
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -521,15 +451,15 @@ impl FileTransferActivity {
self.umount_mkdir();
None
}
(COMPONENT_INPUT_MKDIR, Msg::OnSubmit(Payload::Text(input))) => {
match self.tab {
(COMPONENT_INPUT_MKDIR, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
match self.browser.tab() {
FileExplorerTab::Local => self.action_local_mkdir(input.to_string()),
FileExplorerTab::Remote => self.action_remote_mkdir(input.to_string()),
_ => panic!("Found tab doesn't support MKDIR"),
}
self.umount_mkdir();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -540,15 +470,15 @@ impl FileTransferActivity {
self.umount_newfile();
None
}
(COMPONENT_INPUT_NEWFILE, Msg::OnSubmit(Payload::Text(input))) => {
match self.tab {
(COMPONENT_INPUT_NEWFILE, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
match self.browser.tab() {
FileExplorerTab::Local => self.action_local_newfile(input.to_string()),
FileExplorerTab::Remote => self.action_remote_newfile(input.to_string()),
_ => panic!("Found tab doesn't support NEWFILE"),
}
self.umount_newfile();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -559,15 +489,15 @@ impl FileTransferActivity {
self.umount_rename();
None
}
(COMPONENT_INPUT_RENAME, Msg::OnSubmit(Payload::Text(input))) => {
match self.tab {
(COMPONENT_INPUT_RENAME, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
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();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -578,22 +508,18 @@ impl FileTransferActivity {
self.umount_saveas();
None
}
(COMPONENT_INPUT_SAVEAS, Msg::OnSubmit(Payload::Text(input))) => {
match self.tab {
(COMPONENT_INPUT_SAVEAS, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
match self.browser.tab() {
FileExplorerTab::Local => self.action_local_saveas(input.to_string()),
FileExplorerTab::Remote => self.action_remote_saveas(input.to_string()),
FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => {
// Get entry
if let Some(Payload::Unsigned(idx)) =
self.view.get_value(COMPONENT_EXPLORER_FIND)
{
self.action_find_transfer(idx, Some(input.to_string()));
}
self.action_find_transfer(Some(input.to_string()));
}
}
self.umount_saveas();
// Reload files
match self.tab {
match self.browser.tab() {
// NOTE: Swapped is intentional
FileExplorerTab::Local => self.update_remote_filelist(),
FileExplorerTab::Remote => self.update_local_filelist(),
@@ -609,30 +535,41 @@ impl FileTransferActivity {
}
// -- delete
(COMPONENT_RADIO_DELETE, &MSG_KEY_ESC)
| (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::Unsigned(1))) => {
| (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
self.umount_radio_delete();
None
}
(COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::Unsigned(0))) => {
(COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
// Choice is 'YES'
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.action_local_delete(),
FileExplorerTab::Remote => self.action_remote_delete(),
FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => {
// Get entry
if let Some(Payload::Unsigned(idx)) =
self.view.get_value(COMPONENT_EXPLORER_FIND)
{
self.action_find_delete(idx);
// Reload entries
self.found.as_mut().unwrap().del_entry(idx);
self.update_find_list();
self.action_find_delete();
// Delete entries
match self.view.get_state(COMPONENT_EXPLORER_FIND) {
Some(Payload::One(Value::Usize(idx))) => {
// Reload entries
self.found_mut().unwrap().del_entry(idx);
}
Some(Payload::Vec(values)) => {
values
.iter()
.map(|x| match x {
Value::Usize(v) => *v,
_ => 0,
})
.for_each(|x| self.found_mut().unwrap().del_entry(x));
}
_ => {}
}
self.update_find_list();
}
}
self.umount_radio_delete();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
FileExplorerTab::FindLocal => self.update_local_filelist(),
@@ -641,32 +578,33 @@ impl FileTransferActivity {
}
// -- disconnect
(COMPONENT_RADIO_DISCONNECT, &MSG_KEY_ESC)
| (COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::Unsigned(1))) => {
| (COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
self.umount_disconnect();
None
}
(COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::Unsigned(0))) => {
(COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
self.disconnect();
self.umount_disconnect();
None
}
// -- quit
(COMPONENT_RADIO_QUIT, &MSG_KEY_ESC)
| (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(1))) => {
| (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
self.umount_quit();
None
}
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(0))) => {
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
self.disconnect_and_quit();
self.umount_quit();
None
}
// -- sorting
(COMPONENT_RADIO_SORTING, &MSG_KEY_ESC) => {
(COMPONENT_RADIO_SORTING, &MSG_KEY_ESC)
| (COMPONENT_RADIO_SORTING, Msg::OnSubmit(_)) => {
self.umount_file_sorting();
None
}
(COMPONENT_RADIO_SORTING, Msg::OnSubmit(Payload::Unsigned(mode))) => {
(COMPONENT_RADIO_SORTING, Msg::OnChange(Payload::One(Value::Usize(mode)))) => {
// Get sorting mode
let sorting: FileSorting = match mode {
1 => FileSorting::ByModifyTime,
@@ -674,14 +612,15 @@ impl FileTransferActivity {
3 => FileSorting::BySize,
_ => FileSorting::ByName,
};
match self.tab {
FileExplorerTab::Local => self.local.sort_by(sorting),
FileExplorerTab::Remote => self.remote.sort_by(sorting),
match self.browser.tab() {
FileExplorerTab::Local => self.local_mut().sort_by(sorting),
FileExplorerTab::Remote => self.remote_mut().sort_by(sorting),
_ => panic!("Found result doesn't support SORTING"),
}
self.umount_file_sorting();
// Update status bar
self.refresh_status_bar();
// Reload files
match self.tab {
match self.browser.tab() {
FileExplorerTab::Local => self.update_local_filelist(),
FileExplorerTab::Remote => self.update_remote_filelist(),
_ => None,
@@ -703,9 +642,9 @@ impl FileTransferActivity {
None
}
// -- progress bar
(COMPONENT_PROGRESS_BAR, &MSG_KEY_CTRL_C) => {
(COMPONENT_PROGRESS_BAR_PARTIAL, &MSG_KEY_CTRL_C) => {
// Set transfer aborted to True
self.transfer.aborted = true;
self.transfer.abort();
None
}
// -- fallback
@@ -718,11 +657,7 @@ impl FileTransferActivity {
///
/// Update local file list
pub(super) fn update_local_filelist(&mut self) -> Option<(String, Msg)> {
match self
.view
.get_props(super::COMPONENT_EXPLORER_LOCAL)
.as_mut()
{
match self.view.get_props(super::COMPONENT_EXPLORER_LOCAL) {
Some(props) => {
// Get width
let width: usize = self
@@ -744,20 +679,20 @@ impl FileTransferActivity {
"{}:{} ",
hostname,
FileTransferActivity::elide_wrkdir_path(
self.local.wrkdir.as_path(),
self.local().wrkdir.as_path(),
hostname.as_str(),
width
)
.display()
);
let files: Vec<TextSpan> = self
.local
let files: Vec<String> = self
.local()
.iter_files()
.map(|x: &FsEntry| TextSpan::from(self.local.fmt_file(x)))
.map(|x: &FsEntry| self.local().fmt_file(x))
.collect();
// Update
let props = props
.with_texts(TextParts::new(Some(hostname), Some(files)))
let props = FileListPropsBuilder::from(props)
.with_files(Some(hostname), files)
.build();
// Update
self.view.update(super::COMPONENT_EXPLORER_LOCAL, props)
@@ -770,11 +705,7 @@ impl FileTransferActivity {
///
/// Update remote file list
pub(super) fn update_remote_filelist(&mut self) -> Option<(String, Msg)> {
match self
.view
.get_props(super::COMPONENT_EXPLORER_REMOTE)
.as_mut()
{
match self.view.get_props(super::COMPONENT_EXPLORER_REMOTE) {
Some(props) => {
// Get width
let width: usize = self
@@ -789,20 +720,20 @@ impl FileTransferActivity {
"{}:{} ",
params.address,
FileTransferActivity::elide_wrkdir_path(
self.remote.wrkdir.as_path(),
self.remote().wrkdir.as_path(),
params.address.as_str(),
width
)
.display()
);
let files: Vec<TextSpan> = self
.remote
let files: Vec<String> = self
.remote()
.iter_files()
.map(|x: &FsEntry| TextSpan::from(self.remote.fmt_file(x)))
.map(|x: &FsEntry| self.remote().fmt_file(x))
.collect();
// Update
let props = props
.with_texts(TextParts::new(Some(hostname), Some(files)))
let props = FileListPropsBuilder::from(props)
.with_files(Some(hostname), files)
.build();
self.view.update(super::COMPONENT_EXPLORER_REMOTE, props)
}
@@ -814,21 +745,11 @@ impl FileTransferActivity {
///
/// Update log box
pub(super) fn update_logbox(&mut self) -> Option<(String, Msg)> {
match self.view.get_props(super::COMPONENT_LOG_BOX).as_mut() {
match self.view.get_props(super::COMPONENT_LOG_BOX) {
Some(props) => {
// Get width
let width: usize = self
.context
.as_ref()
.unwrap()
.store
.get_unsigned(super::STORAGE_LOGBOX_WIDTH)
.unwrap_or(256);
// Make log entries
let mut table: TableBuilder = TableBuilder::default();
for (idx, record) in self.log_records.iter().enumerate() {
// Split rows by width NOTE: -37 'cause log prefix -3 cause of log line cursor
let record_rows = textwrap::wrap(record.msg.as_str(), (width as usize) - 40);
// Add row if not first row
if idx > 0 {
table.add_row();
@@ -838,46 +759,33 @@ impl FileTransferActivity {
LogLevel::Warn => Color::Yellow,
LogLevel::Info => Color::Green,
};
for (idx, row) in record_rows.iter().enumerate() {
match idx {
0 => {
// First row
table
.add_col(TextSpan::from(format!(
"{}",
record.time.format("%Y-%m-%dT%H:%M:%S%Z")
)))
.add_col(TextSpan::from(" ["))
.add_col(
TextSpanBuilder::new(
format!(
"{:5}",
match record.level {
LogLevel::Error => "ERROR",
LogLevel::Warn => "WARN",
LogLevel::Info => "INFO",
}
)
.as_str(),
)
.with_foreground(fg)
.build(),
)
.add_col(TextSpan::from("]: "))
.add_col(TextSpan::from(row.as_ref()));
}
_ => {
table.add_col(TextSpan::from(textwrap::indent(
row.as_ref(),
" ",
)));
}
}
}
table
.add_col(TextSpan::from(format!(
"{}",
record.time.format("%Y-%m-%dT%H:%M:%S%Z")
)))
.add_col(TextSpan::from(" ["))
.add_col(
TextSpanBuilder::new(
format!(
"{:5}",
match record.level {
LogLevel::Error => "ERROR",
LogLevel::Warn => "WARN",
LogLevel::Info => "INFO",
}
)
.as_str(),
)
.with_foreground(fg)
.build(),
)
.add_col(TextSpan::from("]: "))
.add_col(TextSpan::from(record.msg.as_ref()));
}
let table = table.build();
let props = props
.with_texts(TextParts::table(Some(String::from("Log")), table))
let props = LogboxPropsBuilder::from(props)
.with_log(Some(String::from("Log")), table)
.build();
self.view.update(super::COMPONENT_LOG_BOX, props)
}
@@ -885,34 +793,22 @@ impl FileTransferActivity {
}
}
pub(super) fn update_progress_bar(&mut self, text: String) -> Option<(String, Msg)> {
match self.view.get_props(COMPONENT_PROGRESS_BAR).as_mut() {
pub(super) fn update_progress_bar(&mut self, filename: String) -> Option<(String, Msg)> {
if let Some(props) = self.view.get_props(COMPONENT_PROGRESS_BAR_FULL) {
let root_name: String = props.texts.title.as_deref().unwrap_or("").to_string();
let props = ProgressBarPropsBuilder::from(props)
.with_texts(Some(root_name), self.transfer.full.to_string())
.with_progress(self.transfer.full.calc_progress())
.build();
let _ = self.view.update(COMPONENT_PROGRESS_BAR_FULL, props);
}
match self.view.get_props(COMPONENT_PROGRESS_BAR_PARTIAL) {
Some(props) => {
// Calculate ETA
let elapsed_secs: u64 = self.transfer.started.elapsed().as_secs();
let eta: String = match self.transfer.progress as u64 {
0 => String::from("--:--"), // NOTE: would divide by 0 :D
_ => {
let eta: u64 =
((elapsed_secs * 100) / (self.transfer.progress as u64)) - elapsed_secs;
format!("{:0width$}:{:0width$}", (eta / 60), (eta % 60), width = 2)
}
};
// Calculate bytes/s
let label = format!(
"{:.2}% - ETA {} ({}/s)",
self.transfer.progress,
eta,
ByteSize(self.transfer.bytes_per_second())
);
let props = props
.with_texts(TextParts::new(
Some(text),
Some(vec![TextSpan::from(label)]),
))
.with_value(PropValue::Float(self.transfer.progress / 100.0))
let props = ProgressBarPropsBuilder::from(props)
.with_texts(Some(filename), self.transfer.partial.to_string())
.with_progress(self.transfer.partial.calc_progress())
.build();
self.view.update(COMPONENT_PROGRESS_BAR, props)
self.view.update(COMPONENT_PROGRESS_BAR_PARTIAL, props)
}
None => None,
}
@@ -923,32 +819,29 @@ impl FileTransferActivity {
/// Finalize find process
fn finalize_find(&mut self) {
// Set found to none
self.found = None;
self.browser.del_found();
// Restore tab
self.tab = match self.tab {
self.browser.change_tab(match self.browser.tab() {
FileExplorerTab::FindLocal => FileExplorerTab::Local,
FileExplorerTab::FindRemote => FileExplorerTab::Remote,
_ => FileExplorerTab::Local,
};
});
}
fn update_find_list(&mut self) -> Option<(String, Msg)> {
match self.view.get_props(COMPONENT_EXPLORER_FIND).as_mut() {
match self.view.get_props(COMPONENT_EXPLORER_FIND) {
None => None,
Some(props) => {
let props = props.build();
let title: String = props.texts.title.clone().unwrap_or_default();
let mut props = PropsBuilder::from(props);
// Prepare files
let file_texts: Vec<TextSpan> = self
.found
.as_ref()
let files: Vec<String> = self
.found()
.unwrap()
.iter_files()
.map(|x: &FsEntry| TextSpan::from(self.found.as_ref().unwrap().fmt_file(x)))
.map(|x: &FsEntry| self.found().unwrap().fmt_file(x))
.collect();
let props = props
.with_texts(TextParts::new(Some(title), Some(file_texts)))
let props = FileListPropsBuilder::from(props)
.with_files(Some(title), files)
.build();
self.view.update(COMPONENT_EXPLORER_FIND, props)
}

View File

@@ -31,26 +31,32 @@ extern crate hostname;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
extern crate users;
// locals
use super::{Context, FileExplorerTab, FileTransferActivity};
use super::{browser::FileExplorerTab, Context, FileTransferActivity};
use crate::fs::explorer::FileSorting;
use crate::fs::FsEntry;
use crate::ui::layout::components::{
file_list::FileList, input::Input, logbox::LogBox, msgbox::MsgBox, progress_bar::ProgressBar,
radio_group::RadioGroup, table::Table,
use crate::ui::components::{
file_list::{FileList, FileListPropsBuilder},
logbox::{LogBox, LogboxPropsBuilder},
msgbox::{MsgBox, MsgBoxPropsBuilder},
};
use crate::ui::layout::props::{
PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
};
use crate::ui::layout::utils::draw_area_in;
use crate::ui::store::Store;
use crate::utils::fmt::fmt_time;
use crate::utils::ui::draw_area_in;
// Ext
use bytesize::ByteSize;
use std::path::PathBuf;
use tui::{
use tuirealm::components::{
input::{Input, InputPropsBuilder},
progress_bar::{ProgressBar, ProgressBarPropsBuilder},
radio::{Radio, RadioPropsBuilder},
span::{Span, SpanPropsBuilder},
table::{Table, TablePropsBuilder},
};
use tuirealm::props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder};
use tuirealm::tui::{
layout::{Constraint, Direction, Layout},
style::Color,
widgets::Clear,
widgets::{BorderType, Borders, Clear},
};
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
use users::{get_group_by_gid, get_user_by_uid};
@@ -66,9 +72,10 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_EXPLORER_LOCAL,
Box::new(FileList::new(
PropsBuilder::default()
FileListPropsBuilder::default()
.with_background(Color::Yellow)
.with_foreground(Color::Yellow)
.with_borders(Borders::ALL, BorderType::Plain, Color::Yellow)
.build(),
)),
);
@@ -76,9 +83,10 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_EXPLORER_REMOTE,
Box::new(FileList::new(
PropsBuilder::default()
FileListPropsBuilder::default()
.with_background(Color::LightBlue)
.with_foreground(Color::LightBlue)
.with_borders(Borders::ALL, BorderType::Plain, Color::LightBlue)
.build(),
)),
);
@@ -86,12 +94,18 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_LOG_BOX,
Box::new(LogBox::new(
PropsBuilder::default()
.with_foreground(Color::LightGreen)
.bold()
LogboxPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen)
.build(),
)),
);
// Mount status bar
self.view.mount(
super::COMPONENT_SPAN_STATUS_BAR,
Box::new(Span::new(SpanPropsBuilder::default().build())),
);
// Load process bar
self.refresh_status_bar();
// Update components
let _ = self.update_local_filelist();
let _ = self.update_remote_filelist();
@@ -125,16 +139,18 @@ impl FileTransferActivity {
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.direction(Direction::Horizontal)
.split(chunks[0]);
// Create log box chunks
let bottom_chunks = Layout::default()
.constraints([Constraint::Length(1), Constraint::Length(10)].as_ref())
.direction(Direction::Vertical)
.split(chunks[1]);
// If width is unset in the storage, set width
if !store.isset(super::STORAGE_EXPLORER_WIDTH) {
store.set_unsigned(super::STORAGE_EXPLORER_WIDTH, tabs_chunks[0].width as usize);
}
if !store.isset(super::STORAGE_LOGBOX_WIDTH) {
store.set_unsigned(super::STORAGE_LOGBOX_WIDTH, chunks[1].width as usize);
}
// Draw explorers
// @! Local explorer (Find or default)
match self.tab {
match self.browser.tab() {
FileExplorerTab::FindLocal => {
self.view
.render(super::COMPONENT_EXPLORER_FIND, f, tabs_chunks[0])
@@ -144,7 +160,7 @@ impl FileTransferActivity {
.render(super::COMPONENT_EXPLORER_LOCAL, f, tabs_chunks[0]),
}
// @! Remote explorer (Find or default)
match self.tab {
match self.browser.tab() {
FileExplorerTab::FindRemote => {
self.view
.render(super::COMPONENT_EXPLORER_FIND, f, tabs_chunks[1])
@@ -153,99 +169,115 @@ impl FileTransferActivity {
.view
.render(super::COMPONENT_EXPLORER_REMOTE, f, tabs_chunks[1]),
}
// Draw log box
self.view.render(super::COMPONENT_LOG_BOX, f, chunks[1]);
// Draw log box and status bar
self.view
.render(super::COMPONENT_LOG_BOX, f, bottom_chunks[1]);
self.view
.render(super::COMPONENT_SPAN_STATUS_BAR, f, bottom_chunks[0]);
// @! Draw popups
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_COPY) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_COPY) {
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_COPY, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_FIND) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_FIND) {
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_FIND, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_GOTO) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_GOTO) {
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_GOTO, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_MKDIR) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_MKDIR) {
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_MKDIR, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_NEWFILE) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_NEWFILE) {
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_NEWFILE, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_RENAME) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_RENAME) {
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_RENAME, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_SAVEAS) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_SAVEAS) {
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_SAVEAS, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_EXEC) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_EXEC) {
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_EXEC, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_LIST_FILEINFO) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_LIST_FILEINFO) {
if props.visible {
let popup = draw_area_in(f.size(), 50, 50);
f.render_widget(Clear, popup);
// make popup
self.view.render(super::COMPONENT_LIST_FILEINFO, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_PROGRESS_BAR) {
if props.build().visible {
let popup = draw_area_in(f.size(), 40, 10);
if let Some(props) = self.view.get_props(super::COMPONENT_PROGRESS_BAR_PARTIAL) {
if props.visible {
let popup = draw_area_in(f.size(), 50, 20);
f.render_widget(Clear, popup);
// make popup
self.view.render(super::COMPONENT_PROGRESS_BAR, f, popup);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage(50), // Full
Constraint::Percentage(50), // Partial
]
.as_ref(),
)
.split(popup);
self.view
.render(super::COMPONENT_PROGRESS_BAR_FULL, f, popup_chunks[0]);
self.view
.render(super::COMPONENT_PROGRESS_BAR_PARTIAL, f, popup_chunks[1]);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_DELETE) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DELETE) {
if props.visible {
let popup = draw_area_in(f.size(), 30, 10);
f.render_widget(Clear, popup);
// make popup
self.view.render(super::COMPONENT_RADIO_DELETE, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_DISCONNECT) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DISCONNECT) {
if props.visible {
let popup = draw_area_in(f.size(), 30, 10);
f.render_widget(Clear, popup);
// make popup
@@ -253,48 +285,48 @@ impl FileTransferActivity {
.render(super::COMPONENT_RADIO_DISCONNECT, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
if props.visible {
let popup = draw_area_in(f.size(), 30, 10);
f.render_widget(Clear, popup);
// make popup
self.view.render(super::COMPONENT_RADIO_QUIT, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_SORTING) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SORTING) {
if props.visible {
let popup = draw_area_in(f.size(), 50, 10);
f.render_widget(Clear, popup);
// make popup
self.view.render(super::COMPONENT_RADIO_SORTING, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
if props.build().visible {
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(mut props) = self.view.get_props(super::COMPONENT_TEXT_FATAL) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_FATAL) {
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_FATAL, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_WAIT) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_WAIT) {
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_WAIT, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
if props.build().visible {
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
if props.visible {
let popup = draw_area_in(f.size(), 50, 80);
f.render_widget(Clear, popup);
// make popup
@@ -316,10 +348,11 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_TEXT_ERROR,
Box::new(MsgBox::new(
PropsBuilder::default()
MsgBoxPropsBuilder::default()
.with_foreground(Color::Red)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Red)
.bold()
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
.with_texts(None, vec![TextSpan::from(text)])
.build(),
)),
);
@@ -339,10 +372,11 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_TEXT_FATAL,
Box::new(MsgBox::new(
PropsBuilder::default()
MsgBoxPropsBuilder::default()
.with_foreground(Color::Red)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Red)
.bold()
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
.with_texts(None, vec![TextSpan::from(text)])
.build(),
)),
);
@@ -355,10 +389,11 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_TEXT_WAIT,
Box::new(MsgBox::new(
PropsBuilder::default()
MsgBoxPropsBuilder::default()
.with_foreground(Color::White)
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.bold()
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
.with_texts(None, vec![TextSpan::from(text)])
.build(),
)),
);
@@ -377,14 +412,15 @@ impl FileTransferActivity {
// Protocol
self.view.mount(
super::COMPONENT_RADIO_QUIT,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Yellow)
.with_background(Color::Black)
.with_texts(TextParts::new(
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Yellow)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
.with_options(
Some(String::from("Are you sure you want to quit?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
@@ -405,14 +441,15 @@ impl FileTransferActivity {
// Protocol
self.view.mount(
super::COMPONENT_RADIO_DISCONNECT,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Yellow)
.with_background(Color::Black)
.with_texts(TextParts::new(
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Yellow)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
.with_options(
Some(String::from("Are you sure you want to disconnect?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
@@ -430,11 +467,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_COPY,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("Insert destination name")),
None,
))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("Copy file(s) to..."))
.build(),
)),
);
@@ -449,8 +484,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_EXEC,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("Execute command")), None))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Plain, Color::White)
.with_label(String::from("Execute command"))
.build(),
)),
);
@@ -463,7 +499,7 @@ impl FileTransferActivity {
pub(super) fn mount_find(&mut self, search: &str) {
// Get color
let color: Color = match self.tab {
let color: Color = match self.browser.tab() {
FileExplorerTab::Local | FileExplorerTab::FindLocal => Color::Yellow,
FileExplorerTab::Remote | FileExplorerTab::FindRemote => Color::LightBlue,
};
@@ -471,11 +507,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_EXPLORER_FIND,
Box::new(FileList::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(format!("Search results for \"{}\"", search)),
Some(vec![]),
))
FileListPropsBuilder::default()
.with_files(Some(format!("Search results for \"{}\"", search)), vec![])
.with_borders(Borders::ALL, BorderType::Plain, color)
.with_background(color)
.with_foreground(color)
.build(),
@@ -493,11 +527,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_FIND,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("Search files by name")),
None,
))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("Search files by name"))
.build(),
)),
);
@@ -514,11 +546,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_GOTO,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("Change working directory")),
None,
))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("Change working directory"))
.build(),
)),
);
@@ -533,11 +563,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_MKDIR,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("Insert directory name")),
None,
))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("Insert directory name"))
.build(),
)),
);
@@ -552,8 +580,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_NEWFILE,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("New file name")), None))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("New file name"))
.build(),
)),
);
@@ -568,8 +597,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_RENAME,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("Insert new name")), None))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("Move file(s) to..."))
.build(),
)),
);
@@ -584,8 +614,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_INPUT_SAVEAS,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("Save as...")), None))
InputPropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_label(String::from("Save as..."))
.build(),
)),
);
@@ -596,28 +627,49 @@ impl FileTransferActivity {
self.view.umount(super::COMPONENT_INPUT_SAVEAS);
}
pub(super) fn mount_progress_bar(&mut self) {
pub(super) fn mount_progress_bar(&mut self, root_name: String) {
self.view.mount(
super::COMPONENT_PROGRESS_BAR,
super::COMPONENT_PROGRESS_BAR_FULL,
Box::new(ProgressBar::new(
PropsBuilder::default()
.with_foreground(Color::LightGreen)
ProgressBarPropsBuilder::default()
.with_progbar_color(Color::Green)
.with_background(Color::Black)
.with_texts(TextParts::new(Some(String::from("Please wait")), None))
.with_borders(
Borders::TOP | Borders::RIGHT | Borders::LEFT,
BorderType::Rounded,
Color::Reset,
)
.with_texts(Some(root_name), String::new())
.build(),
)),
);
self.view.active(super::COMPONENT_PROGRESS_BAR);
self.view.mount(
super::COMPONENT_PROGRESS_BAR_PARTIAL,
Box::new(ProgressBar::new(
ProgressBarPropsBuilder::default()
.with_progbar_color(Color::Green)
.with_background(Color::Black)
.with_borders(
Borders::BOTTOM | Borders::RIGHT | Borders::LEFT,
BorderType::Rounded,
Color::Reset,
)
.with_texts(Some(String::from("Please wait")), String::new())
.build(),
)),
);
self.view.active(super::COMPONENT_PROGRESS_BAR_PARTIAL);
}
pub(super) fn umount_progress_bar(&mut self) {
self.view.umount(super::COMPONENT_PROGRESS_BAR);
self.view.umount(super::COMPONENT_PROGRESS_BAR_PARTIAL);
self.view.umount(super::COMPONENT_PROGRESS_BAR_FULL);
}
pub(super) fn mount_file_sorting(&mut self) {
let sorting: FileSorting = match self.tab {
FileExplorerTab::Local => self.local.get_file_sorting(),
FileExplorerTab::Remote => self.remote.get_file_sorting(),
let sorting: FileSorting = match self.browser.tab() {
FileExplorerTab::Local => self.local().get_file_sorting(),
FileExplorerTab::Remote => self.remote().get_file_sorting(),
_ => panic!("You can't mount file sorting when in found result"),
};
let index: usize = match sorting {
@@ -628,20 +680,21 @@ impl FileTransferActivity {
};
self.view.mount(
super::COMPONENT_RADIO_SORTING,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightMagenta)
.with_background(Color::Black)
.with_texts(TextParts::new(
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("Sort files by")),
Some(vec![
TextSpan::from("Name"),
TextSpan::from("Modify time"),
TextSpan::from("Creation time"),
TextSpan::from("Size"),
]),
))
.with_value(PropValue::Unsigned(index))
vec![
String::from("Name"),
String::from("Modify time"),
String::from("Creation time"),
String::from("Size"),
],
)
.with_value(index)
.build(),
)),
);
@@ -655,15 +708,16 @@ impl FileTransferActivity {
pub(super) fn mount_radio_delete(&mut self) {
self.view.mount(
super::COMPONENT_RADIO_DELETE,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::Red)
.with_background(Color::Black)
.with_texts(TextParts::new(
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::Red)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Plain, Color::Red)
.with_options(
Some(String::from("Delete file")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
.with_value(PropValue::Unsigned(1))
vec![String::from("Yes"), String::from("No")],
)
.with_value(1)
.build(),
)),
);
@@ -772,11 +826,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_LIST_FILEINFO,
Box::new(Table::new(
PropsBuilder::default()
.with_texts(TextParts::table(
Some(file.get_name().to_string()),
texts.build(),
))
TablePropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_table(Some(file.get_name().to_string()), texts.build())
.build(),
)),
);
@@ -787,6 +839,41 @@ impl FileTransferActivity {
self.view.umount(super::COMPONENT_LIST_FILEINFO);
}
pub(super) fn refresh_status_bar(&mut self) {
let bar_spans: Vec<TextSpan> = vec![
TextSpanBuilder::new("Synchronized Browsing: ")
.with_foreground(Color::LightGreen)
.build(),
TextSpanBuilder::new(match self.browser.sync_browsing {
true => "ON ",
false => "OFF",
})
.with_foreground(Color::LightGreen)
.reversed()
.build(),
TextSpanBuilder::new(" Localhost file sorting: ")
.with_foreground(Color::LightYellow)
.build(),
TextSpanBuilder::new(Self::get_file_sorting_str(self.local().get_file_sorting()))
.with_foreground(Color::LightYellow)
.reversed()
.build(),
TextSpanBuilder::new(" Remote host file sorting: ")
.with_foreground(Color::LightBlue)
.build(),
TextSpanBuilder::new(Self::get_file_sorting_str(self.remote().get_file_sorting()))
.with_foreground(Color::LightBlue)
.reversed()
.build(),
];
if let Some(props) = self.view.get_props(super::COMPONENT_SPAN_STATUS_BAR) {
self.view.update(
super::COMPONENT_SPAN_STATUS_BAR,
SpanPropsBuilder::from(props).with_spans(bar_spans).build(),
);
}
}
/// ### mount_help
///
/// Mount help
@@ -794,8 +881,9 @@ impl FileTransferActivity {
self.view.mount(
super::COMPONENT_TEXT_HELP,
Box::new(Table::new(
PropsBuilder::default()
.with_texts(TextParts::table(
TablePropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_table(
Some(String::from("Help")),
TableBuilder::default()
.add_col(
@@ -920,6 +1008,14 @@ impl FileTransferActivity {
)
.add_col(TextSpan::from(" Reload directory content"))
.add_row()
.add_col(
TextSpanBuilder::new("<M>")
.bold()
.with_foreground(Color::Cyan)
.build(),
)
.add_col(TextSpan::from(" Select file"))
.add_row()
.add_col(
TextSpanBuilder::new("<N>")
.bold()
@@ -968,6 +1064,22 @@ impl FileTransferActivity {
)
.add_col(TextSpan::from(" Go to parent directory"))
.add_row()
.add_col(
TextSpanBuilder::new("<X>")
.bold()
.with_foreground(Color::Cyan)
.build(),
)
.add_col(TextSpan::from(" Execute shell command"))
.add_row()
.add_col(
TextSpanBuilder::new("<Y>")
.bold()
.with_foreground(Color::Cyan)
.build(),
)
.add_col(TextSpan::from(" Toggle synchronized browsing"))
.add_row()
.add_col(
TextSpanBuilder::new("<DEL|E>")
.bold()
@@ -976,6 +1088,14 @@ impl FileTransferActivity {
)
.add_col(TextSpan::from(" Delete selected file"))
.add_row()
.add_col(
TextSpanBuilder::new("<CTRL+A>")
.bold()
.with_foreground(Color::Cyan)
.build(),
)
.add_col(TextSpan::from(" Select all files"))
.add_row()
.add_col(
TextSpanBuilder::new("<CTRL+C>")
.bold()
@@ -984,7 +1104,7 @@ impl FileTransferActivity {
)
.add_col(TextSpan::from(" Interrupt file transfer"))
.build(),
))
)
.build(),
)),
);
@@ -995,4 +1115,13 @@ impl FileTransferActivity {
pub(super) fn umount_help(&mut self) {
self.view.umount(super::COMPONENT_TEXT_HELP);
}
fn get_file_sorting_str(mode: FileSorting) -> &'static str {
match mode {
FileSorting::ByName => "By name",
FileSorting::ByCreationTime => "By creation time",
FileSorting::ByModifyTime => "By modify time",
FileSorting::BySize => "By size",
}
}
}

View File

@@ -1,640 +0,0 @@
//! ## 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::{FileExplorerTab, FileTransferActivity, FsEntry, LogLevel};
use crate::ui::layout::Payload;
// externals
use std::path::PathBuf;
impl FileTransferActivity {
/// ### action_change_local_dir
///
/// Change local directory reading value from input
pub(super) fn action_change_local_dir(&mut self, input: String) {
let dir_path: PathBuf = PathBuf::from(input.as_str());
let abs_dir_path: PathBuf = match dir_path.is_relative() {
true => {
let mut d: PathBuf = self.local.wrkdir.clone();
d.push(dir_path);
d
}
false => dir_path,
};
self.local_changedir(abs_dir_path.as_path(), true);
}
/// ### action_change_remote_dir
///
/// Change remote directory reading value from input
pub(super) fn action_change_remote_dir(&mut self, input: String) {
let dir_path: PathBuf = PathBuf::from(input.as_str());
let abs_dir_path: PathBuf = match dir_path.is_relative() {
true => {
let mut wrkdir: PathBuf = self.remote.wrkdir.clone();
wrkdir.push(dir_path);
wrkdir
}
false => dir_path,
};
self.remote_changedir(abs_dir_path.as_path(), true);
}
/// ### action_local_copy
///
/// Copy file on local
pub(super) fn action_local_copy(&mut self, input: String) {
if let Some(idx) = self.get_local_file_idx() {
let dest_path: PathBuf = PathBuf::from(input);
let entry: FsEntry = self.local.get(idx).unwrap().clone();
if let Some(ctx) = self.context.as_mut() {
match ctx.local.copy(&entry, dest_path.as_path()) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest_path.display()
)
.as_str(),
);
// Reload entries
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.local_scan(wrkdir.as_path());
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest_path.display(),
err
),
),
}
}
}
}
/// ### action_remote_copy
///
/// Copy file on remote
pub(super) fn action_remote_copy(&mut self, input: String) {
if let Some(idx) = self.get_remote_file_idx() {
let dest_path: PathBuf = PathBuf::from(input);
let entry: FsEntry = self.remote.get(idx).unwrap().clone();
match self.client.as_mut().copy(&entry, dest_path.as_path()) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest_path.display()
)
.as_str(),
);
self.reload_remote_dir();
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest_path.display(),
err
),
),
}
}
}
pub(super) fn action_local_mkdir(&mut self, input: String) {
match self
.context
.as_mut()
.unwrap()
.local
.mkdir(PathBuf::from(input.as_str()).as_path())
{
Ok(_) => {
// Reload files
self.log(
LogLevel::Info,
format!("Created directory \"{}\"", input).as_ref(),
);
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.local_scan(wrkdir.as_path());
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{}\": {}", input, err),
);
}
}
}
pub(super) fn action_remote_mkdir(&mut self, input: String) {
match self
.client
.as_mut()
.mkdir(PathBuf::from(input.as_str()).as_path())
{
Ok(_) => {
// Reload files
self.log(
LogLevel::Info,
format!("Created directory \"{}\"", input).as_ref(),
);
self.reload_remote_dir();
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{}\": {}", input, err),
);
}
}
}
pub(super) fn action_local_rename(&mut self, input: String) {
let entry: Option<FsEntry> = self.get_local_file_entry().cloned();
if let Some(entry) = entry {
let mut dst_path: PathBuf = PathBuf::from(input);
// Check if path is relative
if dst_path.as_path().is_relative() {
let mut wrkdir: PathBuf = self.local.wrkdir.clone();
wrkdir.push(dst_path);
dst_path = wrkdir;
}
let full_path: PathBuf = entry.get_abs_path();
// Rename file or directory and report status as popup
match self
.context
.as_mut()
.unwrap()
.local
.rename(&entry, dst_path.as_path())
{
Ok(_) => {
// Reload files
let path: PathBuf = self.local.wrkdir.clone();
self.local_scan(path.as_path());
// Log
self.log(
LogLevel::Info,
format!(
"Renamed file \"{}\" to \"{}\"",
full_path.display(),
dst_path.display()
)
.as_ref(),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not rename file \"{}\": {}", full_path.display(), err),
);
}
}
}
}
pub(super) fn action_remote_rename(&mut self, input: String) {
if let Some(idx) = self.get_remote_file_idx() {
if let Some(entry) = self.remote.get(idx) {
let dst_path: PathBuf = PathBuf::from(input);
let full_path: PathBuf = entry.get_abs_path();
// Rename file or directory and report status as popup
match self.client.as_mut().rename(entry, dst_path.as_path()) {
Ok(_) => {
// Reload files
let path: PathBuf = self.remote.wrkdir.clone();
self.remote_scan(path.as_path());
// Log
self.log(
LogLevel::Info,
format!(
"Renamed file \"{}\" to \"{}\"",
full_path.display(),
dst_path.display()
)
.as_ref(),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not rename file \"{}\": {}", full_path.display(), err),
);
}
}
}
}
}
pub(super) fn action_local_delete(&mut self) {
let entry: Option<FsEntry> = self.get_local_file_entry().cloned();
if let Some(entry) = entry {
let full_path: PathBuf = entry.get_abs_path();
// Delete file or directory and report status as popup
match self.context.as_mut().unwrap().local.remove(&entry) {
Ok(_) => {
// Reload files
let p: PathBuf = self.local.wrkdir.clone();
self.local_scan(p.as_path());
// Log
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", full_path.display()).as_ref(),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not delete file \"{}\": {}", full_path.display(), err),
);
}
}
}
}
pub(super) fn action_remote_delete(&mut self) {
if let Some(idx) = self.get_remote_file_idx() {
// Check if file entry exists
if let Some(entry) = self.remote.get(idx) {
let full_path: PathBuf = entry.get_abs_path();
// Delete file
match self.client.remove(entry) {
Ok(_) => {
self.reload_remote_dir();
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", full_path.display()).as_ref(),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not delete file \"{}\": {}", full_path.display(), err),
);
}
}
}
}
}
pub(super) fn action_local_saveas(&mut self, input: String) {
if let Some(idx) = self.get_local_file_idx() {
// Get pwd
let wrkdir: PathBuf = self.remote.wrkdir.clone();
if self.local.get(idx).is_some() {
let file: FsEntry = self.local.get(idx).unwrap().clone();
// Call upload; pass realfile, keep link name
self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(input));
}
}
}
pub(super) fn action_remote_saveas(&mut self, input: String) {
if let Some(idx) = self.get_remote_file_idx() {
// Get pwd
let wrkdir: PathBuf = self.local.wrkdir.clone();
if self.remote.get(idx).is_some() {
let file: FsEntry = self.remote.get(idx).unwrap().clone();
// Call upload; pass realfile, keep link name
self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(input));
}
}
}
pub(super) fn action_local_newfile(&mut self, input: String) {
// Check if file exists
let mut file_exists: bool = false;
for file in self.local.iter_files_all() {
if input == file.get_name() {
file_exists = true;
}
}
if file_exists {
self.log_and_alert(
LogLevel::Warn,
format!("File \"{}\" already exists", input,),
);
return;
}
// Create file
let file_path: PathBuf = PathBuf::from(input.as_str());
if let Some(ctx) = self.context.as_mut() {
if let Err(err) = ctx.local.open_file_write(file_path.as_path()) {
self.log_and_alert(
LogLevel::Error,
format!("Could not create file \"{}\": {}", file_path.display(), err),
);
} else {
self.log(
LogLevel::Info,
format!("Created file \"{}\"", file_path.display()).as_str(),
);
}
// Reload files
let path: PathBuf = self.local.wrkdir.clone();
self.local_scan(path.as_path());
}
}
pub(super) fn action_remote_newfile(&mut self, input: String) {
// Check if file exists
let mut file_exists: bool = false;
for file in self.remote.iter_files_all() {
if input == file.get_name() {
file_exists = true;
}
}
if file_exists {
self.log_and_alert(
LogLevel::Warn,
format!("File \"{}\" already exists", input,),
);
return;
}
// Get path on remote
let file_path: PathBuf = PathBuf::from(input.as_str());
// Create file (on local)
match tempfile::NamedTempFile::new() {
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create tempfile: {}", err),
),
Ok(tfile) => {
// Stat tempfile
if let Some(ctx) = self.context.as_mut() {
let local_file: FsEntry = match ctx.local.stat(tfile.path()) {
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not stat tempfile: {}", err),
);
return;
}
Ok(f) => f,
};
if let FsEntry::File(local_file) = local_file {
// Create file
match self.client.send_file(&local_file, file_path.as_path()) {
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not create file \"{}\": {}",
file_path.display(),
err
),
),
Ok(writer) => {
// Finalize write
if let Err(err) = self.client.on_sent(writer) {
self.log_and_alert(
LogLevel::Warn,
format!("Could not finalize file: {}", err),
);
} else {
self.log(
LogLevel::Info,
format!("Created file \"{}\"", file_path.display())
.as_str(),
);
}
// Reload files
let path: PathBuf = self.remote.wrkdir.clone();
self.remote_scan(path.as_path());
}
}
}
}
}
}
}
pub(super) fn action_local_exec(&mut self, input: String) {
match self.context.as_mut().unwrap().local.exec(input.as_str()) {
Ok(output) => {
// Reload files
self.log(
LogLevel::Info,
format!("\"{}\": {}", input, output).as_ref(),
);
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.local_scan(wrkdir.as_path());
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not execute command \"{}\": {}", input, err),
);
}
}
}
pub(super) fn action_remote_exec(&mut self, input: String) {
match self.client.as_mut().exec(input.as_str()) {
Ok(output) => {
// Reload files
self.log(
LogLevel::Info,
format!("\"{}\": {}", input, output).as_ref(),
);
self.reload_remote_dir();
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not execute command \"{}\": {}", input, err),
);
}
}
}
pub(super) fn action_local_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
match self.context.as_mut().unwrap().local.find(input.as_str()) {
Ok(entries) => Ok(entries),
Err(err) => Err(format!("Could not search for files: {}", err)),
}
}
pub(super) fn action_remote_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
match self.client.as_mut().find(input.as_str()) {
Ok(entries) => Ok(entries),
Err(err) => Err(format!("Could not search for files: {}", err)),
}
}
pub(super) fn action_find_changedir(&mut self, idx: usize) {
// Match entry
if let Some(entry) = self.found.as_ref().unwrap().get(idx) {
// Get path: if a directory, use directory path; if it is a File, get parent path
let path: PathBuf = match entry {
FsEntry::Directory(dir) => dir.abs_path.clone(),
FsEntry::File(file) => match file.abs_path.parent() {
None => PathBuf::from("."),
Some(p) => p.to_path_buf(),
},
};
// Change directory
match self.tab {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
self.local_changedir(path.as_path(), true)
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.remote_changedir(path.as_path(), true)
}
}
}
}
pub(super) fn action_find_transfer(&mut self, idx: usize, name: Option<String>) {
let entry: Option<FsEntry> = self.found.as_ref().unwrap().get(idx).cloned();
if let Some(entry) = entry {
// Download file
match self.tab {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
let wrkdir: PathBuf = self.remote.wrkdir.clone();
self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), name);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), name);
}
}
}
}
pub(super) fn action_find_delete(&mut self, idx: usize) {
let entry: Option<FsEntry> = self.found.as_ref().unwrap().get(idx).cloned();
if let Some(entry) = entry {
// Download file
match self.tab {
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
let full_path: PathBuf = entry.get_abs_path();
// Delete file or directory and report status as popup
match self.context.as_mut().unwrap().local.remove(&entry) {
Ok(_) => {
// Reload files
let p: PathBuf = self.local.wrkdir.clone();
self.local_scan(p.as_path());
// Log
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", full_path.display()).as_ref(),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
full_path.display(),
err
),
);
}
}
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
let full_path: PathBuf = entry.get_abs_path();
// Delete file
match self.client.remove(&entry) {
Ok(_) => {
self.reload_remote_dir();
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", full_path.display()).as_ref(),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
full_path.display(),
err
),
);
}
}
}
}
}
}
/// ### get_local_file_entry
///
/// Get local file entry
pub(super) fn get_local_file_entry(&self) -> Option<&FsEntry> {
match self.get_local_file_idx() {
None => None,
Some(idx) => self.local.get(idx),
}
}
/// ### get_remote_file_entry
///
/// Get remote file entry
pub(super) fn get_remote_file_entry(&self) -> Option<&FsEntry> {
match self.get_remote_file_idx() {
None => None,
Some(idx) => self.remote.get(idx),
}
}
// -- private
/// ### get_local_file_idx
///
/// Get index of selected file in the local tab
fn get_local_file_idx(&self) -> Option<usize> {
match self.view.get_value(super::COMPONENT_EXPLORER_LOCAL) {
Some(Payload::Unsigned(idx)) => Some(idx),
_ => None,
}
}
/// ### get_remote_file_idx
///
/// Get index of selected file in the remote file
fn get_remote_file_idx(&self) -> Option<usize> {
match self.view.get_value(super::COMPONENT_EXPLORER_REMOTE) {
Some(Payload::Unsigned(idx)) => Some(idx),
_ => None,
}
}
}

View File

@@ -28,12 +28,10 @@
*/
// Locals
use super::context::Context;
// keymap
pub(crate) mod keymap;
// Activities
pub mod auth_activity;
pub mod filetransfer_activity;
pub mod setup_activity;
pub mod auth;
pub mod filetransfer;
pub mod setup;
// -- Exit reason

View File

@@ -28,10 +28,10 @@
*/
// Locals
use super::SetupActivity;
use crate::ui::layout::Payload;
// Ext
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::env;
use tuirealm::{Payload, Value};
impl SetupActivity {
/// ### action_save_config
@@ -63,8 +63,8 @@ impl SetupActivity {
// Get key
if let Some(config_cli) = self.context.as_mut().unwrap().config_client.as_mut() {
// get index
let idx: Option<usize> = match self.view.get_value(super::COMPONENT_LIST_SSH_KEYS) {
Some(Payload::Unsigned(idx)) => Some(idx),
let idx: Option<usize> = match self.view.get_state(super::COMPONENT_LIST_SSH_KEYS) {
Some(Payload::One(Value::Usize(idx))) => Some(idx),
_ => None,
};
if let Some(idx) = idx {
@@ -99,25 +99,29 @@ impl SetupActivity {
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_value(super::COMPONENT_INPUT_SSH_HOST) {
Some(Payload::Text(host)) => host,
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_value(super::COMPONENT_INPUT_SSH_USERNAME) {
Some(Payload::Text(user)) => user,
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
let _ = disable_raw_mode();
if let Err(err) = disable_raw_mode() {
error!("Failed to disable raw mode: {}", err);
}
// Leave alternate mode
if let Some(ctx) = self.context.as_mut() {
ctx.leave_alternate_screen();
}
// Re-enable raw mode
let _ = 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) => {

View File

@@ -88,7 +88,9 @@ impl SetupActivity {
env::set_var("EDITOR", config_cli.get_text_editor());
}
// Prepare terminal
let _ = disable_raw_mode();
if let Err(err) = disable_raw_mode() {
error!("Failed to disable raw mode: {}", err);
}
// Leave alternate mode
ctx.leave_alternate_screen();
// Get result
@@ -121,7 +123,9 @@ impl SetupActivity {
// Enter alternate mode
ctx.enter_alternate_screen();
// Re-enable raw mode
let _ = enable_raw_mode();
if let Err(err) = enable_raw_mode() {
error!("Failed to enter raw mode: {}", err);
}
// Return result
result
}

View File

@@ -34,13 +34,13 @@ mod view;
// Deps
extern crate crossterm;
extern crate tui;
extern crate tuirealm;
// Locals
use super::{Activity, Context, ExitReason};
use crate::ui::layout::view::View;
// Ext
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use tuirealm::View;
// -- components
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
@@ -53,7 +53,8 @@ const COMPONENT_RADIO_DEFAULT_PROTOCOL: &str = "RADIO_DEFAULT_PROTOCOL";
const COMPONENT_RADIO_HIDDEN_FILES: &str = "RADIO_HIDDEN_FILES";
const COMPONENT_RADIO_UPDATES: &str = "RADIO_CHECK_UPDATES";
const COMPONENT_RADIO_GROUP_DIRS: &str = "RADIO_GROUP_DIRS";
const COMPONENT_INPUT_FILE_FMT: &str = "INPUT_FILE_FMT";
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";
const COMPONENT_LIST_SSH_KEYS: &str = "LIST_SSH_KEYS";
const COMPONENT_INPUT_SSH_HOST: &str = "INPUT_SSH_HOST";
@@ -109,7 +110,9 @@ impl Activity for SetupActivity {
// Clear terminal
self.context.as_mut().unwrap().clear_screen();
// Put raw mode on enabled
let _ = enable_raw_mode();
if let Err(err) = enable_raw_mode() {
error!("Failed to enter raw mode: {}", err);
}
// Init view
self.init_setup();
// Verify error state from context
@@ -160,7 +163,9 @@ impl Activity for SetupActivity {
/// This function finally releases the context
fn on_destroy(&mut self) -> Option<Context> {
// Disable raw mode
let _ = disable_raw_mode();
if let Err(err) = disable_raw_mode() {
error!("Failed to disable raw mode: {}", err);
}
self.context.as_ref()?;
// Clear terminal and return
match self.context.take() {

View File

@@ -28,14 +28,16 @@
*/
// locals
use super::{
SetupActivity, COMPONENT_INPUT_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, 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::activities::keymap::*;
use crate::ui::layout::{Msg, Payload};
use crate::ui::keymap::*;
// ext
use tuirealm::{Msg, Payload, Value};
impl SetupActivity {
/// ### update
@@ -66,15 +68,23 @@ impl SetupActivity {
None
}
(COMPONENT_RADIO_GROUP_DIRS, &MSG_KEY_DOWN) => {
self.view.active(COMPONENT_INPUT_FILE_FMT);
self.view.active(COMPONENT_INPUT_LOCAL_FILE_FMT);
None
}
(COMPONENT_INPUT_FILE_FMT, &MSG_KEY_DOWN) => {
(COMPONENT_INPUT_LOCAL_FILE_FMT, &MSG_KEY_DOWN) => {
self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT);
None
}
(COMPONENT_INPUT_REMOTE_FILE_FMT, &MSG_KEY_DOWN) => {
self.view.active(COMPONENT_INPUT_TEXT_EDITOR);
None
}
// Input field <UP>
(COMPONENT_INPUT_FILE_FMT, &MSG_KEY_UP) => {
(COMPONENT_INPUT_REMOTE_FILE_FMT, &MSG_KEY_UP) => {
self.view.active(COMPONENT_INPUT_LOCAL_FILE_FMT);
None
}
(COMPONENT_INPUT_LOCAL_FILE_FMT, &MSG_KEY_UP) => {
self.view.active(COMPONENT_RADIO_GROUP_DIRS);
None
}
@@ -95,7 +105,7 @@ impl SetupActivity {
None
}
(COMPONENT_INPUT_TEXT_EDITOR, &MSG_KEY_UP) => {
self.view.active(COMPONENT_INPUT_FILE_FMT);
self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT);
None
}
// Error <ENTER> or <ESC>
@@ -105,7 +115,7 @@ impl SetupActivity {
None
}
// Exit
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(0))) => {
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
// Save changes
if let Err(err) = self.action_save_config() {
self.mount_error(err.as_str());
@@ -114,7 +124,7 @@ impl SetupActivity {
self.exit_reason = Some(super::ExitReason::Quit);
None
}
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(1))) => {
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
// Quit
self.exit_reason = Some(super::ExitReason::Quit);
self.umount_quit();
@@ -132,7 +142,7 @@ impl SetupActivity {
None
}
// Delete key
(COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(Payload::Unsigned(0))) => {
(COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
// Delete key
self.action_delete_ssh_key();
// Reload ssh keys
@@ -147,7 +157,7 @@ impl SetupActivity {
None
}
// Save popup
(COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::Unsigned(0))) => {
(COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
// Save config
if let Err(err) = self.action_save_config() {
self.mount_error(err.as_str());
@@ -216,7 +226,7 @@ impl SetupActivity {
None
}
// <ENTER> Edit key
(COMPONENT_LIST_SSH_KEYS, Msg::OnSubmit(Payload::Unsigned(idx))) => {
(COMPONENT_LIST_SSH_KEYS, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
// Edit ssh key
if let Err(err) = self.edit_ssh_key(*idx) {
self.mount_error(err.as_str());

View File

@@ -30,22 +30,27 @@
use super::{Context, SetupActivity, ViewLayout};
use crate::filetransfer::FileTransferProtocol;
use crate::fs::explorer::GroupDirs;
use crate::ui::layout::components::{
bookmark_list::BookmarkList, input::Input, msgbox::MsgBox, radio_group::RadioGroup,
table::Table, text::Text,
use crate::ui::components::{
bookmark_list::{BookmarkList, BookmarkListPropsBuilder},
msgbox::{MsgBox, MsgBoxPropsBuilder},
};
use crate::ui::layout::props::{
PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
};
use crate::ui::layout::utils::draw_area_in;
use crate::ui::layout::view::View;
use crate::ui::layout::Payload;
use crate::utils::ui::draw_area_in;
// Ext
use std::path::PathBuf;
use tui::{
use tuirealm::components::{
input::{Input, InputPropsBuilder},
radio::{Radio, RadioPropsBuilder},
span::{Span, SpanPropsBuilder},
table::{Table, TablePropsBuilder},
};
use tuirealm::tui::{
layout::{Constraint, Direction, Layout},
style::Color,
widgets::{Borders, Clear},
widgets::{BorderType, Borders, Clear},
};
use tuirealm::{
props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder},
Payload, Value, View,
};
impl SetupActivity {
@@ -61,38 +66,32 @@ impl SetupActivity {
// Radio tab
self.view.mount(
super::COMPONENT_RADIO_TAB,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightYellow)
.with_background(Color::Black)
.with_borders(Borders::BOTTOM)
.with_texts(TextParts::new(
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,
Some(vec![
TextSpan::from("User Interface"),
TextSpan::from("SSH Keys"),
]),
))
.with_value(PropValue::Unsigned(0))
vec![String::from("User Interface"), String::from("SSH Keys")],
)
.with_value(0)
.build(),
)),
);
// Footer
self.view.mount(
super::COMPONENT_TEXT_FOOTER,
Box::new(Text::new(
PropsBuilder::default()
.with_texts(TextParts::new(
None,
Some(vec![
TextSpanBuilder::new("Press ").bold().build(),
TextSpanBuilder::new("<CTRL+H>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to show keybindings").bold().build(),
]),
))
Box::new(Span::new(
SpanPropsBuilder::default()
.with_spans(vec![
TextSpanBuilder::new("Press ").bold().build(),
TextSpanBuilder::new("<CTRL+H>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to show keybindings").bold().build(),
])
.build(),
)),
);
@@ -100,83 +99,96 @@ impl SetupActivity {
self.view.mount(
super::COMPONENT_INPUT_TEXT_EDITOR,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::LightGreen)
.with_texts(TextParts::new(Some(String::from("Text editor")), None))
.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(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightCyan)
.with_background(Color::Black)
.with_texts(TextParts::new(
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")),
Some(vec![
TextSpan::from("SFTP"),
TextSpan::from("SCP"),
TextSpan::from("FTP"),
TextSpan::from("FTPS"),
]),
))
vec![
String::from("SFTP"),
String::from("SCP"),
String::from("FTP"),
String::from("FTPS"),
],
)
.build(),
)),
);
self.view.mount(
super::COMPONENT_RADIO_HIDDEN_FILES,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightRed)
.with_background(Color::Black)
.with_texts(TextParts::new(
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)")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
self.view.mount(
super::COMPONENT_RADIO_UPDATES,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightYellow)
.with_background(Color::Black)
.with_texts(TextParts::new(
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?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
self.view.mount(
super::COMPONENT_RADIO_GROUP_DIRS,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightMagenta)
.with_background(Color::Black)
.with_texts(TextParts::new(
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")),
Some(vec![
TextSpan::from("Display first"),
TextSpan::from("Display Last"),
TextSpan::from("No"),
]),
))
vec![
String::from("Display first"),
String::from("Display Last"),
String::from("No"),
],
)
.build(),
)),
);
self.view.mount(
super::COMPONENT_INPUT_FILE_FMT,
super::COMPONENT_INPUT_LOCAL_FILE_FMT,
Box::new(Input::new(
PropsBuilder::default()
InputPropsBuilder::default()
.with_foreground(Color::LightBlue)
.with_texts(TextParts::new(
Some(String::from("File formatter syntax")),
None,
))
.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(),
)),
);
@@ -196,46 +208,41 @@ impl SetupActivity {
// Radio tab
self.view.mount(
super::COMPONENT_RADIO_TAB,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightYellow)
.with_background(Color::Black)
.with_borders(Borders::BOTTOM)
.with_texts(TextParts::new(
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,
Some(vec![
TextSpan::from("User Interface"),
TextSpan::from("SSH Keys"),
]),
))
.with_value(PropValue::Unsigned(1))
vec![String::from("User Interface"), String::from("SSH Keys")],
)
.with_value(1)
.build(),
)),
);
// Footer
self.view.mount(
super::COMPONENT_TEXT_FOOTER,
Box::new(Text::new(
PropsBuilder::default()
.with_texts(TextParts::new(
None,
Some(vec![
TextSpanBuilder::new("Press ").bold().build(),
TextSpanBuilder::new("<CTRL+H>")
.bold()
.with_foreground(Color::Cyan)
.build(),
TextSpanBuilder::new(" to show keybindings").bold().build(),
]),
))
Box::new(Span::new(
SpanPropsBuilder::default()
.with_spans(vec![
TextSpanBuilder::new("Press ").bold().build(),
TextSpanBuilder::new("<CTRL+H>")
.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(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("SSH Keys")), Some(vec![])))
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(),
@@ -283,7 +290,8 @@ impl SetupActivity {
Constraint::Length(3), // Hidden files
Constraint::Length(3), // Updates tab
Constraint::Length(3), // Group dirs
Constraint::Length(3), // Format input
Constraint::Length(3), // Local Format input
Constraint::Length(3), // Remote Format input
Constraint::Length(1), // Empty ?
]
.as_ref(),
@@ -300,7 +308,9 @@ impl SetupActivity {
self.view
.render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[4]);
self.view
.render(super::COMPONENT_INPUT_FILE_FMT, f, ui_cfg_chunks[5]);
.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()
@@ -312,40 +322,40 @@ impl SetupActivity {
}
}
// Popups
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
if props.build().visible {
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(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
if props.build().visible {
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(mut props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
if props.build().visible {
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(mut props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) {
if props.build().visible {
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(mut props) = self.view.get_props(super::COMPONENT_RADIO_DEL_SSH_KEY) {
if props.build().visible {
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);
@@ -353,8 +363,8 @@ impl SetupActivity {
.render(super::COMPONENT_RADIO_DEL_SSH_KEY, f, popup);
}
}
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_SSH_HOST) {
if props.build().visible {
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);
@@ -389,10 +399,11 @@ impl SetupActivity {
self.view.mount(
super::COMPONENT_TEXT_ERROR,
Box::new(MsgBox::new(
PropsBuilder::default()
MsgBoxPropsBuilder::default()
.with_foreground(Color::Red)
.bold()
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
.with_borders(Borders::ALL, BorderType::Rounded, Color::Red)
.with_texts(None, vec![TextSpan::from(text)])
.build(),
)),
);
@@ -413,15 +424,16 @@ impl SetupActivity {
pub(super) fn mount_del_ssh_key(&mut self) {
self.view.mount(
super::COMPONENT_RADIO_DEL_SSH_KEY,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightRed)
.bold()
.with_texts(TextParts::new(
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?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
.with_value(PropValue::Unsigned(1)) // Default: No
vec![String::from("Yes"), String::from("No")],
)
.with_value(1) // Default: No
.build(),
)),
);
@@ -443,21 +455,26 @@ impl SetupActivity {
self.view.mount(
super::COMPONENT_INPUT_SSH_HOST,
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("Hostname or address")),
None,
))
.with_borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
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(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("Username")), None))
.with_borders(Borders::BOTTOM | Borders::RIGHT | Borders::LEFT)
InputPropsBuilder::default()
.with_label(String::from("Username"))
.with_borders(
Borders::BOTTOM | Borders::RIGHT | Borders::LEFT,
BorderType::Plain,
Color::Reset,
)
.build(),
)),
);
@@ -478,18 +495,19 @@ impl SetupActivity {
pub(super) fn mount_quit(&mut self) {
self.view.mount(
super::COMPONENT_RADIO_QUIT,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightRed)
.bold()
.with_texts(TextParts::new(
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?")),
Some(vec![
TextSpan::from("Save"),
TextSpan::from("Don't save"),
TextSpan::from("Cancel"),
]),
))
vec![
String::from("Save"),
String::from("Don't save"),
String::from("Cancel"),
],
)
.build(),
)),
);
@@ -510,14 +528,15 @@ impl SetupActivity {
pub(super) fn mount_save_popup(&mut self) {
self.view.mount(
super::COMPONENT_RADIO_SAVE,
Box::new(RadioGroup::new(
PropsBuilder::default()
.with_foreground(Color::LightYellow)
.bold()
.with_texts(TextParts::new(
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?")),
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
))
vec![String::from("Yes"), String::from("No")],
)
.build(),
)),
);
@@ -539,8 +558,9 @@ impl SetupActivity {
self.view.mount(
super::COMPONENT_TEXT_HELP,
Box::new(Table::new(
PropsBuilder::default()
.with_texts(TextParts::table(
TablePropsBuilder::default()
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
.with_table(
Some(String::from("Help")),
TableBuilder::default()
.add_col(
@@ -615,7 +635,7 @@ impl SetupActivity {
)
.add_col(TextSpan::from(" Save configuration"))
.build(),
))
)
.build(),
)),
);
@@ -636,78 +656,70 @@ impl SetupActivity {
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)
.as_mut()
{
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 = props.with_value(PropValue::Str(text_editor)).build();
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)
.as_mut()
{
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 = props.with_value(PropValue::Unsigned(protocol)).build();
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)
.as_mut()
{
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 = props.with_value(PropValue::Unsigned(hidden)).build();
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).as_mut() {
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 = props.with_value(PropValue::Unsigned(updates)).build();
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)
.as_mut()
{
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 = props.with_value(PropValue::Unsigned(dirs)).build();
let props = RadioPropsBuilder::from(props).with_value(dirs).build();
let _ = self.view.update(super::COMPONENT_RADIO_GROUP_DIRS, props);
}
// File Fmt
if let Some(props) = self
.view
.get_props(super::COMPONENT_INPUT_FILE_FMT)
.as_mut()
{
let file_fmt: String = cli.get_file_fmt().unwrap_or_default();
let props = props.with_value(PropValue::Str(file_fmt)).build();
let _ = self.view.update(super::COMPONENT_INPUT_FILE_FMT, 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);
}
}
}
@@ -717,13 +729,13 @@ impl SetupActivity {
/// 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::Text(editor)) =
self.view.get_value(super::COMPONENT_INPUT_TEXT_EDITOR)
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::Unsigned(protocol)) =
self.view.get_value(super::COMPONENT_RADIO_DEFAULT_PROTOCOL)
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,
@@ -733,23 +745,30 @@ impl SetupActivity {
};
cli.set_default_protocol(protocol);
}
if let Some(Payload::Unsigned(opt)) =
self.view.get_value(super::COMPONENT_RADIO_HIDDEN_FILES)
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::Unsigned(opt)) =
self.view.get_value(super::COMPONENT_RADIO_UPDATES)
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::Text(fmt)) = self.view.get_value(super::COMPONENT_INPUT_FILE_FMT) {
cli.set_file_fmt(fmt);
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::Unsigned(opt)) =
self.view.get_value(super::COMPONENT_RADIO_GROUP_DIRS)
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<GroupDirs> = match opt {
0 => Some(GroupDirs::First),
@@ -767,17 +786,17 @@ impl SetupActivity {
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).as_mut() {
if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS) {
// Create texts
let keys: Vec<TextSpan> = cli
let keys: Vec<String> = cli
.iter_ssh_keys()
.map(|x| {
let (addr, username, _) = cli.get_ssh_key(x).ok().unwrap().unwrap();
TextSpan::from(format!("{} at {}", addr, username).as_str())
format!("{} at {}", addr, username)
})
.collect();
let props = props
.with_texts(TextParts::new(Some(String::from("SSH Keys")), Some(keys)))
let props = BookmarkListPropsBuilder::from(props)
.with_bookmarks(Some(String::from("SSH Keys")), keys)
.build();
self.view.update(super::COMPONENT_LIST_SSH_KEYS, props);
}

View File

@@ -25,16 +25,106 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// locals
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
// ext
use crossterm::event::KeyCode;
use tui::{
use tuirealm::components::utils::get_block;
use tuirealm::event::{Event, KeyCode};
use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan};
use tuirealm::tui::{
layout::{Corner, Rect},
style::{Color, Style},
text::Span,
widgets::{Block, List, ListItem, ListState},
widgets::{BorderType, Borders, List, ListItem, ListState},
};
use tuirealm::{Canvas, Component, Msg, Payload, Value};
// -- props
pub struct BookmarkListPropsBuilder {
props: Option<Props>,
}
impl Default for BookmarkListPropsBuilder {
fn default() -> Self {
BookmarkListPropsBuilder {
props: Some(Props::default()),
}
}
}
impl PropsBuilder for BookmarkListPropsBuilder {
fn build(&mut self) -> Props {
self.props.take().unwrap()
}
fn hidden(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = false;
}
self
}
fn visible(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = true;
}
self
}
}
impl From<Props> for BookmarkListPropsBuilder {
fn from(props: Props) -> Self {
BookmarkListPropsBuilder { props: Some(props) }
}
}
impl BookmarkListPropsBuilder {
/// ### with_foreground
///
/// Set foreground color for area
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.foreground = color;
}
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
}
/// ### with_borders
///
/// Set component borders style
pub fn with_borders(
&mut self,
borders: Borders,
variant: BorderType,
color: Color,
) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.borders = BordersProps {
borders,
variant,
color,
}
}
self
}
pub fn with_bookmarks(&mut self, title: Option<String>, bookmarks: Vec<String>) -> &mut Self {
if let Some(props) = self.props.as_mut() {
let bookmarks: Vec<TextSpan> = bookmarks.into_iter().map(TextSpan::from).collect();
props.texts = TextParts::new(title, Some(bookmarks));
}
self
}
}
// -- states
@@ -120,7 +210,7 @@ impl BookmarkList {
// Initialize states
let mut states: OwnStates = OwnStates::default();
// Set list length
states.set_list_len(match &props.texts.rows {
states.set_list_len(match &props.texts.spans {
Some(tokens) => tokens.len(),
None => 0,
});
@@ -129,15 +219,11 @@ impl BookmarkList {
}
impl Component for BookmarkList {
/// ### 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) {
if self.props.visible {
// Make list
let list_item: Vec<ListItem> = match self.props.texts.rows.as_ref() {
let list_item: Vec<ListItem> = match self.props.texts.spans.as_ref() {
None => vec![],
Some(lines) => lines
.iter()
@@ -148,30 +234,22 @@ impl Component for BookmarkList {
true => (self.props.foreground, self.props.background),
false => (Color::Reset, Color::Reset),
};
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
// Render
let mut state: ListState = ListState::default();
state.select(Some(self.states.list_index));
render.render_stateful_widget(
List::new(list_item)
.block(
Block::default()
.borders(self.props.borders)
.border_style(match self.states.focus {
true => Style::default().fg(self.props.background),
false => Style::default(),
})
.title(title),
)
.block(get_block(
&self.props.borders,
&self.props.texts.title,
self.states.focus,
))
.start_corner(Corner::TopLeft)
.highlight_style(
Style::default()
.bg(bg)
.fg(fg)
.add_modifier(self.props.get_modifiers()),
.add_modifier(self.props.modifiers),
),
area,
&mut state,
@@ -179,15 +257,10 @@ impl Component for BookmarkList {
}
}
/// ### 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
fn update(&mut self, props: Props) -> Msg {
self.props = props;
// re-Set list length
self.states.set_list_len(match &self.props.texts.rows {
self.states.set_list_len(match &self.props.texts.spans {
Some(tokens) => tokens.len(),
None => 0,
});
@@ -196,21 +269,13 @@ impl Component for BookmarkList {
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
fn get_props(&self) -> Props {
self.props.clone()
}
/// ### on
///
/// Handle input event and update internal states
fn on(&mut self, ev: InputEvent) -> Msg {
fn on(&mut self, ev: Event) -> Msg {
// Match event
if let InputEvent::Key(key) = ev {
if let Event::Key(key) = ev {
match key.code {
KeyCode::Down => {
// Update states
@@ -238,7 +303,7 @@ impl Component for BookmarkList {
}
KeyCode::Enter => {
// Report event
Msg::OnSubmit(self.get_value())
Msg::OnSubmit(self.get_state())
}
_ => {
// Return key event to activity
@@ -251,25 +316,14 @@ impl Component for BookmarkList {
}
}
/// ### get_value
///
/// Return component value. File list return index
fn get_value(&self) -> Payload {
Payload::Unsigned(self.states.get_list_index())
fn get_state(&self) -> Payload {
Payload::One(Value::Usize(self.states.get_list_index()))
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.states.focus = false;
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.states.focus = true;
}
@@ -279,21 +333,34 @@ impl Component for BookmarkList {
mod tests {
use super::*;
use crate::ui::layout::props::{TextParts, TextSpan};
use crossterm::event::KeyEvent;
use pretty_assertions::assert_eq;
use tuirealm::event::KeyEvent;
#[test]
fn test_ui_layout_components_bookmarks_list() {
fn test_ui_components_bookmarks_list() {
// Make component
let mut component: BookmarkList = BookmarkList::new(
PropsBuilder::default()
.with_texts(TextParts::new(
BookmarkListPropsBuilder::default()
.hidden()
.visible()
.with_foreground(Color::Red)
.with_background(Color::Blue)
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
.with_bookmarks(
Some(String::from("filelist")),
Some(vec![TextSpan::from("file1"), TextSpan::from("file2")]),
))
vec![String::from("file1"), String::from("file2")],
)
.build(),
);
assert_eq!(component.props.foreground, Color::Red);
assert_eq!(component.props.background, Color::Blue);
assert_eq!(component.props.visible, true);
assert_eq!(
component.props.texts.title.as_ref().unwrap().as_str(),
"filelist"
);
assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 2);
// Verify states
assert_eq!(component.states.list_index, 0);
assert_eq!(component.states.list_len, 2);
@@ -304,69 +371,72 @@ mod tests {
component.blur();
assert_eq!(component.states.focus, false);
// Update
let props = component.get_props().with_foreground(Color::Red).build();
let props = BookmarkListPropsBuilder::from(component.get_props())
.with_foreground(Color::Yellow)
.hidden()
.build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
assert_eq!(component.props.foreground, Color::Yellow);
assert_eq!(component.props.visible, false);
// Increment list index
component.states.list_index += 1;
assert_eq!(component.states.list_index, 1);
// Update
component.update(
component
.get_props()
.with_texts(TextParts::new(
BookmarkListPropsBuilder::from(component.get_props())
.with_bookmarks(
Some(String::from("filelist")),
Some(vec![
TextSpan::from("file1"),
TextSpan::from("file2"),
TextSpan::from("file3"),
]),
))
vec![
String::from("file1"),
String::from("file2"),
String::from("file3"),
],
)
.build(),
);
// Verify states
assert_eq!(component.states.list_index, 0);
assert_eq!(component.states.list_len, 3);
// get value
assert_eq!(component.get_value(), Payload::Unsigned(0));
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
// Render
assert_eq!(component.states.list_index, 0);
// Handle inputs
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))),
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 1);
// Index should be decremented
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))),
component.on(Event::Key(KeyEvent::from(KeyCode::Up))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 0);
// Index should be 2
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))),
component.on(Event::Key(KeyEvent::from(KeyCode::PageDown))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 2);
// Index should be 0
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))),
component.on(Event::Key(KeyEvent::from(KeyCode::PageUp))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 0);
// Enter
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
Msg::OnSubmit(Payload::Unsigned(0))
component.on(Event::Key(KeyEvent::from(KeyCode::Enter))),
Msg::OnSubmit(Payload::One(Value::Usize(0)))
);
// On key
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
);
}

View File

@@ -0,0 +1,710 @@
//! ## FileList
//!
//! `FileList` component renders a file list tab
/**
* 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.
*/
// ext
use tuirealm::components::utils::get_block;
use tuirealm::event::{Event, KeyCode, KeyModifiers};
use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan};
use tuirealm::tui::{
layout::{Corner, Rect},
style::{Color, Style},
text::Span,
widgets::{BorderType, Borders, List, ListItem, ListState},
};
use tuirealm::{Canvas, Component, Msg, Payload, Value};
// -- props
pub struct FileListPropsBuilder {
props: Option<Props>,
}
impl Default for FileListPropsBuilder {
fn default() -> Self {
FileListPropsBuilder {
props: Some(Props::default()),
}
}
}
impl PropsBuilder for FileListPropsBuilder {
fn build(&mut self) -> Props {
self.props.take().unwrap()
}
fn hidden(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = false;
}
self
}
fn visible(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = true;
}
self
}
}
impl From<Props> for FileListPropsBuilder {
fn from(props: Props) -> Self {
FileListPropsBuilder { props: Some(props) }
}
}
impl FileListPropsBuilder {
/// ### with_foreground
///
/// Set foreground color for area
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.foreground = color;
}
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
}
/// ### with_borders
///
/// Set component borders style
pub fn with_borders(
&mut self,
borders: Borders,
variant: BorderType,
color: Color,
) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.borders = BordersProps {
borders,
variant,
color,
}
}
self
}
pub fn with_files(&mut self, title: Option<String>, files: Vec<String>) -> &mut Self {
if let Some(props) = self.props.as_mut() {
let files: Vec<TextSpan> = files.into_iter().map(TextSpan::from).collect();
props.texts = TextParts::new(title, Some(files));
}
self
}
}
// -- states
/// ## OwnStates
///
/// OwnStates contains states for this component
#[derive(Clone)]
struct OwnStates {
list_index: usize, // Index of selected element in list
selected: Vec<usize>, // Selected files
focus: bool, // Has focus?
}
impl Default for OwnStates {
fn default() -> Self {
OwnStates {
list_index: 0,
selected: Vec::new(),
focus: false,
}
}
}
impl OwnStates {
/// ### init_list_states
///
/// Initialize list states
pub fn init_list_states(&mut self, len: usize) {
self.selected = Vec::with_capacity(len);
self.fix_list_index();
}
/// ### list_index
///
/// Return current value for list index
pub fn list_index(&self) -> usize {
self.list_index
}
/// ### incr_list_index
///
/// Incremenet list index
pub fn incr_list_index(&mut self) {
// Check if index is at last element
if self.list_index + 1 < self.list_len() {
self.list_index += 1;
}
}
/// ### decr_list_index
///
/// Decrement list index
pub fn decr_list_index(&mut self) {
// Check if index is bigger than 0
if self.list_index > 0 {
self.list_index -= 1;
}
}
/// ### list_len
///
/// Returns the length of the file list, which is actually the capacity of the selection vector
pub fn list_len(&self) -> usize {
self.selected.capacity()
}
/// ### is_selected
///
/// Returns whether the file with index `entry` is selected
pub fn is_selected(&self, entry: usize) -> bool {
self.selected.contains(&entry)
}
/// ### is_selection_empty
///
/// Returns whether the selection is currently empty
pub fn is_selection_empty(&self) -> bool {
self.selected.is_empty()
}
/// ### get_selection
///
/// Returns current file selection
pub fn get_selection(&self) -> Vec<usize> {
self.selected.clone()
}
/// ### fix_list_index
///
/// Keep index if possible, otherwise set to lenght - 1
fn fix_list_index(&mut self) {
if self.list_index >= self.list_len() && self.list_len() > 0 {
self.list_index = self.list_len() - 1;
} else if self.list_len() == 0 {
self.list_index = 0;
}
}
// -- select manipulation
/// ### toggle_file
///
/// Select or deselect file with provided entry index
pub fn toggle_file(&mut self, entry: usize) {
match self.is_selected(entry) {
true => self.deselect(entry),
false => self.select(entry),
}
}
/// ### select_all
///
/// Select all files
pub fn select_all(&mut self) {
for i in 0..self.list_len() {
self.select(i);
}
}
/// ### select
///
/// Select provided index if not selected yet
fn select(&mut self, entry: usize) {
if !self.is_selected(entry) {
self.selected.push(entry);
}
}
/// ### deselect
///
/// Remove element file with associated index
fn deselect(&mut self, entry: usize) {
if self.is_selected(entry) {
self.selected.retain(|&x| x != entry);
}
}
}
// -- Component
/// ## FileList
///
/// File list component
pub struct FileList {
props: Props,
states: OwnStates,
}
impl FileList {
/// ### new
///
/// Instantiates a new FileList starting from Props
/// The method also initializes the component states.
pub fn new(props: Props) -> Self {
// Initialize states
let mut states: OwnStates = OwnStates::default();
// Init list states
states.init_list_states(props.texts.spans.as_ref().map(|x| x.len()).unwrap_or(0));
FileList { props, states }
}
}
impl Component for FileList {
#[cfg(not(tarpaulin_include))]
fn render(&self, render: &mut Canvas, area: Rect) {
if self.props.visible {
// Make list
let list_item: Vec<ListItem> = match self.props.texts.spans.as_ref() {
None => vec![],
Some(lines) => lines
.iter()
.enumerate()
.map(|(num, line)| {
let to_display: String = match self.states.is_selected(num) {
true => format!("*{}", line.content),
false => line.content.to_string(),
};
ListItem::new(Span::from(to_display))
})
.collect(),
};
let (fg, bg): (Color, Color) = match self.states.focus {
true => (Color::Black, self.props.background),
false => (self.props.foreground, Color::Reset),
};
// Render
let mut state: ListState = ListState::default();
state.select(Some(self.states.list_index));
render.render_stateful_widget(
List::new(list_item)
.block(get_block(
&self.props.borders,
&self.props.texts.title,
self.states.focus,
))
.start_corner(Corner::TopLeft)
.highlight_style(
Style::default()
.bg(bg)
.fg(fg)
.add_modifier(self.props.modifiers),
),
area,
&mut state,
);
}
}
fn update(&mut self, props: Props) -> Msg {
self.props = props;
// re-Set list states
self.states.init_list_states(
self.props
.texts
.spans
.as_ref()
.map(|x| x.len())
.unwrap_or(0),
);
Msg::None
}
fn get_props(&self) -> Props {
self.props.clone()
}
fn on(&mut self, ev: Event) -> Msg {
// Match event
if let Event::Key(key) = ev {
match key.code {
KeyCode::Down => {
// Update states
self.states.incr_list_index();
Msg::None
}
KeyCode::Up => {
// Update states
self.states.decr_list_index();
Msg::None
}
KeyCode::PageDown => {
// Update states
for _ in 0..8 {
self.states.incr_list_index();
}
Msg::None
}
KeyCode::PageUp => {
// Update states
for _ in 0..8 {
self.states.decr_list_index();
}
Msg::None
}
KeyCode::Char('a') => match key.modifiers.intersects(KeyModifiers::CONTROL) {
// CTRL+A
true => {
// Select all
self.states.select_all();
Msg::None
}
false => Msg::OnKey(key),
},
KeyCode::Char('m') => {
// Toggle current file in selection
self.states.toggle_file(self.states.list_index());
Msg::None
}
KeyCode::Enter => {
// Report event
Msg::OnSubmit(self.get_state())
}
_ => {
// Return key event to activity
Msg::OnKey(key)
}
}
} else {
// Unhandled event
Msg::None
}
}
/// ### get_state
///
/// Get state returns for this component two different payloads based on the states:
/// - if the file selection is empty, returns the highlighted item as `One` of `Usize`
/// - if at least one item is selected, return the selected as a `Vec` of `Usize`
fn get_state(&self) -> Payload {
match self.states.is_selection_empty() {
true => Payload::One(Value::Usize(self.states.list_index())),
false => Payload::Vec(
self.states
.get_selection()
.into_iter()
.map(Value::Usize)
.collect(),
),
}
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.states.focus = false;
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.states.focus = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tuirealm::event::KeyEvent;
#[test]
fn test_ui_components_file_list_states() {
let mut states: OwnStates = OwnStates::default();
assert_eq!(states.list_len(), 0);
assert_eq!(states.selected.len(), 0);
assert_eq!(states.focus, false);
// Init states
states.init_list_states(4);
assert_eq!(states.list_len(), 4);
assert_eq!(states.selected.len(), 0);
assert!(states.is_selection_empty());
// Select all files
states.select_all();
assert_eq!(states.list_len(), 4);
assert_eq!(states.selected.len(), 4);
assert_eq!(states.is_selection_empty(), false);
assert_eq!(states.get_selection(), vec![0, 1, 2, 3]);
// Verify reset
states.init_list_states(5);
assert_eq!(states.list_len(), 5);
assert_eq!(states.selected.len(), 0);
// Toggle file
states.toggle_file(2);
assert_eq!(states.list_len(), 5);
assert_eq!(states.selected.len(), 1);
assert_eq!(states.selected[0], 2);
states.toggle_file(4);
assert_eq!(states.list_len(), 5);
assert_eq!(states.selected.len(), 2);
assert_eq!(states.selected[1], 4);
states.toggle_file(2);
assert_eq!(states.list_len(), 5);
assert_eq!(states.selected.len(), 1);
assert_eq!(states.selected[0], 4);
// Select twice (nothing should change)
states.select(4);
assert_eq!(states.list_len(), 5);
assert_eq!(states.selected.len(), 1);
assert_eq!(states.selected[0], 4);
// Deselect not-selectd item
states.deselect(2);
assert_eq!(states.list_len(), 5);
assert_eq!(states.selected.len(), 1);
assert_eq!(states.selected[0], 4);
// Index
states.init_list_states(2);
states.incr_list_index();
assert_eq!(states.list_index(), 1);
states.incr_list_index();
assert_eq!(states.list_index(), 1);
states.decr_list_index();
assert_eq!(states.list_index(), 0);
states.decr_list_index();
assert_eq!(states.list_index(), 0);
// Try fixing index
states.init_list_states(5);
states.list_index = 4;
states.init_list_states(3);
assert_eq!(states.list_index(), 2);
states.init_list_states(6);
assert_eq!(states.list_index(), 2);
// Focus
states.focus = true;
assert_eq!(states.focus, true);
}
#[test]
fn test_ui_components_file_list() {
// Make component
let mut component: FileList = FileList::new(
FileListPropsBuilder::default()
.hidden()
.visible()
.with_foreground(Color::Red)
.with_background(Color::Blue)
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
.with_files(
Some(String::from("files")),
vec![String::from("file1"), String::from("file2")],
)
.build(),
);
assert_eq!(component.props.foreground, Color::Red);
assert_eq!(component.props.background, Color::Blue);
assert_eq!(component.props.visible, true);
assert_eq!(
component.props.texts.title.as_ref().unwrap().as_str(),
"files"
);
assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 2);
// Verify states
assert_eq!(component.states.list_index, 0);
assert_eq!(component.states.selected.len(), 0);
assert_eq!(component.states.list_len(), 2);
assert_eq!(component.states.selected.capacity(), 2);
assert_eq!(component.states.focus, false);
// Focus
component.active();
assert_eq!(component.states.focus, true);
component.blur();
assert_eq!(component.states.focus, false);
// Update
let props = FileListPropsBuilder::from(component.get_props())
.with_foreground(Color::Yellow)
.hidden()
.build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.visible, false);
assert_eq!(component.props.foreground, Color::Yellow);
// Increment list index
component.states.list_index += 1;
assert_eq!(component.states.list_index, 1);
// Update
component.update(
FileListPropsBuilder::from(component.get_props())
.with_files(
Some(String::from("filelist")),
vec![
String::from("file1"),
String::from("file2"),
String::from("file3"),
],
)
.build(),
);
// Verify states
assert_eq!(component.states.list_index, 1); // Kept
assert_eq!(component.states.list_len(), 3);
// get value
assert_eq!(component.get_state(), Payload::One(Value::Usize(1)));
// Render
assert_eq!(component.states.list_index, 1);
// Handle inputs
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 2);
// Index should be decremented
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Up))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 1);
// Index should be 2
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::PageDown))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 2);
// Index should be 0
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::PageUp))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 0);
// Enter
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Enter))),
Msg::OnSubmit(Payload::One(Value::Usize(0)))
);
// On key
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
);
// Verify 'A' still works
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Char('a')))),
Msg::OnKey(KeyEvent::from(KeyCode::Char('a')))
);
}
#[test]
fn test_ui_components_file_list_selection() {
// Make component
let mut component: FileList = FileList::new(
FileListPropsBuilder::default()
.with_files(
Some(String::from("files")),
vec![
String::from("file1"),
String::from("file2"),
String::from("file3"),
],
)
.build(),
);
// Get state
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
// Select one
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
Msg::None
);
// Now should be a vec
assert_eq!(component.get_state(), Payload::Vec(vec![Value::Usize(0)]));
// De-select
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
Msg::None
);
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
// Go down
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
Msg::None
);
// Select
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
Msg::None
);
assert_eq!(component.get_state(), Payload::Vec(vec![Value::Usize(1)]));
// Go down and select
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
Msg::None
);
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
Msg::None
);
assert_eq!(
component.get_state(),
Payload::Vec(vec![Value::Usize(1), Value::Usize(2)])
);
// Select all
assert_eq!(
component.on(Event::Key(KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
})),
Msg::None
);
// All selected
assert_eq!(
component.get_state(),
Payload::Vec(vec![Value::Usize(1), Value::Usize(2), Value::Usize(0)])
);
// Update files
component.update(
FileListPropsBuilder::from(component.get_props())
.with_files(
Some(String::from("filelist")),
vec![String::from("file1"), String::from("file2")],
)
.build(),
);
// Selection should now be empty
assert_eq!(component.get_state(), Payload::One(Value::Usize(1)));
}
}

View File

@@ -25,17 +25,84 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// locals
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
// ext
use crossterm::event::KeyCode;
use std::collections::VecDeque;
use tui::{
use tuirealm::components::utils::{get_block, wrap_spans};
use tuirealm::event::{Event, KeyCode};
use tuirealm::props::{BordersProps, Props, PropsBuilder, Table as TextTable, TextParts};
use tuirealm::tui::{
layout::{Corner, Rect},
style::Style,
text::{Span, Spans},
widgets::{Block, List, ListItem, ListState},
style::{Color, Style},
widgets::{BorderType, Borders, List, ListItem, ListState},
};
use tuirealm::{Canvas, Component, Msg, Payload, Value};
// -- props
pub struct LogboxPropsBuilder {
props: Option<Props>,
}
impl Default for LogboxPropsBuilder {
fn default() -> Self {
LogboxPropsBuilder {
props: Some(Props::default()),
}
}
}
impl PropsBuilder for LogboxPropsBuilder {
fn build(&mut self) -> Props {
self.props.take().unwrap()
}
fn hidden(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = false;
}
self
}
fn visible(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = true;
}
self
}
}
impl From<Props> for LogboxPropsBuilder {
fn from(props: Props) -> Self {
LogboxPropsBuilder { props: Some(props) }
}
}
impl LogboxPropsBuilder {
/// ### with_borders
///
/// Set component borders style
pub fn with_borders(
&mut self,
borders: Borders,
variant: BorderType,
color: Color,
) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.borders = BordersProps {
borders,
variant,
color,
}
}
self
}
pub fn with_log(&mut self, title: Option<String>, table: TextTable) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.texts = TextParts::table(title, table);
}
self
}
}
// -- states
@@ -132,82 +199,33 @@ impl LogBox {
}
impl Component for LogBox {
/// ### 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) {
if self.props.visible {
let width: usize = area.width as usize - 4;
// Make list
let list_items: Vec<ListItem> = match self.props.texts.table.as_ref() {
None => Vec::new(),
Some(table) => table
.iter()
.map(|row| {
let mut columns: VecDeque<Span> = row
.iter()
.map(|col| {
Span::styled(
col.content.clone(),
Style::default()
.add_modifier(col.get_modifiers())
.fg(col.fg)
.bg(col.bg),
)
})
.collect();
// Let's convert column spans into Spans rows NOTE: -4 because first line is always made by 5 columns; but there's always 1
let mut rows: Vec<Spans> = Vec::with_capacity(columns.len() - 4);
// Get first row
let mut first_row: Vec<Span> = Vec::with_capacity(5);
for _ in 0..5 {
if let Some(col) = columns.pop_front() {
first_row.push(col);
}
}
rows.push(Spans::from(first_row));
// Fill remaining rows
let cycles: usize = columns.len();
for _ in 0..cycles {
if let Some(col) = columns.pop_front() {
rows.push(Spans::from(vec![col]));
}
}
ListItem::new(rows)
})
.map(|row| ListItem::new(wrap_spans(row, width, &self.props)))
.collect(), // Make List item from TextSpan
};
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
// Render
let w = List::new(list_items)
.block(
Block::default()
.borders(self.props.borders)
.border_style(match self.states.focus {
true => Style::default().fg(self.props.foreground),
false => Style::default(),
})
.title(title),
)
.block(get_block(
&self.props.borders,
&self.props.texts.title,
self.states.focus,
))
.start_corner(Corner::BottomLeft)
.highlight_symbol(">> ")
.highlight_style(Style::default().add_modifier(self.props.get_modifiers()));
.highlight_style(Style::default().add_modifier(self.props.modifiers));
let mut state: ListState = ListState::default();
state.select(Some(self.states.list_index));
render.render_stateful_widget(w, area, &mut state);
}
}
/// ### 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
fn update(&mut self, props: Props) -> Msg {
self.props = props;
// re-Set list length
@@ -220,21 +238,13 @@ impl Component for LogBox {
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
fn get_props(&self) -> Props {
self.props.clone()
}
/// ### on
///
/// Handle input event and update internal states
fn on(&mut self, ev: InputEvent) -> Msg {
fn on(&mut self, ev: Event) -> Msg {
// Match event
if let InputEvent::Key(key) = ev {
if let Event::Key(key) = ev {
match key.code {
KeyCode::Up => {
// Update states
@@ -271,25 +281,14 @@ impl Component for LogBox {
}
}
/// ### get_value
///
/// Return component value. File list return index
fn get_value(&self) -> Payload {
Payload::Unsigned(self.states.get_list_index())
fn get_state(&self) -> Payload {
Payload::One(Value::Usize(self.states.get_list_index()))
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.states.focus = false;
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.states.focus = true;
}
@@ -299,16 +298,20 @@ impl Component for LogBox {
mod tests {
use super::*;
use crate::ui::layout::props::{TableBuilder, TextParts, TextSpan};
use crossterm::event::{KeyCode, KeyEvent};
use tui::style::Color;
use pretty_assertions::assert_eq;
use tuirealm::event::{KeyCode, KeyEvent};
use tuirealm::props::{TableBuilder, TextSpan};
use tuirealm::tui::style::Color;
#[test]
fn test_ui_layout_components_logbox() {
fn test_ui_components_logbox() {
let mut component: LogBox = LogBox::new(
PropsBuilder::default()
.with_texts(TextParts::table(
LogboxPropsBuilder::default()
.hidden()
.visible()
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
.with_log(
Some(String::from("Log")),
TableBuilder::default()
.add_col(TextSpan::from("12:29"))
@@ -317,9 +320,15 @@ mod tests {
.add_col(TextSpan::from("12:38"))
.add_col(TextSpan::from("system alive"))
.build(),
))
)
.build(),
);
assert_eq!(component.props.visible, true);
assert_eq!(
component.props.texts.title.as_ref().unwrap().as_str(),
"Log"
);
assert_eq!(component.props.texts.table.as_ref().unwrap().len(), 2);
// Verify states
assert_eq!(component.states.list_index, 0);
assert_eq!(component.states.list_len, 2);
@@ -330,17 +339,18 @@ mod tests {
component.blur();
assert_eq!(component.states.focus, false);
// Update
let props = component.get_props().with_foreground(Color::Red).build();
let props = LogboxPropsBuilder::from(component.get_props())
.hidden()
.build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
assert_eq!(component.props.visible, false);
// Increment list index
component.states.list_index += 1;
assert_eq!(component.states.list_index, 1);
// Update
component.update(
component
.get_props()
.with_texts(TextParts::table(
LogboxPropsBuilder::from(component.get_props())
.with_log(
Some(String::from("Log")),
TableBuilder::default()
.add_col(TextSpan::from("12:29"))
@@ -352,49 +362,49 @@ mod tests {
.add_col(TextSpan::from("12:41"))
.add_col(TextSpan::from("system is going down for REBOOT"))
.build(),
))
)
.build(),
);
// Verify states
assert_eq!(component.states.list_index, 0); // Last item
assert_eq!(component.states.list_len, 3);
// get value
assert_eq!(component.get_value(), Payload::Unsigned(0));
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
// RenderData
assert_eq!(component.states.list_index, 0);
// Set cursor to 0
component.states.list_index = 0;
// Handle inputs
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))),
component.on(Event::Key(KeyEvent::from(KeyCode::Up))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 1);
// Index should be decremented
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))),
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 0);
// Index should be 2
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))),
component.on(Event::Key(KeyEvent::from(KeyCode::PageUp))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 2);
// Index should be 0
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))),
component.on(Event::Key(KeyEvent::from(KeyCode::PageDown))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 0);
// On key
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
);
}

View File

@@ -1,6 +1,6 @@
//! ## Components
//!
//! `Components` is the module which contains the definitions for all the GUI components for TermSCP
//! `Components` is the module which contains the definitions for all the GUI components for termscp
/**
* MIT License
@@ -25,17 +25,8 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// imports
use super::{Canvas, Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder};
// exports
pub mod bookmark_list;
pub mod file_list;
pub mod input;
pub mod logbox;
pub mod msgbox;
pub mod progress_bar;
pub mod radio_group;
pub mod table;
pub mod text;
pub mod title;

View File

@@ -27,16 +27,99 @@
*/
// deps
extern crate textwrap;
extern crate tuirealm;
// locals
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
use crate::utils::fmt::align_text_center;
// ext
use tui::{
use tuirealm::components::utils::{get_block, use_or_default_styles};
use tuirealm::event::Event;
use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan};
use tuirealm::tui::{
layout::{Corner, Rect},
style::{Color, Style},
style::{Color, Modifier, Style},
text::{Span, Spans},
widgets::{Block, BorderType, List, ListItem},
widgets::{BorderType, Borders, List, ListItem},
};
use tuirealm::{Canvas, Component, Msg, Payload};
// -- Props
pub struct MsgBoxPropsBuilder {
props: Option<Props>,
}
impl Default for MsgBoxPropsBuilder {
fn default() -> Self {
MsgBoxPropsBuilder {
props: Some(Props::default()),
}
}
}
impl PropsBuilder for MsgBoxPropsBuilder {
fn build(&mut self) -> Props {
self.props.take().unwrap()
}
fn hidden(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = false;
}
self
}
fn visible(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = true;
}
self
}
}
impl From<Props> for MsgBoxPropsBuilder {
fn from(props: Props) -> Self {
MsgBoxPropsBuilder { props: Some(props) }
}
}
impl MsgBoxPropsBuilder {
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.foreground = color;
}
self
}
pub fn bold(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.modifiers |= Modifier::BOLD;
}
self
}
pub fn with_borders(
&mut self,
borders: Borders,
variant: BorderType,
color: Color,
) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.borders = BordersProps {
borders,
variant,
color,
}
}
self
}
pub fn with_texts(&mut self, title: Option<String>, texts: Vec<TextSpan>) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.texts = TextParts::new(title, Some(texts));
}
self
}
}
// -- component
@@ -54,56 +137,36 @@ impl MsgBox {
}
impl Component for MsgBox {
/// ### 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) {
// Make a Span
if self.props.visible {
let lines: Vec<ListItem> = match self.props.texts.rows.as_ref() {
let lines: Vec<ListItem> = match self.props.texts.spans.as_ref() {
None => Vec::new(),
Some(rows) => {
let mut lines: Vec<ListItem> = Vec::new();
for line in rows.iter() {
// Keep line color, or use default
let line_fg: Color = match line.fg {
Color::Reset => self.props.foreground,
_ => line.fg,
};
let line_bg: Color = match line.bg {
Color::Reset => self.props.background,
_ => line.bg,
};
let (fg, bg, modifiers) = use_or_default_styles(&self.props, line);
let message_row =
textwrap::wrap(line.content.as_str(), area.width as usize);
for msg in message_row.iter() {
lines.push(ListItem::new(Spans::from(vec![Span::styled(
align_text_center(msg, area.width),
Style::default()
.add_modifier(line.get_modifiers())
.fg(line_fg)
.bg(line_bg),
Style::default().add_modifier(modifiers).fg(fg).bg(bg),
)])));
}
}
lines
}
};
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
render.render_widget(
List::new(lines)
.block(
Block::default()
.borders(self.props.borders)
.border_style(Style::default().fg(self.props.foreground))
.border_type(BorderType::Rounded)
.title(title),
)
.block(get_block(
&self.props.borders,
&self.props.texts.title,
true,
))
.start_corner(Corner::TopLeft)
.style(
Style::default()
@@ -115,59 +178,31 @@ impl Component for MsgBox {
}
}
/// ### 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 {
self.props = props;
// Return None
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
fn get_props(&self) -> Props {
self.props.clone()
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view.
/// Returns always None, since cannot have any focus
fn on(&mut self, ev: InputEvent) -> Msg {
fn on(&mut self, ev: Event) -> Msg {
// Return key
if let InputEvent::Key(key) = ev {
if let Event::Key(key) = ev {
Msg::OnKey(key)
} else {
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// For this component returns always None
fn get_value(&self) -> Payload {
fn get_state(&self) -> Payload {
Payload::None
}
// -- events
/// ### blur
///
/// Blur component
fn blur(&mut self) {}
/// ### active
///
/// Active component
fn active(&mut self) {}
}
@@ -175,39 +210,53 @@ impl Component for MsgBox {
mod tests {
use super::*;
use crate::ui::layout::props::{TextParts, TextSpan, TextSpanBuilder};
use crossterm::event::{KeyCode, KeyEvent};
use tui::style::Color;
use pretty_assertions::assert_eq;
use tuirealm::event::{KeyCode, KeyEvent};
use tuirealm::props::{TextSpan, TextSpanBuilder};
use tuirealm::tui::style::Color;
#[test]
fn test_ui_layout_components_msgbox() {
fn test_ui_components_msgbox() {
let mut component: MsgBox = MsgBox::new(
PropsBuilder::default()
.with_texts(TextParts::new(
MsgBoxPropsBuilder::default()
.hidden()
.visible()
.with_foreground(Color::Red)
.bold()
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
.with_texts(
None,
Some(vec![
vec![
TextSpan::from("Press "),
TextSpanBuilder::new("<ESC>")
.with_foreground(Color::Cyan)
.bold()
.build(),
TextSpan::from(" to quit"),
]),
))
],
)
.build(),
);
assert_eq!(component.props.foreground, Color::Red);
assert!(component.props.modifiers.intersects(Modifier::BOLD));
assert_eq!(component.props.visible, true);
assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 3);
component.active();
component.blur();
// Update
let props = component.get_props().with_foreground(Color::Red).build();
let props = MsgBoxPropsBuilder::from(component.get_props())
.hidden()
.with_foreground(Color::Yellow)
.build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
assert_eq!(component.props.visible, false);
assert_eq!(component.props.foreground, Color::Yellow);
// Get value
assert_eq!(component.get_value(), Payload::None);
assert_eq!(component.get_state(), Payload::None);
// Event
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
component.on(Event::Key(KeyEvent::from(KeyCode::Delete))),
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
);
}

View File

@@ -27,13 +27,12 @@
*/
// Dependencies
extern crate crossterm;
extern crate tui;
extern crate tuirealm;
// Locals
use super::input::InputHandler;
use super::store::Store;
use crate::filetransfer::FileTransferProtocol;
use crate::host::Localhost;
use crate::system::config_client::ConfigClient;
// Includes
@@ -42,14 +41,13 @@ use crossterm::execute;
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use std::io::{stdout, Stdout};
use std::path::PathBuf;
use tui::backend::CrosstermBackend;
use tui::Terminal;
use tuirealm::tui::backend::CrosstermBackend;
use tuirealm::tui::Terminal;
/// ## Context
///
/// Context holds data structures used by the ui
pub struct Context {
pub local: Localhost,
pub ft_params: Option<FileTransferParams>,
pub(crate) config_client: Option<ConfigClient>,
pub(crate) store: Store,
@@ -74,16 +72,11 @@ impl Context {
/// ### new
///
/// Instantiates a new Context
pub fn new(
local: Localhost,
config_client: Option<ConfigClient>,
error: Option<String>,
) -> Context {
pub fn new(config_client: Option<ConfigClient>, error: Option<String>) -> Context {
// Create terminal
let mut stdout = stdout();
assert!(execute!(stdout, EnterAlternateScreen).is_ok());
Context {
local,
ft_params: None,
config_client,
store: Store::init(),
@@ -93,14 +86,12 @@ impl Context {
}
}
/* NOTE: in case is necessary
/// ### set_error
///
/// Set context error
pub fn set_error(&mut self, err: String) {
self.error = Some(err);
}
*/
/// ### get_error
///
@@ -113,40 +104,45 @@ impl Context {
///
/// Enter alternate screen (gui window)
pub fn enter_alternate_screen(&mut self) {
let _ = execute!(
match execute!(
self.terminal.backend_mut(),
EnterAlternateScreen,
DisableMouseCapture
);
) {
Err(err) => error!("Failed to enter alternate screen: {}", err),
Ok(_) => info!("Entered alternate screen"),
}
}
/// ### leave_alternate_screen
///
/// Go back to normal screen (gui window)
pub fn leave_alternate_screen(&mut self) {
let _ = execute!(
match execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
) {
Err(err) => error!("Failed to leave alternate screen: {}", err),
Ok(_) => info!("Left alternate screen"),
}
}
/// ### clear_screen
///
/// Clear terminal screen
pub fn clear_screen(&mut self) {
let _ = self.terminal.clear();
match self.terminal.clear() {
Err(err) => error!("Failed to clear screen: {}", err),
Ok(_) => info!("Cleared screen"),
}
}
}
impl Drop for Context {
fn drop(&mut self) {
// Re-enable terminal stuff
let _ = execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
self.leave_alternate_screen();
}
}
@@ -168,6 +164,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_ui_context_ft_params() {
let params: FileTransferParams = FileTransferParams::default();
@@ -178,31 +176,23 @@ mod tests {
assert!(params.password.is_none());
}
//use crate::filetransfer::sftp_transfer::SftpFileTransfer;
//use std::path::PathBuf;
/*
#[test]
fn test_ui_context_new() {
#[cfg(not(feature = "githubActions"))]
fn test_ui_context() {
// Prepare stuff
Context::new(
build_sftp_client(),
Localhost::new(PathBuf::from("/")).ok().unwrap(),
);
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
ctx.enter_alternate_screen();
ctx.clear_screen();
ctx.leave_alternate_screen();
drop(ctx);
}
fn build_sftp_client() -> Box<dyn FileTransfer> {
let mut sftp_client: SftpFileTransfer = SftpFileTransfer::new();
// Connect to remote
assert!(sftp_client
.connect(
String::from("test.rebex.net"),
22,
Some(String::from("demo")),
Some(String::from("password"))
)
.is_ok());
Box::new(sftp_client)
}
*/
}

View File

@@ -25,8 +25,8 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
use crate::ui::layout::Msg;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tuirealm::event::{KeyCode, KeyEvent, KeyModifiers};
use tuirealm::Msg;
// -- Special keys
@@ -179,11 +179,11 @@ pub const MSG_KEY_CHAR_X: Msg = Msg::OnKey(KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::NONE,
});
/*
pub const MSG_KEY_CHAR_Y: Msg = Msg::OnKey(KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::NONE,
});
/*
pub const MSG_KEY_CHAR_Z: Msg = Msg::OnKey(KeyEvent {
code: KeyCode::Char('z'),
modifiers: KeyModifiers::NONE,

View File

@@ -1,377 +0,0 @@
//! ## FileList
//!
//! `FileList` component renders a file list tab
/**
* 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::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
// ext
use crossterm::event::KeyCode;
use tui::{
layout::{Corner, Rect},
style::{Color, Style},
text::Span,
widgets::{Block, List, ListItem, ListState},
};
// -- states
/// ## OwnStates
///
/// OwnStates contains states for this component
#[derive(Clone)]
struct OwnStates {
list_index: usize, // Index of selected element in list
list_len: usize, // Length of file list
focus: bool, // Has focus?
}
impl Default for OwnStates {
fn default() -> Self {
OwnStates {
list_index: 0,
list_len: 0,
focus: false,
}
}
}
impl OwnStates {
/// ### set_list_len
///
/// Set list length
pub fn set_list_len(&mut self, len: usize) {
self.list_len = len;
}
/// ### get_list_index
///
/// Return current value for list index
pub fn get_list_index(&self) -> usize {
self.list_index
}
/// ### incr_list_index
///
/// Incremenet list index
pub fn incr_list_index(&mut self) {
// Check if index is at last element
if self.list_index + 1 < self.list_len {
self.list_index += 1;
}
}
/// ### decr_list_index
///
/// Decrement list index
pub fn decr_list_index(&mut self) {
// Check if index is bigger than 0
if self.list_index > 0 {
self.list_index -= 1;
}
}
/// ### fix_list_index
///
/// Keep index if possible, otherwise set to lenght - 1
pub fn fix_list_index(&mut self) {
if self.list_index >= self.list_len && self.list_len > 0 {
self.list_index = self.list_len - 1;
} else if self.list_len == 0 {
self.list_index = 0;
}
}
}
// -- Component
/// ## FileList
///
/// File list component
pub struct FileList {
props: Props,
states: OwnStates,
}
impl FileList {
/// ### new
///
/// Instantiates a new FileList starting from Props
/// The method also initializes the component states.
pub fn new(props: Props) -> Self {
// Initialize states
let mut states: OwnStates = OwnStates::default();
// Set list length
states.set_list_len(match &props.texts.rows {
Some(tokens) => tokens.len(),
None => 0,
});
FileList { props, states }
}
}
impl Component for FileList {
/// ### 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) {
if self.props.visible {
// Make list
let list_item: Vec<ListItem> = match self.props.texts.rows.as_ref() {
None => vec![],
Some(lines) => lines
.iter()
.map(|line| ListItem::new(Span::from(line.content.to_string())))
.collect(),
};
let (fg, bg): (Color, Color) = match self.states.focus {
true => (Color::Black, self.props.background),
false => (self.props.foreground, Color::Reset),
};
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
// Render
let mut state: ListState = ListState::default();
state.select(Some(self.states.list_index));
render.render_stateful_widget(
List::new(list_item)
.block(
Block::default()
.borders(self.props.borders)
.border_style(match self.states.focus {
true => Style::default().fg(self.props.foreground),
false => Style::default(),
})
.title(title),
)
.start_corner(Corner::TopLeft)
.highlight_style(
Style::default()
.bg(bg)
.fg(fg)
.add_modifier(self.props.get_modifiers()),
),
area,
&mut state,
);
}
}
/// ### 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
fn update(&mut self, props: Props) -> Msg {
self.props = props;
// re-Set list length
self.states.set_list_len(match &self.props.texts.rows {
Some(tokens) => tokens.len(),
None => 0,
});
// Fix list index
self.states.fix_list_index();
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
}
/// ### on
///
/// Handle input event and update internal states
fn on(&mut self, ev: InputEvent) -> Msg {
// Match event
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Down => {
// Update states
self.states.incr_list_index();
Msg::None
}
KeyCode::Up => {
// Update states
self.states.decr_list_index();
Msg::None
}
KeyCode::PageDown => {
// Update states
for _ in 0..8 {
self.states.incr_list_index();
}
Msg::None
}
KeyCode::PageUp => {
// Update states
for _ in 0..8 {
self.states.decr_list_index();
}
Msg::None
}
KeyCode::Enter => {
// Report event
Msg::OnSubmit(self.get_value())
}
_ => {
// Return key event to activity
Msg::OnKey(key)
}
}
} else {
// Unhandled event
Msg::None
}
}
/// ### get_value
///
/// Return component value. File list return index
fn get_value(&self) -> Payload {
Payload::Unsigned(self.states.get_list_index())
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.states.focus = false;
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.states.focus = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::layout::props::{TextParts, TextSpan};
use crossterm::event::KeyEvent;
#[test]
fn test_ui_layout_components_file_list() {
// Make component
let mut component: FileList = FileList::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("filelist")),
Some(vec![TextSpan::from("file1"), TextSpan::from("file2")]),
))
.build(),
);
// Verify states
assert_eq!(component.states.list_index, 0);
assert_eq!(component.states.list_len, 2);
assert_eq!(component.states.focus, false);
// Focus
component.active();
assert_eq!(component.states.focus, true);
component.blur();
assert_eq!(component.states.focus, false);
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Increment list index
component.states.list_index += 1;
assert_eq!(component.states.list_index, 1);
// Update
component.update(
component
.get_props()
.with_texts(TextParts::new(
Some(String::from("filelist")),
Some(vec![
TextSpan::from("file1"),
TextSpan::from("file2"),
TextSpan::from("file3"),
]),
))
.build(),
);
// Verify states
assert_eq!(component.states.list_index, 1); // Kept
assert_eq!(component.states.list_len, 3);
// get value
assert_eq!(component.get_value(), Payload::Unsigned(1));
// Render
assert_eq!(component.states.list_index, 1);
// Handle inputs
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 2);
// Index should be decremented
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 1);
// Index should be 2
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 2);
// Index should be 0
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))),
Msg::None
);
// Index should be incremented
assert_eq!(component.states.list_index, 0);
// Enter
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
Msg::OnSubmit(Payload::Unsigned(0))
);
// On key
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
);
}
}

View File

@@ -1,564 +0,0 @@
//! ## Input
//!
//! `Input` component renders an input box
/**
* 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::super::props::InputType;
use super::{Canvas, Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder};
// ext
use crossterm::event::{KeyCode, KeyModifiers};
use tui::{
layout::Rect,
style::Style,
widgets::{Block, BorderType, Paragraph},
};
// -- states
/// ## OwnStates
///
/// OwnStates contains states for this component
#[derive(Clone)]
struct OwnStates {
input: Vec<char>, // Current input
cursor: usize, // Input position
focus: bool, // Focus
}
impl Default for OwnStates {
fn default() -> Self {
OwnStates {
input: Vec::new(),
cursor: 0,
focus: false,
}
}
}
impl OwnStates {
/// ### append
///
/// Append, if possible according to input type, the character to the input vec
pub fn append(&mut self, ch: char, itype: InputType, max_len: Option<usize>) {
// Check if max length has been reached
if self.input.len() < max_len.unwrap_or(usize::MAX) {
match itype {
InputType::Number => {
if ch.is_digit(10) {
// Must be digit
self.input.insert(self.cursor, ch);
// Increment cursor
self.cursor += 1;
}
}
_ => {
// No rule
self.input.insert(self.cursor, ch);
// Increment cursor
self.cursor += 1;
}
}
}
}
/// ### backspace
///
/// Delete element at cursor -1; then decrement cursor by 1
pub fn backspace(&mut self) {
if self.cursor > 0 && !self.input.is_empty() {
self.input.remove(self.cursor - 1);
// Decrement cursor
self.cursor -= 1;
}
}
/// ### delete
///
/// Delete element at cursor
pub fn delete(&mut self) {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
/// ### incr_cursor
///
/// Increment cursor value by one if possible
pub fn incr_cursor(&mut self) {
if self.cursor < self.input.len() {
self.cursor += 1;
}
}
/// ### cursoro_at_begin
///
/// Place cursor at the begin of the input
pub fn cursor_at_begin(&mut self) {
self.cursor = 0;
}
/// ### cursor_at_end
///
/// Place cursor at the end of the input
pub fn cursor_at_end(&mut self) {
self.cursor = self.input.len();
}
/// ### decr_cursor
///
/// Decrement cursor value by one if possible
pub fn decr_cursor(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
/// ### render_value
///
/// Get value as string to render
pub fn render_value(&self, itype: InputType) -> String {
match itype {
InputType::Password => (0..self.input.len()).map(|_| '*').collect(),
_ => self.get_value(),
}
}
/// ### get_value
///
/// Get value as string
pub fn get_value(&self) -> String {
self.input.iter().collect()
}
}
// -- Component
/// ## FileList
///
/// File list component
pub struct Input {
props: Props,
states: OwnStates,
}
impl Input {
/// ### new
///
/// Instantiates a new Input starting from Props
/// The method also initializes the component states.
pub fn new(props: Props) -> Self {
// Initialize states
let mut states: OwnStates = OwnStates::default();
// Set state value from props
if let PropValue::Str(val) = props.value.clone() {
for ch in val.chars() {
states.append(ch, props.input_type, props.input_len);
}
}
Input { props, states }
}
}
impl Component for Input {
/// ### 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) {
if self.props.visible {
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
let p: Paragraph = Paragraph::new(self.states.render_value(self.props.input_type))
.style(match self.states.focus {
true => Style::default().fg(self.props.foreground),
false => Style::default(),
})
.block(
Block::default()
.borders(self.props.borders)
.border_type(BorderType::Rounded)
.title(title),
);
render.render_widget(p, area);
// Set cursor, if focus
if self.states.focus {
let x: u16 = area.x + (self.states.cursor as u16) + 1;
render.set_cursor(x, area.y + 1);
}
}
}
/// ### 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 {
self.props = props;
// Set value from props
if let PropValue::Str(val) = self.props.value.clone() {
self.states.input = Vec::new();
self.states.cursor = 0;
for ch in val.chars() {
self.states
.append(ch, self.props.input_type, self.props.input_len);
}
}
Msg::None
}
/// ### 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) -> PropsBuilder {
// Make properties with value from states
let mut props: Props = self.props.clone();
props.value = PropValue::Str(self.states.get_value());
PropsBuilder::from(props)
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view
fn on(&mut self, ev: InputEvent) -> Msg {
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Backspace => {
// Backspace and None
self.states.backspace();
Msg::None
}
KeyCode::Delete => {
// Delete and None
self.states.delete();
Msg::None
}
KeyCode::Enter => Msg::OnSubmit(self.get_value()),
KeyCode::Left => {
// Move cursor left; msg None
self.states.decr_cursor();
Msg::None
}
KeyCode::Right => {
// Move cursor right; Msg None
self.states.incr_cursor();
Msg::None
}
KeyCode::End => {
// Cursor at last position
self.states.cursor_at_end();
Msg::None
}
KeyCode::Home => {
// Cursor at first positon
self.states.cursor_at_begin();
Msg::None
}
KeyCode::Char(ch) => {
// Check if modifiers is NOT CTRL OR ALT
if !key.modifiers.intersects(KeyModifiers::CONTROL)
&& !key.modifiers.intersects(KeyModifiers::ALT)
{
// Push char to input
self.states
.append(ch, self.props.input_type, self.props.input_len);
// Message none
Msg::None
} else {
// Return key
Msg::OnKey(key)
}
}
_ => Msg::OnKey(key),
}
} else {
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// Returns the value as string or as a number based on the input value
fn get_value(&self) -> Payload {
match self.props.input_type {
InputType::Number => {
Payload::Unsigned(self.states.get_value().parse::<usize>().ok().unwrap_or(0))
}
_ => Payload::Text(self.states.get_value()),
}
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.states.focus = false;
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.states.focus = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
use tui::style::Color;
#[test]
fn test_ui_layout_components_input_text() {
// Instantiate Input with value
let mut component: Input = Input::new(
PropsBuilder::default()
.with_input(InputType::Text)
.with_input_len(5)
.with_value(PropValue::Str(String::from("home")))
.build(),
);
// Verify initial state
assert_eq!(component.states.cursor, 4);
assert_eq!(component.states.input.len(), 4);
// Focus
assert_eq!(component.states.focus, false);
component.active();
assert_eq!(component.states.focus, true);
component.blur();
assert_eq!(component.states.focus, false);
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Get value
assert_eq!(component.get_value(), Payload::Text(String::from("home")));
// RenderData
//assert_eq!(component.render().unwrap().cursor, 4);
assert_eq!(component.states.cursor, 4);
// Handle events
// Try key with ctrl
assert_eq!(
component.on(InputEvent::Key(KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL
))),
Msg::OnKey(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)),
);
// String shouldn't have changed
assert_eq!(component.get_value(), Payload::Text(String::from("home")));
//assert_eq!(component.render().unwrap().cursor, 4);
assert_eq!(component.states.cursor, 4);
// Character
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('/')))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("home/")));
//assert_eq!(component.render().unwrap().cursor, 5);
assert_eq!(component.states.cursor, 5);
// Verify max length (shouldn't push any character)
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("home/")));
//assert_eq!(component.render().unwrap().cursor, 5);
assert_eq!(component.states.cursor, 5);
// Enter
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
Msg::OnSubmit(Payload::Text(String::from("home/")))
);
// Backspace
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("home")));
//assert_eq!(component.render().unwrap().cursor, 4);
assert_eq!(component.states.cursor, 4);
// Check backspace at 0
component.states.input = vec!['h'];
component.states.cursor = 1;
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("")));
//assert_eq!(component.render().unwrap().cursor, 0);
assert_eq!(component.states.cursor, 0);
// Another one...
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("")));
//assert_eq!(component.render().unwrap().cursor, 0);
assert_eq!(component.states.cursor, 0);
// See del behaviour here
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("")));
//assert_eq!(component.render().unwrap().cursor, 0);
assert_eq!(component.states.cursor, 0);
// Check del behaviour
component.states.input = vec!['h', 'e'];
component.states.cursor = 1;
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("h")));
//assert_eq!(component.render().unwrap().cursor, 1); // Shouldn't move
assert_eq!(component.states.cursor, 1);
// Another one (should do nothing)
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("h")));
//assert_eq!(component.render().unwrap().cursor, 1); // Shouldn't move
assert_eq!(component.states.cursor, 1);
// Move cursor right
component.states.input = vec!['h', 'e', 'l', 'l', 'o'];
component.states.cursor = 1;
component.props.input_len = Some(16); // Let's change length
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))), // between 'e' and 'l'
Msg::None
);
//assert_eq!(component.render().unwrap().cursor, 2); // Should increment
assert_eq!(component.states.cursor, 2);
// Put a character here
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Text(String::from("heallo")));
//assert_eq!(component.render().unwrap().cursor, 3);
assert_eq!(component.states.cursor, 3);
// Move left
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
Msg::None
);
//assert_eq!(component.render().unwrap().cursor, 2); // Should decrement
assert_eq!(component.states.cursor, 2);
// Go at the end
component.states.cursor = 6;
// Move right
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
Msg::None
);
//assert_eq!(component.render().unwrap().cursor, 6); // Should stay
assert_eq!(component.states.cursor, 6);
// Move left
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
Msg::None
);
//assert_eq!(component.render().unwrap().cursor, 5); // Should decrement
assert_eq!(component.states.cursor, 5);
// Go at the beginning
component.states.cursor = 0;
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
Msg::None
);
//assert_eq!(component.render().unwrap().cursor, 0); // Should stay
assert_eq!(component.states.cursor, 0);
// End - begin
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::End))),
Msg::None
);
assert_eq!(component.states.cursor, 6);
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Home))),
Msg::None
);
assert_eq!(component.states.cursor, 0);
// Update value
component.update(
component
.get_props()
.with_value(PropValue::Str("new-value".to_string()))
.build(),
);
assert_eq!(
component.get_value(),
Payload::Text(String::from("new-value"))
);
}
#[test]
fn test_ui_layout_components_input_number() {
// Instantiate Input with value
let mut component: Input = Input::new(
PropsBuilder::default()
.with_input(InputType::Number)
.with_input_len(5)
.with_value(PropValue::Str(String::from("3000")))
.build(),
);
// Verify initial state
assert_eq!(component.states.cursor, 4);
assert_eq!(component.states.input.len(), 4);
// Push a non numeric value
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Unsigned(3000));
//assert_eq!(component.render().unwrap().cursor, 4);
assert_eq!(component.states.cursor, 4);
// Push a number
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('1')))),
Msg::None
);
assert_eq!(component.get_value(), Payload::Unsigned(30001));
//assert_eq!(component.render().unwrap().cursor, 5);
assert_eq!(component.states.cursor, 5);
}
}

View File

@@ -1,184 +0,0 @@
//! ## ProgressBar
//!
//! `ProgressBar` component renders a progress bar
/**
* 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::{Canvas, Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder};
// ext
use tui::{
layout::Rect,
style::Style,
widgets::{Block, Gauge},
};
// -- component
pub struct ProgressBar {
props: Props,
}
impl ProgressBar {
/// ### new
///
/// Instantiate a new Progress Bar
pub fn new(props: Props) -> Self {
ProgressBar { props }
}
}
impl Component for ProgressBar {
/// ### 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) {
// Make a Span
if self.props.visible {
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
// Text
let label: String = match self.props.texts.rows.as_ref() {
Some(rows) => match rows.get(0) {
Some(label) => label.content.clone(),
None => String::new(),
},
None => String::new(),
};
// Get percentage
let percentage: f64 = match self.props.value {
PropValue::Float(ratio) => ratio,
_ => 0.0,
};
// Make progress bar
render.render_widget(
Gauge::default()
.block(Block::default().borders(self.props.borders).title(title))
.gauge_style(
Style::default()
.fg(self.props.foreground)
.bg(self.props.background)
.add_modifier(self.props.get_modifiers()),
)
.label(label)
.ratio(percentage),
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 {
self.props = props;
// Return None
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view.
/// Returns always None, since cannot have any focus
fn on(&mut self, ev: InputEvent) -> Msg {
// Return key
if let InputEvent::Key(key) = ev {
Msg::OnKey(key)
} else {
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// For this component returns always None
fn get_value(&self) -> Payload {
Payload::None
}
// -- events
/// ### blur
///
/// Blur component
fn blur(&mut self) {}
/// ### active
///
/// Active component
fn active(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::layout::props::{TextParts, TextSpan};
use crossterm::event::{KeyCode, KeyEvent};
use tui::style::Color;
#[test]
fn test_ui_layout_components_progress_bar() {
let mut component: ProgressBar = ProgressBar::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("Uploading file...")),
Some(vec![TextSpan::from("96.5% - ETA 00:02 (4.2MB/s)")]),
))
.build(),
);
// Get value
assert_eq!(component.get_value(), Payload::None);
component.active();
component.blur();
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Event
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
);
}
}

View File

@@ -1,334 +0,0 @@
//! ## RadioGroup
//!
//! `RadioGroup` component renders a radio group
/**
* 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::super::props::TextSpan;
use super::{Canvas, Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder};
// ext
use crossterm::event::KeyCode;
use tui::{
layout::Rect,
style::{Color, Style},
text::Spans,
widgets::{Block, BorderType, Tabs},
};
// -- states
/// ## OwnStates
///
/// OwnStates contains states for this component
#[derive(Clone)]
struct OwnStates {
choice: usize, // Selected option
choices: Vec<String>, // Available choices
focus: bool, // has focus?
}
impl Default for OwnStates {
fn default() -> Self {
OwnStates {
choice: 0,
choices: Vec::new(),
focus: false,
}
}
}
impl OwnStates {
/// ### next_choice
///
/// Move choice index to next choice
pub fn next_choice(&mut self) {
if self.choice + 1 < self.choices.len() {
self.choice += 1;
}
}
/// ### prev_choice
///
/// Move choice index to previous choice
pub fn prev_choice(&mut self) {
if self.choice > 0 {
self.choice -= 1;
}
}
/// ### make_choices
///
/// Set OwnStates choices from a vector of text spans
pub fn make_choices(&mut self, spans: &[TextSpan]) {
self.choices = spans.iter().map(|x| x.content.clone()).collect();
}
}
// -- component
/// ## RadioGroup
///
/// RadioGroup component represents a group of tabs to select from
pub struct RadioGroup {
props: Props,
states: OwnStates,
}
impl RadioGroup {
/// ### new
///
/// Instantiate a new Radio Group component
pub fn new(props: Props) -> Self {
// Make states
let mut states: OwnStates = OwnStates::default();
// Update choices (vec of TextSpan to String)
states.make_choices(props.texts.rows.as_ref().unwrap_or(&Vec::new()));
// Get value
if let PropValue::Unsigned(choice) = props.value {
states.choice = choice;
}
RadioGroup { props, states }
}
}
impl Component for RadioGroup {
/// ### 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) {
if self.props.visible {
// Make choices
let choices: Vec<Spans> = self
.states
.choices
.iter()
.map(|x| Spans::from(x.clone()))
.collect();
// Make colors
let (bg, fg, block_color): (Color, Color, Color) = match &self.states.focus {
true => (
self.props.foreground,
self.props.background,
self.props.foreground,
),
false => (Color::Reset, self.props.foreground, Color::Reset),
};
let title: String = match &self.props.texts.title {
Some(t) => t.clone(),
None => String::new(),
};
render.render_widget(
Tabs::new(choices)
.block(
Block::default()
.borders(self.props.borders)
.border_type(BorderType::Rounded)
.style(Style::default())
.title(title),
)
.select(self.states.choice)
.style(Style::default().fg(block_color))
.highlight_style(
Style::default()
.add_modifier(self.props.get_modifiers())
.fg(fg)
.bg(bg),
),
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 {
// Reset choices
self.states
.make_choices(props.texts.rows.as_ref().unwrap_or(&Vec::new()));
// Get value
if let PropValue::Unsigned(choice) = props.value {
self.states.choice = choice;
}
self.props = props;
// Msg none
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view
fn on(&mut self, ev: InputEvent) -> Msg {
// Match event
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Right => {
// Increment choice
self.states.next_choice();
// Return Msg On Change
Msg::OnChange(self.get_value())
}
KeyCode::Left => {
// Decrement choice
self.states.prev_choice();
// Return Msg On Change
Msg::OnChange(self.get_value())
}
KeyCode::Enter => {
// Return Submit
Msg::OnSubmit(self.get_value())
}
_ => {
// Return key event to activity
Msg::OnKey(key)
}
}
} else {
// Ignore event
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// Returns the selected option
fn get_value(&self) -> Payload {
Payload::Unsigned(self.states.choice)
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.states.focus = false;
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.states.focus = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::layout::props::{TextParts, TextSpan};
use crossterm::event::KeyEvent;
#[test]
fn test_ui_layout_components_radio() {
// Make component
let mut component: RadioGroup = RadioGroup::new(
PropsBuilder::default()
.with_texts(TextParts::new(
Some(String::from("yes or no?")),
Some(vec![
TextSpan::from("Yes!"),
TextSpan::from("No"),
TextSpan::from("Maybe"),
]),
))
.with_value(PropValue::Unsigned(1))
.build(),
);
// Verify states
assert_eq!(component.states.choice, 1);
assert_eq!(component.states.choices.len(), 3);
// Focus
assert_eq!(component.states.focus, false);
component.active();
assert_eq!(component.states.focus, true);
component.blur();
assert_eq!(component.states.focus, false);
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Get value
assert_eq!(component.get_value(), Payload::Unsigned(1));
// Handle events
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
Msg::OnChange(Payload::Unsigned(0)),
);
assert_eq!(component.get_value(), Payload::Unsigned(0));
// Left again
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
Msg::OnChange(Payload::Unsigned(0)),
);
assert_eq!(component.get_value(), Payload::Unsigned(0));
// Right
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
Msg::OnChange(Payload::Unsigned(1)),
);
assert_eq!(component.get_value(), Payload::Unsigned(1));
// Right again
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
Msg::OnChange(Payload::Unsigned(2)),
);
assert_eq!(component.get_value(), Payload::Unsigned(2));
// Right again
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
Msg::OnChange(Payload::Unsigned(2)),
);
assert_eq!(component.get_value(), Payload::Unsigned(2));
// Submit
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
Msg::OnSubmit(Payload::Unsigned(2)),
);
// Any key
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
Msg::OnKey(KeyEvent::from(KeyCode::Char('a'))),
);
}
}

View File

@@ -1,202 +0,0 @@
//! ## TextList
//!
//! `TextList` component renders a radio group
/**
* 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::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
// ext
use tui::{
layout::{Corner, Rect},
style::Style,
text::{Span, Spans},
widgets::{Block, BorderType, List, ListItem},
};
// -- component
/// ## Table
///
/// Table is a table component. List n rows with n text span columns
pub struct Table {
props: Props,
}
impl Table {
/// ### new
///
/// Instantiate a new Table component
pub fn new(props: Props) -> Self {
Table { props }
}
}
impl Component for Table {
/// ### 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) {
// Make a Span
if self.props.visible {
let title: String = match self.props.texts.title.as_ref() {
Some(t) => t.clone(),
None => String::new(),
};
// Make list entries
let list_items: Vec<ListItem> = match self.props.texts.table.as_ref() {
None => Vec::new(),
Some(table) => table
.iter()
.map(|row| {
let columns: Vec<Span> = row
.iter()
.map(|col| {
Span::styled(
col.content.clone(),
Style::default()
.add_modifier(col.get_modifiers())
.fg(col.fg)
.bg(col.bg),
)
})
.collect();
ListItem::new(Spans::from(columns))
})
.collect(), // Make List item from TextSpan
};
// Make list
render.render_widget(
List::new(list_items)
.block(
Block::default()
.borders(self.props.borders)
.border_style(Style::default())
.border_type(BorderType::Rounded)
.title(title),
)
.start_corner(Corner::TopLeft),
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 {
self.props = props;
// Return None
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view.
/// Returns always None, since cannot have any focus
fn on(&mut self, ev: InputEvent) -> Msg {
// Return key
if let InputEvent::Key(key) = ev {
Msg::OnKey(key)
} else {
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// For this component returns always None
fn get_value(&self) -> Payload {
Payload::None
}
// -- events
/// ### blur
///
/// Blur component
fn blur(&mut self) {}
/// ### active
///
/// Active component
fn active(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::layout::props::{TableBuilder, TextParts, TextSpan};
use crossterm::event::{KeyCode, KeyEvent};
use tui::style::Color;
#[test]
fn test_ui_layout_components_table() {
let mut component: Table = Table::new(
PropsBuilder::default()
.with_texts(TextParts::table(
Some(String::from("My data")),
TableBuilder::default()
.add_col(TextSpan::from("name"))
.add_col(TextSpan::from("age"))
.add_row()
.add_col(TextSpan::from("omar"))
.add_col(TextSpan::from("24"))
.build(),
))
.build(),
);
component.active();
component.blur();
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Get value
assert_eq!(component.get_value(), Payload::None);
// Event
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
);
}
}

View File

@@ -1,195 +0,0 @@
//! ## Text
//!
//! `Text` component renders a simple readonly no event associated text
/**
* 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::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
// ext
use tui::{
layout::Rect,
style::{Color, Style},
text::{Span, Spans, Text as TuiText},
widgets::Paragraph,
};
// -- component
pub struct Text {
props: Props,
}
impl Text {
/// ### new
///
/// Instantiate a new Text component
pub fn new(props: Props) -> Self {
Text { props }
}
}
impl Component for Text {
/// ### 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) {
// Make a Span
if self.props.visible {
let spans: Vec<Span> = match self.props.texts.rows.as_ref() {
None => Vec::new(),
Some(rows) => rows
.iter()
.map(|x| {
// Keep line color, or use default
let fg: Color = match x.fg {
Color::Reset => self.props.foreground,
_ => x.fg,
};
let bg: Color = match x.bg {
Color::Reset => self.props.background,
_ => x.bg,
};
Span::styled(
x.content.clone(),
Style::default()
.add_modifier(x.get_modifiers())
.fg(fg)
.bg(bg),
)
})
.collect(),
};
// Make text
let mut text: TuiText = TuiText::from(Spans::from(spans));
// Apply style
text.patch_style(
Style::default()
//.add_modifier(self.props.get_modifiers())
//.fg(self.props.foreground) NOTE: don't style twice !!!
//.bg(self.props.background),
);
render.render_widget(Paragraph::new(text), 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 {
self.props = props;
// Return None
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view.
/// Returns always None, since cannot have any focus
fn on(&mut self, ev: InputEvent) -> Msg {
// Return key
if let InputEvent::Key(key) = ev {
Msg::OnKey(key)
} else {
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// For this component returns always None
fn get_value(&self) -> Payload {
Payload::None
}
// -- events
/// ### blur
///
/// Blur component
fn blur(&mut self) {}
/// ### active
///
/// Active component
fn active(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::layout::props::{TextParts, TextSpan, TextSpanBuilder};
use crossterm::event::{KeyCode, KeyEvent};
use tui::style::Color;
#[test]
fn test_ui_layout_components_text() {
let mut component: Text = Text::new(
PropsBuilder::default()
.with_texts(TextParts::new(
None,
Some(vec![
TextSpan::from("Press "),
TextSpanBuilder::new("<ESC>")
.with_foreground(Color::Cyan)
.bold()
.build(),
TextSpan::from(" to quit"),
]),
))
.build(),
);
component.active();
component.blur();
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Get value
assert_eq!(component.get_value(), Payload::None);
// Event
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
);
}
}

View File

@@ -1,159 +0,0 @@
//! ## Title
//!
//! `Title` component renders a simple readonly no event associated title
/**
* 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::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
// ext
use tui::{layout::Rect, style::Style, widgets::Paragraph};
// -- component
pub struct Title {
props: Props,
}
impl Title {
/// ### new
///
/// Instantiate a new Title component
pub fn new(props: Props) -> Self {
Title { props }
}
}
impl Component for Title {
/// ### 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) {
// Make a Span
if self.props.visible {
let title: String = match self.props.texts.title.as_ref() {
None => String::new(),
Some(t) => t.clone(),
};
render.render_widget(
Paragraph::new(title).style(
Style::default()
.fg(self.props.foreground)
.bg(self.props.background)
.add_modifier(self.props.get_modifiers()),
),
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 {
self.props = props;
// Return None
Msg::None
}
/// ### 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) -> PropsBuilder {
PropsBuilder::from(self.props.clone())
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view.
/// Returns always None, since cannot have any focus
fn on(&mut self, ev: InputEvent) -> Msg {
// Return key
if let InputEvent::Key(key) = ev {
Msg::OnKey(key)
} else {
Msg::None
}
}
/// ### get_value
///
/// Get current value from component
/// For this component returns always None
fn get_value(&self) -> Payload {
Payload::None
}
// -- events
/// ### blur
///
/// Blur component
fn blur(&mut self) {}
/// ### active
///
/// Active component
fn active(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::layout::props::TextParts;
use crossterm::event::{KeyCode, KeyEvent};
use tui::style::Color;
#[test]
fn test_ui_layout_components_title() {
let mut component: Title = Title::new(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("Title")), None))
.build(),
);
component.active();
component.blur();
// Update
let props = component.get_props().with_foreground(Color::Red).build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.props.foreground, Color::Red);
// Get value
assert_eq!(component.get_value(), Payload::None);
// Event
assert_eq!(
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
);
}
}

View File

@@ -1,122 +0,0 @@
//! ## Layout
//!
//! `Layout` is the module which provides components, view, state and properties to create layouts
/**
* 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.
*/
// Modules
pub mod components;
pub mod props;
pub mod utils;
pub mod view;
// locals
use props::{PropValue, Props, PropsBuilder};
// ext
use crossterm::event::Event as InputEvent;
use crossterm::event::KeyEvent;
use std::io::Stdout;
use tui::backend::CrosstermBackend;
use tui::layout::Rect;
use tui::Frame;
type Backend = CrosstermBackend<Stdout>;
pub(crate) type Canvas<'a> = Frame<'a, Backend>;
// -- Msg
/// ## Msg
///
/// Msg is an enum returned after an event is raised for a certain component
/// Yep, I took inspiration from Elm.
#[derive(std::fmt::Debug, PartialEq, Eq)]
pub enum Msg {
OnSubmit(Payload),
OnChange(Payload),
OnKey(KeyEvent),
None,
}
/// ## Payload
///
/// Payload describes a component value
#[derive(std::fmt::Debug, PartialEq, Eq)]
pub enum Payload {
Text(String),
//Signed(isize),
Unsigned(usize),
None,
}
// -- Component
/// ## Component
///
/// Component is a trait which defines the behaviours for a Layout component.
/// All layout components must implement a method to render and one to update
pub trait Component {
/// ### render
///
/// Based on the current properties and states, renders the component in the provided area frame
#[cfg(not(tarpaulin_include))]
fn render(&self, frame: &mut Canvas, area: Rect);
/// ### 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;
/// ### 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) -> PropsBuilder;
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view
fn on(&mut self, ev: InputEvent) -> Msg;
/// ### get_value
///
/// Get current value from component
fn get_value(&self) -> Payload;
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self);
/// ### active
///
/// Active component; basically give focus
fn active(&mut self);
}

View File

@@ -1,779 +0,0 @@
//! ## Props
//!
//! `Props` is the module which defines properties for layout components
/**
* 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.
*/
// ext
use tui::style::{Color, Modifier};
use tui::widgets::Borders;
// -- Props
/// ## Props
///
/// Props holds all the possible properties for a layout component
#[derive(Clone)]
pub struct Props {
// Values
pub visible: bool, // Is the element visible ON CREATE?
pub foreground: Color, // Foreground color
pub background: Color, // Background color
pub borders: Borders, // Borders
pub bold: bool, // Text bold
pub italic: bool, // Italic
pub underlined: bool, // Underlined
pub input_type: InputType, // Input type
pub input_len: Option<usize>, // max input len
pub texts: TextParts, // text parts
pub value: PropValue, // Initial value
}
impl Default for Props {
fn default() -> Self {
Self {
// Values
visible: true,
foreground: Color::Reset,
background: Color::Reset,
borders: Borders::ALL,
bold: false,
italic: false,
underlined: false,
input_type: InputType::Text,
input_len: None,
texts: TextParts::default(),
value: PropValue::None,
}
}
}
impl Props {
/// ### get_modifiers
///
/// Get text modifiers from properties
pub fn get_modifiers(&self) -> Modifier {
Modifier::empty()
| (match self.bold {
true => Modifier::BOLD,
false => Modifier::empty(),
})
| (match self.italic {
true => Modifier::ITALIC,
false => Modifier::empty(),
})
| (match self.underlined {
true => Modifier::UNDERLINED,
false => Modifier::empty(),
})
}
}
// -- Props builder
/// ## PropsBuilder
///
/// Chain constructor for `Props`
pub struct PropsBuilder {
props: Option<Props>,
}
#[allow(dead_code)]
impl PropsBuilder {
/// ### build
///
/// Build Props from builder
/// Don't call this method twice for any reasons!
pub fn build(&mut self) -> Props {
self.props.take().unwrap()
}
/// ### hidden
///
/// Initialize props with visible set to False
pub fn hidden(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = false;
}
self
}
/// ### visible
///
/// Initialize props with visible set to True
pub fn visible(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.visible = true;
}
self
}
/// ### with_foreground
///
/// Set foreground color for component
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.foreground = color;
}
self
}
/// ### with_background
///
/// Set background color for component
pub fn with_background(&mut self, color: Color) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.background = color;
}
self
}
/// ### with_borders
///
/// Set component borders style
pub fn with_borders(&mut self, borders: Borders) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.borders = borders;
}
self
}
/// ### bold
///
/// Set bold property for component
pub fn bold(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.bold = true;
}
self
}
/// ### italic
///
/// Set italic property for component
pub fn italic(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.italic = true;
}
self
}
/// ### underlined
///
/// Set underlined property for component
pub fn underlined(&mut self) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.underlined = true;
}
self
}
/// ### with_texts
///
/// Set texts for component
pub fn with_texts(&mut self, texts: TextParts) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.texts = texts;
}
self
}
/// ### with_input
///
/// Set input type for component
pub fn with_input(&mut self, input_type: InputType) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.input_type = input_type;
}
self
}
/// ### with_input_len
///
/// Set max input len
pub fn with_input_len(&mut self, len: usize) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.input_len = Some(len);
}
self
}
/// ### with_value
///
/// Set initial value for component
pub fn with_value(&mut self, value: PropValue) -> &mut Self {
if let Some(props) = self.props.as_mut() {
props.value = value;
}
self
}
}
impl From<Props> for PropsBuilder {
fn from(props: Props) -> Self {
PropsBuilder { props: Some(props) }
}
}
impl Default for PropsBuilder {
fn default() -> Self {
PropsBuilder {
props: Some(Props::default()),
}
}
}
// -- Text parts
/// ## Table
///
/// Table represents a list of rows with a list of columns of text spans
pub type Table = Vec<Vec<TextSpan>>;
/// ## TextParts
///
/// TextParts holds optional component for the text displayed by a component
#[derive(Clone)]
pub struct TextParts {
pub title: Option<String>,
pub rows: Option<Vec<TextSpan>>,
pub table: Option<Table>, // First vector is rows, inner vec is column
}
impl TextParts {
/// ### new
///
/// Instantiates a new TextParts entity
pub fn new(title: Option<String>, rows: Option<Vec<TextSpan>>) -> Self {
TextParts {
title,
rows,
table: None,
}
}
/// ### table
///
/// Instantiates a new TextParts as a Table
pub fn table(title: Option<String>, table: Table) -> Self {
TextParts {
title,
rows: None,
table: Some(table),
}
}
}
impl Default for TextParts {
fn default() -> Self {
TextParts {
title: None,
rows: None,
table: None,
}
}
}
/// ## TableBuilder
///
/// Table builder is a helper to make it easier to build text tables
pub struct TableBuilder {
table: Option<Table>,
}
impl TableBuilder {
/// ### add_col
///
/// Add a column to the last row
pub fn add_col(&mut self, span: TextSpan) -> &mut Self {
if let Some(table) = self.table.as_mut() {
if let Some(row) = table.last_mut() {
row.push(span);
}
}
self
}
/// ### add_row
///
/// Add a new row to the table
pub fn add_row(&mut self) -> &mut Self {
if let Some(table) = self.table.as_mut() {
table.push(vec![]);
}
self
}
/// ### build
///
/// Take table out of builder
/// Don't call this method twice for any reasons!
pub fn build(&mut self) -> Table {
self.table.take().unwrap()
}
}
impl Default for TableBuilder {
fn default() -> Self {
TableBuilder {
table: Some(vec![vec![]]),
}
}
}
/// ### TextSpan
///
/// TextSpan is a "cell" of text with its attributes
#[derive(Clone, std::fmt::Debug)]
pub struct TextSpan {
pub content: String,
pub fg: Color,
pub bg: Color,
pub bold: bool,
pub italic: bool,
pub underlined: bool,
}
impl From<&str> for TextSpan {
fn from(txt: &str) -> Self {
TextSpan {
content: txt.to_string(),
fg: Color::Reset,
bg: Color::Reset,
bold: false,
italic: false,
underlined: false,
}
}
}
impl From<String> for TextSpan {
fn from(content: String) -> Self {
TextSpan {
content,
fg: Color::Reset,
bg: Color::Reset,
bold: false,
italic: false,
underlined: false,
}
}
}
impl TextSpan {
/// ### get_modifiers
///
/// Get text modifiers from properties
pub fn get_modifiers(&self) -> Modifier {
Modifier::empty()
| (match self.bold {
true => Modifier::BOLD,
false => Modifier::empty(),
})
| (match self.italic {
true => Modifier::ITALIC,
false => Modifier::empty(),
})
| (match self.underlined {
true => Modifier::UNDERLINED,
false => Modifier::empty(),
})
}
}
// -- TextSpan builder
/// ## TextSpanBuilder
///
/// TextSpanBuilder is a struct which helps building quickly a TextSpan
pub struct TextSpanBuilder {
text: Option<TextSpan>,
}
#[allow(dead_code)]
impl TextSpanBuilder {
/// ### new
///
/// Instantiate a new TextSpanBuilder
pub fn new(text: &str) -> Self {
TextSpanBuilder {
text: Some(TextSpan::from(text)),
}
}
/// ### with_foreground
///
/// Set foreground for text span
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
if let Some(text) = self.text.as_mut() {
text.fg = color;
}
self
}
/// ### with_background
///
/// Set background for text span
pub fn with_background(&mut self, color: Color) -> &mut Self {
if let Some(text) = self.text.as_mut() {
text.bg = color;
}
self
}
/// ### italic
///
/// Set italic for text span
pub fn italic(&mut self) -> &mut Self {
if let Some(text) = self.text.as_mut() {
text.italic = true;
}
self
}
/// ### bold
///
/// Set bold for text span
pub fn bold(&mut self) -> &mut Self {
if let Some(text) = self.text.as_mut() {
text.bold = true;
}
self
}
/// ### underlined
///
/// Set underlined for text span
pub fn underlined(&mut self) -> &mut Self {
if let Some(text) = self.text.as_mut() {
text.underlined = true;
}
self
}
/// ### build
///
/// Make TextSpan out of builder
/// Don't call this method twice for any reasons!
pub fn build(&mut self) -> TextSpan {
self.text.take().unwrap()
}
}
// -- Prop value
/// ### PropValue
///
/// PropValue describes a property initial value
#[derive(Clone, PartialEq, std::fmt::Debug)]
#[allow(dead_code)]
pub enum PropValue {
Str(String),
Unsigned(usize),
Signed(isize),
Float(f64),
Boolean(bool),
None,
}
// -- Input Type
/// ## InputType
///
/// Input type for text inputs
#[derive(Clone, Copy, PartialEq, std::fmt::Debug)]
pub enum InputType {
Text,
Number,
Password,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ui_layout_props_default() {
let props: Props = Props::default();
assert_eq!(props.visible, true);
assert_eq!(props.background, Color::Reset);
assert_eq!(props.foreground, Color::Reset);
assert_eq!(props.borders, Borders::ALL);
assert_eq!(props.bold, false);
assert_eq!(props.italic, false);
assert_eq!(props.underlined, false);
assert!(props.texts.title.is_none());
assert_eq!(props.input_type, InputType::Text);
assert!(props.input_len.is_none());
assert_eq!(props.value, PropValue::None);
assert!(props.texts.rows.is_none());
}
#[test]
fn test_ui_layout_props_modifiers() {
// Make properties
let props: Props = PropsBuilder::default().bold().italic().underlined().build();
// Get modifiers
let modifiers: Modifier = props.get_modifiers();
assert!(modifiers.intersects(Modifier::BOLD));
assert!(modifiers.intersects(Modifier::ITALIC));
assert!(modifiers.intersects(Modifier::UNDERLINED));
}
#[test]
fn test_ui_layout_props_builder() {
let props: Props = PropsBuilder::default()
.hidden()
.with_background(Color::Blue)
.with_foreground(Color::Green)
.with_borders(Borders::BOTTOM)
.bold()
.italic()
.underlined()
.with_texts(TextParts::new(
Some(String::from("hello")),
Some(vec![TextSpan::from("hey")]),
))
.with_input(InputType::Password)
.with_input_len(16)
.with_value(PropValue::Str(String::from("Hello")))
.build();
assert_eq!(props.background, Color::Blue);
assert_eq!(props.borders, Borders::BOTTOM);
assert_eq!(props.bold, true);
assert_eq!(props.foreground, Color::Green);
assert_eq!(props.italic, true);
assert_eq!(props.texts.title.as_ref().unwrap().as_str(), "hello");
assert_eq!(props.input_type, InputType::Password);
assert_eq!(*props.input_len.as_ref().unwrap(), 16);
if let PropValue::Str(s) = props.value {
assert_eq!(s.as_str(), "Hello");
} else {
panic!("Expected value to be a string");
}
assert_eq!(
props
.texts
.rows
.as_ref()
.unwrap()
.get(0)
.unwrap()
.content
.as_str(),
"hey"
);
assert_eq!(props.underlined, true);
assert_eq!(props.visible, false);
let props: Props = PropsBuilder::default()
.visible()
.with_background(Color::Blue)
.with_foreground(Color::Green)
.bold()
.italic()
.underlined()
.with_texts(TextParts::new(
Some(String::from("hello")),
Some(vec![TextSpan::from("hey")]),
))
.build();
assert_eq!(props.background, Color::Blue);
assert_eq!(props.bold, true);
assert_eq!(props.foreground, Color::Green);
assert_eq!(props.italic, true);
assert_eq!(props.texts.title.as_ref().unwrap().as_str(), "hello");
assert_eq!(
props
.texts
.rows
.as_ref()
.unwrap()
.get(0)
.unwrap()
.content
.as_str(),
"hey"
);
assert_eq!(props.underlined, true);
assert_eq!(props.visible, true);
}
#[test]
#[should_panic]
fn test_ui_layout_props_build_twice() {
let mut builder: PropsBuilder = PropsBuilder::default();
let _ = builder.build();
builder
.hidden()
.with_background(Color::Blue)
.with_foreground(Color::Green)
.bold()
.italic()
.underlined()
.with_texts(TextParts::new(
Some(String::from("hello")),
Some(vec![TextSpan::from("hey")]),
));
// Rebuild
let _ = builder.build();
}
#[test]
fn test_ui_layout_props_builder_from_props() {
let props: Props = PropsBuilder::default()
.hidden()
.with_background(Color::Blue)
.with_foreground(Color::Green)
.bold()
.italic()
.underlined()
.with_texts(TextParts::new(
Some(String::from("hello")),
Some(vec![TextSpan::from("hey")]),
))
.build();
// Ok, now make a builder from properties
let builder: PropsBuilder = PropsBuilder::from(props);
assert!(builder.props.is_some());
}
#[test]
fn test_ui_layout_props_text_parts_with_values() {
let parts: TextParts = TextParts::new(
Some(String::from("Hello world!")),
Some(vec![TextSpan::from("row1"), TextSpan::from("row2")]),
);
assert_eq!(parts.title.as_ref().unwrap().as_str(), "Hello world!");
assert_eq!(
parts
.rows
.as_ref()
.unwrap()
.get(0)
.unwrap()
.content
.as_str(),
"row1"
);
assert_eq!(
parts
.rows
.as_ref()
.unwrap()
.get(1)
.unwrap()
.content
.as_str(),
"row2"
);
}
#[test]
fn test_ui_layout_props_text_parts_default() {
let parts: TextParts = TextParts::default();
assert!(parts.title.is_none());
assert!(parts.rows.is_none());
}
#[test]
fn test_ui_layout_props_text_parts_table() {
let table: TextParts = TextParts::table(
Some(String::from("my data")),
TableBuilder::default()
.add_col(TextSpan::from("name"))
.add_col(TextSpan::from("age"))
.add_row()
.add_col(TextSpan::from("christian"))
.add_col(TextSpan::from("23"))
.add_row()
.add_col(TextSpan::from("omar"))
.add_col(TextSpan::from("25"))
.add_row()
.add_row()
.add_col(TextSpan::from("pippo"))
.build(),
);
// Verify table
assert_eq!(table.title.as_ref().unwrap().as_str(), "my data");
assert!(table.rows.is_none());
assert_eq!(table.table.as_ref().unwrap().len(), 5); // 5 rows
assert_eq!(table.table.as_ref().unwrap().get(0).unwrap().len(), 2); // 2 cols
assert_eq!(table.table.as_ref().unwrap().get(1).unwrap().len(), 2); // 2 cols
assert_eq!(
table
.table
.as_ref()
.unwrap()
.get(1)
.unwrap()
.get(0)
.unwrap()
.content
.as_str(),
"christian"
); // check content
assert_eq!(table.table.as_ref().unwrap().get(2).unwrap().len(), 2); // 2 cols
assert_eq!(table.table.as_ref().unwrap().get(3).unwrap().len(), 0); // 0 cols
assert_eq!(table.table.as_ref().unwrap().get(4).unwrap().len(), 1); // 1 cols
}
#[test]
fn test_ui_layout_props_text_span() {
// from str
let span: TextSpan = TextSpan::from("Hello!");
assert_eq!(span.content.as_str(), "Hello!");
assert_eq!(span.bold, false);
assert_eq!(span.fg, Color::Reset);
assert_eq!(span.bg, Color::Reset);
assert_eq!(span.italic, false);
assert_eq!(span.underlined, false);
// From String
let span: TextSpan = TextSpan::from(String::from("omar"));
assert_eq!(span.content.as_str(), "omar");
assert_eq!(span.bold, false);
assert_eq!(span.fg, Color::Reset);
assert_eq!(span.bg, Color::Reset);
assert_eq!(span.italic, false);
assert_eq!(span.underlined, false);
// With attributes
let span: TextSpan = TextSpanBuilder::new("Error")
.with_background(Color::Red)
.with_foreground(Color::Black)
.bold()
.italic()
.underlined()
.build();
assert_eq!(span.content.as_str(), "Error");
assert_eq!(span.bold, true);
assert_eq!(span.fg, Color::Black);
assert_eq!(span.bg, Color::Red);
assert_eq!(span.italic, true);
assert_eq!(span.underlined, true);
// Check modifiers
let modifiers: Modifier = span.get_modifiers();
assert!(modifiers.intersects(Modifier::BOLD));
assert!(modifiers.intersects(Modifier::ITALIC));
assert!(modifiers.intersects(Modifier::UNDERLINED));
}
}

View File

@@ -1,461 +0,0 @@
//! ## View
//!
//! `View` is the module which handles layout components
/**
* 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.
*/
// imports
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder, Rect};
// ext
use std::collections::HashMap;
/// ## View
///
/// View is the wrapper and manager for all the components.
/// A View is a container for all the components in a certain layout.
/// Each View can have only one focused component.
pub struct View {
components: HashMap<String, Box<dyn Component>>, // all the components in the view
focus: Option<String>, // Current active component
focus_stack: Vec<String>, // Focus stack; used to give focus in case the current element loses focus
}
// -- view
impl View {
/// ### init
///
/// Initialize a new `View`
pub fn init() -> Self {
View {
components: HashMap::new(),
focus: None,
focus_stack: Vec::new(),
}
}
// -- mount / umount
/// ### mount
///
/// Mount a new component in the view
pub fn mount(&mut self, id: &str, component: Box<dyn Component>) {
self.components.insert(id.to_string(), component);
}
/// ### umount
///
/// Umount a component from the view.
/// If component has focus, blur component and remove it from the stack
pub fn umount(&mut self, id: &str) {
// Check if component has focus
if let Some(focus) = self.focus.as_ref() {
// If has focus, blur component
if focus == id {
self.blur();
}
}
// Remove component from focus stack
self.pop_from_stack(id);
self.components.remove(id);
}
// -- render
/// ### render
///
/// RenderData component with the provided id
#[cfg(not(tarpaulin_include))]
pub fn render(&self, id: &str, frame: &mut Canvas, area: Rect) {
if let Some(component) = self.components.get(id) {
component.render(frame, area);
}
}
// -- props
/// ### get_props
///
/// Get component properties
pub fn get_props(&self, id: &str) -> Option<PropsBuilder> {
self.components.get(id).map(|cmp| cmp.get_props())
}
/// update
///
/// Update component properties
/// Returns `None` if component doesn't exist
pub fn update(&mut self, id: &str, props: Props) -> Option<(String, Msg)> {
self.components
.get_mut(id)
.map(|cmp| (id.to_string(), cmp.update(props)))
}
// -- state
/// ### get_value
///
/// Get component value
pub fn get_value(&self, id: &str) -> Option<Payload> {
self.components.get(id).map(|cmp| cmp.get_value())
}
// -- events
/// ### on
///
/// Handle event for the focused component (if any)
/// Returns `None` if no component is focused
pub fn on(&mut self, ev: InputEvent) -> Option<(String, Msg)> {
match self.focus.as_ref() {
None => None,
Some(id) => self
.components
.get_mut(id)
.map(|cmp| (id.to_string(), cmp.on(ev))),
}
}
// -- focus
/// ### blur
///
/// Blur selected element AND DON'T PUSH CURRENT ACTIVE ELEMENT INTO THE STACK
/// Last element in stack becomes active and is removed from the stack
pub fn blur(&mut self) {
if let Some(component) = self.focus.take() {
// Blur component
if let Some(cmp) = self.components.get_mut(component.as_str()) {
cmp.blur();
}
// Set last element in the stack as active
let mut new: Option<String> = None;
if let Some(last) = self.focus_stack.last() {
// Set focus to last element
new = Some(last.clone());
self.focus = Some(last.clone());
// Active
if let Some(new) = self.components.get_mut(last) {
new.active();
}
}
// Pop element from stack
if let Some(new) = new {
self.pop_from_stack(new.as_str());
}
}
}
/// ### active
///
/// Active provided element
/// Current active component, if any, GETS PUSHED to the STACK
pub fn active(&mut self, component: &str) {
// Active component if exists
if let Some(cmp) = self.components.get_mut(component) {
// Active component
cmp.active();
// Put current focus if any, into the stack
if let Some(active_component) = self.focus.take() {
if active_component != component {
// Blur active component if are different
if let Some(active_component) =
self.components.get_mut(active_component.as_str())
{
active_component.blur();
}
}
self.push_to_stack(active_component.as_str());
}
// Give focus to component
self.focus = Some(component.to_string());
// Remove new component from the stack
self.pop_from_stack(component);
}
}
// -- private
/// ### push_to_stack
///
/// Push component to stack; first remove it from the stack if any
fn push_to_stack(&mut self, name: &str) {
self.pop_from_stack(name);
self.focus_stack.push(name.to_string());
}
/// ### pop_from_stack
///
/// Pop element from focus stack
fn pop_from_stack(&mut self, name: &str) {
self.focus_stack.retain(|c| c.as_str() != name);
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::components::input::Input;
use super::super::components::text::Text;
use super::super::props::{PropValue, TextParts, TextSpan};
use crossterm::event::{KeyCode, KeyEvent};
#[test]
fn test_ui_layout_view_init() {
let view: View = View::init();
// Verify view
assert_eq!(view.components.len(), 0);
assert!(view.focus.is_none());
assert_eq!(view.focus_stack.len(), 0);
}
#[test]
fn test_ui_layout_view_mount_umount() {
let mut view: View = View::init();
// Mount component
let input: &str = "INPUT";
view.mount(input, make_component_input());
// Verify is mounted
assert!(view.components.get(input).is_some());
// Mount another
let text: &str = "TEXT";
view.mount(text, make_component_text());
assert!(view.components.get(text).is_some());
assert_eq!(view.components.len(), 2);
// Verify you cannot have duplicates
view.mount(input, make_component_input());
assert_eq!(view.components.len(), 2); // length should still be 2
// Umount
view.umount(text);
assert_eq!(view.components.len(), 1);
assert!(view.components.get(text).is_none());
}
/*
#[test]
fn test_ui_layout_view_mount_render() {
let mut view: View = View::init();
// Mount component
let input: &str = "INPUT";
view.mount(input, make_component_input());
assert!(view.render(input).is_some());
assert!(view.render("unexisting").is_none());
}
*/
#[test]
fn test_ui_layout_view_focus() {
let mut view: View = View::init();
// Prepare ids
let input1: &str = "INPUT_1";
let input2: &str = "INPUT_2";
let input3: &str = "INPUT_3";
let text1: &str = "TEXT_1";
let text2: &str = "TEXT_2";
// Mount components
view.mount(input1, make_component_input());
view.mount(input2, make_component_input());
view.mount(input3, make_component_input());
view.mount(text1, make_component_text());
view.mount(text2, make_component_text());
// Verify focus
assert!(view.focus.is_none());
assert_eq!(view.focus_stack.len(), 0);
// Blur when nothing is selected
view.blur();
assert!(view.focus.is_none());
assert_eq!(view.focus_stack.len(), 0);
// Active unexisting component
view.active("UNEXISTING-COMPONENT");
assert!(view.focus.is_none());
assert_eq!(view.focus_stack.len(), 0);
// Give focus to a component
view.active(input1);
// Check focus
assert_eq!(view.focus.as_ref().unwrap().as_str(), input1);
assert_eq!(view.focus_stack.len(), 0); // NOTE: stack is empty until a focus gets blurred
// Active a new component
view.active(input2);
// Now focus should be on input2, but input 1 should be in the focus stack
assert_eq!(view.focus.as_ref().unwrap().as_str(), input2);
assert_eq!(view.focus_stack.len(), 1);
assert_eq!(view.focus_stack[0].as_str(), input1);
// Active input 3
view.active(input3);
// now focus should be hold by input3, and stack should have len 2
assert_eq!(view.focus.as_ref().unwrap().as_str(), input3);
assert_eq!(view.focus_stack.len(), 2);
assert_eq!(view.focus_stack[0].as_str(), input1);
assert_eq!(view.focus_stack[1].as_str(), input2);
// blur
view.blur();
// Focus should now be hold by input2; input 3 should NOT be in the stack
assert_eq!(view.focus.as_ref().unwrap().as_str(), input2);
assert_eq!(view.focus_stack.len(), 1);
assert_eq!(view.focus_stack[0].as_str(), input1);
// Active twice
view.active(input2);
// Nothing should have changed
assert_eq!(view.focus.as_ref().unwrap().as_str(), input2);
assert_eq!(view.focus_stack.len(), 1);
assert_eq!(view.focus_stack[0].as_str(), input1);
// Blur again; stack should become empty, whether focus should then be hold by input 1
view.blur();
assert_eq!(view.focus.as_ref().unwrap().as_str(), input1);
assert_eq!(view.focus_stack.len(), 0);
// Blur again; now everything should be none
view.blur();
assert!(view.focus.is_none());
assert_eq!(view.focus_stack.len(), 0);
}
#[test]
fn test_ui_layout_view_focus_umount() {
let mut view: View = View::init();
// Mount component
let input: &str = "INPUT";
let text: &str = "TEXT";
let text2: &str = "TEXT2";
view.mount(input, make_component_input());
view.mount(text, make_component_text());
view.mount(text2, make_component_text());
// Give focus to input
view.active(input);
// Give focus to text
view.active(text);
view.active(text2);
// Stack should have 1 element
assert_eq!(view.focus_stack.len(), 2);
// Focus should be some
assert!(view.focus.is_some());
// Umount text
view.umount(text2);
// Focus should now be hold by 'text'; stack should now have size 1
assert_eq!(view.focus.as_ref().unwrap(), text);
assert_eq!(view.focus_stack.len(), 1);
// Umount input
view.umount(input);
assert_eq!(view.focus.as_ref().unwrap(), text);
assert_eq!(view.focus_stack.len(), 0);
// Umount text
view.umount(text);
assert!(view.focus.is_none());
assert_eq!(view.focus_stack.len(), 0);
}
#[test]
fn test_ui_layout_view_update() {
let mut view: View = View::init();
// Prepare ids
let text: &str = "TEXT";
// Mount text
view.mount(text, make_component_text());
// Get properties and update
let props: Props = view.get_props(text).unwrap().build();
// Verify bold is false
assert_eq!(props.bold, false);
// Update properties and set bold to true
let mut builder = view.get_props(text).unwrap();
let (id, msg) = view.update(text, builder.bold().build()).unwrap();
// Verify return values
assert_eq!(id, text);
assert_eq!(msg, Msg::None);
// Verify bold is now true
let props: Props = view.get_props(text).unwrap().build();
// Verify bold is false
assert_eq!(props.bold, true);
// Get properties for unexisting component
assert!(view.update("foobar", props).is_none());
}
#[test]
fn test_ui_layout_view_on() {
let mut view: View = View::init();
// Prepare ids
let text: &str = "TEXT";
let input: &str = "INPUT";
// Mount
view.mount(text, make_component_text());
view.mount(input, make_component_input());
// Verify current value
assert_eq!(view.get_value(text).unwrap(), Payload::None); // Text value is Nothing
assert_eq!(
view.get_value(input).unwrap(),
Payload::Text(String::from("text"))
); // Defined in `make_component_input`
// Handle events WITHOUT ANY ACTIVE ELEMENT
assert!(view
.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter)))
.is_none());
// Active input
view.active(input);
// Now handle events on input
// Try char
assert_eq!(
view.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('1'))))
.unwrap(),
(input.to_string(), Msg::None)
);
// Verify new value
assert_eq!(
view.get_value(input).unwrap(),
Payload::Text(String::from("text1"))
);
// Verify enter
assert_eq!(
view.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter)))
.unwrap(),
(
input.to_string(),
Msg::OnSubmit(Payload::Text(String::from("text1")))
)
);
}
/// ### make_component
///
/// Make a new component; we'll use Input, which uses a rich set of events
fn make_component_input() -> Box<dyn Component> {
Box::new(Input::new(
PropsBuilder::default()
.with_texts(TextParts::new(Some(String::from("mytext")), None))
.with_value(PropValue::Str(String::from("text")))
.build(),
))
}
fn make_component_text() -> Box<dyn Component> {
Box::new(Text::new(
PropsBuilder::default()
.with_texts(TextParts::new(
None,
Some(vec![TextSpan::from("Sample text")]),
))
.build(),
))
}
}

View File

@@ -27,7 +27,8 @@
*/
// Modules
pub mod activities;
pub(crate) mod components;
pub mod context;
pub(crate) mod input;
pub(crate) mod layout;
pub(crate) mod keymap;
pub(crate) mod store;

View File

@@ -175,6 +175,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_ui_store() {
// Create store

View File

@@ -52,6 +52,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_utils_crypto_aes128() {
let key: &str = "MYSUPERSECRETKEY";

57
src/utils/file.rs Normal file
View File

@@ -0,0 +1,57 @@
//! ## File
//!
//! `file` is the module which exposes file related utilities
/**
* 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 std::fs::File;
use std::fs::OpenOptions;
use std::io;
use std::path::Path;
/// ### open_file
///
/// Open file provided as parameter
pub fn open_file<P>(filename: P, create: bool, write: bool, append: bool) -> io::Result<File>
where
P: AsRef<Path>,
{
OpenOptions::new()
.create(create)
.write(write)
.append(append)
.truncate(!append)
.open(filename)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_file_open() {
let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
assert!(open_file(tmpfile.path(), true, true, true).is_ok());
}
}

View File

@@ -152,11 +152,20 @@ pub fn fmt_path_elide(p: &Path, width: usize) -> String {
}
}
/// ### shadow_password
///
/// Return a string with the same length of input string, but each character is replaced by '*'
pub fn shadow_password(s: &str) -> String {
(0..s.len()).map(|_| '*').collect()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_utils_fmt_pex() {
assert_eq!(fmt_pex(7, 7, 7), String::from("rwxrwxrwx"));
@@ -217,4 +226,9 @@ mod tests {
let p: &Path = &Path::new("/develop/pippo/foo/bar");
assert_eq!(fmt_path_elide(p, 16), String::from("/develop/.../foo/bar"));
}
#[test]
fn test_utils_fmt_shadow_password() {
assert_eq!(shadow_password("foobar"), String::from("******"));
}
}

View File

@@ -27,7 +27,9 @@
*/
// modules
pub mod crypto;
pub mod file;
pub mod fmt;
pub mod git;
pub mod parser;
pub mod random;
pub mod ui;

View File

@@ -233,6 +233,8 @@ mod tests {
use super::*;
use crate::utils::fmt::fmt_time;
use pretty_assertions::assert_eq;
#[test]
fn test_utils_parse_remote_opt() {
// Base case

View File

@@ -47,6 +47,8 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_utils_random_alphanumeric_with_len() {
assert_eq!(random_alphanumeric_with_len(256).len(), 256);

View File

@@ -25,7 +25,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
use tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
/// ### draw_area_in
///
@@ -60,8 +60,10 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_ui_layout_utils_draw_area_in() {
fn test_utils_ui_draw_area_in() {
let area: Rect = Rect::new(0, 0, 1024, 512);
let child: Rect = draw_area_in(area, 75, 30);
assert_eq!(child.x, 43);